feat(voting): community voting for hackathon projects via Nostr#36
feat(voting): community voting for hackathon projects via Nostr#36agustinkassis wants to merge 4 commits into
Conversation
Adds a Nostr-native community voting system embedded on each hackathon page. Winners are chosen by the community: anyone who participated in any hackathon and has a linked Nostr pubkey can vote for the current hackathon's projects, with 1 vote per hackathon participated, freely allocatable across projects (no self-votes). Two kind-30078 (NIP-78 replaceable) event roles, both tagged ["client","La Crypta Dev"]: - Voting period event (server-signed with LACRYPTA_NSEC, d=lacrypta.dev: voting:<id>): open/closed status, frozen eligibility snapshot, votable project list, and the canonical signed final tally once closed. - Ballot event (voter-signed, d=lacrypta.dev:vote:<id>): replaceable, one ballot per voter per hackathon. Admin opens/closes voting from the page via a kind-27235 auth request, reusing the soldiers-ranking publish pattern. While open the tally is computed live from relay ballots; after close clients render the signed embedded results verbatim (freeze rule). New: lib/voting.ts (shared contract), lib/votingCache.ts (cached server read), lib/votingClient.ts (publish + live subscriptions), app/api/hackathons/[id]/voting/route.ts, app/hackathons/[id]/ VotingSection.tsx, and a dev-only /dev/voting test harness. Test isolation via NEXT_PUBLIC_VOTING_NS=test (namespaced d-tags) + VOTING_TEST_EXTRA_VOTERS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
More reviews will be available in 22 minutes and 15 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughThis PR introduces a complete Nostr-based community voting system for hackathons with real-time ballot subscriptions and tally results. It adds comprehensive dev-only infrastructure for isolated local testing (identity labs, dummy data seeding, local relay), converts hackathon routes from ID-based to slug-based URLs across the app, and extends all Nostr signers with NIP-44 encryption/decryption support. ChangesCommunity voting system
Slug-based hackathon routing
Development environment infrastructure
NIP-44 encryption support for signers
Supporting features
Sequence DiagramsequenceDiagram
participant User
participant Admin as Admin
participant VotingUI as VotingSection
participant Backend as Voting API
participant Relays as Nostr Relays
Admin->>Backend: POST open-voting (signed)
Backend->>Relays: Fetch soldiers, projects, build period
Backend->>Relays: Publish period event
Backend-->>Admin: success + relay status
User->>VotingUI: Load hackathon page
VotingUI->>Backend: GET /voting (cached)
VotingUI->>Relays: Subscribe to period + ballots
Relays-->>VotingUI: Period and ballot stream
User->>VotingUI: Allocate votes
VotingUI->>VotingUI: Validate budget, mark dirty
User->>VotingUI: Publish ballot
VotingUI->>Relays: Sign and send ballot
Relays-->>VotingUI: Ballot received
VotingUI->>VotingUI: Dedupe, retally
Admin->>Backend: POST close-voting (signed)
Backend->>Relays: Fetch and tally ballots
Backend->>Relays: Publish closed period
VotingUI->>VotingUI: Receive closed period
VotingUI->>User: Show TallyBoard
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (2)
lib/votingCache.ts (1)
9-17: 📐 Maintainability & Code Quality | ⚡ Quick winUse
@/*path alias for imports.Per coding guidelines, imports should use the
@/*path alias resolving to the repo root.Suggested fix
import { cacheLife, cacheTag } from "next/cache"; -import { DEFAULT_RELAYS } from "./nostrRelayConfig"; -import { nostrVotingTag } from "./nostrCacheTags"; +import { DEFAULT_RELAYS } from "`@/lib/nostrRelayConfig`"; +import { nostrVotingTag } from "`@/lib/nostrCacheTags`"; import { VOTING_KIND, parseVotingPeriod, votingPeriodDTag, type VotingPeriod, -} from "./voting"; +} from "`@/lib/voting`";🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/votingCache.ts` around lines 9 - 17, The imports in lib/votingCache.ts use relative paths; update them to use the project path alias (`@/`*) per guidelines — replace "./nostrRelayConfig" with "`@/nostrRelayConfig`", "./nostrCacheTags" with "`@/nostrCacheTags`", and "./voting" with "`@/voting`" while keeping the existing named imports (DEFAULT_RELAYS, nostrVotingTag, VOTING_KIND, parseVotingPeriod, votingPeriodDTag, VotingPeriod) and leaving the next/cache import unchanged so symbols like DEFAULT_RELAYS, nostrVotingTag, parseVotingPeriod, and VOTING_KIND continue to resolve via the alias.Source: Coding guidelines
lib/votingClient.ts (1)
9-20: 📐 Maintainability & Code Quality | ⚡ Quick winUse the repo alias here instead of relative imports.
Lines 9-20 are still importing through
./…, which breaks the repo-wide TS import convention and makes future moves noisier.♻️ Suggested cleanup
-import { FAST_USER_RELAYS, DEFAULT_RELAYS } from "./nostrRelayConfig"; -import type { SignedEvent, UserSigner } from "./nostrSigner"; +import { FAST_USER_RELAYS, DEFAULT_RELAYS } from "`@/lib/nostrRelayConfig`"; +import type { SignedEvent, UserSigner } from "`@/lib/nostrSigner`"; import { VOTE_T_TAG, VOTING_KIND, VOTING_SCHEMA_VERSION, parseVotingPeriod, voteDTag, votingPeriodDTag, type BallotContent, type VotingPeriod, -} from "./voting"; +} from "`@/lib/voting`";As per coding guidelines,
**/*.{ts,tsx}: Use the@/* path alias for imports, resolving to the repo root.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/votingClient.ts` around lines 9 - 20, The imports in this file use relative paths (e.g., "./nostrRelayConfig", "./nostrSigner", "./voting") which violate the repo TS alias convention; update the import statements to use the repository path alias (@"...") instead, keeping the same exported symbols (FAST_USER_RELAYS, DEFAULT_RELAYS, SignedEvent, UserSigner, VOTE_T_TAG, VOTING_KIND, VOTING_SCHEMA_VERSION, parseVotingPeriod, voteDTag, votingPeriodDTag, BallotContent, VotingPeriod) and ensure the new paths resolve from the repo root so the module names remain identical while switching to the `@/` alias.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/dev/voting/DevVotingClient.tsx`:
- Around line 257-260: En el mapeo de HACKATHONS (HACKATHONS.map) y en la
función hackathonStatus cambia los estados mostrados al usuario de inglés a
español (por ejemplo active → activo, upcoming → próximo, closed → cerrado) y en
cualquier etiqueta de votación que muestre open/closed usa abierto/cerrado;
además reemplaza la cadena visible "NAMESPACE TEST" por su equivalente en
español ("NAMESPACE DE PRUEBA" o "NOMBRE DE ESPACIO DE PRUEBA") para mantener
consistencia de idioma en la UI; actualiza solo los textos mostrados al usuario
dejando identificadores y comentarios en inglés.
- Around line 100-104: Al eliminar la identidad en la función removeIdentity
(que usa identities, setIdentities y saveIdentities) también detectá si la
pubkey eliminada coincide con la identidad actualmente autenticada y, si es así,
cerrá la sesión limpiando el estado de autenticación (por ejemplo llamando a
logout(), setActiveIdentity(null), setSession(null) o setAuthenticated(false)
según cómo esté implementado) y persistí ese cambio (por ejemplo
saveSession/guardarEstado) para evitar estado inconsistente; añadí esa
comprobación justo después de saveIdentities dentro de removeIdentity.
In `@app/dev/voting/page.tsx`:
- Line 4: Replace the relative import in page.tsx for DevVotingClient with the
repo-root alias form: change the import of DevVotingClient (currently
"./DevVotingClient") to the equivalent path using the `@/` alias that points to
the repository root (e.g., import DevVotingClient from
"`@/app/dev/voting/DevVotingClient`"), ensuring the module specifier matches the
file's location from the repo root.
In `@app/hackathons/`[id]/VotingSection.tsx:
- Around line 57-58: The ballots Map in VotingSection (state variables ballots /
setBallots) is never cleared and retains entries across hackathon or
VotingPeriod changes; add a useEffect that watches the current hackathon id and
period (period state / setPeriod) and resets ballots by calling setBallots(new
Map()) whenever either changes (also clear any related derived state like
liveTally and ownBallotEvent in the same effect or by calling their setters) so
stale ballots do not contaminate liveTally, ownBallotEvent, or the editor
“already voted” state; apply the same reset logic to the other ballots-related
state blocks referenced around lines 121-141.
- Around line 496-507: The allocations state can contain keys for projects that
were removed from the current project list, causing stale allocations to count
toward used and be republished; update the sync and publish paths to prune
allocations to only include IDs present in the current projects list. When
setting allocations from initialAllocations in the useEffect (and any other
place where initial allocations are applied, e.g., relay updates), filter the
object to remove keys not in the current project IDs before calling
setAllocations; likewise, before toggling publishing / inside the publish
handler (where setPublishing is used), compute a sanitizedAllocations object by
keeping only keys that exist in the current project list and use that for the
publish payload and setAllocations, so hidden/removed projects are never counted
or republished (reference allocations, setAllocations, dirty, the useEffect that
consumes initialAllocations, and the publish handler where publishing is set).
In `@lib/voting.ts`:
- Around line 357-361: The code incorrectly sets existing.maxVotes using
Math.max(existing.maxVotes, hackathons.size), which undercounts when the same
pubkey appears in multiple roster entries; instead, track and union the actual
hackathon sets per pubkey and set existing.maxVotes to the size of that union.
Update the merge logic where existing is found (references: existing, maxVotes,
hackathons, blocked) to compute a unioned set of hackathon IDs across the
existing record and the incoming hackathons, assign existing.maxVotes =
union.size, and retain the existing.blocked union behavior; if no per-pubkey
hackathon set exists yet, create one when first inserting the pubkey so future
merges can union correctly.
In `@lib/votingClient.ts`:
- Around line 111-117: The live ballot bootstrap is being truncated by the fixed
limit in the subscribe filter; in the call to pool.subscribe (the subscription
that uses VOTING_KIND and "`#d`": [dTag]) remove the hardcoded limit: 500 (or set
it to undefined/omit the property) so the initial snapshot returns all matching
events and the liveTally/editor hydration is accurate for contests with >500
voters.
- Around line 165-185: In subscribeToVotingPeriod's onevent handler add a
signature verification guard: call verifyEvent(event) (or the project's
verifyEvent/verifySignedEvent utility) as the very first check inside onevent
and return immediately if it fails, before accessing event.pubkey or parsing
event.content; this ensures events are cryptographically validated before the
existing checks (the handler in subscribeToVotingPeriod, the SignedEvent usage,
and parseVotingPeriod calls).
---
Nitpick comments:
In `@lib/votingCache.ts`:
- Around line 9-17: The imports in lib/votingCache.ts use relative paths; update
them to use the project path alias (`@/`*) per guidelines — replace
"./nostrRelayConfig" with "`@/nostrRelayConfig`", "./nostrCacheTags" with
"`@/nostrCacheTags`", and "./voting" with "`@/voting`" while keeping the existing
named imports (DEFAULT_RELAYS, nostrVotingTag, VOTING_KIND, parseVotingPeriod,
votingPeriodDTag, VotingPeriod) and leaving the next/cache import unchanged so
symbols like DEFAULT_RELAYS, nostrVotingTag, parseVotingPeriod, and VOTING_KIND
continue to resolve via the alias.
In `@lib/votingClient.ts`:
- Around line 9-20: The imports in this file use relative paths (e.g.,
"./nostrRelayConfig", "./nostrSigner", "./voting") which violate the repo TS
alias convention; update the import statements to use the repository path alias
(@"...") instead, keeping the same exported symbols (FAST_USER_RELAYS,
DEFAULT_RELAYS, SignedEvent, UserSigner, VOTE_T_TAG, VOTING_KIND,
VOTING_SCHEMA_VERSION, parseVotingPeriod, voteDTag, votingPeriodDTag,
BallotContent, VotingPeriod) and ensure the new paths resolve from the repo root
so the module names remain identical while switching to the `@/` alias.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 853724bf-9f90-4319-8e16-bb08ccdcf637
📒 Files selected for processing (9)
app/api/hackathons/[id]/voting/route.tsapp/dev/voting/DevVotingClient.tsxapp/dev/voting/page.tsxapp/hackathons/[id]/VotingSection.tsxapp/hackathons/[id]/page.tsxlib/nostrCacheTags.tslib/voting.tslib/votingCache.tslib/votingClient.ts
| function removeIdentity(pubkey: string) { | ||
| const next = identities.filter((i) => i.pubkey !== pubkey); | ||
| setIdentities(next); | ||
| saveIdentities(next); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Al borrar la identidad activa, cerrá también la sesión actual.
Si en Line 100-104 se elimina la identidad actualmente autenticada, la sesión queda vigente aunque ya no exista en la lista local, dejando estado inconsistente en el laboratorio.
💡 Propuesta de ajuste
function removeIdentity(pubkey: string) {
+ const wasActive = auth?.pubkey === pubkey;
const next = identities.filter((i) => i.pubkey !== pubkey);
setIdentities(next);
saveIdentities(next);
+ if (wasActive) clearAuth("user");
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/dev/voting/DevVotingClient.tsx` around lines 100 - 104, Al eliminar la
identidad en la función removeIdentity (que usa identities, setIdentities y
saveIdentities) también detectá si la pubkey eliminada coincide con la identidad
actualmente autenticada y, si es así, cerrá la sesión limpiando el estado de
autenticación (por ejemplo llamando a logout(), setActiveIdentity(null),
setSession(null) o setAuthenticated(false) según cómo esté implementado) y
persistí ese cambio (por ejemplo saveSession/guardarEstado) para evitar estado
inconsistente; añadí esa comprobación justo después de saveIdentities dentro de
removeIdentity.
| {HACKATHONS.map((h) => ( | ||
| <option key={h.id} value={h.id}> | ||
| {h.name} ({h.id}) — {hackathonStatus(h)} | ||
| </option> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Traducí los estados visibles al usuario a español.
En Line 259 y Line 267 se muestran estados en inglés (active/upcoming/closed, open/closed), y en Line 278 aparece NAMESPACE TEST. Esto rompe la consistencia de idioma en UI.
💡 Propuesta de ajuste
+ const hackathonStatusLabel: Record<"upcoming" | "active" | "closed", string> = {
+ upcoming: "próximo",
+ active: "activo",
+ closed: "cerrado",
+ };
+ const votingStatusLabel: Record<"open" | "closed", string> = {
+ open: "abierta",
+ closed: "cerrada",
+ };
...
- {h.name} ({h.id}) — {hackathonStatus(h)}
+ {h.name} ({h.id}) — {hackathonStatusLabel[hackathonStatus(h)]}
...
- ? `Estado: ${period.status} · ${period.eligible.length} votantes · ${period.projects.length} proyectos`
+ ? `Estado: ${votingStatusLabel[period.status]} · ${period.eligible.length} votantes · ${period.projects.length} proyectos`
...
- {testNamespace ? "NAMESPACE TEST" : "NAMESPACE PRODUCCIÓN"}
+ {testNamespace ? "NAMESPACE DE PRUEBA" : "NAMESPACE DE PRODUCCIÓN"}As per coding guidelines, "User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English; error messages shown to users must remain Spanish".
Also applies to: 264-268, 278-278
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/dev/voting/DevVotingClient.tsx` around lines 257 - 260, En el mapeo de
HACKATHONS (HACKATHONS.map) y en la función hackathonStatus cambia los estados
mostrados al usuario de inglés a español (por ejemplo active → activo, upcoming
→ próximo, closed → cerrado) y en cualquier etiqueta de votación que muestre
open/closed usa abierto/cerrado; además reemplaza la cadena visible "NAMESPACE
TEST" por su equivalente en español ("NAMESPACE DE PRUEBA" o "NOMBRE DE ESPACIO
DE PRUEBA") para mantener consistencia de idioma en la UI; actualiza solo los
textos mostrados al usuario dejando identificadores y comentarios en inglés.
Source: Coding guidelines
| import type { Metadata } from "next"; | ||
| import { notFound } from "next/navigation"; | ||
| import { isVotingTestNamespace } from "@/lib/voting"; | ||
| import DevVotingClient from "./DevVotingClient"; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Usá el alias @/* en lugar de import relativo.
En Line 4, ./DevVotingClient incumple la convención de imports del repo para TS/TSX.
💡 Propuesta de ajuste
-import DevVotingClient from "./DevVotingClient";
+import DevVotingClient from "`@/app/dev/voting/DevVotingClient`";As per coding guidelines, "Use the @/* path alias for imports, resolving to the repo root (e.g., @/lib/hackathons, @/components/ui/PageHero)".
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import DevVotingClient from "./DevVotingClient"; | |
| import DevVotingClient from "`@/app/dev/voting/DevVotingClient`"; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/dev/voting/page.tsx` at line 4, Replace the relative import in page.tsx
for DevVotingClient with the repo-root alias form: change the import of
DevVotingClient (currently "./DevVotingClient") to the equivalent path using the
`@/` alias that points to the repository root (e.g., import DevVotingClient from
"`@/app/dev/voting/DevVotingClient`"), ensuring the module specifier matches the
file's location from the repo root.
Source: Coding guidelines
| const [period, setPeriod] = useState<VotingPeriod | null>(initialPeriod); | ||
| const [ballots, setBallots] = useState<Map<string, SignedEvent>>(new Map()); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reset the ballot cache when the hackathon or voting period changes.
This Map only ever grows. If the user client-navigates from one hackathon page to another, or the admin reopens a fresh period, the old ballots stay in memory until relays happen to replace them. That contaminates liveTally, ownBallotEvent, and the editor’s “already voted” state with stale data.
🧹 Suggested reset point
const [period, setPeriod] = useState<VotingPeriod | null>(initialPeriod);
const [ballots, setBallots] = useState<Map<string, SignedEvent>>(new Map());
+
+ useEffect(() => {
+ setBallots(new Map());
+ }, [hackathonId, period?.openedAt]);
// Live ballots while voting is open.
const votingOpen = period?.status === "open";Also applies to: 121-141
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 57 - 58, The ballots Map
in VotingSection (state variables ballots / setBallots) is never cleared and
retains entries across hackathon or VotingPeriod changes; add a useEffect that
watches the current hackathon id and period (period state / setPeriod) and
resets ballots by calling setBallots(new Map()) whenever either changes (also
clear any related derived state like liveTally and ownBallotEvent in the same
effect or by calling their setters) so stale ballots do not contaminate
liveTally, ownBallotEvent, or the editor “already voted” state; apply the same
reset logic to the other ballots-related state blocks referenced around lines
121-141.
| const [allocations, setAllocations] = useState<Record<string, number>>( | ||
| initialAllocations ?? {}, | ||
| ); | ||
| const [publishing, setPublishing] = useState(false); | ||
| // Refresh steppers when our relay ballot arrives, but never clobber edits. | ||
| const dirty = useRef(false); | ||
| useEffect(() => { | ||
| if (!dirty.current && initialAllocations) { | ||
| setAllocations(initialAllocations); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [JSON.stringify(initialAllocations)]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Prune allocations against the current project list before publish.
The admin flow can republish the roster/project list while voting remains open. If a project disappears after this editor mounts, its old allocation stays hidden in local state, still counts toward used, and gets republished even though the user can no longer remove it from the UI.
🛠️ Suggested sanitization
+ const allowedProjectIds = useMemo(
+ () => new Set(period.projects.map((project) => project.id)),
+ [period.projects],
+ );
+
useEffect(() => {
if (!dirty.current && initialAllocations) {
- setAllocations(initialAllocations);
+ setAllocations(
+ Object.fromEntries(
+ Object.entries(initialAllocations).filter(([projectId]) =>
+ allowedProjectIds.has(projectId),
+ ),
+ ),
+ );
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [JSON.stringify(initialAllocations)]);
+ }, [JSON.stringify(initialAllocations), allowedProjectIds]);
async function handlePublish() {
if (!auth || publishing || used === 0 || used > maxVotes) return;
setPublishing(true);
try {
const signer = await getSigner(auth);
+ const sanitizedAllocations = Object.fromEntries(
+ Object.entries(allocations).filter(([projectId]) =>
+ allowedProjectIds.has(projectId),
+ ),
+ );
const ev = await publishBallot(
signer,
hackathonId,
- allocations,
+ sanitizedAllocations,
prevBallotCreatedAt,
);Also applies to: 509-540
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 496 - 507, The
allocations state can contain keys for projects that were removed from the
current project list, causing stale allocations to count toward used and be
republished; update the sync and publish paths to prune allocations to only
include IDs present in the current projects list. When setting allocations from
initialAllocations in the useEffect (and any other place where initial
allocations are applied, e.g., relay updates), filter the object to remove keys
not in the current project IDs before calling setAllocations; likewise, before
toggling publishing / inside the publish handler (where setPublishing is used),
compute a sanitizedAllocations object by keeping only keys that exist in the
current project list and use that for the publish payload and setAllocations, so
hidden/removed projects are never counted or republished (reference allocations,
setAllocations, dirty, the useEffect that consumes initialAllocations, and the
publish handler where publishing is set).
| if (existing) { | ||
| // Same pubkey reachable from two roster entries — keep the larger budget | ||
| // and union the blocked lists. | ||
| existing.maxVotes = Math.max(existing.maxVotes, hackathons.size); | ||
| existing.blocked = [...new Set([...existing.blocked, ...blocked])]; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Incorrect maxVotes when same pubkey appears in multiple roster entries.
Taking Math.max(existing.maxVotes, hackathons.size) compares individual entry counts rather than counting the union of hackathons across all entries for that pubkey. If a voter appears in multiple roster entries (e.g., same Nostr key, different display names) with disjoint hackathon participation, their vote budget will be undercounted.
Example: Entry 1 has hackathons {A, B}, Entry 2 has hackathons {C, D}. Current code yields max(2, 2) = 2, but correct budget is 4 distinct hackathons.
Proposed fix: track hackathon sets per pubkey
export function buildEligibleVoters(
soldiers: Soldier[],
hackathonId: string,
): VotingEligibleVoter[] {
- const byPubkey = new Map<string, VotingEligibleVoter>();
+ const hackathonsByPubkey = new Map<string, Set<string>>();
+ const blockedByPubkey = new Map<string, Set<string>>();
+ const nameByPubkey = new Map<string, string>();
+
for (const s of soldiers) {
if (!s.pubkey) continue;
- const hackathons = new Set(
- s.projects.map((p) => p.hackathonId).filter(Boolean),
- );
- if (hackathons.size === 0) continue;
- const blocked = [
- ...new Set(
- s.projects
- .filter((p) => p.hackathonId === hackathonId)
- .map((p) => p.projectId),
- ),
- ];
const pubkey = s.pubkey.toLowerCase();
- const existing = byPubkey.get(pubkey);
- if (existing) {
- // Same pubkey reachable from two roster entries — keep the larger budget
- // and union the blocked lists.
- existing.maxVotes = Math.max(existing.maxVotes, hackathons.size);
- existing.blocked = [...new Set([...existing.blocked, ...blocked])];
- } else {
- byPubkey.set(pubkey, {
- pubkey,
- name: s.name,
- maxVotes: hackathons.size,
- blocked,
- });
+
+ // Accumulate hackathons for this pubkey
+ let hackathons = hackathonsByPubkey.get(pubkey);
+ if (!hackathons) {
+ hackathons = new Set();
+ hackathonsByPubkey.set(pubkey, hackathons);
}
+ for (const p of s.projects) {
+ if (p.hackathonId) hackathons.add(p.hackathonId);
+ }
+
+ // Accumulate blocked projects for this pubkey
+ let blocked = blockedByPubkey.get(pubkey);
+ if (!blocked) {
+ blocked = new Set();
+ blockedByPubkey.set(pubkey, blocked);
+ }
+ for (const p of s.projects) {
+ if (p.hackathonId === hackathonId) blocked.add(p.projectId);
+ }
+
+ // Keep first name encountered
+ if (!nameByPubkey.has(pubkey)) {
+ nameByPubkey.set(pubkey, s.name);
+ }
+ }
+
+ const result: VotingEligibleVoter[] = [];
+ for (const [pubkey, hackathons] of hackathonsByPubkey) {
+ if (hackathons.size === 0) continue;
+ result.push({
+ pubkey,
+ name: nameByPubkey.get(pubkey) ?? "",
+ maxVotes: hackathons.size,
+ blocked: [...(blockedByPubkey.get(pubkey) ?? [])],
+ });
}
- return [...byPubkey.values()];
+ return result;
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/voting.ts` around lines 357 - 361, The code incorrectly sets
existing.maxVotes using Math.max(existing.maxVotes, hackathons.size), which
undercounts when the same pubkey appears in multiple roster entries; instead,
track and union the actual hackathon sets per pubkey and set existing.maxVotes
to the size of that union. Update the merge logic where existing is found
(references: existing, maxVotes, hackathons, blocked) to compute a unioned set
of hackathon IDs across the existing record and the incoming hackathons, assign
existing.maxVotes = union.size, and retain the existing.blocked union behavior;
if no per-pubkey hackathon set exists yet, create one when first inserting the
pubkey so future merges can union correctly.
| const closer = pool.subscribe( | ||
| relays, | ||
| { | ||
| kinds: [VOTING_KIND], | ||
| "#d": [dTag], | ||
| limit: 500, | ||
| }, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Drop the 500-event cap from the live ballot bootstrap.
Line 116 silently truncates the initial ballot snapshot. Once a hackathon has more than 500 current voters, the open-period UI undercounts liveTally, and some users' own prior ballots may never hydrate into the editor state.
💡 Minimal fix
const closer = pool.subscribe(
relays,
{
kinds: [VOTING_KIND],
"`#d`": [dTag],
- limit: 500,
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const closer = pool.subscribe( | |
| relays, | |
| { | |
| kinds: [VOTING_KIND], | |
| "#d": [dTag], | |
| limit: 500, | |
| }, | |
| const closer = pool.subscribe( | |
| relays, | |
| { | |
| kinds: [VOTING_KIND], | |
| "`#d`": [dTag], | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/votingClient.ts` around lines 111 - 117, The live ballot bootstrap is
being truncated by the fixed limit in the subscribe filter; in the call to
pool.subscribe (the subscription that uses VOTING_KIND and "`#d`": [dTag]) remove
the hardcoded limit: 500 (or set it to undefined/omit the property) so the
initial snapshot returns all matching events and the liveTally/editor hydration
is accurate for contests with >500 voters.
| const { SimplePool } = await import("nostr-tools/pool"); | ||
| if (closed) return; | ||
| const pool = new SimplePool(); | ||
| const closer = pool.subscribe( | ||
| relays, | ||
| { | ||
| kinds: [VOTING_KIND], | ||
| authors: [publisherPubkey], | ||
| "#d": [dTag], | ||
| }, | ||
| { | ||
| onevent(ev) { | ||
| const event = ev as SignedEvent; | ||
| const d = event.tags.find((t) => t[0] === "d")?.[1]; | ||
| if (d !== dTag) return; | ||
| if (event.pubkey !== publisherPubkey) return; | ||
| if (event.created_at <= freshest) return; | ||
| const period = parseVotingPeriod(event.content); | ||
| if (!period || period.hackathonId !== hackathonId) return; | ||
| freshest = event.created_at; | ||
| onPeriod(period, event.created_at); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major
Verify Nostr event signature in subscribeToVotingPeriod before using pubkey/content.
lib/votingClient.ts onevent filters on event.pubkey and parses event.content without checking the event signature; a malicious relay can feed spoofed/invalid events that still match these filters. Add a verifyEvent guard right at the start of onevent.
🔐 Suggested guard
+import { verifyEvent } from "nostr-tools/pure";
+
onevent(ev) {
const event = ev as SignedEvent;
+ if (!verifyEvent(event)) return;
const d = event.tags.find((t) => t[0] === "d")?.[1];
if (d !== dTag) return;
if (event.pubkey !== publisherPubkey) return;
if (event.created_at <= freshest) return;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { SimplePool } = await import("nostr-tools/pool"); | |
| if (closed) return; | |
| const pool = new SimplePool(); | |
| const closer = pool.subscribe( | |
| relays, | |
| { | |
| kinds: [VOTING_KIND], | |
| authors: [publisherPubkey], | |
| "#d": [dTag], | |
| }, | |
| { | |
| onevent(ev) { | |
| const event = ev as SignedEvent; | |
| const d = event.tags.find((t) => t[0] === "d")?.[1]; | |
| if (d !== dTag) return; | |
| if (event.pubkey !== publisherPubkey) return; | |
| if (event.created_at <= freshest) return; | |
| const period = parseVotingPeriod(event.content); | |
| if (!period || period.hackathonId !== hackathonId) return; | |
| freshest = event.created_at; | |
| onPeriod(period, event.created_at); | |
| import { verifyEvent } from "nostr-tools/pure"; | |
| const { SimplePool } = await import("nostr-tools/pool"); | |
| if (closed) return; | |
| const pool = new SimplePool(); | |
| const closer = pool.subscribe( | |
| relays, | |
| { | |
| kinds: [VOTING_KIND], | |
| authors: [publisherPubkey], | |
| "`#d`": [dTag], | |
| }, | |
| { | |
| onevent(ev) { | |
| const event = ev as SignedEvent; | |
| if (!verifyEvent(event)) return; | |
| const d = event.tags.find((t) => t[0] === "d")?.[1]; | |
| if (d !== dTag) return; | |
| if (event.pubkey !== publisherPubkey) return; | |
| if (event.created_at <= freshest) return; | |
| const period = parseVotingPeriod(event.content); | |
| if (!period || period.hackathonId !== hackathonId) return; | |
| freshest = event.created_at; | |
| onPeriod(period, event.created_at); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/votingClient.ts` around lines 165 - 185, In subscribeToVotingPeriod's
onevent handler add a signature verification guard: call verifyEvent(event) (or
the project's verifyEvent/verifySignedEvent utility) as the very first check
inside onevent and return immediately if it fails, before accessing event.pubkey
or parsing event.content; this ensures events are cryptographically validated
before the existing checks (the handler in subscribeToVotingPeriod, the
SignedEvent usage, and parseVotingPeriod calls).
…athon URLs Isolated dev environment (off by default; gated by NEXT_PUBLIC_DEV_MODE): - DEV MODE bar with identity switcher + soldier/dummy impersonation via deterministic stand-in keys (lib/devImpersonation); reads key off the real pubkey while signing stays on the stand-in. - Local nostr-rs-relay (dev/relay, docker) + NEXT_PUBLIC_NOSTR_RELAYS override so publish/read traffic can be fully isolated. - Dummy-data generator (lib/devSeed) + dev-only API routes (app/api/dev). - "Mis hackatones" dashboard page; impersonation-aware dashboard reads. - pnpm scripts gen:dev-keys / relay:up|down|logs; docs in AGENTS.md + .env.example. Encrypted community voting: - NIP-44 ballots encrypted to La Crypta's key, signed by the voter; allocations are unreadable on the relay and tallied server-side only (lib/voting, votingClient, nostrSigner, nip46Client, zap). - Results hidden while voting is open; only who-voted + declared count shown. - Two-step admin close (preview -> confirm): the backend decrypts, re-validates per-voter budgets, signs the frozen result with countedBallotIds, and assigns winners. Prize payout stays manual. Hackathon URL slug decoupling: - Keep the internal id "zaps" (already-published events reference it) but serve the public URL at /hackathons/gaming via an optional slug; getHackathon resolves both id and slug. - All public links, canonicals, sitemap, OG/Twitter images and JSON-LD use the slug; route handlers and data lookups continue keying off the canonical id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat: isolated dev environment, encrypted voting, slug-decoupled hackathon URLs
There was a problem hiding this comment.
Actionable comments posted: 4
♻️ Duplicate comments (2)
app/dev/voting/DevVotingClient.tsx (1)
177-200:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winTraducí los estados visibles al usuario a español.
En Line 179 se muestran estados en inglés (
active/upcoming/closed), en Line 187 apareceopen/closed, y en Line 198 apareceNAMESPACE TEST. Esto rompe la consistencia de idioma en UI.💡 Propuesta de ajuste
+const hackathonStatusLabel: Record<"upcoming" | "active" | "closed", string> = { + upcoming: "próximo", + active: "activo", + closed: "cerrado", +}; +const votingStatusLabel: Record<"open" | "closed", string> = { + open: "abierta", + closed: "cerrada", +}; ... - {h.name} ({h.id}) — {hackathonStatus(h)} + {h.name} ({h.id}) — {hackathonStatusLabel[hackathonStatus(h)]} ... - ? `Estado: ${period.status} · ${period.eligible.length} votantes · ${period.projects.length} proyectos` + ? `Estado: ${votingStatusLabel[period.status]} · ${period.eligible.length} votantes · ${period.projects.length} proyectos` ... - {testNamespace ? "NAMESPACE TEST" : "NAMESPACE PRODUCCIÓN"} + {testNamespace ? "NAMESPACE DE PRUEBA" : "NAMESPACE DE PRODUCCIÓN"}As per coding guidelines, "User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English; error messages shown to users must remain Spanish".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/dev/voting/DevVotingClient.tsx` around lines 177 - 200, The user-facing text in this component is inconsistently displayed in Spanish. You need to translate the following elements to Spanish: the hackathonStatus function output that displays states like active/upcoming/closed in the option labels, the period.status value being displayed in the info span that shows open/closed states, and the "NAMESPACE TEST" string in the namespace badge to maintain consistency with "NAMESPACE PRODUCCIÓN". Either update the hackathonStatus function to return Spanish translations, translate the period.status values when displaying them, and replace "NAMESPACE TEST" with its Spanish equivalent in the conditional className and span content.Source: Coding guidelines
lib/useDevIdentities.ts (1)
79-81:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAl borrar la identidad activa, cerrá también la sesión actual.
Si se elimina la identidad que está autenticada, la sesión queda válida aunque la identidad ya no existe en la lista local, dejando estado inconsistente.
💡 Propuesta de ajuste
+import { setAuth, clearAuth, type Auth } from "`@/lib/auth`"; +import { useAuth as useAuthHook } from "`@/lib/auth`"; + export function useDevIdentities() { const { push } = useToast(); + const { auth } = useAuthHook(); const [identities, setIdentities] = useState<DevIdentity[]>([]); ... const removeIdentity = useCallback((pubkey: string) => { + const wasActive = auth?.pubkey === pubkey; persist(loadIdentities().filter((i) => i.pubkey !== pubkey)); + if (wasActive) clearAuth("user"); - }, []); + }, [auth]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/useDevIdentities.ts` around lines 79 - 81, The removeIdentity function removes an identity from the list without checking if that identity is the currently active/authenticated session, which can leave the application in an inconsistent state. Before persisting the filtered identities, add a check to determine if the pubkey being removed matches the current active identity. If it does match, also clear or logout the current session in addition to removing the identity from storage to ensure the application state remains consistent.
🧹 Nitpick comments (4)
app/soldados/[slug]/page.tsx (1)
26-29: ⚡ Quick winUse
@/*aliases for these component imports.These changed TSX imports still use relative paths instead of the repository alias convention.
♻️ Suggested update
-import SoldierZapButton from "./SoldierZapButton"; -import SoldierFollowButton from "./SoldierFollowButton"; -import SoldierZapWall from "./SoldierZapWall"; -import ImpersonateButton from "./ImpersonateButton"; +import SoldierZapButton from "`@/app/soldados/`[slug]/SoldierZapButton"; +import SoldierFollowButton from "`@/app/soldados/`[slug]/SoldierFollowButton"; +import SoldierZapWall from "`@/app/soldados/`[slug]/SoldierZapWall"; +import ImpersonateButton from "`@/app/soldados/`[slug]/ImpersonateButton";As per coding guidelines,
**/*.{ts,tsx}must use the@/*path alias for imports.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/soldados/`[slug]/page.tsx around lines 26 - 29, The imports for SoldierZapButton, SoldierFollowButton, SoldierZapWall, and ImpersonateButton in the page.tsx file are using relative paths (./ComponentName) instead of the repository path alias convention. Convert all four of these imports to use the `@/`* path alias pattern according to the coding guidelines, replacing the relative ./path syntax with the appropriate `@/` alias path that matches your repository structure.Source: Coding guidelines
lib/jsonld.tsx (1)
2-2: ⚡ Quick winSwitch this import to the
@/*alias.This changed TSX import uses a relative path instead of the repo-wide alias convention.
♻️ Suggested update
-import { hackathonSlug } from "./hackathons"; +import { hackathonSlug } from "`@/lib/hackathons`";As per coding guidelines,
**/*.{ts,tsx}must use the@/*path alias for imports.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/jsonld.tsx` at line 2, The import statement for hackathonSlug from the relative path ./hackathons in lib/jsonld.tsx does not follow the repo-wide alias convention. Replace the relative import path ./hackathons with the `@/` path alias to align with coding guidelines that require all TypeScript and TSX imports to use the `@/`* alias convention instead of relative paths.Source: Coding guidelines
.env.example (1)
69-73: 💤 Low valueConsider clarifying the keypair coordination.
The guidance correctly explains that
NEXT_PUBLIC_DEV_ADMIN_NSECmust matchNEXT_PUBLIC_LACRYPTA_ADMIN_NPUB, but lines 71-72 could be clearer that all three vars (LACRYPTA_NSEC,NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB, andNEXT_PUBLIC_DEV_ADMIN_NSEC) should derive from the same throwaway keypair generated bypnpm gen:dev-keys.📝 Suggested wording improvement
# Browser-side admin secret for the bar's "Entrar como La Crypta" button. -# Must be the nsec whose npub equals NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB above. -# In dev, set LACRYPTA_NSEC + NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB to this same -# throwaway keypair (all three come from `pnpm gen:dev-keys`). +# Must be the nsec whose npub equals NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB above. +# In dev, all three vars (LACRYPTA_NSEC, NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB, +# NEXT_PUBLIC_DEV_ADMIN_NSEC) should use the same throwaway keypair generated +# by `pnpm gen:dev-keys`. # NEXT_PUBLIC_DEV_ADMIN_NSEC=nsec1...🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.env.example around lines 69 - 73, The comment block explaining NEXT_PUBLIC_DEV_ADMIN_NSEC should be clearer that all three environment variables (LACRYPTA_NSEC, NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB, and NEXT_PUBLIC_DEV_ADMIN_NSEC) must derive from the same single throwaway keypair generated by `pnpm gen:dev-keys`. Revise lines 71-72 to explicitly state that these three variables should all use the same keypair from the dev key generation command, rather than leaving it ambiguous which variables are coordinated together.dev/relay/docker-compose.yml (1)
14-14: 💤 Low valueConsider pinning the relay image to a specific version tag.
Using
:latestmeans different developers (or builds) might pull different relay versions, reducing reproducibility. While this is dev-only and the impact is limited, pinning to a specific version tag (e.g.,scsibug/nostr-rs-relay:0.9.0) would ensure consistent behavior across all dev environments.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@dev/relay/docker-compose.yml` at line 14, The scsibug/nostr-rs-relay image is using the `:latest` tag which can cause inconsistency across different development environments as different versions may be pulled at different times. Replace the `:latest` tag with a specific version tag (for example, `0.9.0`) in the image specification to ensure all developers and builds use the same relay version and maintain reproducibility.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@components/DevModeBar.tsx`:
- Around line 289-328: The active check for dummy users in the dummyUsers.map
function is incorrectly using the devPubkeys map lookup when it should use a
direct comparison. The devPubkeys map is built from soldiers, not dummy users,
so u.pubkey will never exist in it. Replace the active variable assignment
(currently checking devPubkeys[u.pubkey] === auth?.pubkey) with a direct
comparison of u.pubkey === auth?.pubkey, since dummy users use their generated
pubkeys directly without requiring a dev stand-in mapping.
In `@lib/nostrRelayConfig.ts`:
- Around line 64-78: The parseEnvRelays() function reads
NEXT_PUBLIC_NOSTR_RELAYS in all environments, creating a production risk if this
variable is accidentally set. Modify the parseEnvRelays() function to check
isDevMode() first and return null immediately if not in dev mode, before
attempting to read the environment variable. This ensures that ENV_RELAYS will
only use the environment override when actually in dev mode, preventing
accidental relay rerouting in production. Import isDevMode from lib/devMode.ts
if not already imported.
In `@lib/useDevEnabled.ts`:
- Around line 17-26: The isDevEnabled function returns true when window is
undefined (SSR), but the React component using this function initializes its
useState with isDevMode() which may be false in production, causing a hydration
mismatch. To fix this, modify the SSR path in isDevEnabled to return isDevMode()
instead of true when window is undefined, ensuring the server and client render
the same initial state. This eliminates the mismatch window and prevents React
hydration warnings when components render different content based on the enabled
state.
In `@lib/voting.ts`:
- Around line 301-312: The validateBallot function needs to reject plaintext
ballots for schema v2 periods to prevent unencrypted allocations from being
exposed on relays. After parsing the ballot content with parseBallotContent, add
a check to verify that the ballot includes the required encryption tag (["enc",
"nip44"]) when the period is v2, and return a rejection if the encryption tag is
missing. Additionally, in the tallying logic around line 405, ensure that the
encryption status is validated by checking the original event tags for the
["enc", "nip44"] marker before counting the ballot, so that ballots without
proper encryption are not processed even after decryption.
---
Duplicate comments:
In `@app/dev/voting/DevVotingClient.tsx`:
- Around line 177-200: The user-facing text in this component is inconsistently
displayed in Spanish. You need to translate the following elements to Spanish:
the hackathonStatus function output that displays states like
active/upcoming/closed in the option labels, the period.status value being
displayed in the info span that shows open/closed states, and the "NAMESPACE
TEST" string in the namespace badge to maintain consistency with "NAMESPACE
PRODUCCIÓN". Either update the hackathonStatus function to return Spanish
translations, translate the period.status values when displaying them, and
replace "NAMESPACE TEST" with its Spanish equivalent in the conditional
className and span content.
In `@lib/useDevIdentities.ts`:
- Around line 79-81: The removeIdentity function removes an identity from the
list without checking if that identity is the currently active/authenticated
session, which can leave the application in an inconsistent state. Before
persisting the filtered identities, add a check to determine if the pubkey being
removed matches the current active identity. If it does match, also clear or
logout the current session in addition to removing the identity from storage to
ensure the application state remains consistent.
---
Nitpick comments:
In @.env.example:
- Around line 69-73: The comment block explaining NEXT_PUBLIC_DEV_ADMIN_NSEC
should be clearer that all three environment variables (LACRYPTA_NSEC,
NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB, and NEXT_PUBLIC_DEV_ADMIN_NSEC) must derive
from the same single throwaway keypair generated by `pnpm gen:dev-keys`. Revise
lines 71-72 to explicitly state that these three variables should all use the
same keypair from the dev key generation command, rather than leaving it
ambiguous which variables are coordinated together.
In `@app/soldados/`[slug]/page.tsx:
- Around line 26-29: The imports for SoldierZapButton, SoldierFollowButton,
SoldierZapWall, and ImpersonateButton in the page.tsx file are using relative
paths (./ComponentName) instead of the repository path alias convention. Convert
all four of these imports to use the `@/`* path alias pattern according to the
coding guidelines, replacing the relative ./path syntax with the appropriate `@/`
alias path that matches your repository structure.
In `@dev/relay/docker-compose.yml`:
- Line 14: The scsibug/nostr-rs-relay image is using the `:latest` tag which can
cause inconsistency across different development environments as different
versions may be pulled at different times. Replace the `:latest` tag with a
specific version tag (for example, `0.9.0`) in the image specification to ensure
all developers and builds use the same relay version and maintain
reproducibility.
In `@lib/jsonld.tsx`:
- Line 2: The import statement for hackathonSlug from the relative path
./hackathons in lib/jsonld.tsx does not follow the repo-wide alias convention.
Replace the relative import path ./hackathons with the `@/` path alias to align
with coding guidelines that require all TypeScript and TSX imports to use the
`@/`* alias convention instead of relative paths.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: eb87b419-3e60-4b40-abd6-41b444e2564c
📒 Files selected for processing (53)
.env.example.gitignoreAGENTS.mdapp/api/dev/revalidate/route.tsapp/api/dev/soldiers/route.tsapp/api/hackathons/[id]/voting/route.tsapp/badges/BadgesClient.tsxapp/dashboard/DashboardClient.tsxapp/dashboard/hackathones/MisHackatonesClient.tsxapp/dashboard/hackathones/page.tsxapp/dashboard/projects/UserProjectsClient.tsxapp/dev/voting/DevVotingClient.tsxapp/hackathons/[id]/HackathonProjectsList.tsxapp/hackathons/[id]/HackathonResultsClient.tsxapp/hackathons/[id]/PrizeZapButton.tsxapp/hackathons/[id]/VotingSection.tsxapp/hackathons/[id]/[projectId]/NostrProjectPageClient.tsxapp/hackathons/[id]/[projectId]/NostrProjectServer.tsxapp/hackathons/[id]/[projectId]/opengraph-image.tsxapp/hackathons/[id]/[projectId]/page.tsxapp/hackathons/[id]/opengraph-image.tsxapp/hackathons/[id]/page.tsxapp/hackathons/page.tsxapp/layout.tsxapp/projects/[pubkey]/UserProjectsPage.tsxapp/projects/[pubkey]/[id]/StandaloneProjectPage.tsxapp/sitemap.tsapp/soldados/SoldiersGrid.tsxapp/soldados/[slug]/ImpersonateButton.tsxapp/soldados/[slug]/page.tsxcomponents/DevModeBar.tsxcomponents/HackathonInscripcionButton.tsxcomponents/Navbar.tsxcomponents/sections/GamingHackathonBanner.tsxdata/hackathons/hackathons.jsondev/relay/config.tomldev/relay/docker-compose.ymllib/auth.tslib/devImpersonation.tslib/devMode.tslib/devSeed.tslib/hackathons.tslib/jsonld.tsxlib/nip46Client.tslib/nostrRelayConfig.tslib/nostrSigner.tslib/useDevEnabled.tslib/useDevIdentities.tslib/voting.tslib/votingClient.tslib/zap.tspackage.jsonscripts/gen-dev-keys.mjs
✅ Files skipped from review due to trivial changes (6)
- app/dashboard/hackathones/page.tsx
- components/sections/GamingHackathonBanner.tsx
- .gitignore
- app/dashboard/DashboardClient.tsx
- app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx
- AGENTS.md
| {dummyUsers.map((u) => { | ||
| const active = | ||
| !!devPubkeys[u.pubkey] && | ||
| devPubkeys[u.pubkey] === auth?.pubkey; | ||
| return ( | ||
| <li | ||
| key={u.pubkey} | ||
| className={cn( | ||
| "flex items-center gap-2 px-3 py-1.5", | ||
| active && "bg-success/5", | ||
| )} | ||
| > | ||
| <span className="flex-1 min-w-0 flex items-center gap-1.5"> | ||
| <span className="text-xs font-semibold truncate"> | ||
| {u.name} | ||
| </span> | ||
| {active && ( | ||
| <UserCheck className="h-3 w-3 text-success shrink-0" /> | ||
| )} | ||
| </span> | ||
| <button | ||
| type="button" | ||
| onClick={() => copy(u.nsec, "nsec")} | ||
| aria-label="Copiar nsec" | ||
| className="rounded-md p-1 text-foreground-subtle hover:text-foreground transition-colors" | ||
| > | ||
| <Copy className="h-3.5 w-3.5" /> | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => impersonate(u.pubkey, u.name)} | ||
| disabled={active} | ||
| className="rounded-md border border-lightning/40 bg-lightning/10 px-2 py-1 text-[10px] font-mono font-bold text-lightning hover:bg-lightning/20 disabled:opacity-40 transition-colors inline-flex items-center gap-1" | ||
| > | ||
| <VenetianMask className="h-3 w-3" /> | ||
| Impersonar | ||
| </button> | ||
| </li> | ||
| ); | ||
| })} |
There was a problem hiding this comment.
Active check for dummy users uses wrong map.
Lines 290-292 check devPubkeys[u.pubkey] to determine if a dummy user is active, but devPubkeys is built from soldiers (lines 88-93), not dummy users. Since u.pubkey (a dummy user pubkey) won't exist in that map, the active check always fails, and dummy users never show the "active" indicator even when the session is impersonating them.
Dummy users use their generated pubkeys directly (no dev stand-in mapping), so the active check should be a direct comparison:
{dummyUsers.map((u) => {
const active =
- !!devPubkeys[u.pubkey] &&
- devPubkeys[u.pubkey] === auth?.pubkey;
+ auth?.pubkey === u.pubkey;
return (📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {dummyUsers.map((u) => { | |
| const active = | |
| !!devPubkeys[u.pubkey] && | |
| devPubkeys[u.pubkey] === auth?.pubkey; | |
| return ( | |
| <li | |
| key={u.pubkey} | |
| className={cn( | |
| "flex items-center gap-2 px-3 py-1.5", | |
| active && "bg-success/5", | |
| )} | |
| > | |
| <span className="flex-1 min-w-0 flex items-center gap-1.5"> | |
| <span className="text-xs font-semibold truncate"> | |
| {u.name} | |
| </span> | |
| {active && ( | |
| <UserCheck className="h-3 w-3 text-success shrink-0" /> | |
| )} | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => copy(u.nsec, "nsec")} | |
| aria-label="Copiar nsec" | |
| className="rounded-md p-1 text-foreground-subtle hover:text-foreground transition-colors" | |
| > | |
| <Copy className="h-3.5 w-3.5" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => impersonate(u.pubkey, u.name)} | |
| disabled={active} | |
| className="rounded-md border border-lightning/40 bg-lightning/10 px-2 py-1 text-[10px] font-mono font-bold text-lightning hover:bg-lightning/20 disabled:opacity-40 transition-colors inline-flex items-center gap-1" | |
| > | |
| <VenetianMask className="h-3 w-3" /> | |
| Impersonar | |
| </button> | |
| </li> | |
| ); | |
| })} | |
| {dummyUsers.map((u) => { | |
| const active = | |
| auth?.pubkey === u.pubkey; | |
| return ( | |
| <li | |
| key={u.pubkey} | |
| className={cn( | |
| "flex items-center gap-2 px-3 py-1.5", | |
| active && "bg-success/5", | |
| )} | |
| > | |
| <span className="flex-1 min-w-0 flex items-center gap-1.5"> | |
| <span className="text-xs font-semibold truncate"> | |
| {u.name} | |
| </span> | |
| {active && ( | |
| <UserCheck className="h-3 w-3 text-success shrink-0" /> | |
| )} | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => copy(u.nsec, "nsec")} | |
| aria-label="Copiar nsec" | |
| className="rounded-md p-1 text-foreground-subtle hover:text-foreground transition-colors" | |
| > | |
| <Copy className="h-3.5 w-3.5" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => impersonate(u.pubkey, u.name)} | |
| disabled={active} | |
| className="rounded-md border border-lightning/40 bg-lightning/10 px-2 py-1 text-[10px] font-mono font-bold text-lightning hover:bg-lightning/20 disabled:opacity-40 transition-colors inline-flex items-center gap-1" | |
| > | |
| <VenetianMask className="h-3 w-3" /> | |
| Impersonar | |
| </button> | |
| </li> | |
| ); | |
| })} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@components/DevModeBar.tsx` around lines 289 - 328, The active check for dummy
users in the dummyUsers.map function is incorrectly using the devPubkeys map
lookup when it should use a direct comparison. The devPubkeys map is built from
soldiers, not dummy users, so u.pubkey will never exist in it. Replace the
active variable assignment (currently checking devPubkeys[u.pubkey] ===
auth?.pubkey) with a direct comparison of u.pubkey === auth?.pubkey, since dummy
users use their generated pubkeys directly without requiring a dev stand-in
mapping.
| function parseEnvRelays(): string[] | null { | ||
| const raw = process.env.NEXT_PUBLIC_NOSTR_RELAYS; | ||
| if (!raw) return null; | ||
| const list = raw | ||
| .split(",") | ||
| .map((s) => s.trim()) | ||
| .filter(Boolean); | ||
| return list.length ? list : null; | ||
| } | ||
|
|
||
| const ENV_RELAYS = parseEnvRelays(); | ||
|
|
||
| export const DEFAULT_RELAYS = ENV_RELAYS ?? [...LACRYPTA_DEFAULT_RELAYS]; | ||
| export const FAST_USER_RELAYS = ENV_RELAYS ?? [...LACRYPTA_FAST_USER_RELAYS]; | ||
| export const NIP46_LOGIN_RELAYS = ENV_RELAYS ?? [...LACRYPTA_NIP46_LOGIN_RELAYS]; |
There was a problem hiding this comment.
Gate the relay override with isDevMode().
Line 65 applies NEXT_PUBLIC_NOSTR_RELAYS in every environment, despite the comment calling it dev-only. If this public env var is accidentally set in production, all read/publish traffic is rerouted globally.
Proposed fix
+import { isDevMode } from "`@/lib/devMode`";
+
/**
* Dev-only relay override. Set NEXT_PUBLIC_NOSTR_RELAYS (comma-separated) to
* route ALL publish/read traffic through a different relay set — typically a
@@
function parseEnvRelays(): string[] | null {
+ if (!isDevMode()) return null;
const raw = process.env.NEXT_PUBLIC_NOSTR_RELAYS;
if (!raw) return null;
const list = rawAs per coding guidelines, use isDevMode() from lib/devMode.ts as the single check for DEV MODE gating. Based on learnings, never set NEXT_PUBLIC_NOSTR_RELAYS in production deploys.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/nostrRelayConfig.ts` around lines 64 - 78, The parseEnvRelays() function
reads NEXT_PUBLIC_NOSTR_RELAYS in all environments, creating a production risk
if this variable is accidentally set. Modify the parseEnvRelays() function to
check isDevMode() first and return null immediately if not in dev mode, before
attempting to read the environment variable. This ensures that ENV_RELAYS will
only use the environment override when actually in dev mode, preventing
accidental relay rerouting in production. Import isDevMode from lib/devMode.ts
if not already imported.
Sources: Coding guidelines, Learnings
| export function isDevEnabled(): boolean { | ||
| if (!isDevMode()) return false; | ||
| if (typeof window === "undefined") return true; // SSR: assume on; client corrects | ||
| try { | ||
| const v = window.localStorage.getItem(KEY); | ||
| return v === null ? true : v === "true"; | ||
| } catch { | ||
| return true; | ||
| } | ||
| } |
There was a problem hiding this comment.
Potential hydration mismatch in SSR path.
Line 19 returns true during SSR when window is undefined, but line 44's useState initializes with isDevMode() (which could be false in production). The effect on line 48 corrects this, but if any component renders different content based on enabled, React will warn about server/client mismatch.
Consider initializing the SSR path to match the client's initial state:
export function isDevEnabled(): boolean {
if (!isDevMode()) return false;
- if (typeof window === "undefined") return true; // SSR: assume on; client corrects
+ if (typeof window === "undefined") return isDevMode(); // SSR: match client initial state
try {This eliminates the mismatch window between mount and the effect running.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/useDevEnabled.ts` around lines 17 - 26, The isDevEnabled function returns
true when window is undefined (SSR), but the React component using this function
initializes its useState with isDevMode() which may be false in production,
causing a hydration mismatch. To fix this, modify the SSR path in isDevEnabled
to return isDevMode() instead of true when window is undefined, ensuring the
server and client render the same initial state. This eliminates the mismatch
window and prevents React hydration warnings when components render different
content based on the enabled state.
| export function validateBallot( | ||
| ev: VotingEventLike, | ||
| period: VotingPeriod, | ||
| opts: { closedAt?: number | null } = {}, | ||
| ): BallotValidation { | ||
| const envelope = ballotEnvelopeOk(ev, period, opts); | ||
| if (!envelope.ok) return { ok: false, reason: envelope.reason }; | ||
| const content = parseBallotContent(ev.content); | ||
| if (!content || content.hackathonId !== period.hackathonId) { | ||
| return { ok: false, reason: "content" }; | ||
| } | ||
| return validateAllocations(content.allocations, envelope.voter, period); |
There was a problem hiding this comment.
Reject plaintext ballots for schema v2 periods.
Line 301 still accepts plaintext ballots, and Line 405 tallies decrypted records without checking the original ["enc", "nip44"] tag. Since the admin decrypt path treats enc !== VOTE_ENC as plaintext migration, a v2 ballot without encryption can be counted and expose allocations on relays while voting is open.
Proposed fix
export function validateBallot(
ev: VotingEventLike,
period: VotingPeriod,
opts: { closedAt?: number | null } = {},
): BallotValidation {
+ if (period.version >= 2) return { ok: false, reason: "encryption" };
const envelope = ballotEnvelopeOk(ev, period, opts);
if (!envelope.ok) return { ok: false, reason: envelope.reason };
const content = parseBallotContent(ev.content);
if (!content || content.hackathonId !== period.hackathonId) {
return { ok: false, reason: "content" };
@@
for (const ev of deduped) {
+ if (
+ period.version >= 2 &&
+ (ev.tags.find((t) => t[0] === "enc")?.[1] ?? null) !== VOTE_ENC
+ ) {
+ rejected.push({ id: ev.id, pubkey: ev.pubkey, reason: "encryption" });
+ continue;
+ }
const envelope = ballotEnvelopeOk(
{ pubkey: ev.pubkey, kind: VOTING_KIND, created_at: ev.created_at, tags: ev.tags },
period,
opts,
);Also applies to: 405-415
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/voting.ts` around lines 301 - 312, The validateBallot function needs to
reject plaintext ballots for schema v2 periods to prevent unencrypted
allocations from being exposed on relays. After parsing the ballot content with
parseBallotContent, add a check to verify that the ballot includes the required
encryption tag (["enc", "nip44"]) when the period is v2, and return a rejection
if the encryption tag is missing. Additionally, in the tallying logic around
line 405, ensure that the encryption status is validated by checking the
original event tags for the ["enc", "nip44"] marker before counting the ballot,
so that ballots without proper encryption are not processed even after
decryption.
…o-a3303c # Conflicts: # package.json
Context
Hackathon winners should be chosen by the community, not only by judges. This adds a Nostr-native community voting system embedded on each hackathon page (
/hackathons/[id]).Rules (as specified):
How it works
Two kind-30078 (NIP-78 parameterized replaceable) event roles, both carrying
["client","La Crypta Dev"]:LACRYPTA_NSEC,d = lacrypta.dev:voting:<hackathonId>. Carries the open/closed status, a frozen eligibility snapshot (voter pubkeys, budgets, blocked projects), the votable project list, and — once closed — the canonical final tally signed by La Crypta.d = lacrypta.dev:vote:<hackathonId>. Replaceable: one ballot per voter per hackathon; re-voting replaces it.Admin open/close goes through a kind-27235 auth request, reusing the existing soldiers-ranking publish pattern (
verifyEvent→ admin pubkey match → action tag → server-signs → publish →revalidateTag). While open, the tally is computed live from relay ballots; once aclosedperiod arrives, clients render the embedded signed results verbatim — the freeze rule, since relays can't freeze and late/forged-timestamp ballots can't change a signed result.Files
New
lib/voting.ts— pure shared contract: schemas,validateBallot,tallyBallots,buildEligibleVoters, namespaced d-tags.lib/votingCache.ts— server-only cached period read (mirrorsnostrSoldiersCache).lib/votingClient.ts—publishBallot, live ballot + period subscriptions.app/api/hackathons/[id]/voting/route.ts— GET state / POST open-close.app/hackathons/[id]/VotingSection.tsx— admin controls, ballot editor, live tally, frozen results.app/dev/voting/{page,DevVotingClient}.tsx— dev-only test harness (404s in prod).Modified
app/hackathons/[id]/page.tsx— page-levelcacheTag(nostrVotingTag(id))+ Suspense-wrapped<VotingSection>.lib/nostrCacheTags.ts—nostrVotingTag().Test environment
/dev/voting(dev-only) generates throwaway identities, logs in as any of them, and embeds the realVotingSection. Isolation viaNEXT_PUBLIC_VOTING_NS=test(moves all d-tags tolacrypta.dev:test:, invisible to production reads) plus a server-onlyVOTING_TEST_EXTRA_VOTERSallowlist. Production is unaffected when the var is unset.Verification
Verified end-to-end against real relays in the test namespace:
/hackathons/zapssaw the live tally + login prompt; ineligible users saw the explanation.pnpm buildpasses.🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes