From e963e581c665cbe292af461900902acf5fe1027a Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Fri, 12 Jun 2026 19:00:37 -0300 Subject: [PATCH 1/2] feat(voting): community voting for hackathon projects via Nostr 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:): 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:): 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 --- app/api/hackathons/[id]/voting/route.ts | 357 ++++++++++++ app/dev/voting/DevVotingClient.tsx | 294 ++++++++++ app/dev/voting/page.tsx | 43 ++ app/hackathons/[id]/VotingSection.tsx | 722 ++++++++++++++++++++++++ app/hackathons/[id]/page.tsx | 16 + lib/nostrCacheTags.ts | 4 + lib/voting.ts | 372 ++++++++++++ lib/votingCache.ts | 104 ++++ lib/votingClient.ts | 207 +++++++ 9 files changed, 2119 insertions(+) create mode 100644 app/api/hackathons/[id]/voting/route.ts create mode 100644 app/dev/voting/DevVotingClient.tsx create mode 100644 app/dev/voting/page.tsx create mode 100644 app/hackathons/[id]/VotingSection.tsx create mode 100644 lib/voting.ts create mode 100644 lib/votingCache.ts create mode 100644 lib/votingClient.ts diff --git a/app/api/hackathons/[id]/voting/route.ts b/app/api/hackathons/[id]/voting/route.ts new file mode 100644 index 0000000..500b972 --- /dev/null +++ b/app/api/hackathons/[id]/voting/route.ts @@ -0,0 +1,357 @@ +import { NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import type { SignedEvent } from "@/lib/nostrSigner"; +import { getSoldiers } from "@/lib/soldiers"; +import { + getHackathon, + mergeWithSubmissions, + type HackathonSubmission, +} from "@/lib/hackathons"; +import { getNostrHackathonSubmissions } from "@/lib/nostrCache"; +import { DEFAULT_RELAYS } from "@/lib/nostrRelayConfig"; +import { nostrVotingTag } from "@/lib/nostrCacheTags"; +import { + fetchVotingPeriodFromRelays, + getCachedVotingPeriod, +} from "@/lib/votingCache"; +import { + VOTING_KIND, + VOTING_SCHEMA_VERSION, + VOTING_T_TAG, + buildEligibleVoters, + isVotingTestNamespace, + serializeVotingPeriod, + tallyBallots, + voteDTag, + votingPeriodDTag, + type VotingEligibleVoter, + type VotingPeriod, + type VotingProjectRef, +} from "@/lib/voting"; + +const OPEN_ACTION = "open-voting"; +const CLOSE_ACTION = "close-voting"; + +function jsonError(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +async function getBackendSecret(): Promise { + const nsec = process.env.LACRYPTA_NSEC; + if (!nsec) throw new Error("Falta LACRYPTA_NSEC."); + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(nsec); + if (decoded.type !== "nsec") throw new Error("LACRYPTA_NSEC invalido."); + return decoded.data as Uint8Array; +} + +async function getAdminPubkey(): Promise { + const npub = + process.env.NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB || + process.env.NEXT_PUBLIC_LACRYPTA_NPUB; + if (!npub) throw new Error("Falta NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB."); + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(npub); + if (decoded.type !== "npub") { + throw new Error("NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB invalido."); + } + return decoded.data as string; +} + +function requestTagValue(request: SignedEvent, name: string): string | null { + return request.tags.find((tag) => tag[0] === name)?.[1] ?? null; +} + +async function publishToRelays( + signed: SignedEvent, + relays: string[], + perRelayTimeoutMs = 8000, +): Promise<{ relay: string; ok: boolean; error?: string }[]> { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const promises = pool.publish(relays, signed); + const results = await Promise.all( + relays.map(async (relay, i) => { + try { + await Promise.race([ + promises[i], + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), perRelayTimeoutMs), + ), + ]); + return { relay, ok: true }; + } catch (error) { + return { + relay, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + try { + pool.close(relays); + } catch { + /* noop */ + } + return results; +} + +/** Collects all ballot events for the hackathon (no `authors` filter — + * eligibility is enforced by `tallyBallots` against the frozen snapshot). */ +async function fetchBallotEvents( + hackathonId: string, + timeoutMs = 5000, +): Promise { + const dTag = voteDTag(hackathonId); + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const events: SignedEvent[] = []; + + const closer = pool.subscribe( + DEFAULT_RELAYS, + { kinds: [VOTING_KIND], "#d": [dTag], limit: 1000 }, + { + onevent(ev) { + const event = ev as SignedEvent; + const d = event.tags.find((t) => t[0] === "d")?.[1]; + if (d === dTag) events.push(event); + }, + oneose() { + /* timeout-driven */ + }, + }, + ); + + await new Promise((r) => setTimeout(r, timeoutMs)); + try { + closer.close(); + } catch { + /* noop */ + } + try { + pool.close(DEFAULT_RELAYS); + } catch { + /* noop */ + } + return events; +} + +/** Test-only extra voters (`hexpk:budget,hexpk:budget`) — hard-gated to the + * test namespace so it can never widen production eligibility. */ +function testExtraVoters(): VotingEligibleVoter[] { + if (!isVotingTestNamespace()) return []; + const raw = process.env.VOTING_TEST_EXTRA_VOTERS; + if (!raw) return []; + const out: VotingEligibleVoter[] = []; + for (const entry of raw.split(",")) { + const [pubkey, budget] = entry.trim().split(":"); + if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) continue; + const maxVotes = Math.max(1, Number.parseInt(budget ?? "1", 10) || 1); + out.push({ + pubkey: pubkey.toLowerCase(), + name: `Tester ${pubkey.slice(0, 8)}`, + maxVotes, + blocked: [], + }); + } + return out; +} + +/** Adds current-hackathon team pubkeys (and Nostr authors) to those voters' + * blocked lists — covers projects whose members carry explicit pubkeys. */ +function applyTeamPubkeyBlocks( + eligible: VotingEligibleVoter[], + projects: HackathonSubmission[], +) { + const byPubkey = new Map(eligible.map((v) => [v.pubkey, v])); + for (const project of projects) { + const memberPubkeys = new Set(); + for (const m of project.team) { + if (m.pubkey) memberPubkeys.add(m.pubkey.toLowerCase()); + } + if (project.nostrAuthor) memberPubkeys.add(project.nostrAuthor.toLowerCase()); + for (const pubkey of memberPubkeys) { + const voter = byPubkey.get(pubkey); + if (voter && !voter.blocked.includes(project.id)) { + voter.blocked.push(project.id); + } + } + } +} + +async function votableProjects( + hackathonId: string, +): Promise { + const nostr = (await getNostrHackathonSubmissions(hackathonId)).map((p) => ({ + ...p, + nostrAuthor: p.author, + nostrEventId: p.eventId, + nostrCreatedAt: p.eventCreatedAt, + })); + return mergeWithSubmissions(hackathonId, nostr); +} + +export async function GET( + _req: Request, + ctx: { params: Promise<{ id: string }> }, +) { + const { id } = await ctx.params; + if (!getHackathon(id)) return jsonError("Hackatón desconocido.", 404); + try { + const period = await getCachedVotingPeriod(id); + return NextResponse.json({ period }); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : "No se pudo leer la votación.", + 500, + ); + } +} + +export async function POST( + req: Request, + ctx: { params: Promise<{ id: string }> }, +) { + const { id } = await ctx.params; + const hackathon = getHackathon(id); + if (!hackathon) return jsonError("Hackatón desconocido.", 404); + + let body: { request?: SignedEvent }; + try { + body = (await req.json()) as { request?: SignedEvent }; + } catch { + return jsonError("Body JSON invalido."); + } + const request = body.request; + if (!request) return jsonError("Falta request firmado."); + + try { + const { finalizeEvent, verifyEvent } = await import("nostr-tools/pure"); + const secret = await getBackendSecret(); + const adminPubkey = await getAdminPubkey(); + + if (!verifyEvent(request)) return jsonError("Request Nostr invalido.", 401); + if (request.pubkey !== adminPubkey) { + return jsonError( + "El usuario logueado debe coincidir con NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB.", + 403, + ); + } + if (Math.abs(Math.floor(Date.now() / 1000) - request.created_at) > 10 * 60) { + return jsonError("Request expirado.", 401); + } + const action = requestTagValue(request, "action"); + if (action !== OPEN_ACTION && action !== CLOSE_ACTION) { + return jsonError("Request no autorizado para administrar la votación.", 401); + } + if (requestTagValue(request, "h") !== id) { + return jsonError("El request no corresponde a este hackatón.", 401); + } + + const existing = await fetchVotingPeriodFromRelays(id); + const now = Math.floor(Date.now() / 1000); + // NIP-01 replaceable tie-break keeps the LOWEST id on equal created_at — + // always publish strictly after the event we're replacing. + const eventCreatedAt = Math.max(now, (existing?.eventCreatedAt ?? 0) + 1); + + let period: VotingPeriod; + + if (action === OPEN_ACTION) { + if ( + existing?.period.status === "closed" && + requestTagValue(request, "force") !== "1" + ) { + return jsonError( + "La votación ya fue cerrada. Reabrir requiere forzar.", + 409, + ); + } + + const projects = await votableProjects(id); + const projectRefs: VotingProjectRef[] = projects.map((p) => ({ + id: p.id, + name: p.name, + })); + const soldiers = await getSoldiers().catch(() => []); + const eligible = buildEligibleVoters(soldiers, id); + for (const extra of testExtraVoters()) { + if (!eligible.some((v) => v.pubkey === extra.pubkey)) { + eligible.push(extra); + } + } + applyTeamPubkeyBlocks(eligible, projects); + + period = { + version: VOTING_SCHEMA_VERSION, + hackathonId: id, + status: "open", + // Re-publishing an already-open period refreshes the snapshot + // (projects/eligibility) without restarting the window. + openedAt: + existing?.period.status === "open" ? existing.period.openedAt : now, + closedAt: null, + projects: projectRefs, + eligible, + results: null, + }; + } else { + if (!existing || existing.period.status !== "open") { + return jsonError("No hay una votación abierta para cerrar.", 409); + } + const ballots = await fetchBallotEvents(id); + const { results } = tallyBallots(ballots, existing.period, now); + period = { + ...existing.period, + status: "closed", + closedAt: now, + results, + }; + } + + const signed = finalizeEvent( + { + kind: VOTING_KIND, + created_at: eventCreatedAt, + content: serializeVotingPeriod(period), + tags: [ + ["d", votingPeriodDTag(id)], + ["t", VOTING_T_TAG], + ["h", id], + ["status", period.status], + ["client", "La Crypta Dev"], + ], + }, + secret, + ) as SignedEvent; + + const relayResults = await publishToRelays(signed, DEFAULT_RELAYS); + if (!relayResults.some((r) => r.ok)) { + return NextResponse.json( + { error: "Ningún relay aceptó el evento de votación.", relays: relayResults }, + { status: 502 }, + ); + } + + // Bust the server cache so SSR reads the freshly-published period. + revalidateTag(nostrVotingTag(id), { expire: 0 }); + + return NextResponse.json({ + ok: true, + eventId: signed.id, + status: period.status, + eligibleCount: period.eligible.length, + projectCount: period.projects.length, + results: period.results, + relays: relayResults, + }); + } catch (error) { + console.error("[api/hackathons/voting] failed", error); + return jsonError( + error instanceof Error + ? error.message + : "No se pudo administrar la votación.", + 500, + ); + } +} diff --git a/app/dev/voting/DevVotingClient.tsx b/app/dev/voting/DevVotingClient.tsx new file mode 100644 index 0000000..f1c37ed --- /dev/null +++ b/app/dev/voting/DevVotingClient.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Copy, KeyRound, Plus, Trash2, UserCheck } from "lucide-react"; +import { HACKATHONS, hackathonStatus } from "@/lib/hackathons"; +import { useAuth, setAuth, clearAuth } from "@/lib/auth"; +import { useToast } from "@/components/Toast"; +import { cn } from "@/lib/cn"; +import type { VotingPeriod } from "@/lib/voting"; +import VotingSection from "@/app/hackathons/[id]/VotingSection"; + +type DevIdentity = { + label: string; + pubkey: string; + npub: string; + /** 32-byte secret as plain array (same encoding as Auth.localSecret). */ + secret: number[]; +}; + +const STORAGE_KEY = "labs:dev:voting-voters:v1"; + +function loadIdentities(): DevIdentity[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as DevIdentity[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveIdentities(identities: DevIdentity[]) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(identities)); + } catch { + /* quota */ + } +} + +export default function DevVotingClient({ + testNamespace, +}: { + testNamespace: boolean; +}) { + const { auth } = useAuth(); + const { push } = useToast(); + + const activeHackathon = useMemo( + () => HACKATHONS.find((h) => hackathonStatus(h) === "active") ?? HACKATHONS[0], + [], + ); + const [hackathonId, setHackathonId] = useState(activeHackathon.id); + const [identities, setIdentities] = useState([]); + const [period, setPeriod] = useState(null); + const [loadingPeriod, setLoadingPeriod] = useState(false); + + useEffect(() => { + setIdentities(loadIdentities()); + }, []); + + useEffect(() => { + let cancelled = false; + setLoadingPeriod(true); + fetch(`/api/hackathons/${hackathonId}/voting`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { period?: VotingPeriod | null } | null) => { + if (!cancelled) setPeriod(data?.period ?? null); + }) + .catch(() => { + if (!cancelled) setPeriod(null); + }) + .finally(() => { + if (!cancelled) setLoadingPeriod(false); + }); + return () => { + cancelled = true; + }; + }, [hackathonId]); + + async function generateIdentity() { + const { generateSecretKey, getPublicKey } = await import( + "nostr-tools/pure" + ); + const { npubEncode } = await import("nostr-tools/nip19"); + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + const identity: DevIdentity = { + label: `Identidad ${identities.length + 1}`, + pubkey, + npub: npubEncode(pubkey), + secret: Array.from(secret), + }; + const next = [...identities, identity]; + setIdentities(next); + saveIdentities(next); + } + + function removeIdentity(pubkey: string) { + const next = identities.filter((i) => i.pubkey !== pubkey); + setIdentities(next); + saveIdentities(next); + } + + function loginAs(identity: DevIdentity) { + setAuth({ + method: "local", + pubkey: identity.pubkey, + localSecret: identity.secret, + }); + push({ + kind: "success", + title: `Sesión iniciada como ${identity.label}`, + description: identity.npub.slice(0, 20) + "…", + }); + } + + async function copy(text: string, what: string) { + try { + await navigator.clipboard.writeText(text); + push({ kind: "info", title: `${what} copiado` }); + } catch { + push({ kind: "error", title: "No se pudo copiar" }); + } + } + + return ( +
+ {/* ── Identity lab ── */} +
+
+
+ +

+ Identidades de prueba +

+
+ +
+ +

+ Para actuar como admin: copiá el npub de una + identidad a NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB. + Para habilitar votantes: copiá sus pubkeys hex a{" "} + VOTING_TEST_EXTRA_VOTERS (formato{" "} + hexpk:presupuesto,hexpk:presupuesto). + Reiniciá el server tras cambiar el .env.local. +

+ + {identities.length === 0 ? ( +

+ Sin identidades todavía — generá un par para empezar. +

+ ) : ( +
    + {identities.map((identity) => { + const active = auth?.pubkey === identity.pubkey; + return ( +
  • + {identity.label} + {active && ( + + + ACTIVA + + )} + + {identity.npub} + + + + + + + +
  • + ); + })} +
+ )} + + {auth && ( +
+ + Sesión actual: {auth.method} · {auth.pubkey.slice(0, 16)}… + + +
+ )} +
+ + {/* ── Period status + embedded production component ── */} +
+
+ + + + {loadingPeriod + ? "Cargando estado…" + : period + ? `Estado: ${period.status} · ${period.eligible.length} votantes · ${period.projects.length} proyectos` + : "Sin votación publicada"} + + + {testNamespace ? "NAMESPACE TEST" : "NAMESPACE PRODUCCIÓN"} + +
+ + {/* The real production component — what you test here is what ships. */} + h.id === hackathonId)?.name ?? hackathonId + } + initialPeriod={period} + /> +
+
+ ); +} diff --git a/app/dev/voting/page.tsx b/app/dev/voting/page.tsx new file mode 100644 index 0000000..57d1626 --- /dev/null +++ b/app/dev/voting/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { isVotingTestNamespace } from "@/lib/voting"; +import DevVotingClient from "./DevVotingClient"; + +export const metadata: Metadata = { + title: "Dev · Votación", + robots: { index: false, follow: false }, +}; + +/** + * Dev-only harness to exercise the community voting flow end to end: generate + * throwaway identities, act as admin (open/close) and as several voters, and + * watch the live tally — all against the `lacrypta.dev:test:` d-tag namespace + * so production voting data is never touched. 404s in production builds. + */ +export default function DevVotingPage() { + if (process.env.NODE_ENV === "production") notFound(); + + return ( +
+
+

+ Laboratorio de votación +

+

+ Entorno de prueba para la votación comunitaria. Generá identidades + descartables, usalas como admin o votantes y probá el flujo completo + sin tocar datos de producción. +

+ {!isVotingTestNamespace() && ( +
+ ⚠ Namespace de producción. Agregá{" "} + NEXT_PUBLIC_VOTING_NS=test a tu{" "} + .env.local y reiniciá el server — + si no, los eventos de prueba van al namespace real. +
+ )} + +
+
+ ); +} diff --git a/app/hackathons/[id]/VotingSection.tsx b/app/hackathons/[id]/VotingSection.tsx new file mode 100644 index 0000000..f32db51 --- /dev/null +++ b/app/hackathons/[id]/VotingSection.tsx @@ -0,0 +1,722 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + CheckCircle2, + Loader2, + Lock, + Megaphone, + Minus, + Plus, + Trophy, + Vote, + X, +} from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { getSigner, type SignedEvent } from "@/lib/nostrSigner"; +import { useToast } from "@/components/Toast"; +import { useScrollLock } from "@/lib/useScrollLock"; +import { cn } from "@/lib/cn"; +import { + isVotingTestNamespace, + tallyBallots, + type VotingPeriod, + type VotingResults, +} from "@/lib/voting"; +import { + publishBallot, + subscribeToBallots, + subscribeToVotingPeriod, +} from "@/lib/votingClient"; + +type Pubkeys = { adminPubkey: string | null; publisherPubkey: string | null }; + +/** + * Community voting for the hackathon's projects. Eligibility, vote budgets and + * the votable project list come frozen inside the period event La Crypta + * publishes when the admin opens the voting; ballots are replaceable Nostr + * events signed by each voter. While open the tally is computed live from + * relay ballots; once closed the embedded official results are rendered + * verbatim (the freeze rule — late ballots can't change a signed result). + */ +export default function VotingSection({ + hackathonId, + hackathonName, + initialPeriod, +}: { + hackathonId: string; + hackathonName: string; + initialPeriod: VotingPeriod | null; +}) { + const { auth, ready } = useAuth(); + + const [pubkeys, setPubkeys] = useState({ + adminPubkey: null, + publisherPubkey: null, + }); + const [period, setPeriod] = useState(initialPeriod); + const [ballots, setBallots] = useState>(new Map()); + + useEffect(() => { + let cancelled = false; + fetch("/api/lacrypta-pubkeys") + .then((res) => (res.ok ? res.json() : null)) + .then((data: Pubkeys | null) => { + if (!cancelled && data) { + setPubkeys({ + adminPubkey: data.adminPubkey ?? null, + publisherPubkey: data.publisherPubkey ?? null, + }); + } + }) + .catch(() => { + /* section degrades to read-only */ + }); + return () => { + cancelled = true; + }; + }, []); + + // Authoritative period read on mount — the SSR'd page is cached and may + // predate the latest open/close, and relays can be slow to answer the + // subscription below. + useEffect(() => { + let cancelled = false; + fetch(`/api/hackathons/${hackathonId}/voting`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { period?: VotingPeriod | null } | null) => { + if (cancelled || !data?.period) return; + setPeriod((prev) => { + // Never downgrade a closed period back to open with stale data. + if (prev?.status === "closed" && data.period!.status === "open") { + return prev; + } + return data.period!; + }); + }) + .catch(() => { + /* relay subscription still covers us */ + }); + return () => { + cancelled = true; + }; + }, [hackathonId]); + + // Live period flips (open/close) — what makes admin actions visible + // everywhere without a reload. + useEffect(() => { + if (!pubkeys.publisherPubkey) return; + let freshest = 0; + return subscribeToVotingPeriod( + hackathonId, + pubkeys.publisherPubkey, + (next, createdAt) => { + if (createdAt <= freshest) return; + freshest = createdAt; + setPeriod(next); + }, + ); + }, [hackathonId, pubkeys.publisherPubkey]); + + // Live ballots while voting is open. + const votingOpen = period?.status === "open"; + useEffect(() => { + if (!votingOpen) return; + return subscribeToBallots(hackathonId, (ev) => { + setBallots((prev) => { + const key = ev.pubkey.toLowerCase(); + const existing = prev.get(key); + if ( + existing && + (existing.created_at > ev.created_at || + (existing.created_at === ev.created_at && existing.id <= ev.id)) + ) { + return prev; + } + const next = new Map(prev); + next.set(key, ev); + return next; + }); + }); + }, [hackathonId, votingOpen]); + + const isAdmin = + !!auth?.pubkey && + !!pubkeys.adminPubkey && + auth.pubkey === pubkeys.adminPubkey; + + const liveTally = useMemo(() => { + if (!period) return null; + return tallyBallots([...ballots.values()], period); + }, [ballots, period]); + + // Nothing to show before the first opening (admins see the open button). + if (!period && !isAdmin) return null; + + const results: VotingResults | null = + period?.status === "closed" + ? period.results + : (liveTally?.results ?? null); + + const voter = + period && auth?.pubkey + ? (period.eligible.find( + (v) => v.pubkey === auth.pubkey.toLowerCase(), + ) ?? null) + : null; + + const ownBallotEvent = auth?.pubkey + ? (ballots.get(auth.pubkey.toLowerCase()) ?? null) + : null; + const ownAllocations = + voter && auth?.pubkey + ? (liveTally?.byVoter.get(auth.pubkey.toLowerCase()) ?? null) + : null; + + return ( +
+
+
+
+
+ +

+ Votación comunitaria +

+ {isVotingTestNamespace() && ( + + MODO TEST + + )} + {period && ( + + {period.status === "open" ? "ABIERTA" : "CERRADA"} + + )} +
+ {isAdmin && ( + + )} +
+ + {!period ? ( +

+ La votación comunitaria de {hackathonName} todavía no fue abierta. +

+ ) : ( + <> +

+ {period.status === "open" + ? "La comunidad elige a los ganadores. Vota cualquiera que haya participado de algún hackatón y tenga su identidad Nostr vinculada — 1 voto por hackatón participado, repartidos como quieras." + : "La votación está cerrada. Estos son los resultados oficiales."} +

+ + {period.status === "open" && ready && ( +
+ {!auth ? ( +

+ Iniciá sesión con Nostr para votar. +

+ ) : voter ? ( + { + setBallots((prev) => { + const next = new Map(prev); + next.set(ev.pubkey.toLowerCase(), ev); + return next; + }); + }} + /> + ) : ( +

+ Solo pueden votar quienes participaron de algún hackatón y + tienen su identidad Nostr vinculada. +

+ )} +
+ )} + + {results && ( + + )} + + )} +
+
+
+ ); +} + +/* ───────────────────────── Admin controls ───────────────────────── */ + +type AdminStep = "idle" | "signing" | "publishing"; + +function AdminVotingControls({ + hackathonId, + period, + onPeriod, +}: { + hackathonId: string; + period: VotingPeriod | null; + onPeriod: (period: VotingPeriod) => void; +}) { + const { auth } = useAuth(); + const { push } = useToast(); + const [step, setStep] = useState("idle"); + const [confirmClose, setConfirmClose] = useState(false); + useScrollLock(confirmClose); + + const busy = step !== "idle"; + + const runAction = useCallback( + async (action: "open-voting" | "close-voting", force = false) => { + if (!auth || busy) return; + setStep("signing"); + try { + const signer = await getSigner(auth); + const tags: string[][] = [ + ["u", `/api/hackathons/${hackathonId}/voting`], + ["method", "POST"], + ["action", action], + ["h", hackathonId], + ]; + if (force) tags.push(["force", "1"]); + const request = await signer.signEvent({ + kind: 27235, + pubkey: signer.pubkey, + created_at: Math.floor(Date.now() / 1000), + content: + action === "open-voting" + ? "Abrir votación comunitaria" + : "Cerrar votación comunitaria", + tags, + }); + + setStep("publishing"); + const res = await fetch(`/api/hackathons/${hackathonId}/voting`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request }), + }); + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean; + status?: "open" | "closed"; + eligibleCount?: number; + error?: string; + }; + if (!res.ok || !data.ok) { + throw new Error(data.error || "No se pudo actualizar la votación."); + } + + // Optimistic refresh — the relay subscription will confirm shortly. + const fresh = await fetch( + `/api/hackathons/${hackathonId}/voting`, + ).then((r) => (r.ok ? r.json() : null)); + if (fresh?.period) onPeriod(fresh.period as VotingPeriod); + + push({ + kind: "success", + title: + data.status === "open" ? "Votación abierta" : "Votación cerrada", + description: + data.status === "open" + ? `${data.eligibleCount ?? 0} votantes habilitados.` + : "Los resultados quedaron congelados y publicados en Nostr.", + }); + } catch (error) { + push({ + kind: "error", + title: "Error de votación", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + } finally { + setStep("idle"); + setConfirmClose(false); + } + }, + [auth, busy, hackathonId, onPeriod, push], + ); + + const label = + step === "signing" + ? "Firmando…" + : step === "publishing" + ? "Publicando…" + : null; + + return ( + <> +
+ {(!period || period.status === "closed") && ( + + )} + {period?.status === "open" && ( + <> + + + + )} +
+ + {confirmClose && ( +
!busy && setConfirmClose(false)} + > +
e.stopPropagation()} + > +
+

+ ¿Cerrar la votación? +

+ +
+

+ Se calculará el resultado final con los votos recibidos hasta + ahora y se publicará firmado por La Crypta. Después del cierre + los votos nuevos no cuentan. +

+
+ + +
+
+
+ )} + + ); +} + +/* ───────────────────────── Ballot editor ───────────────────────── */ + +function BallotEditor({ + hackathonId, + period, + voterPubkey, + maxVotes, + blocked, + initialAllocations, + prevBallotCreatedAt, + onPublished, +}: { + hackathonId: string; + period: VotingPeriod; + voterPubkey: string; + maxVotes: number; + blocked: string[]; + initialAllocations: Record | null; + prevBallotCreatedAt: number; + onPublished: (ev: SignedEvent) => void; +}) { + const { auth } = useAuth(); + const { push } = useToast(); + const [allocations, setAllocations] = useState>( + 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)]); + + const used = Object.values(allocations).reduce((sum, n) => sum + n, 0); + const remaining = maxVotes - used; + const hasPrev = prevBallotCreatedAt > 0 || !!initialAllocations; + + function adjust(projectId: string, delta: number) { + dirty.current = true; + setAllocations((prev) => { + const current = prev[projectId] ?? 0; + const next = current + delta; + if (next < 0) return prev; + // Compute against `prev`, not the rendered `remaining` — rapid clicks + // batched into one render would otherwise overshoot the budget. + const prevUsed = Object.values(prev).reduce((sum, n) => sum + n, 0); + if (delta > 0 && prevUsed >= maxVotes) return prev; + const out = { ...prev }; + if (next === 0) delete out[projectId]; + else out[projectId] = next; + return out; + }); + } + + async function handlePublish() { + if (!auth || publishing || used === 0 || used > maxVotes) return; + setPublishing(true); + try { + const signer = await getSigner(auth); + const ev = await publishBallot( + signer, + hackathonId, + allocations, + prevBallotCreatedAt, + ); + dirty.current = false; + onPublished(ev); + push({ + kind: "success", + title: hasPrev ? "Votos actualizados" : "Votos publicados", + description: `Repartiste ${used} ${used === 1 ? "voto" : "votos"} firmados con tu clave Nostr.`, + }); + } catch (error) { + push({ + kind: "error", + title: "No se pudo publicar tu voto", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + } finally { + setPublishing(false); + } + } + + return ( +
+
+ + Tenés {maxVotes} {maxVotes === 1 ? "voto" : "votos"} ·{" "} + + te {remaining === 1 ? "queda" : "quedan"} {remaining} + + + {voterPubkey && hasPrev && ( + + + Ya votaste — podés cambiar tu voto + + )} +
+ +
    + {period.projects.map((p) => { + const isBlocked = blocked.includes(p.id); + const count = allocations[p.id] ?? 0; + return ( +
  • 0 + ? "border-nostr/40 bg-nostr/5" + : "border-border bg-white/[0.02]", + )} + > + + {p.name} + + {isBlocked ? ( + + Tu proyecto + + ) : ( + + + 0 ? "text-nostr" : "text-foreground-subtle", + )} + > + {count} + + + + )} +
  • + ); + })} +
+ +
+ +
+
+ ); +} + +/* ───────────────────────── Tally board ───────────────────────── */ + +function TallyBoard({ + results, + closed, +}: { + results: VotingResults; + closed: boolean; +}) { + const max = Math.max(1, ...results.tally.map((r) => r.votes)); + return ( +
+
+ + {closed ? "Resultados finales" : "Resultados en vivo"} + + + {results.ballotsCounted}{" "} + {results.ballotsCounted === 1 ? "votante" : "votantes"} ·{" "} + {results.totalVotesCast} votos + +
+
    + {results.tally.map((row, i) => { + const leader = closed && i === 0 && row.votes > 0; + return ( +
  1. +
    +
    +
    + {leader && } + + {row.name} + + + {row.votes} + +
    +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index 3e4191a..1f3e50b 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -46,7 +46,10 @@ import { NOSTR_SUBMISSIONS_TAG, } from "@/lib/nostrCache"; import { getCachedNostrProfile } from "@/lib/nostrProfileCache"; +import { getCachedVotingPeriod } from "@/lib/votingCache"; +import { nostrVotingTag } from "@/lib/nostrCacheTags"; import HackathonProjectsList from "./HackathonProjectsList"; +import VotingSection from "./VotingSection"; import HackathonResultsClient from "./HackathonResultsClient"; import PrizeBadgeButton, { type PrizeBadgeTask } from "./PrizeBadgeButton"; import PrizeZapButton from "./PrizeZapButton"; @@ -439,6 +442,10 @@ export default async function HackathonPage({ const { id } = await params; const hackathon = getHackathon(id); if (!hackathon) notFound(); + // Inner "use cache" tags don't bubble in Next 16 — register the voting tag + // at page level so open/close revalidations refresh this page too. + cacheTag(nostrVotingTag(id)); + const votingPeriod = await getCachedVotingPeriod(id); const status = hackathonStatus(hackathon); const statusMeta = STATUS_META[status]; const projects = rankedProjects(id); @@ -698,6 +705,15 @@ export default async function HackathonPage({ /> + {/* Community voting — same Suspense requirement as the projects list. */} + + + +
{/* Dates timeline */} diff --git a/lib/nostrCacheTags.ts b/lib/nostrCacheTags.ts index f7b75de..0d20dbd 100644 --- a/lib/nostrCacheTags.ts +++ b/lib/nostrCacheTags.ts @@ -18,6 +18,10 @@ export function nostrReportsTag(hackathonId: string) { return `nostr:reports:${hackathonId}`; } +export function nostrVotingTag(hackathonId: string) { + return `nostr:voting:${hackathonId}`; +} + export function nostrHackathonBadgesTag(hackathonId: string) { return `nostr:hackathon-badges:${hackathonId}`; } diff --git a/lib/voting.ts b/lib/voting.ts new file mode 100644 index 0000000..105df45 --- /dev/null +++ b/lib/voting.ts @@ -0,0 +1,372 @@ +/** + * Shared contract for the community voting system — two NIP-78 (kind 30078) + * parameterized replaceable event roles: + * + * 1. Voting period event — published by La Crypta (server-signed with + * LACRYPTA_NSEC), `d = lacrypta.dev:voting:`. Carries the + * open/closed status, a frozen eligibility snapshot and, once closed, the + * canonical final tally. + * 2. Ballot event — signed by the voter, `d = lacrypta.dev:vote:`. + * Replaceable: one ballot per voter per hackathon; re-voting replaces it. + * + * Pure module shared by the server cache reader (`lib/votingCache.ts`), the + * admin API route (`app/api/hackathons/[id]/voting/route.ts`) and the client + * (`lib/votingClient.ts`, `VotingSection`). No Nostr I/O, no `"use cache"`. + */ + +import type { Soldier } from "./soldiers"; + +export const VOTING_KIND = 30078; +export const VOTING_T_TAG = "lacrypta-dev-voting"; +export const VOTE_T_TAG = "lacrypta-dev-vote"; +export const VOTING_SCHEMA_VERSION = 1; + +/** + * Dev/test isolation: with `NEXT_PUBLIC_VOTING_NS=test` every d-tag moves to + * the `lacrypta.dev:test:` namespace, so test events on the public relays are + * invisible to production reads (which query the un-namespaced tags signed by + * the production publisher key). Build-time inlined on both server and client. + */ +export function isVotingTestNamespace(): boolean { + return process.env.NEXT_PUBLIC_VOTING_NS === "test"; +} + +function dTagPrefix(): string { + return isVotingTestNamespace() ? "lacrypta.dev:test" : "lacrypta.dev"; +} + +export function votingPeriodDTag(hackathonId: string): string { + return `${dTagPrefix()}:voting:${hackathonId}`; +} + +export function voteDTag(hackathonId: string): string { + return `${dTagPrefix()}:vote:${hackathonId}`; +} + +export type VotingEligibleVoter = { + pubkey: string; + name: string; + /** 1 vote per distinct hackathon the voter participated in. */ + maxVotes: number; + /** Project ids in the current hackathon the voter cannot vote for (own projects). */ + blocked: string[]; +}; + +export type VotingProjectRef = { + id: string; + name: string; +}; + +export type VotingTallyRow = { + projectId: string; + name: string; + votes: number; + /** Distinct voters that allocated at least one vote to this project. */ + voters: number; +}; + +export type VotingResults = { + tally: VotingTallyRow[]; + ballotsCounted: number; + ballotsRejected: number; + totalVotesCast: number; +}; + +export type VotingPeriod = { + version: number; + hackathonId: string; + status: "open" | "closed"; + /** Unix seconds the voting opened. */ + openedAt: number; + /** Unix seconds the voting closed; null while open. */ + closedAt: number | null; + projects: VotingProjectRef[]; + eligible: VotingEligibleVoter[]; + /** Canonical final tally — only present once status is "closed". */ + results: VotingResults | null; +}; + +export type BallotContent = { + version: number; + hackathonId: string; + /** projectId → votes allocated (positive integers). */ + allocations: Record; +}; + +/** Minimal event shape — matches `SignedEvent` without importing client code. */ +export type VotingEventLike = { + id: string; + pubkey: string; + kind: number; + created_at: number; + tags: string[][]; + content: string; +}; + +export function serializeVotingPeriod(period: VotingPeriod): string { + return JSON.stringify(period); +} + +/** Defensive parse — returns null for anything that isn't a valid period. */ +export function parseVotingPeriod(content: string): VotingPeriod | null { + try { + const parsed = JSON.parse(content) as Partial; + if ( + !parsed || + typeof parsed !== "object" || + typeof parsed.hackathonId !== "string" || + (parsed.status !== "open" && parsed.status !== "closed") || + typeof parsed.openedAt !== "number" || + !Array.isArray(parsed.projects) || + !Array.isArray(parsed.eligible) + ) { + return null; + } + return { + version: + typeof parsed.version === "number" + ? parsed.version + : VOTING_SCHEMA_VERSION, + hackathonId: parsed.hackathonId, + status: parsed.status, + openedAt: parsed.openedAt, + closedAt: typeof parsed.closedAt === "number" ? parsed.closedAt : null, + projects: parsed.projects.filter( + (p): p is VotingProjectRef => + !!p && typeof p.id === "string" && typeof p.name === "string", + ), + eligible: parsed.eligible + .filter( + (v): v is VotingEligibleVoter => + !!v && + typeof v.pubkey === "string" && + typeof v.maxVotes === "number" && + v.maxVotes > 0, + ) + .map((v) => ({ + pubkey: v.pubkey.toLowerCase(), + name: typeof v.name === "string" ? v.name : "", + maxVotes: Math.floor(v.maxVotes), + blocked: Array.isArray(v.blocked) + ? v.blocked.filter((b): b is string => typeof b === "string") + : [], + })), + results: + parsed.results && Array.isArray(parsed.results.tally) + ? (parsed.results as VotingResults) + : null, + }; + } catch { + return null; + } +} + +/** Defensive parse of a ballot's content — null on garbage. */ +export function parseBallotContent(content: string): BallotContent | null { + try { + const parsed = JSON.parse(content) as Partial; + if ( + !parsed || + typeof parsed !== "object" || + typeof parsed.hackathonId !== "string" || + !parsed.allocations || + typeof parsed.allocations !== "object" || + Array.isArray(parsed.allocations) + ) { + return null; + } + const allocations: Record = {}; + for (const [projectId, votes] of Object.entries(parsed.allocations)) { + if (typeof votes !== "number") return null; + allocations[projectId] = votes; + } + return { + version: + typeof parsed.version === "number" + ? parsed.version + : VOTING_SCHEMA_VERSION, + hackathonId: parsed.hackathonId, + allocations, + }; + } catch { + return null; + } +} + +function eventTagValue(ev: VotingEventLike, name: string): string | null { + return ev.tags.find((t) => t[0] === name)?.[1] ?? null; +} + +export type BallotValidation = + | { ok: true; allocations: Record } + | { ok: false; reason: string }; + +/** + * A ballot counts iff its `d` tag matches the hackathon, its author is in the + * frozen eligibility snapshot, it was created inside the voting window, every + * allocation targets a votable (non-blocked) project with a positive integer + * amount, and the total stays within the voter's budget. + */ +export function validateBallot( + ev: VotingEventLike, + period: VotingPeriod, + opts: { closedAt?: number | null } = {}, +): BallotValidation { + if (ev.kind !== VOTING_KIND) return { ok: false, reason: "kind" }; + if (eventTagValue(ev, "d") !== voteDTag(period.hackathonId)) { + return { ok: false, reason: "d-tag" }; + } + const voter = period.eligible.find( + (v) => v.pubkey === ev.pubkey.toLowerCase(), + ); + if (!voter) return { ok: false, reason: "not-eligible" }; + if (ev.created_at < period.openedAt) return { ok: false, reason: "too-early" }; + const closedAt = opts.closedAt ?? period.closedAt; + if (closedAt !== null && closedAt !== undefined && ev.created_at > closedAt) { + return { ok: false, reason: "too-late" }; + } + + const content = parseBallotContent(ev.content); + if (!content || content.hackathonId !== period.hackathonId) { + return { ok: false, reason: "content" }; + } + + const projectIds = new Set(period.projects.map((p) => p.id)); + let total = 0; + const allocations: Record = {}; + for (const [projectId, votes] of Object.entries(content.allocations)) { + if (!Number.isInteger(votes) || votes < 1) { + return { ok: false, reason: "invalid-amount" }; + } + if (!projectIds.has(projectId)) { + return { ok: false, reason: "unknown-project" }; + } + if (voter.blocked.includes(projectId)) { + return { ok: false, reason: "self-vote" }; + } + allocations[projectId] = votes; + total += votes; + } + if (total === 0) return { ok: false, reason: "empty" }; + if (total > voter.maxVotes) return { ok: false, reason: "over-budget" }; + + return { ok: true, allocations }; +} + +/** + * Latest-per-author dedupe for replaceable ballots: keep the highest + * `created_at`; on ties keep the lowest event id (NIP-01 — relays may return + * divergent versions of the same replaceable event). + */ +export function dedupeBallots(events: VotingEventLike[]): VotingEventLike[] { + const byAuthor = new Map(); + for (const ev of events) { + const key = ev.pubkey.toLowerCase(); + const prev = byAuthor.get(key); + if ( + !prev || + ev.created_at > prev.created_at || + (ev.created_at === prev.created_at && ev.id < prev.id) + ) { + byAuthor.set(key, ev); + } + } + return [...byAuthor.values()]; +} + +export function tallyBallots( + events: VotingEventLike[], + period: VotingPeriod, + closedAt?: number | null, +): { + results: VotingResults; + /** voter pubkey (lowercase hex) → their counted allocations. */ + byVoter: Map>; +} { + const deduped = dedupeBallots(events); + const byVoter = new Map>(); + let rejected = 0; + + for (const ev of deduped) { + const result = validateBallot(ev, period, { closedAt }); + if (result.ok) { + byVoter.set(ev.pubkey.toLowerCase(), result.allocations); + } else { + rejected++; + } + } + + const votesByProject = new Map(); + let totalVotesCast = 0; + for (const allocations of byVoter.values()) { + for (const [projectId, votes] of Object.entries(allocations)) { + const row = votesByProject.get(projectId) ?? { votes: 0, voters: 0 }; + row.votes += votes; + row.voters += 1; + votesByProject.set(projectId, row); + totalVotesCast += votes; + } + } + + const tally: VotingTallyRow[] = period.projects + .map((p) => ({ + projectId: p.id, + name: p.name, + votes: votesByProject.get(p.id)?.votes ?? 0, + voters: votesByProject.get(p.id)?.voters ?? 0, + })) + .sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name)); + + return { + results: { + tally, + ballotsCounted: byVoter.size, + ballotsRejected: rejected, + totalVotesCast, + }, + byVoter, + }; +} + +/** + * Builds the frozen eligibility snapshot from the soldiers roster: anyone with + * a Nostr pubkey who participated in at least one hackathon. Vote budget = 1 + * per distinct hackathon participated in; `blocked` = the voter's own projects + * in the hackathon being voted (no self-votes). + */ +export function buildEligibleVoters( + soldiers: Soldier[], + hackathonId: string, +): VotingEligibleVoter[] { + const byPubkey = new Map(); + 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, + }); + } + } + return [...byPubkey.values()]; +} diff --git a/lib/votingCache.ts b/lib/votingCache.ts new file mode 100644 index 0000000..7c79f91 --- /dev/null +++ b/lib/votingCache.ts @@ -0,0 +1,104 @@ +/** + * Server-only read of the voting period event — the kind-30078 replaceable + * event published by La Crypta (see `app/api/hackathons/[id]/voting`). + * Mirrors `lib/nostrSoldiersCache.ts`: one cached relay round-trip, freshest + * event wins, `null` on any miss. The `authors` filter (publisher pubkey + * derived from LACRYPTA_NSEC) is the trust anchor. + */ + +import { cacheLife, cacheTag } from "next/cache"; +import { DEFAULT_RELAYS } from "./nostrRelayConfig"; +import { nostrVotingTag } from "./nostrCacheTags"; +import { + VOTING_KIND, + parseVotingPeriod, + votingPeriodDTag, + type VotingPeriod, +} from "./voting"; + +type IncomingEvent = { + id: string; + pubkey: string; + content: string; + tags: string[][]; + created_at: number; +}; + +async function publisherPubkeyFromNsec(): Promise { + const nsec = process.env.LACRYPTA_NSEC; + if (!nsec) return ""; + const { decode } = await import("nostr-tools/nip19"); + const { getPublicKey } = await import("nostr-tools/pure"); + const decoded = decode(nsec); + if (decoded.type !== "nsec") return ""; + return getPublicKey(decoded.data as Uint8Array); +} + +/** Uncached relay fetch — used by the admin POST route to read current state. */ +export async function fetchVotingPeriodFromRelays( + hackathonId: string, + timeoutMs = 4500, +): Promise<{ period: VotingPeriod; eventCreatedAt: number } | null> { + const publisherPubkey = await publisherPubkeyFromNsec(); + if (!publisherPubkey) return null; + + const relays = DEFAULT_RELAYS; + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const events: IncomingEvent[] = []; + + const closer = pool.subscribe( + relays, + { + kinds: [VOTING_KIND], + authors: [publisherPubkey], + "#d": [votingPeriodDTag(hackathonId)], + }, + { + onevent(ev: IncomingEvent) { + events.push(ev); + }, + oneose() { + /* timeout-driven */ + }, + }, + ); + + await new Promise((r) => setTimeout(r, timeoutMs)); + try { + closer.close(); + } catch { + /* noop */ + } + try { + pool.close(relays); + } catch { + /* noop */ + } + + events.sort( + (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), + ); + for (const ev of events) { + // Some relays match `#d` loosely — re-check the exact tag. + const d = ev.tags.find((t) => t[0] === "d")?.[1]; + if (d !== votingPeriodDTag(hackathonId)) continue; + const period = parseVotingPeriod(ev.content); + if (period) return { period, eventCreatedAt: ev.created_at }; + } + return null; +} + +export async function getCachedVotingPeriod( + hackathonId: string, +): Promise { + "use cache"; + cacheLife("hours"); + cacheTag(nostrVotingTag(hackathonId)); + try { + const found = await fetchVotingPeriodFromRelays(hackathonId); + return found?.period ?? null; + } catch { + return null; + } +} diff --git a/lib/votingClient.ts b/lib/votingClient.ts new file mode 100644 index 0000000..807f3a5 --- /dev/null +++ b/lib/votingClient.ts @@ -0,0 +1,207 @@ +"use client"; + +/** + * Client side of the voting system: publish the user's ballot and live-stream + * ballots / period flips from the relays. Server reads live in + * `lib/votingCache.ts` — never import this module from server code. + */ + +import { FAST_USER_RELAYS, DEFAULT_RELAYS } from "./nostrRelayConfig"; +import type { SignedEvent, UserSigner } from "./nostrSigner"; +import { + VOTE_T_TAG, + VOTING_KIND, + VOTING_SCHEMA_VERSION, + parseVotingPeriod, + voteDTag, + votingPeriodDTag, + type BallotContent, + type VotingPeriod, +} from "./voting"; + +async function publishToRelays( + signed: SignedEvent, + relays: string[], + perRelayTimeoutMs = 8000, +): Promise<{ relay: string; ok: boolean }[]> { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const promises = pool.publish(relays, signed); + const results = await Promise.all( + relays.map(async (relay, i) => { + try { + await Promise.race([ + promises[i], + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), perRelayTimeoutMs), + ), + ]); + return { relay, ok: true }; + } catch { + return { relay, ok: false }; + } + }), + ); + try { + pool.close(relays); + } catch { + /* noop */ + } + return results; +} + +/** + * Signs and publishes the user's (replaceable) ballot. `createdAtFloor` should + * be the created_at of the user's previous ballot, if any — NIP-01 keeps the + * LOWEST id on created_at ties, so we bump past it to guarantee replacement. + */ +export async function publishBallot( + signer: UserSigner, + hackathonId: string, + allocations: Record, + createdAtFloor = 0, +): Promise { + const content: BallotContent = { + version: VOTING_SCHEMA_VERSION, + hackathonId, + allocations, + }; + const createdAt = Math.max( + Math.floor(Date.now() / 1000), + createdAtFloor + 1, + ); + const signed = await signer.signEvent({ + kind: VOTING_KIND, + pubkey: signer.pubkey, + created_at: createdAt, + content: JSON.stringify(content), + tags: [ + ["d", voteDTag(hackathonId)], + ["t", VOTE_T_TAG], + ["h", hackathonId], + ["client", "La Crypta Dev"], + ], + }); + + const results = await publishToRelays(signed, [...DEFAULT_RELAYS]); + if (!results.some((r) => r.ok)) { + throw new Error("Ningún relay aceptó tu voto. Probá de nuevo."); + } + return signed; +} + +/** + * Live-subscribe to ballot events for a hackathon (historical + new), keeping + * the relay subscription open until the returned cleanup function runs. + * Eligibility/validity is NOT enforced here — callers run `tallyBallots`. + */ +export function subscribeToBallots( + hackathonId: string, + onEvent: (ev: SignedEvent) => void, +): () => void { + let closed = false; + let teardown: (() => void) | null = null; + const dTag = voteDTag(hackathonId); + const relays = [...FAST_USER_RELAYS]; + + void (async () => { + const { SimplePool } = await import("nostr-tools/pool"); + if (closed) return; + const pool = new SimplePool(); + const closer = pool.subscribe( + relays, + { + kinds: [VOTING_KIND], + "#d": [dTag], + limit: 500, + }, + { + onevent(ev) { + const event = ev as SignedEvent; + // Relay-side `#d` filtering is not universal — re-check the tag. + const d = event.tags.find((t) => t[0] === "d")?.[1]; + if (d !== dTag) return; + onEvent(event); + }, + oneose() { + // Keep the subscription open for live ballots — do not close here. + }, + }, + ); + teardown = () => { + closer.close(); + try { + pool.close(relays); + } catch { + /* noop */ + } + }; + if (closed) teardown(); + })(); + + return () => { + closed = true; + teardown?.(); + }; +} + +/** + * Live-subscribe to the voting period event published by La Crypta. Calls + * `onPeriod` with the freshest valid period whenever one arrives, so open and + * close flips reach every viewer without a page reload. + */ +export function subscribeToVotingPeriod( + hackathonId: string, + publisherPubkey: string, + onPeriod: (period: VotingPeriod, eventCreatedAt: number) => void, +): () => void { + let closed = false; + let teardown: (() => void) | null = null; + let freshest = 0; + const dTag = votingPeriodDTag(hackathonId); + const relays = [...FAST_USER_RELAYS]; + + void (async () => { + 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); + }, + oneose() { + // Keep open for live open/close flips. + }, + }, + ); + teardown = () => { + closer.close(); + try { + pool.close(relays); + } catch { + /* noop */ + } + }; + if (closed) teardown(); + })(); + + return () => { + closed = true; + teardown?.(); + }; +} From 72e4b23dadd159135233d0b6c14633ffee67b347 Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Thu, 18 Jun 2026 11:08:46 -0300 Subject: [PATCH 2/2] feat: isolated dev environment, encrypted voting, slug-decoupled hackathon 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) --- .env.example | 23 + .gitignore | 3 + AGENTS.md | 53 ++ app/api/dev/revalidate/route.ts | 24 + app/api/dev/soldiers/route.ts | 24 + app/api/hackathons/[id]/voting/route.ts | 227 ++++- app/badges/BadgesClient.tsx | 3 +- app/dashboard/DashboardClient.tsx | 24 + .../hackathones/MisHackatonesClient.tsx | 199 +++++ app/dashboard/hackathones/page.tsx | 17 + app/dashboard/projects/UserProjectsClient.tsx | 14 +- app/dev/voting/DevVotingClient.tsx | 88 +- app/hackathons/[id]/HackathonProjectsList.tsx | 8 +- .../[id]/HackathonResultsClient.tsx | 4 +- app/hackathons/[id]/PrizeZapButton.tsx | 6 +- app/hackathons/[id]/VotingSection.tsx | 819 +++++++++++++++--- .../[projectId]/NostrProjectPageClient.tsx | 8 +- .../[id]/[projectId]/NostrProjectServer.tsx | 11 +- .../[id]/[projectId]/opengraph-image.tsx | 38 +- app/hackathons/[id]/[projectId]/page.tsx | 56 +- app/hackathons/[id]/opengraph-image.tsx | 3 +- app/hackathons/[id]/page.tsx | 17 +- app/hackathons/page.tsx | 5 +- app/layout.tsx | 8 +- app/projects/[pubkey]/UserProjectsPage.tsx | 3 +- .../[pubkey]/[id]/StandaloneProjectPage.tsx | 4 +- app/sitemap.ts | 8 +- app/soldados/SoldiersGrid.tsx | 5 +- app/soldados/[slug]/ImpersonateButton.tsx | 39 + app/soldados/[slug]/page.tsx | 13 +- components/DevModeBar.tsx | 476 ++++++++++ components/HackathonInscripcionButton.tsx | 3 +- components/Navbar.tsx | 10 +- components/sections/GamingHackathonBanner.tsx | 2 +- data/hackathons/hackathons.json | 1 + dev/relay/config.toml | 39 + dev/relay/docker-compose.yml | 26 + lib/auth.ts | 5 + lib/devImpersonation.ts | 34 + lib/devMode.ts | 15 + lib/devSeed.ts | 227 +++++ lib/hackathons.ts | 44 +- lib/jsonld.tsx | 7 +- lib/nip46Client.ts | 10 + lib/nostrRelayConfig.ts | 25 +- lib/nostrSigner.ts | 59 ++ lib/useDevEnabled.ts | 60 ++ lib/useDevIdentities.ts | 184 ++++ lib/voting.ts | 226 ++++- lib/votingClient.ts | 104 ++- lib/zap.ts | 8 + package.json | 6 +- scripts/gen-dev-keys.mjs | 42 + 53 files changed, 3035 insertions(+), 332 deletions(-) create mode 100644 app/api/dev/revalidate/route.ts create mode 100644 app/api/dev/soldiers/route.ts create mode 100644 app/dashboard/hackathones/MisHackatonesClient.tsx create mode 100644 app/dashboard/hackathones/page.tsx create mode 100644 app/soldados/[slug]/ImpersonateButton.tsx create mode 100644 components/DevModeBar.tsx create mode 100644 dev/relay/config.toml create mode 100644 dev/relay/docker-compose.yml create mode 100644 lib/devImpersonation.ts create mode 100644 lib/devMode.ts create mode 100644 lib/devSeed.ts create mode 100644 lib/useDevEnabled.ts create mode 100644 lib/useDevIdentities.ts create mode 100644 scripts/gen-dev-keys.mjs diff --git a/.env.example b/.env.example index 8a8035a..0d0f422 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,26 @@ EVENTS_SUBSCRIBE_LISTS=0135a251-8a46-4f88-b5bc-315d982eb7fa # project pages, dynamic OG images, and the Nostr sitemap pick up new # community submissions. Send the value as `x-revalidate-secret` header. REVALIDATE_SECRET=change-me + +# ─── Local dev environment (optional, DEV ONLY) ───────────────────────────── +# +# Isolated testing: a local relay + throwaway dev key + in-app impersonation. +# See AGENTS.md ("Local dev environment"). Run `pnpm gen:dev-keys` to generate +# the keypair, then `pnpm relay:up` to start the local relay. +# +# ⚠ NEVER set any NEXT_PUBLIC_DEV_* / NEXT_PUBLIC_NOSTR_RELAYS in production — +# they expose impersonation UI and a signing secret to the browser. Leave all +# of these unset in prod; the app falls back to real relays and hides the bar. + +# Turn on the DEV MODE bar (account impersonation, one-click admin login). +# NEXT_PUBLIC_DEV_MODE=true + +# Route ALL Nostr publish/read traffic to a relay set (comma-separated). +# Point at the local relay so nothing reaches public relays. +# NEXT_PUBLIC_NOSTR_RELAYS=ws://localhost:7777 + +# 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`). +# NEXT_PUBLIC_DEV_ADMIN_NSEC=nsec1... diff --git a/.gitignore b/.gitignore index 7b8da95..0ee302c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ yarn-error.log* .env* !.env.example +# local dev nostr relay data (sqlite db + wal/shm) +/dev/relay/data/ + # vercel .vercel diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..133d919 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,56 @@ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +## Local dev environment (isolated Nostr) + +**Why this exists.** Nostr events can never be deleted once a relay accepts them. Testing flows that publish — community voting, project submissions, badges — against public relays with La Crypta's real key would permanently pollute production. So local dev runs against an **isolated local relay** signed by a **throwaway dev key**, and surfaces a **DEV MODE bar** for impersonating accounts. Three independent pillars, all off by default: + +1. **Local relay** — `dev/relay/` runs `nostr-rs-relay` (Docker) at `ws://localhost:7777`. It implements parameterized-replaceable events (kind 30078 / NIP-33), so ballot re-votes replace correctly. Nothing published here leaves the machine. +2. **Dev keypair** — a throwaway key, **never** the production nsec. Generate with `pnpm gen:dev-keys`; it prints the `.env.local` lines below. +3. **`NEXT_PUBLIC_DEV_MODE=true`** — gates the DEV MODE bar (`components/DevModeBar.tsx`) and impersonation. `isDevMode()` in `lib/devMode.ts` is the single check (build-time inlined, hydration-safe). + +### Setup + +```bash +pnpm install +pnpm gen:dev-keys # prints dev keys — paste the block into .env.local +pnpm relay:up # start the local relay (ws://localhost:7777) +pnpm dev # NEXT_PUBLIC_DEV_MODE=true → DEV MODE bar appears +# pnpm relay:logs / pnpm relay:down to tail / stop the relay +``` + +### The DEV MODE bar + +A fixed 32px strip above the header (header shifts to `top-8`, `
` gets `pt-8` — both gated by `isDevMode()`, no per-page edits). Its switcher (backed by `lib/useDevIdentities.ts`, shared with `/dev/voting`) lets you: + +- Generate throwaway identities; log in as any of them. +- **"Entrar como La Crypta (admin)"** via `NEXT_PUBLIC_DEV_ADMIN_NSEC` to open/close voting. +- **Impersonate any soldier with a linked Nostr pubkey** (listed under "Soldados con Nostr", also available as an "Impersonar" button on each `/soldados/[slug]` profile). +- **"Generar datos dummy"** — seed a complete fake dataset (see below). + +### Impersonation model (stand-in keys) + +We never hold real users' secret keys, so impersonating a user logs in with a **deterministic dev stand-in key** derived from their pubkey (`lib/devImpersonation.ts`: `sha256("lacrypta-dev-impersonation:v1:" + pubkey)`). To make this consistent across the app: + +- **Voting** (`app/api/hackathons/[id]/voting/route.ts`): in dev mode the eligibility snapshot is remapped real-pubkey → stand-in, so impersonated users are eligible and self-vote blocks/budgets are preserved. +- **Reads** (`/dashboard/projects`, `/dashboard/hackathones`): `auth.impersonating` holds the real pubkey; in dev these read the impersonated user's data by it. + +`lib/voting.ts` (the shared contract) is untouched — all dev behaviour is gated by `isDevMode()`. + +### Dummy data generator ("complete dev") + +`lib/devSeed.ts` (`generateDummyData`, triggered by the bar's "Generar datos dummy") creates 8 dummy users + ~14 projects across hackathons and publishes **real kind-0 profiles + kind-30078 project events, each signed by that user's own key**, to the local relay. Because they go through the same event shapes the app reads, the dummy users automatically become soldiers (impersonatable + voting-eligible) with vote budgets matching their hackathon count, and their projects appear on hackathon pages, `/projects`, and the dashboard. The generated **nsecs are stored** in `localStorage["labs:dev:dummy-users:v1"]` and logged to the console. After seeding, the client flushes the roster cache via the dev-only `POST /api/dev/revalidate` (gated by `isDevMode()`, no secret needed). + +`/dashboard/hackathones` ("Mis hackatones") shows the logged-in (or impersonated) user's hackathon participations grouped by hackathon, derived from their Nostr projects. + +### Env vars (all dev-only; see `.env.example`) + +| Var | Effect | +| --- | --- | +| `NEXT_PUBLIC_DEV_MODE` | `true` → DEV MODE bar + impersonation. | +| `NEXT_PUBLIC_NOSTR_RELAYS` | Comma-separated relay override. `ws://localhost:7777` routes **all** traffic local. Single chokepoint in `lib/nostrRelayConfig.ts`. | +| `NEXT_PUBLIC_DEV_ADMIN_NSEC` | Browser-side dev admin secret for the one-click admin login. Must match `NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB`. | +| `LACRYPTA_NSEC` / `NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB` | In dev, set both to the throwaway keypair from `gen:dev-keys`. | + +> ⚠️ **Never set `NEXT_PUBLIC_DEV_MODE`, `NEXT_PUBLIC_DEV_ADMIN_NSEC`, or `NEXT_PUBLIC_NOSTR_RELAYS` in a production deploy.** They expose impersonation UI and a signing secret to the browser. Production leaves all three unset — the app falls back to the real relays and the bar never renders. diff --git a/app/api/dev/revalidate/route.ts b/app/api/dev/revalidate/route.ts new file mode 100644 index 0000000..32e174b --- /dev/null +++ b/app/api/dev/revalidate/route.ts @@ -0,0 +1,24 @@ +import { revalidateTag } from "next/cache"; +import { NextResponse } from "next/server"; +import { isDevMode } from "@/lib/devMode"; +import { + NOSTR_PROJECTS_TAG, + NOSTR_LEGACY_SUBMISSIONS_TAG, + NOSTR_SOLDIERS_RANKING_TAG, +} from "@/lib/nostrCacheTags"; + +/** + * Dev-only cache flush. After seeding dummy users/projects to the local relay, + * the client calls this so the soldiers roster + voting eligibility (which read + * cached relay snapshots) pick up the new data immediately — no production + * REVALIDATE_SECRET needed. 404s unless NEXT_PUBLIC_DEV_MODE. + */ +export async function POST() { + if (!isDevMode()) { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + revalidateTag(NOSTR_PROJECTS_TAG, { expire: 0 }); + revalidateTag(NOSTR_LEGACY_SUBMISSIONS_TAG, { expire: 0 }); + revalidateTag(NOSTR_SOLDIERS_RANKING_TAG, { expire: 0 }); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/dev/soldiers/route.ts b/app/api/dev/soldiers/route.ts new file mode 100644 index 0000000..0848605 --- /dev/null +++ b/app/api/dev/soldiers/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { isDevMode } from "@/lib/devMode"; +import { getSoldiers } from "@/lib/soldiers"; +import { buildEligibleVoters } from "@/lib/voting"; +import { HACKATHONS } from "@/lib/hackathons"; + +/** + * Dev-only roster of impersonatable users (soldiers with a linked Nostr pubkey) + * for the DEV MODE bar's account switcher. Returns the same set + vote budget + * the voting eligibility builder produces. 404s unless NEXT_PUBLIC_DEV_MODE. + */ +export async function GET() { + if (!isDevMode()) { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + const soldiers = await getSoldiers().catch(() => []); + // maxVotes (distinct hackathons participated) is hackathon-independent, so + // any id reuses the canonical budget logic. Only voters with a pubkey appear. + const eligible = buildEligibleVoters(soldiers, HACKATHONS[0]?.id ?? ""); + const list = eligible + .map((v) => ({ pubkey: v.pubkey, name: v.name, maxVotes: v.maxVotes })) + .sort((a, b) => b.maxVotes - a.maxVotes || a.name.localeCompare(b.name)); + return NextResponse.json({ soldiers: list }); +} diff --git a/app/api/hackathons/[id]/voting/route.ts b/app/api/hackathons/[id]/voting/route.ts index 500b972..090e572 100644 --- a/app/api/hackathons/[id]/voting/route.ts +++ b/app/api/hackathons/[id]/voting/route.ts @@ -5,32 +5,51 @@ import { getSoldiers } from "@/lib/soldiers"; import { getHackathon, mergeWithSubmissions, + primaryProjectPubkey, type HackathonSubmission, } from "@/lib/hackathons"; import { getNostrHackathonSubmissions } from "@/lib/nostrCache"; import { DEFAULT_RELAYS } from "@/lib/nostrRelayConfig"; +import { isDevMode } from "@/lib/devMode"; +import { devPubkeyForPubkey } from "@/lib/devImpersonation"; import { nostrVotingTag } from "@/lib/nostrCacheTags"; import { fetchVotingPeriodFromRelays, getCachedVotingPeriod, } from "@/lib/votingCache"; import { + VOTE_ENC, VOTING_KIND, VOTING_SCHEMA_VERSION, VOTING_T_TAG, buildEligibleVoters, + computeVotingRanking, isVotingTestNamespace, + parseBallotContent, serializeVotingPeriod, - tallyBallots, + tallyDecryptedBallots, voteDTag, votingPeriodDTag, + type DecryptedBallot, type VotingEligibleVoter, type VotingPeriod, type VotingProjectRef, + type VotingResults, + type VotingWinner, } from "@/lib/voting"; const OPEN_ACTION = "open-voting"; +/** Legacy single-shot close (kept as an alias for close-confirm). */ const CLOSE_ACTION = "close-voting"; +/** Step 1: decrypt + tally + return preview (no publish). */ +const CLOSE_PREVIEW_ACTION = "close-preview"; +/** Step 2: re-validate the confirmed set, sign + publish the frozen result. */ +const CLOSE_CONFIRM_ACTION = "close-confirm"; +const CLOSE_ACTIONS = new Set([ + CLOSE_ACTION, + CLOSE_PREVIEW_ACTION, + CLOSE_CONFIRM_ACTION, +]); function jsonError(message: string, status = 400) { return NextResponse.json({ error: message }, { status }); @@ -192,12 +211,144 @@ async function votableProjects( return mergeWithSubmissions(hackathonId, nostr); } +/** Decrypts a ballot's content with LACRYPTA_NSEC. v2 = NIP-44 (nip04 fallback); + * v1 plaintext passes through. Returns null if it can't be read. */ +async function decryptBallotContent( + secret: Uint8Array, + ev: SignedEvent, +): Promise { + const enc = ev.tags.find((t) => t[0] === "enc")?.[1]; + if (enc !== VOTE_ENC) return ev.content; // v1 plaintext (migration) + try { + const nip44 = await import("nostr-tools/nip44"); + return nip44.decrypt(ev.content, nip44.getConversationKey(secret, ev.pubkey)); + } catch { + try { + const nip04 = await import("nostr-tools/nip04"); + return await nip04.decrypt(secret, ev.pubkey, ev.content); + } catch { + return null; + } + } +} + +export type ClosePreview = { + tally: VotingResults; + winners: VotingWinner[]; + countedBallotIds: string[]; + perVoter: { + pubkey: string; + name: string; + allocations: Record; + total: number; + }[]; + rejected: { pubkey: string; reason: string }[]; +}; + +/** + * Authoritative close pipeline (admin-only): verify each posted ballot's + * signature, decrypt with LACRYPTA_NSEC, re-validate eligibility + budget, tally + * and rank. Returns the preview the admin reviews and the exact result the + * backend will sign on confirm. Does NOT publish. + */ +async function buildClosePreview( + hackathonId: string, + period: VotingPeriod, + ballots: SignedEvent[], + secret: Uint8Array, + closedAt: number, +): Promise { + const { verifyEvent } = await import("nostr-tools/pure"); + const dTag = voteDTag(hackathonId); + + const decrypted: DecryptedBallot[] = []; + const earlyRejected: { pubkey: string; reason: string }[] = []; + for (const ev of ballots) { + // Never trust client-supplied events: signature + envelope first. + if (ev.kind !== VOTING_KIND || ev.tags.find((t) => t[0] === "d")?.[1] !== dTag) { + continue; + } + if (!verifyEvent(ev)) { + earlyRejected.push({ pubkey: ev.pubkey, reason: "bad-signature" }); + continue; + } + const plaintext = await decryptBallotContent(secret, ev); + if (plaintext === null) { + earlyRejected.push({ pubkey: ev.pubkey, reason: "undecryptable" }); + continue; + } + const content = parseBallotContent(plaintext); + if (!content || content.hackathonId !== hackathonId) { + earlyRejected.push({ pubkey: ev.pubkey, reason: "content" }); + continue; + } + decrypted.push({ + id: ev.id, + pubkey: ev.pubkey, + created_at: ev.created_at, + tags: ev.tags, + allocations: content.allocations, + }); + } + + const { results, byVoter, rejected } = tallyDecryptedBallots( + decrypted, + period, + { closedAt }, + ); + + // Resolve each project's primary recipient pubkey (dev → stand-in). + const projects = await votableProjects(hackathonId); + const byProjectId = new Map(projects.map((p) => [p.id, p])); + const recipientCache = new Map(); + const resolveRecipient = (projectId: string): string | null => { + if (recipientCache.has(projectId)) return recipientCache.get(projectId)!; + const project = byProjectId.get(projectId); + const real = project ? primaryProjectPubkey(project) : null; + recipientCache.set(projectId, real); // dev remap applied below (async) + return real; + }; + let winners = computeVotingRanking(results.tally, resolveRecipient); + if (isDevMode()) { + winners = await Promise.all( + winners.map(async (w) => ({ + ...w, + recipientPubkey: w.recipientPubkey + ? await devPubkeyForPubkey(w.recipientPubkey) + : null, + })), + ); + } + + const eligibleByPubkey = new Map(period.eligible.map((v) => [v.pubkey, v])); + const perVoter = [...byVoter.entries()].map(([pubkey, allocations]) => ({ + pubkey, + name: eligibleByPubkey.get(pubkey)?.name ?? `${pubkey.slice(0, 8)}…`, + allocations, + total: Object.values(allocations).reduce((s, n) => s + n, 0), + })); + + return { + tally: { ...results, winners }, + winners, + countedBallotIds: results.countedBallotIds ?? [], + perVoter, + rejected: [ + ...earlyRejected, + ...rejected.map((r) => ({ pubkey: r.pubkey, reason: r.reason })), + ], + }; +} + export async function GET( _req: Request, ctx: { params: Promise<{ id: string }> }, ) { - const { id } = await ctx.params; - if (!getHackathon(id)) return jsonError("Hackatón desconocido.", 404); + const { id: routeParam } = await ctx.params; + const hackathon = getHackathon(routeParam); + if (!hackathon) return jsonError("Hackatón desconocido.", 404); + // Normalize slug → canonical id so voting data keys off "zaps", not "gaming". + const id = hackathon.id; try { const period = await getCachedVotingPeriod(id); return NextResponse.json({ period }); @@ -213,18 +364,24 @@ export async function POST( req: Request, ctx: { params: Promise<{ id: string }> }, ) { - const { id } = await ctx.params; - const hackathon = getHackathon(id); + const { id: routeParam } = await ctx.params; + const hackathon = getHackathon(routeParam); if (!hackathon) return jsonError("Hackatón desconocido.", 404); + // Normalize slug → canonical id so voting data keys off "zaps", not "gaming". + const id = hackathon.id; - let body: { request?: SignedEvent }; + let body: { request?: SignedEvent; ballots?: SignedEvent[] }; try { - body = (await req.json()) as { request?: SignedEvent }; + body = (await req.json()) as { + request?: SignedEvent; + ballots?: SignedEvent[]; + }; } catch { return jsonError("Body JSON invalido."); } const request = body.request; if (!request) return jsonError("Falta request firmado."); + const postedBallots = Array.isArray(body.ballots) ? body.ballots : []; try { const { finalizeEvent, verifyEvent } = await import("nostr-tools/pure"); @@ -241,14 +398,32 @@ export async function POST( if (Math.abs(Math.floor(Date.now() / 1000) - request.created_at) > 10 * 60) { return jsonError("Request expirado.", 401); } - const action = requestTagValue(request, "action"); - if (action !== OPEN_ACTION && action !== CLOSE_ACTION) { + const action = requestTagValue(request, "action") ?? ""; + if (action !== OPEN_ACTION && !CLOSE_ACTIONS.has(action)) { return jsonError("Request no autorizado para administrar la votación.", 401); } if (requestTagValue(request, "h") !== id) { return jsonError("El request no corresponde a este hackatón.", 401); } + // ── Close step 1: decrypt + tally + return preview (admin-gated, no publish) ── + if (action === CLOSE_PREVIEW_ACTION) { + const existing = await fetchVotingPeriodFromRelays(id); + if (!existing || existing.period.status !== "open") { + return jsonError("No hay una votación abierta para previsualizar.", 409); + } + const ballots = + postedBallots.length > 0 ? postedBallots : await fetchBallotEvents(id); + const preview = await buildClosePreview( + id, + existing.period, + ballots, + secret, + Math.floor(Date.now() / 1000), + ); + return NextResponse.json({ ok: true, preview }); + } + const existing = await fetchVotingPeriodFromRelays(id); const now = Math.floor(Date.now() / 1000); // NIP-01 replaceable tie-break keeps the LOWEST id on equal created_at — @@ -274,13 +449,27 @@ export async function POST( name: p.name, })); const soldiers = await getSoldiers().catch(() => []); - const eligible = buildEligibleVoters(soldiers, id); + let eligible = buildEligibleVoters(soldiers, id); + // Block self-votes using real pubkeys before any dev remap. + applyTeamPubkeyBlocks(eligible, projects); + if (isDevMode()) { + // Real users never share secret keys, so dev impersonation signs with a + // deterministic stand-in key derived from each pubkey. Remap eligibility + // to those stand-ins so impersonated soldiers can cast valid ballots + // against the local relay. Budgets and blocks are preserved. + eligible = await Promise.all( + eligible.map(async (v) => ({ + ...v, + pubkey: await devPubkeyForPubkey(v.pubkey), + })), + ); + } + // Env testers carry their own dev keys — merge after the remap. for (const extra of testExtraVoters()) { if (!eligible.some((v) => v.pubkey === extra.pubkey)) { eligible.push(extra); } } - applyTeamPubkeyBlocks(eligible, projects); period = { version: VOTING_SCHEMA_VERSION, @@ -296,16 +485,26 @@ export async function POST( results: null, }; } else { + // close-confirm (and the close-voting alias): re-validate the confirmed + // ballot set and freeze the signed result. if (!existing || existing.period.status !== "open") { return jsonError("No hay una votación abierta para cerrar.", 409); } - const ballots = await fetchBallotEvents(id); - const { results } = tallyBallots(ballots, existing.period, now); + const ballots = + postedBallots.length > 0 ? postedBallots : await fetchBallotEvents(id); + const preview = await buildClosePreview( + id, + existing.period, + ballots, + secret, + now, + ); period = { ...existing.period, status: "closed", closedAt: now, - results, + // tally already carries winners + countedBallotIds (the FREEZE set). + results: preview.tally, }; } diff --git a/app/badges/BadgesClient.tsx b/app/badges/BadgesClient.tsx index 9756e65..7bfd86d 100644 --- a/app/badges/BadgesClient.tsx +++ b/app/badges/BadgesClient.tsx @@ -34,6 +34,7 @@ import { } from "lucide-react"; import { queryProfile } from "nostr-tools/nip05"; import { cn } from "@/lib/cn"; +import { hackathonSlugForId } from "@/lib/hackathons"; import { useAuth, type Auth } from "@/lib/auth"; import { fetchNostrProfile } from "@/lib/nostrProfile"; import { DEFAULT_RELAYS, mergeDataRelays } from "@/lib/nostrRelayConfig"; @@ -484,7 +485,7 @@ export default function BadgesClient({
diff --git a/app/dashboard/DashboardClient.tsx b/app/dashboard/DashboardClient.tsx index 4433a0f..27520aa 100644 --- a/app/dashboard/DashboardClient.tsx +++ b/app/dashboard/DashboardClient.tsx @@ -224,6 +224,30 @@ export default function DashboardClient() {
+ +
+
+
+ +
+
+
+ PARTICIPACIONES +
+
+ Mis hackatones +
+
+ En qué hackatones participaste y con qué proyectos. +
+
+ +
+ + }>
diff --git a/app/dashboard/hackathones/MisHackatonesClient.tsx b/app/dashboard/hackathones/MisHackatonesClient.tsx new file mode 100644 index 0000000..2c48999 --- /dev/null +++ b/app/dashboard/hackathones/MisHackatonesClient.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft, Trophy, FolderGit2, Loader2, Award } from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { isDevMode } from "@/lib/devMode"; +import { + fetchUserProjects, + getCachedUserProjects, + type UserProject, +} from "@/lib/userProjects"; +import { HACKATHONS, hackathonSlugForId } from "@/lib/hackathons"; +import { cn } from "@/lib/cn"; + +const HACKATHON_NAME = new Map(HACKATHONS.map((h) => [h.id, h.name])); +function hackathonName(id: string): string { + return HACKATHON_NAME.get(id) ?? id; +} + +type Group = { hackathonId: string; projects: UserProject[] }; + +export default function MisHackatonesClient() { + const { auth, ready } = useAuth(); + const router = useRouter(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + + // In dev, an impersonated session reads the impersonated user's projects. + const readPubkey = + isDevMode() && auth?.impersonating ? auth.impersonating : auth?.pubkey; + + useEffect(() => { + if (!ready) return; + if (!auth) router.replace("/"); + }, [ready, auth, router]); + + useEffect(() => { + if (!readPubkey) return; + const cached = getCachedUserProjects(readPubkey); + if (cached) { + setProjects(cached.projects); + setLoading(false); + } + let cancelled = false; + fetchUserProjects(readPubkey) + .then((doc) => { + if (!cancelled) setProjects(doc.projects); + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [readPubkey]); + + const groups = useMemo(() => { + const byHackathon = new Map(); + for (const p of projects) { + if (!p.hackathon) continue; + const list = byHackathon.get(p.hackathon) ?? []; + list.push(p); + byHackathon.set(p.hackathon, list); + } + return [...byHackathon.entries()] + .map(([hackathonId, ps]) => ({ hackathonId, projects: ps })) + .sort((a, b) => hackathonName(a.hackathonId).localeCompare(hackathonName(b.hackathonId))); + }, [projects]); + + const projectsInHackathons = groups.reduce((n, g) => n + g.projects.length, 0); + + return ( +
+
+ + + Volver al dashboard + + +

+ Mis hackatones +

+

+ Los hackatones en los que participaste y con qué proyectos. + {isDevMode() && auth?.impersonating && ( + (usuario impersonado) + )} +

+ + {/* Summary */} +
+ } /> + } /> +
+ + {loading ? ( +
+ + Cargando participaciones… +
+ ) : groups.length === 0 ? ( +
+

+ Todavía no participaste en ningún hackatón con un proyecto Nostr. +

+ + + Ir a Mis proyectos + +
+ ) : ( +
+ {groups.map((g) => ( +
+
+ + + {hackathonName(g.hackathonId)} + + + {g.projects.length} proyecto{g.projects.length === 1 ? "" : "s"} + +
+
    + {g.projects.map((p) => ( +
  • + +
    +
    + {p.name} +
    + {p.description && ( +
    + {p.description} +
    + )} +
    + + {p.status} + + +
  • + ))} +
+
+ ))} +
+ )} +
+
+ ); +} + +function SummaryCell({ + label, + value, + icon, +}: { + label: string; + value: number; + icon: React.ReactNode; +}) { + return ( +
+ {icon} +
+
+ {value} +
+
+ {label} +
+
+
+ ); +} diff --git a/app/dashboard/hackathones/page.tsx b/app/dashboard/hackathones/page.tsx new file mode 100644 index 0000000..e738bf0 --- /dev/null +++ b/app/dashboard/hackathones/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import MisHackatonesClient from "./MisHackatonesClient"; + +export const metadata: Metadata = { + title: "Mis hackatones", + description: "Los hackatones en los que participaste y con qué proyectos.", + robots: { index: false, follow: false }, +}; + +export default function MisHackatonesPage() { + return ( + + + + ); +} diff --git a/app/dashboard/projects/UserProjectsClient.tsx b/app/dashboard/projects/UserProjectsClient.tsx index 30ee837..c8ce1d8 100644 --- a/app/dashboard/projects/UserProjectsClient.tsx +++ b/app/dashboard/projects/UserProjectsClient.tsx @@ -26,6 +26,7 @@ import { NIP05_REGEX, queryProfile } from "nostr-tools/nip05"; import { GithubIcon } from "@/components/BrandIcons"; import { useToast } from "@/components/Toast"; import { useAuth } from "@/lib/auth"; +import { isDevMode } from "@/lib/devMode"; import { useScrollLock } from "@/lib/useScrollLock"; import { getSigner } from "@/lib/nostrSigner"; import { @@ -41,7 +42,7 @@ import { type TeamMember, type UserProject, } from "@/lib/userProjects"; -import { HACKATHONS } from "@/lib/hackathons"; +import { HACKATHONS, hackathonSlugForId } from "@/lib/hackathons"; import { useNostrProfile } from "@/lib/nostrProfile"; import { mergeDataRelays } from "@/lib/nostrRelayConfig"; @@ -272,8 +273,13 @@ export default function UserProjectsClient() { useEffect(() => { if (!auth) return; + // In dev mode, when impersonating a user, read THEIR projects (the real + // pubkey) rather than the stand-in session key. + const readPubkey = + isDevMode() && auth.impersonating ? auth.impersonating : auth.pubkey; + // Hydrate from cache synchronously — no flicker, no loading state. - const cached = getCachedUserProjects(auth.pubkey); + const cached = getCachedUserProjects(readPubkey); if (cached) { setDoc(cached); setLoading(false); @@ -285,7 +291,7 @@ export default function UserProjectsClient() { let cancelled = false; setRefreshing(true); setError(null); - fetchUserProjects(auth.pubkey, relays) + fetchUserProjects(readPubkey, relays) .then((fresh) => { if (cancelled) return; setDoc((prev) => { @@ -849,7 +855,7 @@ function ProjectCard({ disabled: boolean; }) { const detailHref = project.hackathon - ? `/hackathons/${project.hackathon}/${project.id}` + ? `/hackathons/${hackathonSlugForId(project.hackathon)}/${project.id}` : pubkey ? `/projects/${pubkey}/${project.id}` : null; diff --git a/app/dev/voting/DevVotingClient.tsx b/app/dev/voting/DevVotingClient.tsx index f1c37ed..fc60ef7 100644 --- a/app/dev/voting/DevVotingClient.tsx +++ b/app/dev/voting/DevVotingClient.tsx @@ -3,63 +3,29 @@ import { useEffect, useMemo, useState } from "react"; import { Copy, KeyRound, Plus, Trash2, UserCheck } from "lucide-react"; import { HACKATHONS, hackathonStatus } from "@/lib/hackathons"; -import { useAuth, setAuth, clearAuth } from "@/lib/auth"; -import { useToast } from "@/components/Toast"; +import { useAuth, clearAuth } from "@/lib/auth"; +import { useDevIdentities } from "@/lib/useDevIdentities"; import { cn } from "@/lib/cn"; import type { VotingPeriod } from "@/lib/voting"; import VotingSection from "@/app/hackathons/[id]/VotingSection"; -type DevIdentity = { - label: string; - pubkey: string; - npub: string; - /** 32-byte secret as plain array (same encoding as Auth.localSecret). */ - secret: number[]; -}; - -const STORAGE_KEY = "labs:dev:voting-voters:v1"; - -function loadIdentities(): DevIdentity[] { - if (typeof window === "undefined") return []; - try { - const raw = window.localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw) as DevIdentity[]; - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function saveIdentities(identities: DevIdentity[]) { - try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(identities)); - } catch { - /* quota */ - } -} - export default function DevVotingClient({ testNamespace, }: { testNamespace: boolean; }) { const { auth } = useAuth(); - const { push } = useToast(); + const { identities, generateIdentity, removeIdentity, loginAs, copy } = + useDevIdentities(); const activeHackathon = useMemo( () => HACKATHONS.find((h) => hackathonStatus(h) === "active") ?? HACKATHONS[0], [], ); const [hackathonId, setHackathonId] = useState(activeHackathon.id); - const [identities, setIdentities] = useState([]); const [period, setPeriod] = useState(null); const [loadingPeriod, setLoadingPeriod] = useState(false); - useEffect(() => { - setIdentities(loadIdentities()); - }, []); - useEffect(() => { let cancelled = false; setLoadingPeriod(true); @@ -79,52 +45,6 @@ export default function DevVotingClient({ }; }, [hackathonId]); - async function generateIdentity() { - const { generateSecretKey, getPublicKey } = await import( - "nostr-tools/pure" - ); - const { npubEncode } = await import("nostr-tools/nip19"); - const secret = generateSecretKey(); - const pubkey = getPublicKey(secret); - const identity: DevIdentity = { - label: `Identidad ${identities.length + 1}`, - pubkey, - npub: npubEncode(pubkey), - secret: Array.from(secret), - }; - const next = [...identities, identity]; - setIdentities(next); - saveIdentities(next); - } - - function removeIdentity(pubkey: string) { - const next = identities.filter((i) => i.pubkey !== pubkey); - setIdentities(next); - saveIdentities(next); - } - - function loginAs(identity: DevIdentity) { - setAuth({ - method: "local", - pubkey: identity.pubkey, - localSecret: identity.secret, - }); - push({ - kind: "success", - title: `Sesión iniciada como ${identity.label}`, - description: identity.npub.slice(0, 20) + "…", - }); - } - - async function copy(text: string, what: string) { - try { - await navigator.clipboard.writeText(text); - push({ kind: "info", title: `${what} copiado` }); - } catch { - push({ kind: "error", title: "No se pudo copiar" }); - } - } - return (
{/* ── Identity lab ── */} diff --git a/app/hackathons/[id]/HackathonProjectsList.tsx b/app/hackathons/[id]/HackathonProjectsList.tsx index 2a98df6..390bd5a 100644 --- a/app/hackathons/[id]/HackathonProjectsList.tsx +++ b/app/hackathons/[id]/HackathonProjectsList.tsx @@ -42,7 +42,7 @@ import { TOP10_RELAYS, type CommunityProject, } from "@/lib/userProjects"; -import { formatSats } from "@/lib/hackathons"; +import { formatSats, hackathonSlugForId } from "@/lib/hackathons"; import { useHackathonResults, type WinnerEntry } from "@/lib/nostrReports"; import { GithubIcon } from "@/components/BrandIcons"; import { cn } from "@/lib/cn"; @@ -127,7 +127,7 @@ export default function HackathonProjectsList({ ); useLayoutEffect(() => { - return restoreScrollPosition(`/hackathons/${hackathon.id}`); + return restoreScrollPosition(`/hackathons/${hackathonSlugForId(hackathon.id)}`); }, [hackathon.id]); const awards = useMemo( @@ -465,7 +465,7 @@ function ProjectRow({ const score = project.report?.finalScore ?? null; const prize = award?.prize ?? nostrWinner?.sats ?? null; const isNostr = !!project.nostrEventId; - const href = `/hackathons/${hackathonId}/${project.id}`; + const href = `/hackathons/${hackathonSlugForId(hackathonId)}/${project.id}`; const authorDisplayName = isNostr ? displayNameForNostrProject(project) : null; @@ -485,7 +485,7 @@ function ProjectRow({ ) { return; } - rememberScrollPosition(`/hackathons/${hackathonId}`); + rememberScrollPosition(`/hackathons/${hackathonSlugForId(hackathonId)}`); }, }; diff --git a/app/hackathons/[id]/HackathonResultsClient.tsx b/app/hackathons/[id]/HackathonResultsClient.tsx index 639334c..e9280e2 100644 --- a/app/hackathons/[id]/HackathonResultsClient.tsx +++ b/app/hackathons/[id]/HackathonResultsClient.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { Trophy, Loader2 } from "lucide-react"; import { useHackathonResults, type HackathonResults } from "@/lib/nostrReports"; -import { formatSats } from "@/lib/hackathons"; +import { formatSats, hackathonSlugForId } from "@/lib/hackathons"; import { cn } from "@/lib/cn"; function medal(position: number): string { @@ -149,7 +149,7 @@ function PrizeList({ .map((w) => (
  • diff --git a/app/hackathons/[id]/VotingSection.tsx b/app/hackathons/[id]/VotingSection.tsx index f32db51..c60bd21 100644 --- a/app/hackathons/[id]/VotingSection.tsx +++ b/app/hackathons/[id]/VotingSection.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckCircle2, @@ -11,26 +12,56 @@ import { Trophy, Vote, X, + Users, + ListChecks, } from "lucide-react"; import { useAuth } from "@/lib/auth"; +import { hackathonSlugForId } from "@/lib/hackathons"; import { getSigner, type SignedEvent } from "@/lib/nostrSigner"; import { useToast } from "@/components/Toast"; import { useScrollLock } from "@/lib/useScrollLock"; import { cn } from "@/lib/cn"; import { isVotingTestNamespace, - tallyBallots, type VotingPeriod, type VotingResults, + type VotingWinner, } from "@/lib/voting"; import { + claimedVotes, + decryptOwnBallot, + fetchAllBallotEvents, publishBallot, subscribeToBallots, subscribeToVotingPeriod, } from "@/lib/votingClient"; +/** Shape of the close-preview the backend returns (decrypted, admin-only). */ +type ClosePreviewData = { + tally: VotingResults; + winners: VotingWinner[]; + countedBallotIds: string[]; + perVoter: { + pubkey: string; + name: string; + allocations: Record; + total: number; + }[]; + rejected: { pubkey: string; reason: string }[]; +}; + type Pubkeys = { adminPubkey: string | null; publisherPubkey: string | null }; +type VoterRow = { + pubkey: string; + name: string; + maxVotes: number; + /** Declared total from the ballot's ["votes"] tag (allocations are encrypted). */ + used: number; + remaining: number; + voted: boolean; +}; + /** * Community voting for the hackathon's projects. Eligibility, vote budgets and * the votable project list come frozen inside the period event La Crypta @@ -54,7 +85,22 @@ export default function VotingSection({ adminPubkey: null, publisherPubkey: null, }); - const [period, setPeriod] = useState(initialPeriod); + const [rawPeriod, setPeriod] = useState(initialPeriod); + // Defensive: a frozen period event may carry duplicate projects (e.g. it was + // opened before community submissions were deduped). Collapse by id so every + // consumer — ballot editor, tally, modal — keeps unique React keys. + const period = useMemo(() => { + if (!rawPeriod) return null; + const seen = new Set(); + const projects = rawPeriod.projects.filter((p) => { + if (seen.has(p.id)) return false; + seen.add(p.id); + return true; + }); + return projects.length === rawPeriod.projects.length + ? rawPeriod + : { ...rawPeriod, projects }; + }, [rawPeriod]); const [ballots, setBallots] = useState>(new Map()); useEffect(() => { @@ -145,18 +191,84 @@ export default function VotingSection({ !!pubkeys.adminPubkey && auth.pubkey === pubkeys.adminPubkey; - const liveTally = useMemo(() => { - if (!period) return null; - return tallyBallots([...ballots.values()], period); - }, [ballots, period]); + const admin = useAdminVoting(hackathonId, setPeriod); + const [detailOpen, setDetailOpen] = useState(false); + + // Ballots are NIP-44 encrypted, so the client can't tally them. While open we + // only surface WHO voted + their DECLARED count (the plaintext ["votes"] tag); + // the real allocations/tally are revealed only when the admin closes. + const voterRows = useMemo(() => { + if (!period) return []; + return period.eligible + .map((v) => { + const ballot = ballots.get(v.pubkey); + const used = ballot ? claimedVotes(ballot) : 0; + return { + pubkey: v.pubkey, + name: v.name, + maxVotes: v.maxVotes, + used, + remaining: Math.max(0, v.maxVotes - used), + voted: !!ballot, + }; + }) + .sort((a, b) => Number(b.voted) - Number(a.voted) || a.name.localeCompare(b.name)); + }, [period, ballots]); + + // The voter's own ballot, self-decrypted (symmetric NIP-44 key) to pre-fill + // the editor. Runs only when their own ballot changes. + const [ownAllocations, setOwnAllocations] = useState | null>(null); + const ownBallotId = auth?.pubkey + ? (ballots.get(auth.pubkey.toLowerCase())?.id ?? null) + : null; + useEffect(() => { + let cancelled = false; + const ev = auth?.pubkey ? ballots.get(auth.pubkey.toLowerCase()) : null; + if (!ev || !auth || !pubkeys.publisherPubkey) { + setOwnAllocations(null); + return; + } + void (async () => { + try { + const signer = await getSigner(auth); + const alloc = await decryptOwnBallot( + signer, + pubkeys.publisherPubkey!, + ev, + ); + if (!cancelled) setOwnAllocations(alloc); + } catch { + if (!cancelled) setOwnAllocations(null); + } + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [auth?.pubkey, ownBallotId, pubkeys.publisherPubkey]); + + const totals = useMemo(() => { + const budget = voterRows.reduce((s, r) => s + r.maxVotes, 0); + const used = voterRows.reduce((s, r) => s + r.used, 0); + return { + budget, + used, + remaining: budget - used, + votedCount: voterRows.filter((r) => r.voted).length, + eligibleCount: voterRows.length, + }; + }, [voterRows]); // Nothing to show before the first opening (admins see the open button). if (!period && !isAdmin) return null; + // Results stay hidden while open (ballots are encrypted); only the closed, + // signed period carries the canonical tally + winners. const results: VotingResults | null = - period?.status === "closed" - ? period.results - : (liveTally?.results ?? null); + period?.status === "closed" ? period.results : null; const voter = period && auth?.pubkey @@ -168,10 +280,6 @@ export default function VotingSection({ const ownBallotEvent = auth?.pubkey ? (ballots.get(auth.pubkey.toLowerCase()) ?? null) : null; - const ownAllocations = - voter && auth?.pubkey - ? (liveTally?.byVoter.get(auth.pubkey.toLowerCase()) ?? null) - : null; return (
    @@ -201,19 +309,43 @@ export default function VotingSection({ )}
  • - {isAdmin && ( - - )} +
    + {period && ( + + )} + {isAdmin && } +
    {!period ? ( -

    - La votación comunitaria de {hackathonName} todavía no fue abierta. -

    +
    +

    + La votación comunitaria de {hackathonName} todavía no fue + abierta. +

    + {isAdmin && ( + + )} +
    ) : ( <>

    @@ -236,6 +368,7 @@ export default function VotingSection({ voterPubkey={auth.pubkey.toLowerCase()} maxVotes={voter.maxVotes} blocked={voter.blocked} + lacryptaPubkey={pubkeys.publisherPubkey ?? ""} initialAllocations={ownAllocations} prevBallotCreatedAt={ownBallotEvent?.created_at ?? 0} onPublished={(ev) => { @@ -255,65 +388,233 @@ export default function VotingSection({

    )} - {results && ( - + {period.status === "open" && ( +

    + + Los votos van cifrados a La Crypta. Los resultados se revelan + al cerrar la votación — por ahora solo se ve quién votó. +

    + )} + + {period.status === "closed" && results && ( + <> + + {results.winners && results.winners.length > 0 && ( + + )} + )} )}
    + + {detailOpen && period && ( + setDetailOpen(false)} + /> + )}
    ); } -/* ───────────────────────── Admin controls ───────────────────────── */ - -type AdminStep = "idle" | "signing" | "publishing"; +/* ───────────────────────── Detail modal ───────────────────────── */ -function AdminVotingControls({ - hackathonId, +function VotingDetailModal({ period, - onPeriod, + rows, + totals, + closed, + isAdmin, + onClose, }: { - hackathonId: string; - period: VotingPeriod | null; - onPeriod: (period: VotingPeriod) => void; + period: VotingPeriod; + rows: VoterRow[]; + totals: { + budget: number; + used: number; + remaining: number; + votedCount: number; + eligibleCount: number; + }; + closed: boolean; + isAdmin: boolean; + onClose: () => void; +}) { + useScrollLock(true); + return ( +
    +
    e.stopPropagation()} + > +
    +
    + +

    Padrón y votos

    + + {closed ? "CERRADA" : "ABIERTA"} + +
    + +
    + + {/* Totals */} +
    + + + +
    + + {/* Per-voter roll */} +
    +
      + {rows.map((r) => ( +
    • +
      + + + {r.name} + + + {r.used}/{r.maxVotes} votos declarados · {r.remaining}{" "} + restante{r.remaining === 1 ? "" : "s"} + + + {r.voted ? ( + + + VOTÓ + + ) : ( + + SIN VOTAR + + )} +
      +
    • + ))} +
    +
    + +
    + + {closed + ? "Resultados congelados y publicados en Nostr." + : "Los votos están cifrados — el detalle (qué votó cada uno) se revela al cerrar la votación."} + {" · "} + {period.projects.length} proyectos votables +
    +
    +
    + ); +} + +function Stat({ + label, + value, + accent, +}: { + label: string; + value: number | string; + accent: string; }) { + return ( +
    +
    + {value} +
    +
    + {label} +
    +
    + ); +} + +/* ───────────────────────── Admin controls ───────────────────────── */ + +type AdminStep = "idle" | "signing" | "publishing" | "previewing"; + +function useAdminVoting( + hackathonId: string, + onPeriod: (period: VotingPeriod) => void, +) { const { auth } = useAuth(); const { push } = useToast(); const [step, setStep] = useState("idle"); - const [confirmClose, setConfirmClose] = useState(false); - useScrollLock(confirmClose); - const busy = step !== "idle"; + // The exact ballot set the admin reviewed in the preview — posted verbatim on + // confirm so the signed result freezes precisely what was shown. + const frozenBallots = useRef([]); - const runAction = useCallback( - async (action: "open-voting" | "close-voting", force = false) => { - if (!auth || busy) return; - setStep("signing"); - try { - const signer = await getSigner(auth); - const tags: string[][] = [ + const signRequest = useCallback( + async (action: string, extraTags: string[][] = []) => { + const signer = await getSigner(auth!); + return signer.signEvent({ + kind: 27235, + pubkey: signer.pubkey, + created_at: Math.floor(Date.now() / 1000), + content: `${action} · votación comunitaria`, + tags: [ ["u", `/api/hackathons/${hackathonId}/voting`], ["method", "POST"], ["action", action], ["h", hackathonId], - ]; - if (force) tags.push(["force", "1"]); - const request = await signer.signEvent({ - kind: 27235, - pubkey: signer.pubkey, - created_at: Math.floor(Date.now() / 1000), - content: - action === "open-voting" - ? "Abrir votación comunitaria" - : "Cerrar votación comunitaria", - tags, - }); + ...extraTags, + ], + }); + }, + [auth, hackathonId], + ); + /** Open / refresh-padrón (no encryption involved at open). */ + const runAction = useCallback( + async (action: "open-voting", force = false) => { + if (!auth || busy) return; + setStep("signing"); + try { + const request = await signRequest( + action, + force ? [["force", "1"]] : [], + ); setStep("publishing"); const res = await fetch(`/api/hackathons/${hackathonId}/voting`, { method: "POST", @@ -329,21 +630,14 @@ function AdminVotingControls({ if (!res.ok || !data.ok) { throw new Error(data.error || "No se pudo actualizar la votación."); } - - // Optimistic refresh — the relay subscription will confirm shortly. const fresh = await fetch( `/api/hackathons/${hackathonId}/voting`, ).then((r) => (r.ok ? r.json() : null)); if (fresh?.period) onPeriod(fresh.period as VotingPeriod); - push({ kind: "success", - title: - data.status === "open" ? "Votación abierta" : "Votación cerrada", - description: - data.status === "open" - ? `${data.eligibleCount ?? 0} votantes habilitados.` - : "Los resultados quedaron congelados y publicados en Nostr.", + title: "Votación abierta", + description: `${data.eligibleCount ?? 0} votantes habilitados.`, }); } catch (error) { push({ @@ -354,18 +648,118 @@ function AdminVotingControls({ }); } finally { setStep("idle"); - setConfirmClose(false); } }, - [auth, busy, hackathonId, onPeriod, push], + [auth, busy, hackathonId, onPeriod, push, signRequest], ); + /** Close step 1: fetch all ballots, ask the backend to decrypt + tally them + * into a preview the admin reviews. Nothing is published. */ + const closePreview = useCallback(async (): Promise => { + if (!auth || busy) return null; + setStep("previewing"); + try { + const ballots = await fetchAllBallotEvents(hackathonId); + frozenBallots.current = ballots; + const request = await signRequest("close-preview"); + const res = await fetch(`/api/hackathons/${hackathonId}/voting`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request, ballots }), + }); + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean; + preview?: ClosePreviewData; + error?: string; + }; + if (!res.ok || !data.ok || !data.preview) { + throw new Error(data.error || "No se pudo previsualizar el cierre."); + } + return data.preview; + } catch (error) { + push({ + kind: "error", + title: "Error al previsualizar", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + return null; + } finally { + setStep("idle"); + } + }, [auth, busy, hackathonId, push, signRequest]); + + /** Close step 2: the admin authorizes — re-post the SAME frozen ballot set; + * the backend re-validates, signs and publishes the frozen result. */ + const closeConfirm = useCallback(async (): Promise => { + if (!auth || busy) return false; + setStep("publishing"); + try { + const request = await signRequest("close-confirm"); + const res = await fetch(`/api/hackathons/${hackathonId}/voting`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request, ballots: frozenBallots.current }), + }); + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean; + error?: string; + }; + if (!res.ok || !data.ok) { + throw new Error(data.error || "No se pudo cerrar la votación."); + } + const fresh = await fetch( + `/api/hackathons/${hackathonId}/voting`, + ).then((r) => (r.ok ? r.json() : null)); + if (fresh?.period) onPeriod(fresh.period as VotingPeriod); + push({ + kind: "success", + title: "Votación cerrada", + description: "El resultado quedó firmado y publicado en Nostr.", + }); + return true; + } catch (error) { + push({ + kind: "error", + title: "Error al cerrar", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + return false; + } finally { + setStep("idle"); + } + }, [auth, busy, hackathonId, onPeriod, push, signRequest]); + + return { step, busy, runAction, closePreview, closeConfirm }; +} + +type AdminVoting = ReturnType; + +function AdminVotingControls({ + period, + admin, +}: { + period: VotingPeriod | null; + admin: AdminVoting; +}) { + const { step, busy, runAction, closePreview, closeConfirm } = admin; + const [preview, setPreview] = useState(null); + useScrollLock(!!preview); + const label = step === "signing" ? "Firmando…" - : step === "publishing" - ? "Publicando…" - : null; + : step === "previewing" + ? "Descifrando…" + : step === "publishing" + ? "Publicando…" + : null; + + async function handleStartClose() { + const p = await closePreview(); + if (p) setPreview(p); + } return ( <> @@ -401,7 +795,7 @@ function AdminVotingControls({ + + + + +
    + La Crypta descifró los votos. Revisá el resultado antes de firmarlo — + una vez confirmado, estos {counted} voto{counted === 1 ? "" : "s"}{" "} + quedan congelados y los posteriores no cuentan. +
    + +
    + {/* Ranking */} +
    +
    + Ranking ({preview.winners.length})
    -

    - Se calculará el resultado final con los votos recibidos hasta - ahora y se publicará firmado por La Crypta. Después del cierre - los votos nuevos no cuentan. -

    -
    - - +
      + {preview.winners.map((w) => ( +
    1. + + {w.position}° + + + {w.projectName} + + + {w.votes} + +
    2. + ))} + {preview.winners.length === 0 && ( +
    3. + Nadie recibió votos. +
    4. + )} +
    +
    + + {/* Per-voter decrypted ballots */} +
    +
    + Votos por votante ({preview.perVoter.length})
    +
      + {preview.perVoter.map((v) => ( +
    • +
      + + {v.name} + + + {v.total} voto{v.total === 1 ? "" : "s"} + +
      +
        + {Object.entries(v.allocations) + .sort((a, b) => b[1] - a[1]) + .map(([projectId, votes]) => ( +
      • + + {projectName.get(projectId) ?? projectId} + + + ×{votes} + +
      • + ))} +
      +
    • + ))} + {preview.perVoter.length === 0 && ( +
    • + Ningún voto válido todavía. +
    • + )} +
    + + {/* Rejected */} + {preview.rejected.length > 0 && ( +
    +
    + Rechazados ({preview.rejected.length}) +
    +
      + {preview.rejected.map((r, i) => ( +
    • + {r.pubkey.slice(0, 16)}… + {r.reason} +
    • + ))} +
    +
    + )}
    - )} - + +
    + + +
    +
    + ); } @@ -478,6 +1002,7 @@ function BallotEditor({ voterPubkey, maxVotes, blocked, + lacryptaPubkey, initialAllocations, prevBallotCreatedAt, onPublished, @@ -487,6 +1012,7 @@ function BallotEditor({ voterPubkey: string; maxVotes: number; blocked: string[]; + lacryptaPubkey: string; initialAllocations: Record | null; prevBallotCreatedAt: number; onPublished: (ev: SignedEvent) => void; @@ -536,6 +1062,7 @@ function BallotEditor({ signer, hackathonId, allocations, + lacryptaPubkey, prevBallotCreatedAt, ); dirty.current = false; @@ -720,3 +1247,55 @@ function TallyBoard({ ); } + +/* ───────────────────────── Winners (post-close) ───────────────────────── */ + +const MEDAL = ["🥇", "🥈", "🥉"]; + +function WinnersPanel({ + winners, + hackathonId, + countedBallots, +}: { + winners: VotingWinner[]; + hackathonId: string; + countedBallots: number; +}) { + return ( +
    +
    + + + Ganadores de la comunidad + +
    +
      + {winners.map((w) => ( +
    1. + + {MEDAL[w.position - 1] ?? `${w.position}°`} + + + {w.projectName} + + + {w.votes} {w.votes === 1 ? "voto" : "votos"} + +
    2. + ))} +
    +

    + Resultado firmado por La Crypta · {countedBallots} voto + {countedBallots === 1 ? "" : "s"} contado + {countedBallots === 1 ? "" : "s"}. La entrega de premios (Lightning y + badges) se realiza por separado. +

    +
    + ); +} diff --git a/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx b/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx index 08fac51..a3c75e2 100644 --- a/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx +++ b/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx @@ -32,7 +32,7 @@ import { type CommunityScanProgress, type RelayScanStatus, } from "@/lib/userProjects"; -import { getHackathon, type Hackathon, type ProjectReport } from "@/lib/hackathons"; +import { getHackathon, hackathonSlugForId, type Hackathon, type ProjectReport } from "@/lib/hackathons"; import { useProjectReport } from "@/lib/nostrReports"; import { useAuth } from "@/lib/auth"; import { getSigner } from "@/lib/nostrSigner"; @@ -93,7 +93,7 @@ function ProjectRelaySearchLoading({
    @@ -664,7 +664,7 @@ export default function NostrProjectPage({
    @@ -687,7 +687,7 @@ export default function NostrProjectPage({ project={project} authorPubkey={project.author} authorPicture={authorPicture} - backHref={`/hackathons/${hackathonId}`} + backHref={`/hackathons/${hackathonSlugForId(hackathonId)}`} backLabel={hackathon?.name ?? "Hackatones"} contextLabel={`${hackathon?.icon ?? ""} ${hackathon?.name ?? hackathonId}${ hackathon?.monthShort && hackathon?.year diff --git a/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx b/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx index f48b0c5..ce5ee53 100644 --- a/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx +++ b/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx @@ -1,5 +1,5 @@ import { getNostrProject } from "@/lib/nostrCache"; -import { getHackathon } from "@/lib/hackathons"; +import { getHackathon, hackathonSlugForId } from "@/lib/hackathons"; import { breadcrumbLd, jsonLdScript } from "@/lib/jsonld"; import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks"; import NostrProjectPageClient from "./NostrProjectPageClient"; @@ -36,7 +36,10 @@ export default async function NostrProjectServer({ } const hackathon = getHackathon(hackathonId); - const url = `https://lacrypta.dev/hackathons/${hackathonId}/${projectId}`; + // Public URLs use the slug (e.g. "gaming"); data lookups above use the + // canonical id ("zaps") that published events reference. + const slug = hackathonSlugForId(hackathonId); + const url = `https://lacrypta.dev/hackathons/${slug}/${projectId}`; const team = dedupeSoldierProfileMembers(project.team); const projectLd = { @@ -54,7 +57,7 @@ export default async function NostrProjectServer({ ? { "@type": "Event", name: `${hackathon.name} — Hackatón #${hackathon.number}`, - url: `https://lacrypta.dev/hackathons/${hackathon.id}`, + url: `https://lacrypta.dev/hackathons/${slug}`, } : undefined, author: team.map((m) => ({ @@ -77,7 +80,7 @@ export default async function NostrProjectServer({ ? [ { name: hackathon.name, - url: `https://lacrypta.dev/hackathons/${hackathon.id}`, + url: `https://lacrypta.dev/hackathons/${slug}`, }, ] : []), diff --git a/app/hackathons/[id]/[projectId]/opengraph-image.tsx b/app/hackathons/[id]/[projectId]/opengraph-image.tsx index 3644edb..3857d83 100644 --- a/app/hackathons/[id]/[projectId]/opengraph-image.tsx +++ b/app/hackathons/[id]/[projectId]/opengraph-image.tsx @@ -5,6 +5,8 @@ import { getHackathon, getHackathonProjects, getProject, + hackathonSlug, + hackathonSlugForId, prizeForProject, } from "@/lib/hackathons"; import { @@ -17,20 +19,30 @@ export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; export async function generateStaticParams() { - const curated = HACKATHONS.flatMap((h) => - getHackathonProjects(h.id).map((p) => ({ id: h.id, projectId: p.id })), - ); - const hackathonIds = new Set(HACKATHONS.map((h) => h.id)); - const seen = new Set(curated.map((p) => `${p.id}/${p.projectId}`)); + // Dedup keys off the canonical id; the route segment is the public slug. + const seen = new Set(); + const out: { id: string; projectId: string }[] = []; + + for (const h of HACKATHONS) { + for (const p of getHackathonProjects(h.id)) { + const key = `${h.id}/${p.id}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ id: hackathonSlug(h), projectId: p.id }); + } + } const { projects } = await getNostrSubmissionsSnapshot(); - const community = projects - .filter((p) => p.hackathon && hackathonIds.has(p.hackathon)) - .filter((p) => !seen.has(`${p.hackathon}/${p.id}`)) - .map((p) => ({ id: p.hackathon as string, projectId: p.id })); + for (const p of projects) { + if (!p.hackathon || !hackathonIds.has(p.hackathon)) continue; + const key = `${p.hackathon}/${p.id}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ id: hackathonSlugForId(p.hackathon), projectId: p.id }); + } - return [...curated, ...community]; + return out; } const STATUS_COLOR: Record = { @@ -54,12 +66,14 @@ export default async function Image({ }: { params: Promise<{ id: string; projectId: string }>; }) { - const { id, projectId } = await params; - const h = getHackathon(id); + const { id: routeParam, projectId } = await params; + const h = getHackathon(routeParam); if (!h) { const { default: DefaultOG } = await import("../../../opengraph-image"); return DefaultOG(); } + // Data lookups key off the canonical id, not the slug. + const id = h.id; const curated = getProject(id, projectId); let name: string; diff --git a/app/hackathons/[id]/[projectId]/page.tsx b/app/hackathons/[id]/[projectId]/page.tsx index 371a014..5cf25f1 100644 --- a/app/hackathons/[id]/[projectId]/page.tsx +++ b/app/hackathons/[id]/[projectId]/page.tsx @@ -20,6 +20,8 @@ import { getHackathon, getProject, getHackathonProjects, + hackathonSlug, + hackathonSlugForId, prizeForProject, } from "@/lib/hackathons"; import { GithubIcon } from "@/components/BrandIcons"; @@ -36,26 +38,34 @@ import { import NostrProjectServer from "./NostrProjectServer"; export async function generateStaticParams() { - const curated = HACKATHONS.flatMap((h) => - getHackathonProjects(h.id).map((p) => ({ - id: h.id, - projectId: p.id, - })), - ); - const hackathonIds = new Set(HACKATHONS.map((h) => h.id)); - const seen = new Set(curated.map((p) => `${p.id}/${p.projectId}`)); + // Dedup keys off the canonical hackathon id; the emitted route segment is the + // public slug (e.g. "gaming" for the "zaps" hackathon). + const seen = new Set(); + const out: { id: string; projectId: string }[] = []; + + for (const h of HACKATHONS) { + for (const p of getHackathonProjects(h.id)) { + const key = `${h.id}/${p.id}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ id: hackathonSlug(h), projectId: p.id }); + } + } // Prerender every Nostr-submitted project visible at build time. Without // this, community-only projects (no curated JSON entry) 404 in production // because the dynamic fallback never gets a chance to run. const { projects } = await getNostrSubmissionsSnapshot(); - const community = projects - .filter((p) => p.hackathon && hackathonIds.has(p.hackathon)) - .filter((p) => !seen.has(`${p.hackathon}/${p.id}`)) - .map((p) => ({ id: p.hackathon as string, projectId: p.id })); + for (const p of projects) { + if (!p.hackathon || !hackathonIds.has(p.hackathon)) continue; + const key = `${p.hackathon}/${p.id}`; + if (seen.has(key)) continue; // dedup vs curated AND other community events + seen.add(key); + out.push({ id: hackathonSlugForId(p.hackathon), projectId: p.id }); + } - return [...curated, ...community]; + return out; } function truncate(s: string, max = 155): string { @@ -69,9 +79,11 @@ export async function generateMetadata({ params: Promise<{ id: string; projectId: string }>; }): Promise { await connection(); - const { id, projectId } = await params; - const h = getHackathon(id); + const { id: routeParam, projectId } = await params; + const h = getHackathon(routeParam); if (!h) return { title: "Proyecto" }; + // Data lookups key off the canonical id; the public URL uses the slug. + const id = h.id; const curated = getProject(id, projectId); let name: string | null = null; @@ -90,7 +102,7 @@ export async function generateMetadata({ if (!name) return { title: "Proyecto" }; - const url = `/hackathons/${id}/${projectId}`; + const url = `/hackathons/${hackathonSlug(h)}/${projectId}`; const desc = truncate(description || `Proyecto presentado en ${h.name}.`); return { title: `${name} · ${h.name}`, @@ -201,9 +213,11 @@ async function ProjectPageContent({ params: Promise; }) { await connection(); - const { id, projectId } = await params; - const hackathon = getHackathon(id); + const { id: routeParam, projectId } = await params; + const hackathon = getHackathon(routeParam); if (!hackathon) notFound(); + // Data lookups key off the canonical id; public links use the slug. + const id = hackathon.id; const project = getProject(id, projectId); if (!project) { return ; @@ -223,18 +237,18 @@ async function ProjectPageContent({ { name: "Hackatones", url: "https://lacrypta.dev/hackathons" }, { name: hackathon.name, - url: `https://lacrypta.dev/hackathons/${hackathon.id}`, + url: `https://lacrypta.dev/hackathons/${hackathonSlug(hackathon)}`, }, { name: project.name, - url: `https://lacrypta.dev/hackathons/${hackathon.id}/${project.id}`, + url: `https://lacrypta.dev/hackathons/${hackathonSlug(hackathon)}/${project.id}`, }, ]), "ld-breadcrumbs", )}
    diff --git a/app/hackathons/[id]/opengraph-image.tsx b/app/hackathons/[id]/opengraph-image.tsx index 3cb4722..ff87829 100644 --- a/app/hackathons/[id]/opengraph-image.tsx +++ b/app/hackathons/[id]/opengraph-image.tsx @@ -4,6 +4,7 @@ import { PROGRAM, formatSats, getHackathon, + hackathonSlug, } from "@/lib/hackathons"; export const alt = "Hackatón · La Crypta Dev"; @@ -11,7 +12,7 @@ export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; export function generateStaticParams() { - return HACKATHONS.map((h) => ({ id: h.id })); + return HACKATHONS.map((h) => ({ id: hackathonSlug(h) })); } const DIFFICULTY_COLOR: Record = { diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index 1f3e50b..2a26752 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -29,6 +29,7 @@ import { PROGRAM, formatSats, getHackathon, + hackathonSlug, hackathonStatus, primaryProjectPubkey, prizedProjects, @@ -56,7 +57,8 @@ import PrizeZapButton from "./PrizeZapButton"; import HackathonInscripcionButton from "@/components/HackathonInscripcionButton"; export function generateStaticParams() { - return HACKATHONS.map((h) => ({ id: h.id })); + // The dynamic segment is the public slug (falls back to id). + return HACKATHONS.map((h) => ({ id: hackathonSlug(h) })); } function truncate(s: string, max = 155): string { @@ -73,7 +75,7 @@ export async function generateMetadata({ const h = getHackathon(id); if (!h) return { title: "Hackatón" }; const description = truncate(`${h.focus}. ${h.description}`); - const url = `/hackathons/${h.id}`; + const url = `/hackathons/${hackathonSlug(h)}`; return { title: `${h.name} · Hackatón #${h.number}`, description, @@ -439,9 +441,12 @@ export default async function HackathonPage({ "use cache"; cacheTag(NOSTR_PROJECTS_TAG); cacheTag(NOSTR_SUBMISSIONS_TAG); - const { id } = await params; - const hackathon = getHackathon(id); + const { id: routeParam } = await params; + const hackathon = getHackathon(routeParam); if (!hackathon) notFound(); + // The route segment is the public slug; all data ops key off the canonical id + // (e.g. "zaps"), which never changes because published events reference it. + const id = hackathon.id; // Inner "use cache" tags don't bubble in Next 16 — register the voting tag // at page level so open/close revalidations refresh this page too. cacheTag(nostrVotingTag(id)); @@ -496,7 +501,7 @@ export default async function HackathonPage({ { name: "Hackatones", url: "https://lacrypta.dev/hackathons" }, { name: hackathon.name, - url: `https://lacrypta.dev/hackathons/${hackathon.id}`, + url: `https://lacrypta.dev/hackathons/${hackathonSlug(hackathon)}`, }, ]), "ld-breadcrumbs", @@ -609,7 +614,7 @@ export default async function HackathonPage({ className="flex items-center gap-2" >
    diff --git a/app/layout.tsx b/app/layout.tsx index 7e0f621..85cfdc8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,7 +4,10 @@ import { Inter, Space_Grotesk, JetBrains_Mono } from "next/font/google"; import "./globals.css"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; +import DevModeBar from "@/components/DevModeBar"; import { ToastProvider } from "@/components/Toast"; +import { isDevMode } from "@/lib/devMode"; +import { cn } from "@/lib/cn"; import { GoogleTagManagerNoscript, GoogleTagManagerScript, @@ -103,10 +106,13 @@ export default function RootLayout({ + {isDevMode() && } -
    {children}
    +
    + {children} +
    diff --git a/app/projects/[pubkey]/UserProjectsPage.tsx b/app/projects/[pubkey]/UserProjectsPage.tsx index eb345c4..96c96e5 100644 --- a/app/projects/[pubkey]/UserProjectsPage.tsx +++ b/app/projects/[pubkey]/UserProjectsPage.tsx @@ -11,6 +11,7 @@ import { } from "@/lib/userProjects"; import { GithubIcon } from "@/components/BrandIcons"; import { cn } from "@/lib/cn"; +import { hackathonSlugForId } from "@/lib/hackathons"; import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks"; const STATUS_BADGE: Record = { @@ -85,7 +86,7 @@ export default function UserProjectsPage({ pubkey }: { pubkey: string }) {
    {projects.map((p) => { const href = p.hackathon - ? `/hackathons/${p.hackathon}/${p.id}` + ? `/hackathons/${hackathonSlugForId(p.hackathon)}/${p.id}` : `/projects/${pubkey}/${p.id}`; const team = dedupeSoldierProfileMembers(p.team); return ( diff --git a/app/projects/[pubkey]/[id]/StandaloneProjectPage.tsx b/app/projects/[pubkey]/[id]/StandaloneProjectPage.tsx index 04a9b71..e2dc866 100644 --- a/app/projects/[pubkey]/[id]/StandaloneProjectPage.tsx +++ b/app/projects/[pubkey]/[id]/StandaloneProjectPage.tsx @@ -18,7 +18,7 @@ import { useAuth } from "@/lib/auth"; import NewProjectModal, { type ProjectEditField, } from "@/components/NewProjectModal"; -import { getHackathon } from "@/lib/hackathons"; +import { getHackathon, hackathonSlugForId } from "@/lib/hackathons"; import { projectMatchesIdentifier } from "@/lib/projectIdentity"; import { ProjectDetailView } from "@/components/ProjectDetailView"; @@ -204,7 +204,7 @@ export default function StandaloneProjectPage({ ); } - const backHref = project.hackathon ? `/hackathons/${project.hackathon}` : "/projects"; + const backHref = project.hackathon ? `/hackathons/${hackathonSlugForId(project.hackathon)}` : "/projects"; const hackathon = project.hackathon ? getHackathon(project.hackathon) : null; const backLabel = hackathon?.name ?? (project.hackathon ? project.hackathon.toUpperCase() : "Proyectos"); const contextLabel = hackathon diff --git a/app/sitemap.ts b/app/sitemap.ts index 2928473..ab9dcc9 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -2,6 +2,8 @@ import type { MetadataRoute } from "next"; import { HACKATHONS, getHackathonProjects, + hackathonSlug, + hackathonSlugForId, hackathonStatus, } from "@/lib/hackathons"; import { getAllNostrSubmissionsForSitemap } from "@/lib/nostrCache"; @@ -61,7 +63,7 @@ function staticSitemap(): MetadataRoute.Sitemap { const lastDate = h.dates[h.dates.length - 1]?.date; const status = hackathonStatus(h, now); entries.push({ - url: `${BASE_URL}/hackathons/${h.id}`, + url: `${BASE_URL}/hackathons/${hackathonSlug(h)}`, lastModified: lastDate ? new Date(lastDate) : now, changeFrequency: status === "closed" ? "yearly" : "weekly", priority: 0.8, @@ -70,7 +72,7 @@ function staticSitemap(): MetadataRoute.Sitemap { const projectMod = project.submittedAt ? new Date(project.submittedAt) : lastDate ? new Date(lastDate) : now; entries.push({ - url: `${BASE_URL}/hackathons/${h.id}/${project.id}`, + url: `${BASE_URL}/hackathons/${hackathonSlug(h)}/${project.id}`, lastModified: projectMod, changeFrequency: "monthly", priority: 0.7, @@ -102,7 +104,7 @@ async function nostrSitemap(): Promise { const key = `${s.hackathon}/${s.id}`; if (curatedKeys.has(key)) continue; entries.push({ - url: `${BASE_URL}/hackathons/${s.hackathon}/${s.id}`, + url: `${BASE_URL}/hackathons/${hackathonSlugForId(s.hackathon)}/${s.id}`, lastModified: new Date(s.eventCreatedAt * 1000), changeFrequency: "daily", priority: 0.6, diff --git a/app/soldados/SoldiersGrid.tsx b/app/soldados/SoldiersGrid.tsx index 019a3f6..7989baf 100644 --- a/app/soldados/SoldiersGrid.tsx +++ b/app/soldados/SoldiersGrid.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { Zap, Trophy } from "lucide-react"; import { GithubIcon } from "@/components/BrandIcons"; +import { hackathonSlugForId } from "@/lib/hackathons"; import { HACKATHON_LABELS } from "@/lib/projects"; import { getCachedProfile, type NostrProfile } from "@/lib/nostrProfile"; import { cn } from "@/lib/cn"; @@ -394,7 +395,7 @@ function SoldierCard({ {hackathons.map((h) => ( @@ -407,7 +408,7 @@ function SoldierCard({
      {soldier.projects.slice(0, 4).map((p, i) => { const href = p.hackathonId - ? `/hackathons/${p.hackathonId}/${p.projectId}` + ? `/hackathons/${hackathonSlugForId(p.hackathonId)}/${p.projectId}` : p.source === "nostr" && p.authorPubkey ? `/projects/${p.authorPubkey}/${p.projectId}` : `/projects/${p.projectId}`; diff --git a/app/soldados/[slug]/ImpersonateButton.tsx b/app/soldados/[slug]/ImpersonateButton.tsx new file mode 100644 index 0000000..0ccde32 --- /dev/null +++ b/app/soldados/[slug]/ImpersonateButton.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { VenetianMask } from "lucide-react"; +import { useDevIdentities } from "@/lib/useDevIdentities"; +import { useDevEnabled } from "@/lib/useDevEnabled"; + +/** + * Dev-only "impersonate this user" button on a soldier profile. Logs in as the + * deterministic dev stand-in for the soldier's pubkey (we never have their real + * secret), so the voting flow can be exercised as them. The page only renders + * this when `isDevMode()` (env) is true; it additionally hides itself when the + * runtime dev switch is OFF, and wears the dashed-amber "dev" style so it's + * obvious it isn't a real production button. + */ +export default function ImpersonateButton({ + pubkey, + name, +}: { + pubkey: string; + name: string; +}) { + const { impersonate } = useDevIdentities(); + const { enabled } = useDevEnabled(); + if (!enabled) return null; + return ( + + ); +} diff --git a/app/soldados/[slug]/page.tsx b/app/soldados/[slug]/page.tsx index 706dcad..a04dea8 100644 --- a/app/soldados/[slug]/page.tsx +++ b/app/soldados/[slug]/page.tsx @@ -12,6 +12,7 @@ import { Zap, } from "lucide-react"; import { GithubIcon } from "@/components/BrandIcons"; +import { hackathonSlugForId } from "@/lib/hackathons"; import { HACKATHON_LABELS } from "@/lib/projects"; import { getSoldierBySlug, @@ -21,9 +22,11 @@ import { } from "@/lib/soldiers"; import { getCachedNostrProfile } from "@/lib/nostrProfileCache"; import { cn } from "@/lib/cn"; +import { isDevMode } from "@/lib/devMode"; import SoldierZapButton from "./SoldierZapButton"; import SoldierFollowButton from "./SoldierFollowButton"; import SoldierZapWall from "./SoldierZapWall"; +import ImpersonateButton from "./ImpersonateButton"; type RouteParams = { slug: string }; @@ -207,6 +210,12 @@ export default async function SoldierProfilePage({ recipientName={displayName} recipientAvatar={avatar} /> + {isDevMode() && ( + + )}
    )}
    @@ -273,7 +282,7 @@ export default async function SoldierProfilePage({ {[...hackathons].map((h) => (
  • @@ -425,7 +434,7 @@ function Card({ function projectHref(project: SoldierProjectRef): string { // 1. Hackathon-scoped project → /hackathons// if (project.hackathonId) { - return `/hackathons/${project.hackathonId}/${project.projectId}`; + return `/hackathons/${hackathonSlugForId(project.hackathonId)}/${project.projectId}`; } // 2. Nostr-only project with known author → /projects// if (project.source === "nostr" && project.authorPubkey) { diff --git a/components/DevModeBar.tsx b/components/DevModeBar.tsx new file mode 100644 index 0000000..9644340 --- /dev/null +++ b/components/DevModeBar.tsx @@ -0,0 +1,476 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + FlaskConical, + ChevronDown, + Plus, + Copy, + LogOut, + ShieldCheck, + UserCheck, + Trash2, + Radio, + VenetianMask, + Users, + KeyRound, + Database, + Loader2, + Power, + PowerOff, +} from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { useDevIdentities } from "@/lib/useDevIdentities"; +import { useDevEnabled } from "@/lib/useDevEnabled"; +import { useToast } from "@/components/Toast"; +import { generateDummyData, loadDummyUsers, type DummyUser } from "@/lib/devSeed"; +import { cn } from "@/lib/cn"; + +type ImpersonatableSoldier = { pubkey: string; name: string; maxVotes: number }; + +/** Host of the first configured local relay, for the "todo local" indicator. */ +function relayHost(): string | null { + const raw = process.env.NEXT_PUBLIC_NOSTR_RELAYS; + if (!raw) return null; + const first = raw.split(",")[0]?.trim(); + if (!first) return null; + try { + return new URL(first).host; + } catch { + return first; + } +} + +/** + * Dev-only top strip + account-impersonation switcher. Rendered by the root + * layout only when `isDevMode()` is true. Deliberately a
    (not
    ) + * so it dodges the `header.fixed` scroll-lock rule in globals.css; it + * self-applies the same `--sbw` compensation so it tracks the header when a + * modal locks scroll. + */ +export default function DevModeBar() { + const { auth } = useAuth(); + const { + identities, + generateIdentity, + removeIdentity, + loginAs, + loginAsAdmin, + impersonate, + logout, + copy, + } = useDevIdentities(); + const { push, dismiss } = useToast(); + const { enabled, setEnabled } = useDevEnabled(); + const [open, setOpen] = useState(false); + const [adminPubkey, setAdminPubkey] = useState(null); + const [soldiers, setSoldiers] = useState([]); + // Map of soldier real pubkey → dev stand-in pubkey, so we can tell which + // soldier the current session is impersonating. + const [devPubkeys, setDevPubkeys] = useState>({}); + const [seeding, setSeeding] = useState(false); + const [dummyUsers, setDummyUsers] = useState([]); + const panelRef = useRef(null); + const relay = relayHost(); + + useEffect(() => { + setDummyUsers(loadDummyUsers()); + }, []); + + const loadSoldiers = useCallback(async () => { + try { + const res = await fetch("/api/dev/soldiers"); + if (!res.ok) return; + const data = (await res.json()) as { soldiers?: ImpersonatableSoldier[] }; + const list = data.soldiers ?? []; + setSoldiers(list); + const { devPubkeyForPubkey } = await import("@/lib/devImpersonation"); + const entries = await Promise.all( + list.map( + async (s) => [s.pubkey, await devPubkeyForPubkey(s.pubkey)] as const, + ), + ); + setDevPubkeys(Object.fromEntries(entries)); + } catch { + /* ignore */ + } + }, []); + + async function handleSeed() { + if (seeding) return; + setSeeding(true); + const id = push({ + kind: "info", + title: "Generando datos dummy…", + duration: 60000, + }); + try { + const result = await generateDummyData((msg, doneN, total) => { + // eslint-disable-next-line no-console + console.log(`[dev-seed] ${doneN}/${total} ${msg}`); + }); + setDummyUsers(result.users); + await loadSoldiers(); + push({ + kind: "success", + title: "Datos dummy generados", + description: `${result.users.length} usuarios · ${result.projectsPublished} proyectos. nsecs en consola.`, + }); + } catch (e) { + push({ + kind: "error", + title: "Falló la generación", + description: e instanceof Error ? e.message : String(e), + }); + } finally { + dismiss(id); + setSeeding(false); + } + } + + useEffect(() => { + let cancelled = false; + fetch("/api/lacrypta-pubkeys") + .then((res) => (res.ok ? res.json() : null)) + .then((data: { adminPubkey?: string } | null) => { + if (!cancelled) setAdminPubkey(data?.adminPubkey ?? null); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + loadSoldiers(); + }, [loadSoldiers]); + + useEffect(() => { + if (!open) return; + function onClick(e: MouseEvent) { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, [open]); + + const isAdmin = !!auth?.pubkey && !!adminPubkey && auth.pubkey === adminPubkey; + const activeIdentity = identities.find((i) => i.pubkey === auth?.pubkey); + const activeSoldier = soldiers.find( + (s) => devPubkeys[s.pubkey] && devPubkeys[s.pubkey] === auth?.pubkey, + ); + const sessionLabel = !auth + ? "Sin sesión" + : isAdmin + ? "La Crypta (admin)" + : activeSoldier + ? `${activeSoldier.name} (imp.)` + : (activeIdentity?.label ?? `${auth.pubkey.slice(0, 8)}…`); + + return ( +
    + + + Dev Mode + {enabled ? ( + <> + {relay && ( + + + relay {relay} + + )} + + · impersonación habilitada + + + ) : ( + · UI desactivada — solo botones reales + )} + + +
    + {/* Master runtime switch: hide every dev-injected button at once. */} + + + {enabled && ( +
    + + + {open && ( +
    +
    + + + +
    + + {/* Dummy users we generated (we hold their keys) — impersonate any. */} + {dummyUsers.length > 0 && ( +
    +
    + + Usuarios dummy ({dummyUsers.length}) +
    +
    +
      + {dummyUsers.map((u) => { + const active = + !!devPubkeys[u.pubkey] && + devPubkeys[u.pubkey] === auth?.pubkey; + return ( +
    • + + + {u.name} + + {active && ( + + )} + + + +
    • + ); + })} +
    +
    +
    + )} + + {/* Soldiers with a linked Nostr pubkey — impersonate any of them. */} +
    +
    + + Soldados con Nostr ({soldiers.length}) +
    +
    + {soldiers.length === 0 ? ( +

    + Cargando roster… +

    + ) : ( +
      + {soldiers.map((s) => { + const active = + !!devPubkeys[s.pubkey] && + devPubkeys[s.pubkey] === auth?.pubkey; + return ( +
    • + + + {s.name} + + {active && ( + + )} + + + {s.maxVotes}🗳 + + +
    • + ); + })} +
    + )} +
    +
    + +
    +
    + + Identidades descartables +
    +
    + {identities.length === 0 ? ( +

    + Sin identidades todavía. +

    + ) : ( +
      + {identities.map((identity) => { + const active = auth?.pubkey === identity.pubkey; + return ( +
    • +
      +
      + + {identity.label} + + {active && ( + + )} +
      + + {identity.npub} + +
      + + + +
    • + ); + })} +
    + )} +
    +
    + + {auth && ( +
    + +
    + )} +
    + )} +
    + )} +
    +
    + ); +} diff --git a/components/HackathonInscripcionButton.tsx b/components/HackathonInscripcionButton.tsx index 65e3b47..13466c1 100644 --- a/components/HackathonInscripcionButton.tsx +++ b/components/HackathonInscripcionButton.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { UserPlus } from "lucide-react"; import { useAuth } from "@/lib/auth"; +import { hackathonSlugForId } from "@/lib/hackathons"; import LoginModal from "./LoginModal"; import NewProjectModal from "./NewProjectModal"; @@ -52,7 +53,7 @@ export default function HackathonInscripcionButton({ // out of nowhere. setPendingInscription(false); }} - redirectTo={`/hackathons/${hackathonId}`} + redirectTo={`/hackathons/${hackathonSlugForId(hackathonId)}`} />
    {NAV_LINKS.map((link, i) => { diff --git a/components/sections/GamingHackathonBanner.tsx b/components/sections/GamingHackathonBanner.tsx index 0e6f5f7..101a445 100644 --- a/components/sections/GamingHackathonBanner.tsx +++ b/components/sections/GamingHackathonBanner.tsx @@ -86,7 +86,7 @@ export default function GamingHackathonBanner() {
    Ver hackaton diff --git a/data/hackathons/hackathons.json b/data/hackathons/hackathons.json index 5ec51e1..56385dc 100644 --- a/data/hackathons/hackathons.json +++ b/data/hackathons/hackathons.json @@ -100,6 +100,7 @@ }, { "id": "zaps", + "slug": "gaming", "number": 4, "name": "GAMING", "focus": "Game Development", diff --git a/dev/relay/config.toml b/dev/relay/config.toml new file mode 100644 index 0000000..b7c5b77 --- /dev/null +++ b/dev/relay/config.toml @@ -0,0 +1,39 @@ +# nostr-rs-relay — La Crypta Labs local dev relay. +# Schema per scsibug/nostr-rs-relay. Ephemeral, non-production. + +[info] +relay_url = "ws://localhost:7777/" +name = "La Crypta Labs (dev)" +description = "Local development relay. Ephemeral, isolated from production." +# pubkey = "" # optional admin contact (hex) — unset for dev +# contact = "" # optional admin contact URI + +[database] +engine = "sqlite" +# NOTE: the published image's CMD passes `--db /usr/src/app/db`, which OVERRIDES +# this at runtime. Kept in sync for clarity / non-default commands. +data_directory = "/usr/src/app/db" + +[network] +# Bind all interfaces inside the container; the host reaches it via the +# 7777:8080 port mapping. These are nostr-rs-relay's defaults. +address = "0.0.0.0" +port = 8080 + +[options] +# Reject events timestamped further than this many seconds into the future. +# Generous so mild dev-machine clock skew never silently drops a ballot. +reject_future_seconds = 1800 + +[limits] +# Unlimited for a single-developer relay so rapid re-vote (ballot replacement) +# testing is never throttled. max_event_bytes left at the 128 KB default. +messages_per_sec = 0 +max_event_bytes = 131072 + +[authorization] +# No whitelist / no NIP-42: arbitrary test pubkeys must be able to publish +# kind-30078 ballots. Do NOT add an allowlist (it would have to enumerate every +# kind the app uses). Parameterized-replaceable (kind 30078) handling is +# compiled in — re-votes replace the prior ballot by author + kind + d-tag. +# pubkey_whitelist = [] diff --git a/dev/relay/docker-compose.yml b/dev/relay/docker-compose.yml new file mode 100644 index 0000000..90e1841 --- /dev/null +++ b/dev/relay/docker-compose.yml @@ -0,0 +1,26 @@ +# Local Nostr relay for La Crypta Labs dev. +# +# Fully isolated: nothing published here ever reaches a public relay, so you can +# exercise voting / projects / badges without permanently polluting production +# (Nostr events can't be deleted once they hit a real relay). +# +# pnpm relay:up # start (ws://localhost:7777) +# pnpm relay:logs # tail +# pnpm relay:down # stop +# +# Point the app at it with NEXT_PUBLIC_NOSTR_RELAYS=ws://localhost:7777 +services: + nostr-relay: + image: scsibug/nostr-rs-relay:latest + container_name: labs-nostr-relay + restart: unless-stopped + # Host 7777 -> container 8080 (nostr-rs-relay's default listen port). + ports: + - "7777:8080" + volumes: + # Config is read from ./config.toml relative to WORKDIR /usr/src/app. + # Read-only so the container can't rewrite our checked-in config. + - ./config.toml:/usr/src/app/config.toml:ro + # SQLite DB dir. The image CMD hardcodes `--db /usr/src/app/db`, which + # overrides config's data_directory — so the DB volume MUST mount here. + - ./data:/usr/src/app/db diff --git a/lib/auth.ts b/lib/auth.ts index 0076c90..02c3c9b 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -24,6 +24,11 @@ export type Auth = { * private key in localStorage is inherently insecure; this method is for * throwaway/test identities and dev convenience. */ localSecret?: number[]; + /** Dev-only: when impersonating a user via a stand-in key, this holds the + * REAL pubkey being impersonated. Reads (projects, participations) key off + * this so the impersonated user's data shows; signing still uses the + * stand-in (`localSecret`). Ignored by the signer. See lib/devImpersonation. */ + impersonating?: string; }; const STORAGE_KEY = "labs:auth"; diff --git a/lib/devImpersonation.ts b/lib/devImpersonation.ts new file mode 100644 index 0000000..19afbee --- /dev/null +++ b/lib/devImpersonation.ts @@ -0,0 +1,34 @@ +/** + * Dev-only impersonation of real users by their PUBLIC key. + * + * We never have real users' (soldiers') secret keys, and a vote is only valid + * when signed by an eligible pubkey. So dev impersonation derives a + * deterministic throwaway keypair from the real pubkey. The voting route + * derives the SAME stand-in key when building eligibility (dev mode only), so + * an impersonated stand-in is recognised as a valid voter. The input is public + * and the salt is known — anyone can recompute these keys, which is fine for a + * local dev relay and is NEVER enabled in production. + * + * Isomorphic: uses the global Web Crypto (`crypto.subtle`), available in both + * the Node server runtime and the browser, so server and client agree. + */ +const DEV_IMPERSONATION_SALT = "lacrypta-dev-impersonation:v1:"; + +/** 32-byte stand-in secret for a given real pubkey (hex). */ +export async function devSecretForPubkey( + realPubkeyHex: string, +): Promise { + const data = new TextEncoder().encode( + DEV_IMPERSONATION_SALT + realPubkeyHex.trim().toLowerCase(), + ); + const digest = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(digest); +} + +/** Stand-in PUBLIC key (hex) that a given real pubkey maps to in dev. */ +export async function devPubkeyForPubkey( + realPubkeyHex: string, +): Promise { + const { getPublicKey } = await import("nostr-tools/pure"); + return getPublicKey(await devSecretForPubkey(realPubkeyHex)); +} diff --git a/lib/devMode.ts b/lib/devMode.ts new file mode 100644 index 0000000..46dd254 --- /dev/null +++ b/lib/devMode.ts @@ -0,0 +1,15 @@ +/** + * Dev-only feature gate. + * + * Enables the in-app DEV MODE bar (account impersonation, admin login) used to + * exercise flows like community voting against a local relay without touching + * production data. `NEXT_PUBLIC_*` is inlined at build time, so this is a + * compile-time constant — server and client agree (no hydration mismatch). + * + * OFF by default. NEVER set NEXT_PUBLIC_DEV_MODE=true in a production deploy: + * it exposes impersonation UI and (paired with NEXT_PUBLIC_DEV_ADMIN_NSEC) a + * signing secret to the browser. + */ +export function isDevMode(): boolean { + return process.env.NEXT_PUBLIC_DEV_MODE === "true"; +} diff --git a/lib/devSeed.ts b/lib/devSeed.ts new file mode 100644 index 0000000..b43df84 --- /dev/null +++ b/lib/devSeed.ts @@ -0,0 +1,227 @@ +"use client"; + +/** + * Dev-only fake-data generator. Creates dummy Nostr users (keypairs we hold) + * and dummy projects assigned to hackathons, then publishes real kind-0 + * profiles + kind-30078 project events — signed by each user's own key — to the + * configured relays (the local relay in dev). Because they go through the same + * event shapes the app reads, the dummy users automatically become soldiers + * (impersonatable + voting-eligible) and their projects show on hackathon + * pages, /projects, the dashboard, and "Mis hackatones". + * + * The generated nsecs are persisted to localStorage so you have the keys to + * sign as each user. Local-relay only — nothing reaches production. + */ +import type { UserSigner } from "@/lib/nostrSigner"; +import type { UserProject, TeamMember } from "@/lib/userProjects"; +import { publishUserProject } from "@/lib/userProjects"; +import { publishNostrProfile } from "@/lib/nostrProfile"; +import { DEFAULT_RELAYS } from "@/lib/nostrRelayConfig"; + +export const DUMMY_USERS_KEY = "labs:dev:dummy-users:v1"; + +export type DummyUser = { + name: string; + pubkey: string; + npub: string; + nsec: string; + /** 32-byte secret as a plain array (same encoding as Auth.localSecret). */ + secret: number[]; +}; + +type UserSpec = { name: string; about: string }; +type ProjectSpec = { + id: string; + name: string; + description: string; + repo: string; + hackathon: string; + /** index into USERS — the author/owner */ + author: number; + /** extra teammates (indexes into USERS) */ + team?: number[]; + status?: UserProject["status"]; +}; + +// 8 dummy builders. Avatars come from a deterministic public avatar service. +const USERS: UserSpec[] = [ + { name: "Satoshi Tester", about: "Probando todo lo que se mueva ⚡" }, + { name: "Lightning Lucía", about: "Pagos instantáneos o nada." }, + { name: "Nostrich Nico", about: "Relays, zaps y café." }, + { name: "Zap Zoe", about: "Gamedev sobre Bitcoin." }, + { name: "Block Bruno", about: "Infra y nodos toda la noche." }, + { name: "Relay Rita", about: "Self-hosting evangelist." }, + { name: "Sat Sergio", about: "Comercio Lightning para todos." }, + { name: "HODL Hilda", about: "Identidad soberana primero." }, +]; + +// Projects spread across hackathons so vote budgets (distinct hackathons) vary. +const PROJECTS: ProjectSpec[] = [ + { id: "dummy-zappy-pos", name: "Zappy POS", description: "Punto de venta Lightning sin custodia.", repo: "https://github.com/dummy/zappy-pos", hackathon: "foundations", author: 0, team: [1] }, + { id: "dummy-nostr-id", name: "NostrID", description: "Identidad portátil NIP-05 + NIP-07.", repo: "https://github.com/dummy/nostr-id", hackathon: "identity", author: 0, team: [7] }, + { id: "dummy-sat-checkout", name: "SatCheckout", description: "Checkout para tiendas con LNURL.", repo: "https://github.com/dummy/sat-checkout", hackathon: "commerce", author: 0, team: [6] }, + { id: "dummy-bolt-board", name: "BoltBoard", description: "Leaderboard de zaps en vivo.", repo: "https://github.com/dummy/bolt-board", hackathon: "foundations", author: 1 }, + { id: "dummy-keychain", name: "Keychain", description: "Backup social de claves Nostr.", repo: "https://github.com/dummy/keychain", hackathon: "identity", author: 1, team: [2] }, + { id: "dummy-zap-arena", name: "Zap Arena", description: "Juego PvP con apuestas en sats.", repo: "https://github.com/dummy/zap-arena", hackathon: "zaps", author: 3, team: [4] }, + { id: "dummy-tetris-sats", name: "TetriSats", description: "Tetris que paga por líneas.", repo: "https://github.com/dummy/tetrisats", hackathon: "zaps", author: 4 }, + { id: "dummy-shopstr", name: "ShopNostr", description: "Marketplace descentralizado.", repo: "https://github.com/dummy/shopnostr", hackathon: "commerce", author: 3 }, + { id: "dummy-noderunner", name: "NodeRunner", description: "Dashboard de liquidez y rutas.", repo: "https://github.com/dummy/noderunner", hackathon: "foundations", author: 5 }, + { id: "dummy-mediavault", name: "MediaVault", description: "Almacenamiento cifrado sobre Blossom.", repo: "https://github.com/dummy/mediavault", hackathon: "media", author: 5, team: [6] }, + { id: "dummy-castr", name: "Castr", description: "Podcasting 2.0 con value-for-value.", repo: "https://github.com/dummy/castr", hackathon: "media", author: 6 }, + { id: "dummy-soberano", name: "Soberano", description: "Wallet de identidad + pagos.", repo: "https://github.com/dummy/soberano", hackathon: "identity", author: 7, team: [0] }, + { id: "dummy-tienda-zap", name: "TiendaZap", description: "Catálogo y cobros Lightning.", repo: "https://github.com/dummy/tienda-zap", hackathon: "commerce", author: 7 }, + { id: "dummy-arcade", name: "SatArcade", description: "Arcade retro pay-per-play.", repo: "https://github.com/dummy/sat-arcade", hackathon: "zaps", author: 7 }, +]; + +function avatarFor(name: string): string { + return `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${encodeURIComponent(name)}`; +} + +/** + * Deterministic 32-byte secret per dummy user (keyed by name). Re-running the + * generator yields the SAME keypairs, so the replaceable project/profile events + * are REPLACED rather than duplicated under fresh authors. Isomorphic via the + * global Web Crypto. + */ +async function dummySecretFor(name: string): Promise { + const data = new TextEncoder().encode("lacrypta-dev-dummy:v1:" + name); + const digest = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(digest); +} + +function makeSigner(secret: Uint8Array, finalizeEvent: typeof import("nostr-tools/pure").finalizeEvent, pubkey: string): UserSigner { + return { + pubkey, + async signEvent(ev) { + return finalizeEvent( + { kind: ev.kind, tags: ev.tags, content: ev.content, created_at: ev.created_at }, + secret, + ); + }, + async nip44Encrypt(peer, plaintext) { + const nip44 = await import("nostr-tools/nip44"); + return nip44.encrypt(plaintext, nip44.getConversationKey(secret, peer)); + }, + async nip44Decrypt(peer, ciphertext) { + const nip44 = await import("nostr-tools/nip44"); + return nip44.decrypt(ciphertext, nip44.getConversationKey(secret, peer)); + }, + }; +} + +export type SeedProgress = (msg: string, done: number, total: number) => void; + +export type SeedResult = { + users: DummyUser[]; + projectsPublished: number; + profilesPublished: number; +}; + +/** Generates and publishes the full dummy dataset to DEFAULT_RELAYS (local). */ +export async function generateDummyData(onProgress?: SeedProgress): Promise { + const { getPublicKey, finalizeEvent } = await import("nostr-tools/pure"); + const { npubEncode, nsecEncode } = await import("nostr-tools/nip19"); + + const total = USERS.length + PROJECTS.length + 1; + let done = 0; + const step = (msg: string) => onProgress?.(msg, ++done, total); + + // 1. Deterministic keypairs — idempotent: re-seeding replaces, never dups. + const users: DummyUser[] = await Promise.all( + USERS.map(async (u) => { + const secret = await dummySecretFor(u.name); + const pubkey = getPublicKey(secret); + return { + name: u.name, + pubkey, + npub: npubEncode(pubkey), + nsec: nsecEncode(secret), + secret: Array.from(secret), + }; + }), + ); + const signerFor = (i: number) => + makeSigner(Uint8Array.from(users[i].secret), finalizeEvent, users[i].pubkey); + + // 2. Profiles (kind 0). + let profilesPublished = 0; + for (let i = 0; i < USERS.length; i++) { + try { + await publishNostrProfile( + signerFor(i), + { + name: USERS[i].name, + display_name: USERS[i].name, + about: USERS[i].about, + picture: avatarFor(USERS[i].name), + }, + DEFAULT_RELAYS, + ); + profilesPublished++; + } catch { + /* keep going; partial seed is still useful */ + } + step(`Perfil: ${USERS[i].name}`); + } + + // 3. Projects (kind 30078), each signed by its author. + const now = Math.floor(Date.now() / 1000); + let projectsPublished = 0; + for (const spec of PROJECTS) { + const memberIdxs = [spec.author, ...(spec.team ?? [])]; + const team: TeamMember[] = memberIdxs.map((idx, n) => ({ + name: users[idx].name, + role: n === 0 ? "Lead Dev" : "Builder", + pubkey: users[idx].pubkey, + picture: avatarFor(users[idx].name), + })); + const project: UserProject = { + id: spec.id, + name: spec.name, + description: spec.description, + team, + repo: spec.repo, + tech: [], + status: spec.status ?? "submitted", + hackathon: spec.hackathon, + createdAt: now, + updatedAt: now, + }; + try { + await publishUserProject(signerFor(spec.author), project, DEFAULT_RELAYS); + projectsPublished++; + } catch { + /* keep going */ + } + step(`Proyecto: ${spec.name}`); + } + + // 4. Persist the dummy users (with nsecs) for reference / manual signing. + try { + window.localStorage.setItem(DUMMY_USERS_KEY, JSON.stringify(users)); + } catch { + /* quota */ + } + // eslint-disable-next-line no-console + console.table(users.map((u) => ({ name: u.name, npub: u.npub, nsec: u.nsec }))); + + // 5. Flush the server caches so the roster + voting eligibility see them. + try { + await fetch("/api/dev/revalidate", { method: "POST" }); + } catch { + /* best effort */ + } + step("Refrescando caché…"); + + return { users, projectsPublished, profilesPublished }; +} + +export function loadDummyUsers(): DummyUser[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(DUMMY_USERS_KEY); + return raw ? (JSON.parse(raw) as DummyUser[]) : []; + } catch { + return []; + } +} diff --git a/lib/hackathons.ts b/lib/hackathons.ts index 378e55c..fb46baf 100644 --- a/lib/hackathons.ts +++ b/lib/hackathons.ts @@ -38,7 +38,12 @@ export type Sponsor = { }; export type Hackathon = { + /** Internal, stable id — used as the key for submissions, voting and badges. + * NEVER rename: published Nostr events reference it. */ id: string; + /** Optional URL slug. When set, the public route is `/hackathons/` but + * all data keeps using `id`. Defaults to `id` when absent. */ + slug?: string; number: number; name: string; focus: string; @@ -198,8 +203,21 @@ const PROJECTS_BY_HACKATHON: Record = { ), }; -export function getHackathon(id: string): Hackathon | null { - return HACKATHONS.find((h) => h.id === id) ?? null; +/** Resolve a hackathon by its internal id OR its URL slug (both routes work). */ +export function getHackathon(idOrSlug: string): Hackathon | null { + return ( + HACKATHONS.find((h) => h.id === idOrSlug || h.slug === idOrSlug) ?? null + ); +} + +/** Public URL segment for a hackathon — the slug when set, else the id. */ +export function hackathonSlug(h: Hackathon): string { + return h.slug ?? h.id; +} + +/** Public URL segment for a hackathon id (used when only the id is in hand). */ +export function hackathonSlugForId(id: string): string { + return getHackathon(id)?.slug ?? id; } export function getHackathonProjects(id: string): HackathonProject[] { @@ -283,7 +301,27 @@ export function mergeWithSubmissions( }); uniqueNostr.sort((a, b) => (b.nostrCreatedAt ?? 0) - (a.nostrCreatedAt ?? 0)); - return [...curated, ...uniqueNostr]; + + // Dedup community submissions against EACH OTHER (freshest wins). Two events + // can share a project id/repo/name across different authors — e.g. the same + // project republished, or dev dummy data regenerated under fresh keys — which + // would otherwise collide on React keys and render twice. + const seenIds = new Set(); + const seenRepos = new Set(); + const seenNames = new Set(); + const dedupedNostr = uniqueNostr.filter((s) => { + const repo = comparableRepo(s.repo); + const name = comparableProjectName(s.name); + if (seenIds.has(s.id) || (repo && seenRepos.has(repo)) || seenNames.has(name)) { + return false; + } + seenIds.add(s.id); + if (repo) seenRepos.add(repo); + seenNames.add(name); + return true; + }); + + return [...curated, ...dedupedNostr]; } /** Returns projects ordered by jury position asc (winners first). */ diff --git a/lib/jsonld.tsx b/lib/jsonld.tsx index d435fac..d56e626 100644 --- a/lib/jsonld.tsx +++ b/lib/jsonld.tsx @@ -1,4 +1,5 @@ import type { Hackathon, HackathonProject } from "./hackathons"; +import { hackathonSlug } from "./hackathons"; import { dedupeSoldierProfileMembers } from "./soldierProfileLinks"; const BASE_URL = "https://lacrypta.dev"; @@ -39,7 +40,7 @@ function eventEndDate(h: Hackathon): string | undefined { } export function eventLd(h: Hackathon): JsonLdValue { - const url = `${BASE_URL}/hackathons/${h.id}`; + const url = `${BASE_URL}/hackathons/${hackathonSlug(h)}`; const start = eventStartDate(h); const end = eventEndDate(h); @@ -78,7 +79,7 @@ export function creativeWorkLd( project: HackathonProject, hackathon: Hackathon, ): JsonLdValue { - const url = `${BASE_URL}/hackathons/${hackathon.id}/${project.id}`; + const url = `${BASE_URL}/hackathons/${hackathonSlug(hackathon)}/${project.id}`; return { "@context": "https://schema.org", "@type": "SoftwareApplication", @@ -93,7 +94,7 @@ export function creativeWorkLd( isPartOf: { "@type": "Event", name: `${hackathon.name} — Hackatón #${hackathon.number}`, - url: `${BASE_URL}/hackathons/${hackathon.id}`, + url: `${BASE_URL}/hackathons/${hackathonSlug(hackathon)}`, }, author: dedupeSoldierProfileMembers(project.team).map((m) => ({ "@type": "Person", diff --git a/lib/nip46Client.ts b/lib/nip46Client.ts index f06f366..2e062c9 100644 --- a/lib/nip46Client.ts +++ b/lib/nip46Client.ts @@ -340,6 +340,16 @@ export class Nip46Client { return JSON.parse(resp) as SignedEvent; } + /** NIP-46 remote NIP-44 encrypt: bunker encrypts `plaintext` to `peer`. */ + async nip44Encrypt(peer: string, plaintext: string): Promise { + return this.sendRequest("nip44_encrypt", [peer, plaintext]); + } + + /** NIP-46 remote NIP-44 decrypt: bunker decrypts `ciphertext` from `peer`. */ + async nip44Decrypt(peer: string, ciphertext: string): Promise { + return this.sendRequest("nip44_decrypt", [peer, ciphertext]); + } + async close(): Promise { this.isOpen = false; for (const id of Object.keys(this.listeners)) { diff --git a/lib/nostrRelayConfig.ts b/lib/nostrRelayConfig.ts index c724f0b..091ff74 100644 --- a/lib/nostrRelayConfig.ts +++ b/lib/nostrRelayConfig.ts @@ -54,9 +54,28 @@ export function mergeNonAuthRelays( return withoutAuthOnlyRelays([...(baseRelays ?? []), ...(extraRelays ?? [])]); } -export const DEFAULT_RELAYS = [...LACRYPTA_DEFAULT_RELAYS]; -export const FAST_USER_RELAYS = [...LACRYPTA_FAST_USER_RELAYS]; -export const NIP46_LOGIN_RELAYS = [...LACRYPTA_NIP46_LOGIN_RELAYS]; +/** + * Dev-only relay override. Set NEXT_PUBLIC_NOSTR_RELAYS (comma-separated) to + * route ALL publish/read traffic through a different relay set — typically a + * local relay (ws://localhost:7777) for fully-isolated testing. When unset, + * the hardcoded La Crypta defaults are used. Single chokepoint: every importer + * of DEFAULT_RELAYS / FAST_USER_RELAYS / NIP46_LOGIN_RELAYS inherits it. + */ +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]; function normalizeRelayForPolicy(raw: string): string | null { const trimmed = raw.trim(); diff --git a/lib/nostrSigner.ts b/lib/nostrSigner.ts index 4650c50..163c69b 100644 --- a/lib/nostrSigner.ts +++ b/lib/nostrSigner.ts @@ -23,6 +23,12 @@ export type SignedEvent = { export type UserSigner = { pubkey: string; signEvent: (event: UnsignedEvent) => Promise; + /** NIP-44 encrypt `plaintext` to `peerPubkeyHex` (e.g. La Crypta's key for + * encrypted ballots). Throws a clear error if the method can't encrypt. */ + nip44Encrypt: (peerPubkeyHex: string, plaintext: string) => Promise; + /** NIP-44 decrypt `ciphertext` from `peerPubkeyHex`. The conversation key is + * symmetric, so a voter can decrypt their own ballot (peer = La Crypta). */ + nip44Decrypt: (peerPubkeyHex: string, ciphertext: string) => Promise; close?: () => Promise; }; @@ -31,10 +37,17 @@ declare global { nostr?: { getPublicKey: () => Promise; signEvent?: (event: UnsignedEvent) => Promise; + nip44?: { + encrypt?: (pubkey: string, plaintext: string) => Promise; + decrypt?: (pubkey: string, ciphertext: string) => Promise; + }; }; } } +const NIP44_UNSUPPORTED = + "Tu firmante no soporta cifrado NIP-44. Usá una extensión actualizada (Alby/nos2x) o una identidad local."; + export type GetSignerOptions = { /** Called by the bunker when it requires user approval in its web UI */ onAuthUrl?: (url: string) => void; @@ -75,6 +88,18 @@ export async function getSigner( const signed = await ext.signEvent!(event); return signed; }, + async nip44Encrypt(peer, plaintext) { + if (typeof ext.nip44?.encrypt !== "function") { + throw new Error(NIP44_UNSUPPORTED); + } + return ext.nip44.encrypt(peer, plaintext); + }, + async nip44Decrypt(peer, ciphertext) { + if (typeof ext.nip44?.decrypt !== "function") { + throw new Error(NIP44_UNSUPPORTED); + } + return ext.nip44.decrypt(peer, ciphertext); + }, }; } @@ -108,6 +133,14 @@ export async function getSigner( }; return finalizeEvent(template, sk); }, + async nip44Encrypt(peer, plaintext) { + const nip44 = await import("nostr-tools/nip44"); + return nip44.encrypt(plaintext, nip44.getConversationKey(sk, peer)); + }, + async nip44Decrypt(peer, ciphertext) { + const nip44 = await import("nostr-tools/nip44"); + return nip44.decrypt(ciphertext, nip44.getConversationKey(sk, peer)); + }, }; } @@ -175,6 +208,14 @@ export async function getSigner( }); return signed; }, + async nip44Encrypt(peer, plaintext) { + await ensureConnected(); + return client.nip44Encrypt(peer, plaintext); + }, + async nip44Decrypt(peer, ciphertext) { + await ensureConnected(); + return client.nip44Decrypt(peer, ciphertext); + }, async close() { try { await Promise.race([ @@ -232,6 +273,10 @@ export async function getSigner( // Kick it off immediately so first signEvent is faster ensureConnected(); + const innerEnc = inner as unknown as { + nip44Encrypt?: (peer: string, text: string) => Promise; + nip44Decrypt?: (peer: string, text: string) => Promise; + }; return { pubkey: auth.pubkey, async signEvent(event) { @@ -243,6 +288,20 @@ export async function getSigner( }); return signed; }, + async nip44Encrypt(peer, plaintext) { + if (typeof innerEnc.nip44Encrypt !== "function") { + throw new Error(NIP44_UNSUPPORTED); + } + await ensureConnected(); + return innerEnc.nip44Encrypt(peer, plaintext); + }, + async nip44Decrypt(peer, ciphertext) { + if (typeof innerEnc.nip44Decrypt !== "function") { + throw new Error(NIP44_UNSUPPORTED); + } + await ensureConnected(); + return innerEnc.nip44Decrypt(peer, ciphertext); + }, async close() { // Don't block the UI — close with a hard cap try { diff --git a/lib/useDevEnabled.ts b/lib/useDevEnabled.ts new file mode 100644 index 0000000..201551a --- /dev/null +++ b/lib/useDevEnabled.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { isDevMode } from "@/lib/devMode"; + +/** + * Runtime on/off switch for the dev UI, layered on top of the build-time + * `NEXT_PUBLIC_DEV_MODE` env gate. `isDevMode()` says dev features are + * *available*; this toggle says they're *active right now*. Flipping it off + * hides every dev-injected button so you can see the real production UI without + * restarting the server. Backed by localStorage + a change event so all + * consumers stay in sync. Defaults ON when dev mode is available. + */ +const KEY = "labs:dev:enabled"; +const EVENT = "labs:dev:enabled:changed"; + +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; + } +} + +export function setDevEnabled(on: boolean) { + try { + window.localStorage.setItem(KEY, on ? "true" : "false"); + window.dispatchEvent(new CustomEvent(EVENT)); + } catch { + /* quota */ + } +} + +export function useDevEnabled(): { + /** dev mode available (env) AND switched on (runtime). */ + enabled: boolean; + /** dev mode available at all (env). The switch only matters when true. */ + available: boolean; + setEnabled: (on: boolean) => void; +} { + const available = isDevMode(); + const [enabled, setEnabledState] = useState(available); + + useEffect(() => { + setEnabledState(isDevEnabled()); + const sync = () => setEnabledState(isDevEnabled()); + window.addEventListener(EVENT, sync); + window.addEventListener("storage", sync); + return () => { + window.removeEventListener(EVENT, sync); + window.removeEventListener("storage", sync); + }; + }, []); + + const setEnabled = useCallback((on: boolean) => setDevEnabled(on), []); + return { enabled: available && enabled, available, setEnabled }; +} diff --git a/lib/useDevIdentities.ts b/lib/useDevIdentities.ts new file mode 100644 index 0000000..6c62d14 --- /dev/null +++ b/lib/useDevIdentities.ts @@ -0,0 +1,184 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { setAuth, clearAuth } from "@/lib/auth"; +import { useToast } from "@/components/Toast"; + +/** A throwaway dev account. `secret` is a plain array so it round-trips JSON + * and matches `Auth.localSecret` exactly. */ +export type DevIdentity = { + label: string; + pubkey: string; + npub: string; + secret: number[]; +}; + +/** Shared with the original /dev/voting harness so identities carry over. */ +const STORAGE_KEY = "labs:dev:voting-voters:v1"; +/** Fired on mutation so every mounted consumer (header bar + voting lab) stays + * in sync, mirroring the `labs:auth:changed` pattern in lib/auth.ts. */ +const CHANGED_EVENT = "labs:dev:identities:changed"; + +function loadIdentities(): DevIdentity[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as DevIdentity[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function persist(identities: DevIdentity[]) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(identities)); + window.dispatchEvent(new CustomEvent(CHANGED_EVENT)); + } catch { + /* quota */ + } +} + +/** + * Manages the local pool of throwaway dev identities and the impersonation + * actions built on top of them. Used by the global DEV MODE bar and the + * /dev/voting lab — single source of truth, kept in sync across instances. + */ +export function useDevIdentities() { + const { push } = useToast(); + const [identities, setIdentities] = useState([]); + + useEffect(() => { + setIdentities(loadIdentities()); + const sync = () => setIdentities(loadIdentities()); + window.addEventListener(CHANGED_EVENT, sync); + window.addEventListener("storage", sync); + return () => { + window.removeEventListener(CHANGED_EVENT, sync); + window.removeEventListener("storage", sync); + }; + }, []); + + const generateIdentity = useCallback(async () => { + const { generateSecretKey, getPublicKey } = await import("nostr-tools/pure"); + const { npubEncode } = await import("nostr-tools/nip19"); + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + const current = loadIdentities(); + const identity: DevIdentity = { + label: `Identidad ${current.length + 1}`, + pubkey, + npub: npubEncode(pubkey), + secret: Array.from(secret), + }; + persist([...current, identity]); + return identity; + }, []); + + const removeIdentity = useCallback((pubkey: string) => { + persist(loadIdentities().filter((i) => i.pubkey !== pubkey)); + }, []); + + const loginAs = useCallback( + (identity: DevIdentity) => { + setAuth({ + method: "local", + pubkey: identity.pubkey, + localSecret: identity.secret, + }); + push({ + kind: "success", + title: `Sesión: ${identity.label}`, + description: identity.npub.slice(0, 20) + "…", + }); + }, + [push], + ); + + /** Logs in as La Crypta using the dev-only NEXT_PUBLIC_DEV_ADMIN_NSEC, so the + * developer can open/close voting and exercise admin-guarded actions. The + * matching npub must equal NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB. */ + const loginAsAdmin = useCallback(async () => { + const raw = process.env.NEXT_PUBLIC_DEV_ADMIN_NSEC; + if (!raw) { + push({ + kind: "error", + title: "Falta NEXT_PUBLIC_DEV_ADMIN_NSEC", + description: "Generá la clave con pnpm gen:dev-keys y reiniciá el server.", + }); + return; + } + try { + const { decode } = await import("nostr-tools/nip19"); + const { getPublicKey } = await import("nostr-tools/pure"); + const decoded = decode(raw.trim()); + if (decoded.type !== "nsec") throw new Error("no es una nsec válida"); + const secret = decoded.data as Uint8Array; + const pubkey = getPublicKey(secret); + setAuth({ method: "local", pubkey, localSecret: Array.from(secret) }); + push({ + kind: "success", + title: "Sesión: La Crypta (admin)", + description: pubkey.slice(0, 16) + "…", + }); + } catch (e) { + push({ + kind: "error", + title: "NSEC de admin inválida", + description: e instanceof Error ? e.message : String(e), + }); + } + }, [push]); + + /** Impersonate a real user by their PUBLIC key (e.g. a soldier from the + * roster). Logs in as the deterministic dev stand-in for that pubkey — the + * same key the voting route treats as eligible in dev mode. */ + const impersonate = useCallback( + async (realPubkey: string, name: string) => { + const { devSecretForPubkey } = await import("@/lib/devImpersonation"); + const { getPublicKey } = await import("nostr-tools/pure"); + const { npubEncode } = await import("nostr-tools/nip19"); + const secret = await devSecretForPubkey(realPubkey); + const pubkey = getPublicKey(secret); + setAuth({ + method: "local", + pubkey, + localSecret: Array.from(secret), + // Track the real pubkey so dashboards show the impersonated user's data. + impersonating: realPubkey.trim().toLowerCase(), + }); + push({ + kind: "success", + title: `Impersonando: ${name}`, + description: npubEncode(pubkey).slice(0, 18) + "…", + }); + }, + [push], + ); + + const logout = useCallback(() => clearAuth("user"), []); + + const copy = useCallback( + async (text: string, what: string) => { + try { + await navigator.clipboard.writeText(text); + push({ kind: "info", title: `${what} copiado` }); + } catch { + push({ kind: "error", title: "No se pudo copiar" }); + } + }, + [push], + ); + + return { + identities, + generateIdentity, + removeIdentity, + loginAs, + loginAsAdmin, + impersonate, + logout, + copy, + }; +} diff --git a/lib/voting.ts b/lib/voting.ts index 105df45..392cffc 100644 --- a/lib/voting.ts +++ b/lib/voting.ts @@ -19,7 +19,10 @@ import type { Soldier } from "./soldiers"; export const VOTING_KIND = 30078; export const VOTING_T_TAG = "lacrypta-dev-voting"; export const VOTE_T_TAG = "lacrypta-dev-vote"; -export const VOTING_SCHEMA_VERSION = 1; +/** v2 = NIP-44-encrypted ballots (allocations hidden on relays). v1 = plaintext. */ +export const VOTING_SCHEMA_VERSION = 2; +/** Value of the ballot's `["enc", …]` tag when the content is NIP-44 ciphertext. */ +export const VOTE_ENC = "nip44"; /** * Dev/test isolation: with `NEXT_PUBLIC_VOTING_NS=test` every d-tag moves to @@ -65,11 +68,40 @@ export type VotingTallyRow = { voters: number; }; +/** A ranked winner in the closed result. Ranking only — prize amounts and + * payout are handled separately (manual via existing tooling). */ +export type VotingWinner = { + /** 1-based rank in the final result. */ + position: number; + projectId: string; + projectName: string; + votes: number; + voters: number; + /** Primary team pubkey to send prizes to, resolved at close (null if none). */ + recipientPubkey: string | null; +}; + export type VotingResults = { tally: VotingTallyRow[]; ballotsCounted: number; ballotsRejected: number; totalVotesCast: number; + /** Ranking of projects with at least one vote (present on v2 closes). */ + winners?: VotingWinner[]; + /** The exact ballot event ids counted into this result — the FREEZE set. + * Ballots re-published after close (new ids) are never in this set. */ + countedBallotIds?: string[]; +}; + +/** A ballot whose encrypted content has already been decrypted (by the backend + * with LACRYPTA_NSEC, or by the voter with their own key). Lets validate/tally + * run in this pure module without any crypto. */ +export type DecryptedBallot = { + id: string; + pubkey: string; + created_at: number; + tags: string[][]; + allocations: Record; }; export type VotingPeriod = { @@ -193,27 +225,28 @@ export function parseBallotContent(content: string): BallotContent | null { } } -function eventTagValue(ev: VotingEventLike, name: string): string | null { - return ev.tags.find((t) => t[0] === name)?.[1] ?? null; -} export type BallotValidation = | { ok: true; allocations: Record } | { ok: false; reason: string }; /** - * A ballot counts iff its `d` tag matches the hackathon, its author is in the - * frozen eligibility snapshot, it was created inside the voting window, every - * allocation targets a votable (non-blocked) project with a positive integer - * amount, and the total stays within the voter's budget. + * Content-independent ballot gate: correct `d` tag, eligible author, and within + * the voting window. Returns the matched eligible voter on success. Reused by + * both the plaintext (v1) path and the decrypted (v2) path. */ -export function validateBallot( - ev: VotingEventLike, +export function ballotEnvelopeOk( + ev: { pubkey: string; kind: number; created_at: number; tags: string[][] }, period: VotingPeriod, opts: { closedAt?: number | null } = {}, -): BallotValidation { +): + | { ok: true; voter: VotingEligibleVoter } + | { ok: false; reason: string } { if (ev.kind !== VOTING_KIND) return { ok: false, reason: "kind" }; - if (eventTagValue(ev, "d") !== voteDTag(period.hackathonId)) { + if ( + (ev.tags.find((t) => t[0] === "d")?.[1] ?? null) !== + voteDTag(period.hackathonId) + ) { return { ok: false, reason: "d-tag" }; } const voter = period.eligible.find( @@ -225,16 +258,24 @@ export function validateBallot( if (closedAt !== null && closedAt !== undefined && ev.created_at > closedAt) { return { ok: false, reason: "too-late" }; } + return { ok: true, voter }; +} - const content = parseBallotContent(ev.content); - if (!content || content.hackathonId !== period.hackathonId) { - return { ok: false, reason: "content" }; - } - +/** + * Validates already-decrypted allocations against the period + voter budget: + * positive integers, votable (non-blocked) projects, total within budget. + * This is the AUTHORITATIVE budget check (the plaintext `["votes"]` tag on a + * ballot is never trusted — only this counts). + */ +export function validateAllocations( + allocations: Record, + voter: VotingEligibleVoter, + period: VotingPeriod, +): BallotValidation { const projectIds = new Set(period.projects.map((p) => p.id)); let total = 0; - const allocations: Record = {}; - for (const [projectId, votes] of Object.entries(content.allocations)) { + const out: Record = {}; + for (const [projectId, votes] of Object.entries(allocations)) { if (!Number.isInteger(votes) || votes < 1) { return { ok: false, reason: "invalid-amount" }; } @@ -244,13 +285,31 @@ export function validateBallot( if (voter.blocked.includes(projectId)) { return { ok: false, reason: "self-vote" }; } - allocations[projectId] = votes; + out[projectId] = votes; total += votes; } if (total === 0) return { ok: false, reason: "empty" }; if (total > voter.maxVotes) return { ok: false, reason: "over-budget" }; + return { ok: true, allocations: out }; +} - return { ok: true, allocations }; +/** + * Validates a PLAINTEXT (v1) ballot event end-to-end. Kept for backward compat + * with un-encrypted ballots; v2 encrypted ballots are decrypted first and run + * through `ballotEnvelopeOk` + `validateAllocations`. + */ +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); } /** @@ -274,6 +333,131 @@ export function dedupeBallots(events: VotingEventLike[]): VotingEventLike[] { return [...byAuthor.values()]; } +/** Latest-per-author dedupe over any event-like with id/pubkey/created_at. */ +function pickLatestPerAuthor< + T extends { id: string; pubkey: string; created_at: number }, +>(events: T[]): T[] { + const byAuthor = new Map(); + for (const ev of events) { + const key = ev.pubkey.toLowerCase(); + const prev = byAuthor.get(key); + if ( + !prev || + ev.created_at > prev.created_at || + (ev.created_at === prev.created_at && ev.id < prev.id) + ) { + byAuthor.set(key, ev); + } + } + return [...byAuthor.values()]; +} + +/** + * Builds tally rows + per-project voter counts from a `byVoter` map. + */ +function tallyFromByVoter( + byVoter: Map>, + period: VotingPeriod, +): { tally: VotingTallyRow[]; totalVotesCast: number } { + const votesByProject = new Map(); + let totalVotesCast = 0; + for (const allocations of byVoter.values()) { + for (const [projectId, votes] of Object.entries(allocations)) { + const row = votesByProject.get(projectId) ?? { votes: 0, voters: 0 }; + row.votes += votes; + row.voters += 1; + votesByProject.set(projectId, row); + totalVotesCast += votes; + } + } + const tally: VotingTallyRow[] = period.projects + .map((p) => ({ + projectId: p.id, + name: p.name, + votes: votesByProject.get(p.id)?.votes ?? 0, + voters: votesByProject.get(p.id)?.voters ?? 0, + })) + .sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name)); + return { tally, totalVotesCast }; +} + +/** + * The AUTHORITATIVE tally — run by the backend over ballots whose content has + * already been decrypted (with LACRYPTA_NSEC). Dedupes latest-per-author, + * re-checks envelope + allocations (eligibility + budget + projects), and + * returns the survivors plus the FREEZE set (`countedBallotIds`). + */ +export function tallyDecryptedBallots( + ballots: DecryptedBallot[], + period: VotingPeriod, + opts: { closedAt?: number | null } = {}, +): { + results: Omit; + byVoter: Map>; + counted: DecryptedBallot[]; + rejected: { id: string; pubkey: string; reason: string }[]; +} { + const deduped = pickLatestPerAuthor(ballots); + const byVoter = new Map>(); + const counted: DecryptedBallot[] = []; + const rejected: { id: string; pubkey: string; reason: string }[] = []; + + for (const ev of deduped) { + const envelope = ballotEnvelopeOk( + { pubkey: ev.pubkey, kind: VOTING_KIND, created_at: ev.created_at, tags: ev.tags }, + period, + opts, + ); + if (!envelope.ok) { + rejected.push({ id: ev.id, pubkey: ev.pubkey, reason: envelope.reason }); + continue; + } + const validation = validateAllocations(ev.allocations, envelope.voter, period); + if (!validation.ok) { + rejected.push({ id: ev.id, pubkey: ev.pubkey, reason: validation.reason }); + continue; + } + byVoter.set(ev.pubkey.toLowerCase(), validation.allocations); + counted.push(ev); + } + + const { tally, totalVotesCast } = tallyFromByVoter(byVoter, period); + return { + results: { + tally, + ballotsCounted: byVoter.size, + ballotsRejected: rejected.length, + totalVotesCast, + countedBallotIds: counted.map((b) => b.id), + }, + byVoter, + counted, + rejected, + }; +} + +/** + * Ranks projects with at least one vote (tally is already sorted votes desc, + * name asc). `resolveRecipient` injects the project's primary team pubkey for + * later (manual) prize payout. Ranking only — no prize amounts. + */ +export function computeVotingRanking( + tally: VotingTallyRow[], + resolveRecipient: (projectId: string) => string | null, +): VotingWinner[] { + return tally + .filter((row) => row.votes > 0) + .map((row, i) => ({ + position: i + 1, + projectId: row.projectId, + projectName: row.name, + votes: row.votes, + voters: row.voters, + recipientPubkey: resolveRecipient(row.projectId), + })); +} + +/** @deprecated v1 plaintext live tally. v2 hides the tally until close. */ export function tallyBallots( events: VotingEventLike[], period: VotingPeriod, diff --git a/lib/votingClient.ts b/lib/votingClient.ts index 807f3a5..e33105e 100644 --- a/lib/votingClient.ts +++ b/lib/votingClient.ts @@ -9,9 +9,12 @@ import { FAST_USER_RELAYS, DEFAULT_RELAYS } from "./nostrRelayConfig"; import type { SignedEvent, UserSigner } from "./nostrSigner"; import { + VOTE_ENC, VOTE_T_TAG, VOTING_KIND, VOTING_SCHEMA_VERSION, + dedupeBallots, + parseBallotContent, parseVotingPeriod, voteDTag, votingPeriodDTag, @@ -51,21 +54,34 @@ async function publishToRelays( } /** - * Signs and publishes the user's (replaceable) ballot. `createdAtFloor` should - * be the created_at of the user's previous ballot, if any — NIP-01 keeps the - * LOWEST id on created_at ties, so we bump past it to guarantee replacement. + * Signs and publishes the user's (replaceable) ballot, ENCRYPTED to La Crypta's + * key (NIP-44) so the relay reveals nothing. `lacryptaPubkey` is the publisher + * hex from /api/lacrypta-pubkeys. A plaintext `["votes", N]` tag carries the + * declared total for the live "who voted + count" display — it is NOT trusted + * for the result (the backend re-derives the count by decrypting at close). + * `createdAtFloor` should be the previous ballot's created_at (NIP-01 keeps the + * LOWEST id on ties, so we bump past it to guarantee replacement). */ export async function publishBallot( signer: UserSigner, hackathonId: string, allocations: Record, + lacryptaPubkey: string, createdAtFloor = 0, ): Promise { + if (!lacryptaPubkey) { + throw new Error("Falta la clave de La Crypta para cifrar el voto."); + } const content: BallotContent = { version: VOTING_SCHEMA_VERSION, hackathonId, allocations, }; + const ciphertext = await signer.nip44Encrypt( + lacryptaPubkey, + JSON.stringify(content), + ); + const totalAllocated = Object.values(allocations).reduce((s, n) => s + n, 0); const createdAt = Math.max( Math.floor(Date.now() / 1000), createdAtFloor + 1, @@ -74,11 +90,13 @@ export async function publishBallot( kind: VOTING_KIND, pubkey: signer.pubkey, created_at: createdAt, - content: JSON.stringify(content), + content: ciphertext, tags: [ ["d", voteDTag(hackathonId)], ["t", VOTE_T_TAG], ["h", hackathonId], + ["enc", VOTE_ENC], + ["votes", String(totalAllocated)], ["client", "La Crypta Dev"], ], }); @@ -90,6 +108,84 @@ export async function publishBallot( return signed; } +/** Declared vote total from a ballot's plaintext `["votes"]` tag (display only; + * untrusted — the authoritative count comes from decryption at close). */ +export function claimedVotes(ev: SignedEvent): number { + const raw = ev.tags.find((t) => t[0] === "votes")?.[1]; + const n = raw ? Number.parseInt(raw, 10) : 0; + return Number.isFinite(n) && n >= 0 ? n : 0; +} + +/** + * Decrypts the voter's OWN ballot so the editor can pre-fill it. The NIP-44 + * conversation key is symmetric (voterSecret↔lacryptaPubkey), so the voter + * reads their own ballot without La Crypta's secret. v1 plaintext falls back. + */ +export async function decryptOwnBallot( + signer: UserSigner, + lacryptaPubkey: string, + ballot: SignedEvent, +): Promise | null> { + try { + const enc = ballot.tags.find((t) => t[0] === "enc")?.[1]; + const plaintext = + enc === VOTE_ENC + ? await signer.nip44Decrypt(lacryptaPubkey, ballot.content) + : ballot.content; + return parseBallotContent(plaintext)?.allocations ?? null; + } catch { + return null; + } +} + +/** + * One-shot fetch of every ballot for a hackathon, deduped latest-per-author — + * the frozen set the admin posts to the backend for close-preview/confirm. + */ +export async function fetchAllBallotEvents( + hackathonId: string, + timeoutMs = 6000, +): Promise { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const dTag = voteDTag(hackathonId); + const relays = [...DEFAULT_RELAYS]; + const events: SignedEvent[] = []; + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + const closer = pool.subscribe( + relays, + { kinds: [VOTING_KIND], "#d": [dTag], limit: 1000 }, + { + onevent(ev) { + const event = ev as SignedEvent; + if (event.tags.find((t) => t[0] === "d")?.[1] === dTag) { + events.push(event); + } + }, + oneose() { + finish(); + }, + }, + ); + setTimeout(() => { + closer.close(); + finish(); + }, timeoutMs); + }); + try { + pool.close(relays); + } catch { + /* noop */ + } + return dedupeBallots(events) as SignedEvent[]; +} + /** * Live-subscribe to ballot events for a hackathon (historical + new), keeping * the relay subscription open until the returned cleanup function runs. diff --git a/lib/zap.ts b/lib/zap.ts index e381442..228ace3 100644 --- a/lib/zap.ts +++ b/lib/zap.ts @@ -183,6 +183,14 @@ export async function createAnonymousSigner(): Promise { sk, ); }, + async nip44Encrypt(peer, plaintext) { + const nip44 = await import("nostr-tools/nip44"); + return nip44.encrypt(plaintext, nip44.getConversationKey(sk, peer)); + }, + async nip44Decrypt(peer, ciphertext) { + const nip44 = await import("nostr-tools/nip44"); + return nip44.decrypt(ciphertext, nip44.getConversationKey(sk, peer)); + }, }; } diff --git a/package.json b/package.json index aaeeab3..c072478 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,11 @@ "scripts": { "dev": "next dev", "build": "next build", - "start": "next start" + "start": "next start", + "gen:dev-keys": "node scripts/gen-dev-keys.mjs", + "relay:up": "docker compose -f dev/relay/docker-compose.yml up -d", + "relay:down": "docker compose -f dev/relay/docker-compose.yml down", + "relay:logs": "docker compose -f dev/relay/docker-compose.yml logs -f" }, "dependencies": { "clsx": "^2.1.1", diff --git a/scripts/gen-dev-keys.mjs b/scripts/gen-dev-keys.mjs new file mode 100644 index 0000000..5c4a894 --- /dev/null +++ b/scripts/gen-dev-keys.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** + * Generate a throwaway dev keypair for the isolated local dev environment. + * + * pnpm gen:dev-keys + * + * Prints ready-to-paste .env.local lines: a dev signing key for La Crypta's + * official events (LACRYPTA_NSEC + matching admin npub) plus the browser-side + * dev admin secret used by the DEV MODE bar's "Entrar como La Crypta" button. + * + * NEVER use these values in production — they are meant for a local relay only. + */ +import { generateSecretKey, getPublicKey } from "nostr-tools/pure"; +import { npubEncode, nsecEncode } from "nostr-tools/nip19"; + +const sk = generateSecretKey(); // Uint8Array (32 bytes) +const pubkeyHex = getPublicKey(sk); +const nsec = nsecEncode(sk); +const npub = npubEncode(pubkeyHex); + +const line = "─".repeat(72); +console.log(`\n${line}`); +console.log(" Dev keypair — paste into .env.local (LOCAL DEV ONLY)"); +console.log(line); +console.log(` +# Server-only signing key for La Crypta's official dev events. +LACRYPTA_NSEC=${nsec} + +# Admin npub — must match the key above so admin guards recognise you. +NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB=${npub} + +# Browser-side admin secret for the DEV MODE bar "Entrar como La Crypta" button. +# Same key as LACRYPTA_NSEC. Dev-only — exposed to the browser on purpose. +NEXT_PUBLIC_DEV_ADMIN_NSEC=${nsec} + +# Turn on the DEV MODE bar + impersonation, and route Nostr to the local relay. +NEXT_PUBLIC_DEV_MODE=true +NEXT_PUBLIC_NOSTR_RELAYS=ws://localhost:7777 +`); +console.log(`${line}`); +console.log(` pubkey (hex): ${pubkeyHex}`); +console.log(`${line}\n`);