Skip to content

feat(voting): community voting for hackathon projects via Nostr#36

Open
agustinkassis wants to merge 4 commits into
mainfrom
claude/thirsty-galileo-a3303c
Open

feat(voting): community voting for hackathon projects via Nostr#36
agustinkassis wants to merge 4 commits into
mainfrom
claude/thirsty-galileo-a3303c

Conversation

@agustinkassis

@agustinkassis agustinkassis commented Jun 12, 2026

Copy link
Copy Markdown
Member

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):

  • Eligible voters = anyone who participated in any hackathon so far and has a linked Nostr pubkey (drawn from the soldiers roster).
  • Vote budget = number of distinct hackathons participated in (1 vote per hackathon).
  • Votes are freely allocatable across projects; no self-votes (your own projects are disabled).
  • Admin opens voting with a button and closes it manually (no countdown).
  • Votes are Nostr events signed by the voter's own key.

How it works

Two kind-30078 (NIP-78 parameterized replaceable) event roles, both carrying ["client","La Crypta Dev"]:

  1. Voting period event — server-signed with 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.
  2. Ballot event — voter-signed, 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 a closed period 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 (mirrors nostrSoldiersCache).
  • lib/votingClient.tspublishBallot, 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-level cacheTag(nostrVotingTag(id)) + Suspense-wrapped <VotingSection>.
  • lib/nostrCacheTags.tsnostrVotingTag().

Test environment

/dev/voting (dev-only) generates throwaway identities, logs in as any of them, and embeds the real VotingSection. Isolation via NEXT_PUBLIC_VOTING_NS=test (moves all d-tags to lacrypta.dev:test:, invisible to production reads) plus a server-only VOTING_TEST_EXTRA_VOTERS allowlist. Production is unaffected when the var is unset.

Verification

Verified end-to-end against real relays in the test namespace:

  • Admin opened voting for gaming → 13 projects, 17 eligible voters (15 roster + 2 test).
  • Voter B (budget 3) voted 2+1, re-voted 1/1/1 — tally moved, no double-counting. Voter C capped at budget 1.
  • Anonymous visitors on /hackathons/zaps saw the live tally + login prompt; ineligible users saw the explanation.
  • Admin closed via confirm modal → board flipped live to frozen "Resultados finales" (🏆 Zaptris 2; signed results: 2 ballots counted, 0 rejected, 4 votes).
  • Two bugs found & fixed during verification: batched-click budget overshoot, and a relay-latency case that could hide the section.
  • pnpm build passes.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Added community voting for hackathons, with administrator-managed open/close periods and live ballot submission plus results.
    • Added encrypted ballot handling and a detailed results/tally experience once voting closes.
  • New Features (Dev/Local)
    • Introduced a dev-only voting lab with identity management and period status previews.
    • Added isolated local relay/dev seeding tooling for testing voting flows.
  • Improvements
    • Updated hackathon links across the app to use public slugs for consistent routing and sharing.
  • Documentation
    • Expanded local dev environment guidance and configuration examples.

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>
@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lacrypta-dev Ready Ready Preview, Comment Jun 18, 2026 6:43pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@agustinkassis, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e1ae8ff1-f164-42d6-884b-a3956cc18506

📥 Commits

Reviewing files that changed from the base of the PR and between 9d68088 and 8874003.

📒 Files selected for processing (6)
  • .env.example
  • app/badges/BadgesClient.tsx
  • app/hackathons/[id]/page.tsx
  • app/sitemap.ts
  • components/Navbar.tsx
  • package.json
📝 Walkthrough

Walkthrough

This 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.

Changes

Community voting system

Layer / File(s) Summary
Voting domain contracts and shared logic
lib/voting.ts
Defines Nostr kind 30078 constants, d-tag namespacing with test-namespace support, and shared types (VotingPeriod, VotingEligibleVoter, VotingResults, BallotContent); implements defensive parsing, ballot validation against eligibility/timing/budget constraints, ballot deduplication, and vote tallying into project results with per-project totals and voter counts.
Server-side cache and voting period API
lib/nostrCacheTags.ts, lib/votingCache.ts
Adds cache tag helper for voting revalidation; fetches voting period events from Nostr relays filtered by author and hackathon d-tag, validates content, sorts by creation time, and exposes both uncached relay reads and Next.js-cached application reads with error handling.
Backend voting admin API
app/api/hackathons/[id]/voting/route.ts
Implements GET (returns cached period) and POST (admin only). POST verifies signed Nostr event auth, computes replaceable-event timestamps, fetches projects/soldiers/ballots, injects test voters when in test namespace, applies team pubkey blocks to eligibility snapshots, constructs or tallies the period, publishes signed events to relays with per-relay timeouts, and revalidates cache.
Client-side ballot publishing and subscriptions
lib/votingClient.ts
Provides ballot publishing to relays with NIP-44 encryption and replaceable event semantics using optional createdAtFloor; live subscription to ballot events with client-side d-tag validation and deduplication; live subscription to voting period updates from specific publisher with freshness tracking to ignore stale periods.
Production voting UI
app/hackathons/[id]/VotingSection.tsx
Manages voting state with pubkey fetching, period initialization, and live subscriptions to ballots and period updates. Renders conditional UI: open voting allows BallotEditor with dirty-state tracking and budget enforcement, closed voting shows TallyBoard with proportional bars and ranked results, and admins access AdminVotingControls for signing period-change actions with preview modal and optimistic refresh.
Hackathon page voting integration
app/hackathons/[id]/page.tsx
Registers voting cache tag, fetches cached voting period at page render, and renders VotingSection inside Suspense boundary with hackathon context and initial period data.
Development voting lab
app/dev/voting/DevVotingClient.tsx, app/dev/voting/page.tsx
Provides dev-only voting page with identity lab: localStorage persistence for test keypairs, identity generation, login/logout, Nostr pubkey copying, and period fetching. Hosts production VotingSection with test-identity auth. Page guards production access and warns when not in test namespace.

Slug-based hackathon routing

Layer / File(s) Summary
Hackathon model and slug helpers
lib/hackathons.ts
Adds optional slug field to Hackathon type with documentation that id remains the stable internal key; extends getHackathon to resolve by either id or slug; provides hackathonSlug and hackathonSlugForId helpers for consistent public URL generation; dedupes community Nostr submissions by id, repo, and normalized name.
Hackathon list and detail pages
app/hackathons/page.tsx, app/hackathons/[id]/page.tsx, app/hackathons/[id]/opengraph-image.tsx
Updates hackathon routes to use slug-based route segments while using canonical IDs for data lookups; updates static param generation, metadata URLs, and OG image generation to emit slug segments.
Project detail and OG image pages
app/hackathons/[id]/[projectId]/page.tsx, app/hackathons/[id]/[projectId]/opengraph-image.tsx, app/hackathons/[id]/[projectId]/NostrProjectServer.tsx, app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx
Deduplicates prerender params by canonical hackathon ID while emitting slug-based route segments; derives canonical IDs from route slug parameters for data lookups; updates breadcrumbs and back links to use slug-based URLs.
Navigation link updates
Multiple files across app
Updates hackathon-related navigation links to use slug-based URLs in hackathon list cards, project links, soldier profiles, badges, prize zap buttons, dashboard, sitemap, and other pages.
JSON-LD and sitemap updates
lib/jsonld.tsx, app/sitemap.ts
Updates JSON-LD generation and sitemap URLs to use slug-based hackathon paths while maintaining canonical ID-based data lookups.

Development environment infrastructure

Layer / File(s) Summary
Dev mode feature gates and state
lib/devMode.ts, lib/useDevEnabled.ts
Adds compile-time dev mode gate via NEXT_PUBLIC_DEV_MODE; implements runtime toggle stored in localStorage with cross-tab synchronization via custom events and storage listeners; provides React hook for UI components to read and set the toggle.
Dev identity and impersonation management
lib/useDevIdentities.ts, lib/devImpersonation.ts, lib/auth.ts
Implements localStorage-based dev identity pool with keypair generation/removal/login; reads dev admin secret from NEXT_PUBLIC_DEV_ADMIN_NSEC; provides deterministic impersonation key derivation to create stand-in pubkeys for real users; extends Auth type with optional impersonating field for dev-only real pubkey tracking.
Dummy Nostr event seeding
lib/devSeed.ts
Provides client-side deterministic dummy user/project generation with hardcoded specs, publishes kind-0 profiles and kind-30078 project events, persists generated users with secrets to localStorage, logs output, and triggers server cache revalidation.
Dev mode UI bar
components/DevModeBar.tsx
Renders fixed top dev bar showing status and optional impersonation indicator; provides dropdown with admin login, identity generation, dummy seeding, user/soldier impersonation, identity management, and logout actions with toasts and disabled states.
Dev API endpoints and local relay
app/api/dev/revalidate/route.ts, app/api/dev/soldiers/route.ts, dev/relay/config.toml, dev/relay/docker-compose.yml
Adds dev-only API routes: POST revalidates Nostr cache tags, GET soldiers returns eligible voters for seeding/impersonation. Provides local relay Docker Compose and nostr-rs-relay TOML configuration.
Dev scripts and documentation
scripts/gen-dev-keys.mjs, .env.example, AGENTS.md, .gitignore, package.json, data/hackathons/hackathons.json
Adds CLI script for throwaway keypair generation with environment variable output; updates .env.example with dev section; adds comprehensive AGENTS.md guide covering isolated setup, impersonation model, dummy seeding, and production warnings; adds gitignore for relay data; adds package scripts for dev keys and relay control; updates gaming hackathon slug field.
Layout and navbar dev bar integration
app/layout.tsx, components/Navbar.tsx
Updates app layout to conditionally render DevModeBar in dev mode; adjusts main element and drawer padding to accommodate fixed dev bar offset.

NIP-44 encryption support for signers

Layer / File(s) Summary
UserSigner interface and window.nostr typing
lib/nostrSigner.ts (types section)
Adds nip44Encrypt and nip44Decrypt methods to UserSigner interface; augments window.nostr global typing with optional nip44 object containing encrypt/decrypt methods.
NIP-07, local, and NIP-46 signer implementations
lib/nostrSigner.ts (implementation sections)
Implements NIP-44 encrypt/decrypt for NIP-07 by delegating to extension; for local signer using nostr-tools/nip44 with conversation key from secret and peer; for NIP-46 dual-encryption client by delegating to bunker; for legacy BunkerSigner with runtime support checks.
Bunker client and anonymous signer NIP-44
lib/nip46Client.ts, lib/zap.ts
Adds nip44Encrypt and nip44Decrypt methods to Nip46Client that forward RPC calls; adds NIP-44 to anonymous signer using nostr-tools/nip44.
Relay config environment override
lib/nostrRelayConfig.ts
Adds support for NEXT_PUBLIC_NOSTR_RELAYS environment variable to override relay lists for dev-only local relay routing with fallback to hardcoded defaults.

Supporting features

Layer / File(s) Summary
Dashboard "Mis hackatones" page
app/dashboard/DashboardClient.tsx, app/dashboard/hackathones/MisHackatonesClient.tsx, app/dashboard/hackathones/page.tsx
Implements new dashboard page showing authenticated user's hackathon participations grouped by hackathon with project counts, summary stats, and links; supports dev mode impersonation of user projects; adds dashboard tile linking to the new page.
Soldier profile impersonation
app/soldados/[slug]/ImpersonateButton.tsx, app/soldados/[slug]/page.tsx
Adds dev-only ImpersonateButton on soldier profiles allowing impersonation via useDevIdentities when dev mode is enabled.
Dev-aware project loading
app/dashboard/projects/UserProjectsClient.tsx
Updates UserProjectsClient to read projects using impersonated pubkey when in dev mode.

Sequence Diagram

sequenceDiagram
  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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • lacrypta/lacrypta-dev#25: Both PRs modify slug-based routing in app/hackathons/[id]/[projectId]/page.tsx for generateMetadata and ProjectPageContent, affecting the same code paths for canonical URL handling and parameter resolution.

Poem

🐰 A voting booth blooms in the Nostr night,
Where soldiers gather and cast their plight,
Smart contracts tally each ballot clean,
The fairest hackathon ever seen!
Test labs breed keypairs with glee,
Community decides—wild and free! 🗳️✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and clearly summarizes the main change: adding a Nostr-based community voting system for hackathon projects, which is the primary focus of this large PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/thirsty-galileo-a3303c

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
lib/votingCache.ts (1)

9-17: 📐 Maintainability & Code Quality | ⚡ Quick win

Use @/* 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 win

Use 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

📥 Commits

Reviewing files that changed from the base of the PR and between c4b6e36 and e963e58.

📒 Files selected for processing (9)
  • app/api/hackathons/[id]/voting/route.ts
  • app/dev/voting/DevVotingClient.tsx
  • app/dev/voting/page.tsx
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/page.tsx
  • lib/nostrCacheTags.ts
  • lib/voting.ts
  • lib/votingCache.ts
  • lib/votingClient.ts

Comment thread app/dev/voting/DevVotingClient.tsx Outdated
Comment on lines +100 to +104
function removeIdentity(pubkey: string) {
const next = identities.filter((i) => i.pubkey !== pubkey);
setIdentities(next);
saveIdentities(next);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +257 to +260
{HACKATHONS.map((h) => (
<option key={h.id} value={h.id}>
{h.name} ({h.id}) — {hackathonStatus(h)}
</option>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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

Comment thread app/dev/voting/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { isVotingTestNamespace } from "@/lib/voting";
import DevVotingClient from "./DevVotingClient";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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.

Suggested change
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

Comment thread app/hackathons/[id]/VotingSection.tsx Outdated
Comment on lines +57 to +58
const [period, setPeriod] = useState<VotingPeriod | null>(initialPeriod);
const [ballots, setBallots] = useState<Map<string, SignedEvent>>(new Map());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +496 to +507
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)]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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).

Comment thread lib/voting.ts
Comment on lines +357 to +361
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])];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread lib/votingClient.ts
Comment on lines +111 to +117
const closer = pool.subscribe(
relays,
{
kinds: [VOTING_KIND],
"#d": [dTag],
limit: 500,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Comment thread lib/votingClient.ts
Comment on lines +165 to +185
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.

Suggested change
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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (2)
app/dev/voting/DevVotingClient.tsx (1)

177-200: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Traducí los estados visibles al usuario a español.

En Line 179 se muestran estados en inglés (active/upcoming/closed), en Line 187 aparece open/closed, y en Line 198 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".

🤖 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 win

Al 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 win

Use @/* 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 win

Switch 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 value

Consider clarifying the keypair coordination.

The guidance correctly explains that NEXT_PUBLIC_DEV_ADMIN_NSEC must match NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB, but lines 71-72 could be clearer that all three vars (LACRYPTA_NSEC, NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB, and NEXT_PUBLIC_DEV_ADMIN_NSEC) should derive from the same throwaway keypair generated by pnpm 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 value

Consider pinning the relay image to a specific version tag.

Using :latest means 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

📥 Commits

Reviewing files that changed from the base of the PR and between e963e58 and 9d68088.

📒 Files selected for processing (53)
  • .env.example
  • .gitignore
  • AGENTS.md
  • app/api/dev/revalidate/route.ts
  • app/api/dev/soldiers/route.ts
  • app/api/hackathons/[id]/voting/route.ts
  • app/badges/BadgesClient.tsx
  • app/dashboard/DashboardClient.tsx
  • app/dashboard/hackathones/MisHackatonesClient.tsx
  • app/dashboard/hackathones/page.tsx
  • app/dashboard/projects/UserProjectsClient.tsx
  • app/dev/voting/DevVotingClient.tsx
  • app/hackathons/[id]/HackathonProjectsList.tsx
  • app/hackathons/[id]/HackathonResultsClient.tsx
  • app/hackathons/[id]/PrizeZapButton.tsx
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx
  • app/hackathons/[id]/[projectId]/NostrProjectServer.tsx
  • app/hackathons/[id]/[projectId]/opengraph-image.tsx
  • app/hackathons/[id]/[projectId]/page.tsx
  • app/hackathons/[id]/opengraph-image.tsx
  • app/hackathons/[id]/page.tsx
  • app/hackathons/page.tsx
  • app/layout.tsx
  • app/projects/[pubkey]/UserProjectsPage.tsx
  • app/projects/[pubkey]/[id]/StandaloneProjectPage.tsx
  • app/sitemap.ts
  • app/soldados/SoldiersGrid.tsx
  • app/soldados/[slug]/ImpersonateButton.tsx
  • app/soldados/[slug]/page.tsx
  • components/DevModeBar.tsx
  • components/HackathonInscripcionButton.tsx
  • components/Navbar.tsx
  • components/sections/GamingHackathonBanner.tsx
  • data/hackathons/hackathons.json
  • dev/relay/config.toml
  • dev/relay/docker-compose.yml
  • lib/auth.ts
  • lib/devImpersonation.ts
  • lib/devMode.ts
  • lib/devSeed.ts
  • lib/hackathons.ts
  • lib/jsonld.tsx
  • lib/nip46Client.ts
  • lib/nostrRelayConfig.ts
  • lib/nostrSigner.ts
  • lib/useDevEnabled.ts
  • lib/useDevIdentities.ts
  • lib/voting.ts
  • lib/votingClient.ts
  • lib/zap.ts
  • package.json
  • scripts/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

Comment thread components/DevModeBar.tsx
Comment on lines +289 to +328
{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>
);
})}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
{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.

Comment thread lib/nostrRelayConfig.ts
Comment on lines +64 to +78
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];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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 = raw

As 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

Comment thread lib/useDevEnabled.ts
Comment on lines +17 to +26
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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Comment thread lib/voting.ts
Comment on lines +301 to +312
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant