From 0f179e293c3db55cbfde3f22437f8eeffe0a1009 Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Fri, 29 May 2026 12:17:26 -0300 Subject: [PATCH] add hackathon badge catalog flow --- .env.example | 18 +- app/api/hackathon-badges/bootstrap/route.ts | 123 ++++ app/api/lacrypta-pubkeys/route.ts | 35 + app/database/DatabaseClient.tsx | 4 +- .../[id]/badges/HackathonBadgesClient.tsx | 641 ++++++++++++++++++ app/hackathons/[id]/badges/page.tsx | 40 ++ app/hackathons/[id]/page.tsx | 10 + data/hackathons/projects-commerce.json | 55 +- data/hackathons/reports.json | 24 +- docs/nostr-hackathon-badges.md | 336 +++++++++ lib/databaseCatalog.ts | 26 +- lib/hackathonBadgeClient.ts | 172 +++++ lib/hackathonBadges.ts | 350 ++++++++++ lib/nostrReports.ts | 20 +- 14 files changed, 1781 insertions(+), 73 deletions(-) create mode 100644 app/api/hackathon-badges/bootstrap/route.ts create mode 100644 app/api/lacrypta-pubkeys/route.ts create mode 100644 app/hackathons/[id]/badges/HackathonBadgesClient.tsx create mode 100644 app/hackathons/[id]/badges/page.tsx create mode 100644 docs/nostr-hackathon-badges.md create mode 100644 lib/hackathonBadgeClient.ts create mode 100644 lib/hackathonBadges.ts diff --git a/.env.example b/.env.example index c6adce0..c582ef9 100644 --- a/.env.example +++ b/.env.example @@ -8,19 +8,15 @@ # ─── Nostr identity (required) ────────────────────────────────────────────── -# La Crypta's official npub (NIP-19 bech32). Public. -# Read by: lib/nostrReports.ts (resolveLacryptaPubkey, cached on window.__lcpk__). -# Used for: `authors` filter on hackathon reports/results/badges, dashboard -# admin guard, and signature verification of "official" events. -# Without this, /database categorías scoped to La Crypta are disabled and -# hackathon reports/results fall back to the static JSON in data/. +# Admin npub (NIP-19 bech32). Public. +# Used by browser flows to decide whether the logged user can trigger admin +# actions such as bootstrapping hackathon badge definitions. NEXT_PUBLIC_LACRYPTA_NPUB=npub1... -# La Crypta's signing key (NIP-19 bech32 nsec). SERVER-ONLY. -# Documented contract in CLAUDE.md for server-side signing of reports/results. -# Not yet read by any source file — leave commented until the signing flow -# is wired up, but keep it here so deployment env stays in lockstep with docs. -# LACRYPTA_NSEC=nsec1... +# La Crypta's official publisher signing key (NIP-19 bech32 nsec). +# SERVER-ONLY. The public official pubkey is derived from this key by backend +# routes and is used as the author for official reports/results/badges. +LACRYPTA_NSEC=nsec1... # ─── Cache revalidation (required for /api/revalidate-nostr) ──────────────── diff --git a/app/api/hackathon-badges/bootstrap/route.ts b/app/api/hackathon-badges/bootstrap/route.ts new file mode 100644 index 0000000..c5350da --- /dev/null +++ b/app/api/hackathon-badges/bootstrap/route.ts @@ -0,0 +1,123 @@ +import { NextResponse } from "next/server"; +import { getHackathon } from "@/lib/hackathons"; +import type { SignedEvent } from "@/lib/nostrSigner"; +import { + buildHackathonBadgeCatalogEvent, + buildHackathonBadgeDefinitionEvents, +} from "@/lib/hackathonBadges"; + +type BootstrapBody = { + hackathonId?: string; + request?: SignedEvent; +}; + +function jsonError(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +async function getBackendSecret() { + 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_NPUB; + if (!npub) throw new Error("Falta NEXT_PUBLIC_LACRYPTA_NPUB."); + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(npub); + if (decoded.type !== "npub") { + throw new Error("NEXT_PUBLIC_LACRYPTA_NPUB invalido."); + } + return decoded.data as string; +} + +function requestHasTag(request: SignedEvent, name: string, value: string): boolean { + return request.tags.some((tag) => tag[0] === name && tag[1] === value); +} + +export async function POST(req: Request) { + let body: BootstrapBody; + try { + body = (await req.json()) as BootstrapBody; + } catch { + return jsonError("Body JSON invalido."); + } + + const hackathonId = body.hackathonId?.trim(); + const request = body.request; + if (!hackathonId) return jsonError("Falta hackathonId."); + if (!request) return jsonError("Falta request firmado."); + + const hackathon = getHackathon(hackathonId); + if (!hackathon) return jsonError("Hackaton no encontrada.", 404); + + try { + const { finalizeEvent, getPublicKey, verifyEvent } = await import( + "nostr-tools/pure" + ); + const secret = await getBackendSecret(); + const issuerPubkey = getPublicKey(secret); + 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_NPUB.", + 403, + ); + } + if (Math.abs(Math.floor(Date.now() / 1000) - request.created_at) > 10 * 60) { + return jsonError("Request expirado.", 401); + } + if ( + !requestHasTag(request, "action", "bootstrap-hackathon-badges") || + !requestHasTag(request, "hackathon", hackathonId) + ) { + return jsonError("Request no autorizado para este bootstrap.", 401); + } + + const createdAt = Math.floor(Date.now() / 1000); + const unsignedEvents = [ + ...buildHackathonBadgeDefinitionEvents( + issuerPubkey, + hackathon.id, + undefined, + createdAt, + ), + buildHackathonBadgeCatalogEvent( + issuerPubkey, + hackathon.id, + hackathon.name, + undefined, + createdAt, + ), + ]; + const events = unsignedEvents.map((event) => + finalizeEvent( + { + kind: event.kind, + created_at: event.created_at, + tags: event.tags, + content: event.content, + }, + secret, + ), + ); + + return NextResponse.json({ + issuerPubkey, + events, + count: events.length, + }); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : "No se pudo generar bootstrap.", + 500, + ); + } +} diff --git a/app/api/lacrypta-pubkeys/route.ts b/app/api/lacrypta-pubkeys/route.ts new file mode 100644 index 0000000..3419b2c --- /dev/null +++ b/app/api/lacrypta-pubkeys/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; + +async function decodeNpub(npub: string): Promise { + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(npub); + if (decoded.type !== "npub") throw new Error("npub invalido."); + return decoded.data as string; +} + +async function publisherPubkeyFromNsec(): Promise { + const nsec = process.env.LACRYPTA_NSEC; + if (!nsec) throw new Error("Falta LACRYPTA_NSEC."); + const { decode } = await import("nostr-tools/nip19"); + const { getPublicKey } = await import("nostr-tools/pure"); + const decoded = decode(nsec); + if (decoded.type !== "nsec") throw new Error("LACRYPTA_NSEC invalido."); + return getPublicKey(decoded.data as Uint8Array); +} + +export async function GET() { + try { + const adminNpub = process.env.NEXT_PUBLIC_LACRYPTA_NPUB; + if (!adminNpub) throw new Error("Falta NEXT_PUBLIC_LACRYPTA_NPUB."); + const [adminPubkey, publisherPubkey] = await Promise.all([ + decodeNpub(adminNpub), + publisherPubkeyFromNsec(), + ]); + return NextResponse.json({ adminPubkey, publisherPubkey }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Config invalida." }, + { status: 500 }, + ); + } +} diff --git a/app/database/DatabaseClient.tsx b/app/database/DatabaseClient.tsx index e89c209..e12e9f0 100644 --- a/app/database/DatabaseClient.tsx +++ b/app/database/DatabaseClient.tsx @@ -764,8 +764,8 @@ function PubkeyOverride({
- NEXT_PUBLIC_LACRYPTA_NPUB no resuelto. Pegá una pubkey para habilitar el - resto de los eventos. + Pubkey oficial no resuelta desde LACRYPTA_NSEC. Pegá una pubkey para + habilitar el resto de los eventos.
> = { + award: Award, + brush: Brush, + flame: Flame, + gem: Gem, + heart: Heart, + medal: Medal, + mic: Mic2, + rocket: Rocket, + send: Send, + "shield-check": ShieldCheck, + sparkles: Sparkles, + star: Star, + trophy: Trophy, +}; + +const TONE_STYLE: Record< + HackathonBadgeTone, + { + ring: string; + icon: string; + glow: string; + chip: string; + preview: string; + } +> = { + gold: { + ring: "border-yellow-300/45 bg-yellow-300/10", + icon: "text-yellow-200", + glow: "bg-yellow-300/20", + chip: "border-yellow-300/35 bg-yellow-300/10 text-yellow-100", + preview: "from-yellow-200 via-bitcoin to-yellow-600", + }, + silver: { + ring: "border-slate-200/35 bg-slate-200/10", + icon: "text-slate-100", + glow: "bg-slate-200/15", + chip: "border-slate-200/30 bg-slate-200/10 text-slate-100", + preview: "from-slate-100 via-slate-300 to-slate-500", + }, + bronze: { + ring: "border-orange-400/40 bg-orange-400/10", + icon: "text-orange-200", + glow: "bg-orange-400/18", + chip: "border-orange-400/30 bg-orange-400/10 text-orange-100", + preview: "from-orange-200 via-orange-500 to-amber-800", + }, + bitcoin: { + ring: "border-bitcoin/40 bg-bitcoin/10", + icon: "text-bitcoin", + glow: "bg-bitcoin/20", + chip: "border-bitcoin/30 bg-bitcoin/10 text-bitcoin", + preview: "from-bitcoin via-lightning to-yellow-300", + }, + nostr: { + ring: "border-nostr/40 bg-nostr/10", + icon: "text-nostr", + glow: "bg-nostr/20", + chip: "border-nostr/30 bg-nostr/10 text-nostr", + preview: "from-nostr via-fuchsia-400 to-cyan", + }, + cyan: { + ring: "border-cyan/40 bg-cyan/10", + icon: "text-cyan", + glow: "bg-cyan/20", + chip: "border-cyan/30 bg-cyan/10 text-cyan", + preview: "from-cyan via-sky-300 to-nostr", + }, + success: { + ring: "border-success/40 bg-success/10", + icon: "text-success", + glow: "bg-success/20", + chip: "border-success/30 bg-success/10 text-success", + preview: "from-success via-emerald-300 to-cyan", + }, + pink: { + ring: "border-pink-400/40 bg-pink-400/10", + icon: "text-pink-300", + glow: "bg-pink-400/20", + chip: "border-pink-400/30 bg-pink-400/10 text-pink-200", + preview: "from-pink-300 via-rose-400 to-bitcoin", + }, +}; + +export default function HackathonBadgesClient({ + hackathonId, + hackathonName, +}: { + hackathonId: string; + hackathonName: string; +}) { + const { auth, signerReady } = useAuth(); + const [adminPubkey, setAdminPubkey] = useState(""); + const [publisherPubkey, setPublisherPubkey] = useState(""); + const [state, setState] = useState({ + status: "loading", + catalog: null, + error: null, + }); + const [bootstrap, setBootstrap] = useState({ + phase: "idle", + message: "", + ok: 0, + total: 0, + }); + + const isAdmin = !!auth && !!adminPubkey && auth.pubkey === adminPubkey; + const hasConfiguredAdmin = !!adminPubkey; + const hasPublisher = !!publisherPubkey; + const adminMismatch = !!auth && hasConfiguredAdmin && auth.pubkey !== adminPubkey; + const busy = ["signing", "generating", "publishing"].includes(bootstrap.phase); + + async function loadCatalog() { + setState({ status: "loading", catalog: null, error: null }); + try { + const keys = await fetchLacryptaBadgePubkeys(); + setAdminPubkey(keys.adminPubkey); + setPublisherPubkey(keys.publisherPubkey); + const found = await fetchHackathonBadgeCatalog( + hackathonId, + keys.publisherPubkey, + ); + if (found) { + setState({ status: "ready", catalog: found.catalog, error: null }); + } else { + setState({ status: "missing", catalog: null, error: null }); + } + } catch (error) { + setState({ + status: "error", + catalog: null, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + useEffect(() => { + loadCatalog(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hackathonId]); + + async function bootstrapCatalog() { + if (!auth || !isAdmin || busy) return; + setBootstrap({ + phase: "signing", + message: "Firmando solicitud de bootstrap", + ok: 0, + total: DEFAULT_HACKATHON_BADGES.length + 1, + }); + let signer: Awaited> | null = null; + try { + signer = await getSigner(auth); + setBootstrap((prev) => ({ + ...prev, + phase: "generating", + message: "El backend esta generando definiciones y catalogo", + })); + const result = await requestBadgeBootstrap( + hackathonId, + signer, + ); + + setBootstrap({ + phase: "publishing", + message: "Publicando eventos firmados por el backend", + ok: 0, + total: result.events.length, + }); + const published = await publishSignedEventsToRelays(result.events); + const ok = published.filter((relay) => relay.ok).length; + if (ok === 0) { + throw new Error("Ningun relay acepto los eventos firmados."); + } + setBootstrap({ + phase: "done", + message: `Publicado en ${ok}/${published.length} relays.`, + ok, + total: published.length, + }); + await loadCatalog(); + } catch (error) { + setBootstrap((prev) => ({ + ...prev, + phase: "error", + message: error instanceof Error ? error.message : String(error), + })); + } finally { + signer?.close?.().catch(() => {}); + } + } + + const catalog = state.catalog; + const categories = catalog?.categories ?? []; + const orderedCategories = useMemo( + () => + [...categories].sort((a, b) => { + const ai = GROUP_ORDER.indexOf(a.id); + const bi = GROUP_ORDER.indexOf(b.id); + return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); + }), + [categories], + ); + + return ( +
+
+
+
+
+
+ +
+ + + Volver a {hackathonName} + + +
+
+
+ + Badges desde Nostr +
+

+ Badges de{" "} + {hackathonName} +

+

+ El catalogo visible se resuelve desde un evento reemplazable de + Nostr que linkea cada badge con su definicion NIP-58. +

+
+ + +
+ + {state.status === "loading" && ( +
+ + Buscando catalogo publicado en Nostr... +
+ )} + + {state.status === "missing" && ( + + )} + + {state.status === "error" && ( +
+ {state.error} +
+ )} + + {catalog && ( +
+ {orderedCategories.map((category) => { + const badges = catalog.badges.filter( + (badge) => badge.category === category.id, + ); + if (badges.length === 0) return null; + return ( +
+
+
+ {groupIcon(category.id)} +
+
+

+ {category.label} +

+

+ {badges.length} badges +

+
+
+
+ {badges.map((badge) => ( + + ))} +
+
+ ); + })} +
+ )} +
+
+ ); +} + +function StatusPanel({ + state, + isAdmin, + adminMismatch, + hasConfiguredAdmin, + hasPublisher, + signerReady, + busy, + bootstrap, + onBootstrap, + onRefresh, +}: { + state: CatalogState; + isAdmin: boolean; + adminMismatch: boolean; + hasConfiguredAdmin: boolean; + hasPublisher: boolean; + signerReady: boolean; + busy: boolean; + bootstrap: BootstrapState; + onBootstrap: () => void; + onRefresh: () => void; +}) { + const found = state.status === "ready"; + const missing = state.status === "missing"; + + return ( +
+
+
+
+ Estado Nostr +
+
+ {found ? "Publicado" : missing ? "No publicado" : "Buscando"} +
+
+
+ {found ? ( + + ) : missing ? ( + + ) : ( + + )} +
+
+ + {bootstrap.phase !== "idle" && ( +
+ {bootstrap.message} +
+ )} + + {!hasConfiguredAdmin && ( +
+ Falta NEXT_PUBLIC_LACRYPTA_NPUB. +
+ )} + {!hasPublisher && ( +
+ Falta LACRYPTA_NSEC para resolver la pubkey oficial. +
+ )} + {adminMismatch && ( +
+ El usuario logueado no coincide con NEXT_PUBLIC_LACRYPTA_NPUB. +
+ )} + +
+ + {missing && isAdmin && ( + + )} +
+
+ ); +} + +function MissingCatalog({ + isAdmin, + adminMismatch, + hasConfiguredAdmin, + hasPublisher, + signerReady, + busy, + onBootstrap, +}: { + isAdmin: boolean; + adminMismatch: boolean; + hasConfiguredAdmin: boolean; + hasPublisher: boolean; + signerReady: boolean; + busy: boolean; + onBootstrap: () => void; +}) { + return ( +
+
+
+
+ + Catalogo no encontrado +
+

+ Los badges default todavia no estan publicados en Nostr. +

+

+ El bootstrap crea {DEFAULT_HACKATHON_BADGES.length} definiciones + NIP-58 y un evento reemplazable que las linkea por categoria. El + backend firma los eventos y este front los publica. +

+
+ {isAdmin && ( + + )} + {!hasConfiguredAdmin && ( +

+ Configurá NEXT_PUBLIC_LACRYPTA_NPUB para habilitar bootstrap. +

+ )} + {!hasPublisher && ( +

+ Configurá LACRYPTA_NSEC para publicar con la identidad oficial. +

+ )} + {adminMismatch && ( +

+ Para publicar defaults, el usuario logueado debe coincidir con + NEXT_PUBLIC_LACRYPTA_NPUB. +

+ )} +
+
+ ); +} + +function groupIcon(group: HackathonBadgeCategoryId) { + if (group === "ranking") return ; + if (group === "favorites") return ; + if (group === "specials") return ; + return ; +} + +function BadgeCard({ badge }: { badge: HackathonBadgeCatalogBadge }) { + const Icon = ICONS[badge.icon] ?? Award; + const tone = TONE_STYLE[badge.tone]; + + return ( +
+
+
+
+
+ +
+ +
+
+ + + {badge.category} + +
+

+ {badge.name} +

+

+ {badge.description} +

+
+
+ +
+ + {badge.id} + +
+ +
+
+
+ ); +} diff --git a/app/hackathons/[id]/badges/page.tsx b/app/hackathons/[id]/badges/page.tsx new file mode 100644 index 0000000..8943171 --- /dev/null +++ b/app/hackathons/[id]/badges/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { HACKATHONS, getHackathon } from "@/lib/hackathons"; +import HackathonBadgesClient from "./HackathonBadgesClient"; + +export function generateStaticParams() { + return HACKATHONS.map((h) => ({ id: h.id })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string }>; +}): Promise { + const { id } = await params; + const hackathon = getHackathon(id); + if (!hackathon) return { title: "Badges de hackatón" }; + return { + title: `Badges · ${hackathon.name}`, + description: `Badges disponibles para la hackatón ${hackathon.name}.`, + alternates: { canonical: `/hackathons/${hackathon.id}/badges` }, + }; +} + +export default async function HackathonBadgesPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const hackathon = getHackathon(id); + if (!hackathon) notFound(); + + return ( + + ); +} diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index 527127d..3a955a2 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { ArrowLeft, ArrowRight, + Award, BookOpen, Calendar, Trophy, @@ -413,6 +414,15 @@ export default async function HackathonPage({
)} +
+ + + Ver badges + +
diff --git a/data/hackathons/projects-commerce.json b/data/hackathons/projects-commerce.json index 2b3f4ee..32c30a3 100644 --- a/data/hackathons/projects-commerce.json +++ b/data/hackathons/projects-commerce.json @@ -58,32 +58,10 @@ "status": "winner", "submittedAt": "2026-05-26" }, - { - "id": "zaploop", - "name": "Zaploop", - "description": "Tercer puesto de Commerce con score final 7.52 y pitch en el minuto 86:38 de la CC #161.", - "team": [ - { - "name": "Burgos", - "github": "Burgos247", - "role": "Lead Dev", - "nip05": "burgos@primal.net", - "pubkey": "afc8a6df0841909c981c7c5a5536e562cbd5ff5bb22beb8357f3f2798465e4dc" - } - ], - "repo": "https://github.com/Burgos247/Zaploop", - "demo": "https://zaploop.vercel.app", - "tech": [ - "Lightning", - "Commerce" - ], - "status": "winner", - "submittedAt": "2026-05-26" - }, { "id": "wapufy", "name": "wapufy", - "description": "Cuarto puesto de Commerce con score final 7.42 y mención fuerte en los resultados finales.", + "description": "Tercer puesto de Commerce.", "team": [ { "name": "Looker", @@ -104,7 +82,7 @@ { "id": "tiendita", "name": "tiendita", - "description": "Quinto puesto de Commerce con score final 5.36 luego de penalización por no pitch.", + "description": "Cuarto puesto de Commerce.", "team": [ { "name": "negr0", @@ -124,19 +102,32 @@ "submittedAt": "2026-05-26" }, { - "id": "hash-21", - "name": "hash 21", - "description": "Sexto puesto de Commerce con score final 4.95 luego de penalización por no pitch.", + "id": "wapubot", + "name": "wapubot", + "description": "Quinto puesto de Commerce.", + "team": [], + "tech": [ + "Lightning", + "Commerce" + ], + "status": "winner", + "submittedAt": "2026-05-26" + }, + { + "id": "zaploop", + "name": "Zaploop", + "description": "Sexto puesto de Commerce.", "team": [ { - "name": "Lai", - "github": "warrior-lai", + "name": "Burgos", + "github": "Burgos247", "role": "Lead Dev", - "pubkey": "a78a391888c6a7a2e114ad66dc0e473b9f561734c7f098c9552b2e5bb840d26c" + "nip05": "burgos@primal.net", + "pubkey": "afc8a6df0841909c981c7c5a5536e562cbd5ff5bb22beb8357f3f2798465e4dc" } ], - "repo": "https://github.com/warrior-lai/hash-21", - "demo": "https://hash21.studio/", + "repo": "https://github.com/Burgos247/Zaploop", + "demo": "https://zaploop.vercel.app", "tech": [ "Lightning", "Commerce" diff --git a/data/hackathons/reports.json b/data/hackathons/reports.json index 0adbcc1..acaf44b 100644 --- a/data/hackathons/reports.json +++ b/data/hackathons/reports.json @@ -1742,8 +1742,8 @@ }, "zaploop": { "title": "Zaploop", - "position": 3, - "finalScore": 7.52, + "position": 6, + "finalScore": null, "team": [ "Burgos" ], @@ -1756,8 +1756,8 @@ }, "wapufy": { "title": "wapufy", - "position": 4, - "finalScore": 7.42, + "position": 3, + "finalScore": null, "team": [ "Looker" ], @@ -1770,8 +1770,8 @@ }, "tiendita": { "title": "tiendita", - "position": 5, - "finalScore": 5.36, + "position": 4, + "finalScore": null, "team": [ "negr0" ], @@ -1782,13 +1782,11 @@ "improvements": [] } }, - "hash-21": { - "title": "hash 21", - "position": 6, - "finalScore": 4.95, - "team": [ - "Lai" - ], + "wapubot": { + "title": "wapubot", + "position": 5, + "finalScore": null, + "team": [], "stack": [], "judges": [], "feedback": { diff --git a/docs/nostr-hackathon-badges.md b/docs/nostr-hackathon-badges.md new file mode 100644 index 0000000..6a72dd9 --- /dev/null +++ b/docs/nostr-hackathon-badges.md @@ -0,0 +1,336 @@ +# Nostr Hackathon Badges + +Esta especificacion define como La Crypta publica el catalogo de badges +disponibles para una hackaton y como cada badge apunta a su definicion NIP-58. + +## Objetivo + +Cada hackaton tiene un catalogo versionado de badges. Ese catalogo debe ser un +evento reemplazable en Nostr para que se pueda actualizar sin crear multiples +fuentes de verdad. + +El catalogo no otorga badges por si mismo. Solo declara que badges existen, +a que categoria pertenecen y cual es la definicion NIP-58 que debe usarse al +otorgarlos. + +## Eventos usados + +- Catalogo de badges: `kind: 30078`, evento parametrizado reemplazable. +- Definicion de badge: `kind: 30009`, NIP-58 Badge Definition. +- Otorgamiento de badge: `kind: 8`, NIP-58 Badge Award. +- Perfil de badges del usuario: `kind: 30008`, NIP-58 Profile Badges. + +## Catalogo + +El catalogo se publica como `kind: 30078` por la pubkey oficial de publicacion +de La Crypta. Esa pubkey se deriva server-side desde `LACRYPTA_NSEC`. +Debe tener un tag `d` estable: + +```json +["d", "lacrypta.dev:hackathon-badges:"] +``` + +Ejemplo para Commerce: + +```json +["d", "lacrypta.dev:hackathon-badges:commerce"] +``` + +### Tags requeridos + +```json +[ + ["d", "lacrypta.dev:hackathon-badges:commerce"], + ["client", "lacrypta.dev"], + ["hackathon", "commerce"], + ["schema", "lacrypta.dev/hackathon-badges", "1"], + ["title", "Badges Commerce"], + ["a", "30009::lacrypta.dev:badge:commerce:rank-1"], + ["a", "30009::lacrypta.dev:badge:commerce:rank-2"] +] +``` + +Los tags `a` son un indice rapido de todas las definiciones NIP-58 usadas por +el catalogo. La informacion completa vive en `content`. + +### Content + +`content` debe ser JSON valido. `version` versiona el schema, no el evento. La +ultima version del evento se obtiene por `(kind, pubkey, d)`. + +```json +{ + "version": 1, + "hackathon": "commerce", + "title": "Badges Commerce", + "categories": [ + { + "id": "ranking", + "label": "Ranking", + "description": "Badges por posicion final." + } + ], + "badges": [ + { + "id": "rank-1", + "category": "ranking", + "definition": "30009::lacrypta.dev:badge:commerce:rank-1", + "name": "1er Puesto", + "description": "Proyecto ganador de la hackaton.", + "criteria": { + "type": "rank", + "position": 1 + } + } + ] +} +``` + +### Campos de categoria + +- `id`: identificador estable en kebab-case. +- `label`: nombre visible. +- `description`: descripcion corta opcional. + +Categorias iniciales: + +```json +[ + { "id": "ranking", "label": "Ranking" }, + { "id": "favorites", "label": "Favoritos" }, + { "id": "specials", "label": "Especiales" }, + { "id": "streaks", "label": "Rachas" } +] +``` + +### Campos de badge + +- `id`: identificador estable dentro de la hackaton. +- `category`: `id` de una categoria declarada en `categories`. +- `definition`: a-tag de la definicion NIP-58 (`30009::`). +- `name`: nombre visible. +- `description`: descripcion visible. +- `criteria`: objeto que describe la regla de asignacion. +- `image`: opcional; URL de imagen del badge, si se quiere duplicar desde la + definicion para render rapido. +- `thumb`: opcional; thumbnail del badge. + +`definition` es obligatorio y debe apuntar a un evento `kind: 30009` publicado +por la pubkey emisora oficial. + +## Definiciones NIP-58 + +Cada badge del catalogo debe tener una definicion NIP-58 independiente: + +```json +{ + "kind": 30009, + "pubkey": "", + "content": "", + "tags": [ + ["d", "lacrypta.dev:badge:commerce:rank-1"], + ["name", "1er Puesto"], + ["description", "Proyecto ganador de Commerce."], + ["image", "https://lacrypta.dev/badges/commerce/rank-1.png"], + ["thumb", "https://lacrypta.dev/badges/commerce/rank-1-thumb.png"] + ] +} +``` + +El `d` de cada definicion debe seguir esta forma: + +```txt +lacrypta.dev:badge:: +``` + +Ejemplo: + +```txt +lacrypta.dev:badge:commerce:great-pitch +``` + +## Badges iniciales + +```json +[ + { + "id": "rank-1", + "category": "ranking", + "name": "1er Puesto", + "criteria": { "type": "rank", "position": 1 } + }, + { + "id": "rank-2", + "category": "ranking", + "name": "2do Puesto", + "criteria": { "type": "rank", "position": 2 } + }, + { + "id": "rank-3", + "category": "ranking", + "name": "3er Puesto", + "criteria": { "type": "rank", "position": 3 } + }, + { + "id": "top-6", + "category": "ranking", + "name": "Podio (top 6)", + "criteria": { "type": "rank-range", "min": 1, "max": 6 } + }, + { + "id": "favorite-gorilatron", + "category": "favorites", + "name": "Favorito de Gorilatron", + "criteria": { "type": "manual", "juror": "gorilatron" } + }, + { + "id": "favorite-gorilator", + "category": "favorites", + "name": "Favorito de Gorilator", + "criteria": { "type": "manual", "juror": "gorilator" } + }, + { + "id": "favorite-claudio", + "category": "favorites", + "name": "Favorito de Claudio", + "criteria": { "type": "manual", "juror": "claudio" } + }, + { + "id": "viability", + "category": "specials", + "name": "Premio a la viabilidad", + "criteria": { "type": "manual" } + }, + { + "id": "beautiful-ui", + "category": "specials", + "name": "Hermoso UI", + "criteria": { "type": "manual" } + }, + { + "id": "great-pitch", + "category": "specials", + "name": "Tremendo Pitch", + "criteria": { "type": "manual" } + }, + { + "id": "first-submit", + "category": "specials", + "name": "Primer submit", + "criteria": { "type": "first-submit" } + }, + { + "id": "streak-2", + "category": "streaks", + "name": "2 hackatons al hilo", + "criteria": { "type": "streak", "count": 2 } + }, + { + "id": "streak-3", + "category": "streaks", + "name": "3 hackatons al hilo", + "criteria": { "type": "streak", "count": 3 } + }, + { + "id": "streak-4", + "category": "streaks", + "name": "4 hackatons al hilo", + "criteria": { "type": "streak", "count": 4 } + }, + { + "id": "streak-5", + "category": "streaks", + "name": "5 hackatons al hilo", + "criteria": { "type": "streak", "count": 5 } + } +] +``` + +## Ejemplo completo de catalogo + +```json +{ + "kind": 30078, + "pubkey": "", + "content": "{\"version\":1,\"hackathon\":\"commerce\",\"title\":\"Badges Commerce\",\"categories\":[{\"id\":\"ranking\",\"label\":\"Ranking\"},{\"id\":\"favorites\",\"label\":\"Favoritos\"},{\"id\":\"specials\",\"label\":\"Especiales\"},{\"id\":\"streaks\",\"label\":\"Rachas\"}],\"badges\":[{\"id\":\"rank-1\",\"category\":\"ranking\",\"definition\":\"30009::lacrypta.dev:badge:commerce:rank-1\",\"name\":\"1er Puesto\",\"description\":\"Proyecto ganador de Commerce.\",\"criteria\":{\"type\":\"rank\",\"position\":1}}]}", + "tags": [ + ["d", "lacrypta.dev:hackathon-badges:commerce"], + ["client", "lacrypta.dev"], + ["hackathon", "commerce"], + ["schema", "lacrypta.dev/hackathon-badges", "1"], + ["title", "Badges Commerce"], + ["a", "30009::lacrypta.dev:badge:commerce:rank-1"] + ] +} +``` + +## Otorgamiento + +Para otorgar un badge se publica un `kind: 8` NIP-58 Badge Award. El award debe +referenciar: + +- La definicion NIP-58 con tag `a`. +- El receptor con tag `p`. +- La hackaton con tag `hackathon`. +- El proyecto, si aplica, con tag `project`. +- El badge id con tag `badge`. + +Ejemplo: + +```json +{ + "kind": 8, + "pubkey": "", + "content": "1er Puesto Commerce: Cursats", + "tags": [ + ["a", "30009::lacrypta.dev:badge:commerce:rank-1"], + ["p", ""], + ["hackathon", "commerce"], + ["project", "cursats"], + ["badge", "rank-1"], + ["category", "ranking"] + ] +} +``` + +## Reglas de lectura + +1. Buscar el ultimo evento `kind: 30078` por `(pubkey oficial, d)`. +2. Parsear `content` como JSON. +3. Validar que cada `badge.category` exista en `categories`. +4. Resolver cada `badge.definition` buscando su evento `kind: 30009`. +5. Mostrar solo badges con definicion resuelta o marcarlos como incompletos. +6. Para awards, buscar `kind: 8` con `#p` del usuario y cruzar el tag `a` con + las definiciones del catalogo. + +## Bootstrap + +Si el catalogo no existe en Nostr, el frontend puede mostrar un boton +`Bootstrap` solo al admin logueado. Ese admin es la pubkey configurada en +`NEXT_PUBLIC_LACRYPTA_NPUB`. + +Flujo: + +1. El admin (`NEXT_PUBLIC_LACRYPTA_NPUB`) firma un request Nostr efimero + `kind: 27235`. +2. El frontend envia ese request a `/api/hackathon-badges/bootstrap`. +3. El backend verifica firma, pubkey y tags del request. +4. El backend genera y firma con `LACRYPTA_NSEC`: + - una definicion `kind: 30009` por cada badge default; + - un catalogo `kind: 30078` que linkea todas esas definiciones. +5. El backend devuelve los eventos firmados. +6. El frontend publica esos eventos en los relays de datos. +7. El frontend vuelve a leer el catalogo desde Nostr. + +El backend nunca publica directo a relays en este flujo. Solo firma los eventos. +La publicacion queda visible y trazable desde el browser. + +## Compatibilidad + +La fuente de verdad para mostrar el catalogo es el evento `kind: 30078`. Las +definiciones `kind: 30009` siguen siendo necesarias para compatibilidad NIP-58 +y para que otras apps puedan reconocer los awards. + +Si una definicion cambia de imagen o descripcion, se actualiza su `kind: 30009`. +Si cambia el orden, categoria o lista disponible de badges para la hackaton, se +actualiza el catalogo `kind: 30078`. diff --git a/lib/databaseCatalog.ts b/lib/databaseCatalog.ts index 718dd17..a7d7b51 100644 --- a/lib/databaseCatalog.ts +++ b/lib/databaseCatalog.ts @@ -10,6 +10,7 @@ import { PROJECT_TAG, PROJECT_D_PREFIX, } from "./userProjects"; +import { HACKATHON_BADGE_CATALOG_TAG } from "./hackathonBadges"; export type RelayFilter = { kinds: number[]; @@ -33,7 +34,7 @@ export type EventCategory = { kind: number; source: string; replaceability: EventReplaceability; - /** True if the filter requires La Crypta's pubkey (NEXT_PUBLIC_LACRYPTA_NPUB). */ + /** True if the filter requires La Crypta's official publisher pubkey. */ requiresLacryptaPubkey: boolean; /** Builds the relay filter. ctx.lacryptaPubkey is hex (may be empty). */ buildFilter: (ctx: { lacryptaPubkey: string }) => RelayFilter | null; @@ -116,6 +117,26 @@ export const CATEGORIES: EventCategory[] = [ }; }, }, + { + id: "hackathon-badge-catalogs", + label: "Catalogos de badges de hackaton", + shortLabel: "Badges catalogo", + description: + "kind:30078 firmado por La Crypta · tag t=lacrypta-dev-hackathon-badges.", + kind: 30078, + source: "lib/hackathonBadges.ts", + replaceability: "parameterized", + requiresLacryptaPubkey: true, + buildFilter: ({ lacryptaPubkey }) => { + if (!lacryptaPubkey) return null; + return { + kinds: [30078], + authors: [lacryptaPubkey], + "#t": [HACKATHON_BADGE_CATALOG_TAG], + limit: 100, + }; + }, + }, { id: "lacrypta-profile", label: "Perfil de La Crypta", @@ -243,6 +264,9 @@ export function eventDisplayTitle( if (category.id === "hackathon-results" && d) { return d.replace("lacrypta.dev:hackathon:", "").replace(":results", ""); } + if (category.id === "hackathon-badge-catalogs" && d) { + return d.replace("lacrypta.dev:hackathon-badges:", ""); + } if (category.id === "lacrypta-profile") { try { const parsed = JSON.parse(ev.content) as { name?: string; display_name?: string }; diff --git a/lib/hackathonBadgeClient.ts b/lib/hackathonBadgeClient.ts new file mode 100644 index 0000000..96c51b6 --- /dev/null +++ b/lib/hackathonBadgeClient.ts @@ -0,0 +1,172 @@ +"use client"; + +import type { SignedEvent } from "./nostrSigner"; +import { DEFAULT_BADGE_RELAYS } from "./nostrBadges"; +import { + HACKATHON_BADGE_CATALOG_KIND, + hackathonBadgeCatalogDTag, + parseHackathonBadgeCatalogEvent, + type HackathonBadgeCatalogEvent, +} from "./hackathonBadges"; +import { mergeDataRelays } from "./nostrRelayConfig"; + +export async function fetchLacryptaBadgePubkeys(): Promise<{ + adminPubkey: string; + publisherPubkey: string; +}> { + const res = await fetch("/api/lacrypta-pubkeys"); + const data = (await res.json()) as { + adminPubkey?: string; + publisherPubkey?: string; + error?: string; + }; + if (!res.ok || !data.adminPubkey || !data.publisherPubkey) { + throw new Error(data.error || "No se pudo resolver pubkeys de La Crypta."); + } + return { + adminPubkey: data.adminPubkey, + publisherPubkey: data.publisherPubkey, + }; +} + +export async function fetchHackathonBadgeCatalog( + hackathonId: string, + issuerPubkey: string, + relays: string[] = DEFAULT_BADGE_RELAYS, + timeoutMs = 5000, +): Promise { + if (!issuerPubkey) return null; + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const events: SignedEvent[] = []; + const dTag = hackathonBadgeCatalogDTag(hackathonId); + const readRelays = mergeDataRelays(relays); + + const closer = pool.subscribe( + readRelays, + { + kinds: [HACKATHON_BADGE_CATALOG_KIND], + authors: [issuerPubkey], + "#d": [dTag], + }, + { + onevent(ev: SignedEvent) { + events.push(ev); + }, + oneose() { + closer.close(); + }, + }, + ); + + await new Promise((resolve) => setTimeout(resolve, timeoutMs)); + closer.close(); + try { + pool.close(readRelays); + } catch { + /* noop */ + } + + const parsed = events + .sort((a, b) => b.created_at - a.created_at) + .map((event) => parseHackathonBadgeCatalogEvent(event)) + .find((event): event is HackathonBadgeCatalogEvent => Boolean(event)); + + return parsed ?? null; +} + +export async function publishSignedEventsToRelays( + events: SignedEvent[], + relays: string[] = DEFAULT_BADGE_RELAYS, + timeoutMs = 8000, +): Promise> { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const publishRelays = mergeDataRelays(relays); + const byRelay = new Map(); + + await Promise.all( + publishRelays.map(async (relay) => { + try { + for (const event of events) { + const [published] = pool.publish([relay], event); + await Promise.race([ + published, + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Timeout publicando en ${relay}`)), + timeoutMs, + ), + ), + ]); + } + byRelay.set(relay, { relay, ok: true }); + } catch (error) { + byRelay.set(relay, { + relay, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); + + try { + pool.close(publishRelays); + } catch { + /* noop */ + } + + return publishRelays.map( + (relay) => byRelay.get(relay) ?? { relay, ok: false, error: "Sin respuesta" }, + ); +} + +export async function requestBadgeBootstrap( + hackathonId: string, + signer: { + pubkey: string; + signEvent: (event: { + kind: number; + created_at: number; + tags: string[][]; + content: string; + pubkey?: string; + }) => Promise; + }, +): Promise<{ events: SignedEvent[]; publisherPubkey: string }> { + const request = await signer.signEvent({ + kind: 27235, + pubkey: signer.pubkey, + created_at: Math.floor(Date.now() / 1000), + content: `Bootstrap badges for ${hackathonId}`, + tags: [ + ["u", "/api/hackathon-badges/bootstrap"], + ["method", "POST"], + ["action", "bootstrap-hackathon-badges"], + ["hackathon", hackathonId], + ], + }); + + const res = await fetch("/api/hackathon-badges/bootstrap", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hackathonId, request }), + }); + const data = (await res.json()) as { + events?: SignedEvent[]; + issuerPubkey?: string; + error?: string; + }; + if (!res.ok) { + throw new Error(data.error || "No se pudo generar el bootstrap."); + } + if (!data.events?.length) { + throw new Error("El backend no devolvio eventos firmados."); + } + + return { + events: data.events, + publisherPubkey: data.issuerPubkey ?? "", + }; +} diff --git a/lib/hackathonBadges.ts b/lib/hackathonBadges.ts new file mode 100644 index 0000000..c1b74b0 --- /dev/null +++ b/lib/hackathonBadges.ts @@ -0,0 +1,350 @@ +import type { SignedEvent, UnsignedEvent } from "./nostrSigner"; + +export const HACKATHON_BADGE_DEFINITION_KIND = 30009; +export const HACKATHON_BADGE_CATALOG_KIND = 30078; +export const HACKATHON_BADGE_SCHEMA = "lacrypta.dev/hackathon-badges"; +export const HACKATHON_BADGE_SCHEMA_VERSION = 1; +export const HACKATHON_BADGE_CATALOG_TAG = "lacrypta-dev-hackathon-badges"; +export const HACKATHON_BADGE_DEFINITION_TAG = "lacrypta-dev-hackathon-badge"; + +export type HackathonBadgeCategoryId = + | "ranking" + | "favorites" + | "specials" + | "streaks"; + +export type HackathonBadgeTone = + | "gold" + | "silver" + | "bronze" + | "bitcoin" + | "nostr" + | "cyan" + | "success" + | "pink"; + +export type HackathonBadgeCriterion = + | { type: "rank"; position: number } + | { type: "rank-range"; min: number; max: number } + | { type: "manual"; juror?: string } + | { type: "first-submit" } + | { type: "streak"; count: number }; + +export type HackathonBadgeCategory = { + id: HackathonBadgeCategoryId; + label: string; + description?: string; +}; + +export type HackathonBadgeTemplate = { + id: string; + category: HackathonBadgeCategoryId; + name: string; + description: string; + tone: HackathonBadgeTone; + icon: string; + criteria: HackathonBadgeCriterion; +}; + +export type HackathonBadgeCatalogBadge = HackathonBadgeTemplate & { + definition: string; + image?: string; + thumb?: string; +}; + +export type HackathonBadgeCatalog = { + version: number; + hackathon: string; + title: string; + categories: HackathonBadgeCategory[]; + badges: HackathonBadgeCatalogBadge[]; +}; + +export type HackathonBadgeCatalogEvent = { + event: SignedEvent; + catalog: HackathonBadgeCatalog; +}; + +export const HACKATHON_BADGE_CATEGORIES: HackathonBadgeCategory[] = [ + { + id: "ranking", + label: "Ranking", + description: "Badges por posicion final.", + }, + { + id: "favorites", + label: "Favoritos", + description: "Selecciones especiales del jurado.", + }, + { + id: "specials", + label: "Especiales", + description: "Menciones por calidad, entrega o presentacion.", + }, + { + id: "streaks", + label: "Rachas", + description: "Participacion sostenida en hackatones consecutivos.", + }, +]; + +export const DEFAULT_HACKATHON_BADGES: HackathonBadgeTemplate[] = [ + { + id: "rank-1", + category: "ranking", + name: "1er Puesto", + description: "Proyecto ganador de la hackaton.", + tone: "gold", + icon: "trophy", + criteria: { type: "rank", position: 1 }, + }, + { + id: "rank-2", + category: "ranking", + name: "2do Puesto", + description: "Segundo mejor score del ranking final.", + tone: "silver", + icon: "medal", + criteria: { type: "rank", position: 2 }, + }, + { + id: "rank-3", + category: "ranking", + name: "3er Puesto", + description: "Tercer lugar del podio principal.", + tone: "bronze", + icon: "medal", + criteria: { type: "rank", position: 3 }, + }, + { + id: "top-6", + category: "ranking", + name: "Podio (top 6)", + description: "Top 6 del ranking final.", + tone: "bitcoin", + icon: "award", + criteria: { type: "rank-range", min: 1, max: 6 }, + }, + { + id: "favorite-gorilatron", + category: "favorites", + name: "Favorito de Gorilatron", + description: "Seleccion especial de Gorilatron.", + tone: "nostr", + icon: "heart", + criteria: { type: "manual", juror: "gorilatron" }, + }, + { + id: "favorite-gorilator", + category: "favorites", + name: "Favorito de Gorilator", + description: "Seleccion especial de Gorilator.", + tone: "pink", + icon: "star", + criteria: { type: "manual", juror: "gorilator" }, + }, + { + id: "favorite-claudio", + category: "favorites", + name: "Favorito de Claudio", + description: "Seleccion especial de Claudio.", + tone: "cyan", + icon: "sparkles", + criteria: { type: "manual", juror: "claudio" }, + }, + { + id: "viability", + category: "specials", + name: "Premio a la viabilidad", + description: "Reconoce el proyecto con mejor camino a produccion.", + tone: "success", + icon: "shield-check", + criteria: { type: "manual" }, + }, + { + id: "beautiful-ui", + category: "specials", + name: "Hermoso UI", + description: "Interfaz cuidada, clara y memorable.", + tone: "pink", + icon: "brush", + criteria: { type: "manual" }, + }, + { + id: "great-pitch", + category: "specials", + name: "Tremendo Pitch", + description: "Presentacion solida, entretenida y convincente.", + tone: "bitcoin", + icon: "mic", + criteria: { type: "manual" }, + }, + { + id: "first-submit", + category: "specials", + name: "Primer submit", + description: "Primer proyecto enviado oficialmente.", + tone: "cyan", + icon: "send", + criteria: { type: "first-submit" }, + }, + { + id: "streak-2", + category: "streaks", + name: "2 hackatons al hilo", + description: "Participacion sostenida durante dos ediciones seguidas.", + tone: "success", + icon: "flame", + criteria: { type: "streak", count: 2 }, + }, + { + id: "streak-3", + category: "streaks", + name: "3 hackatons al hilo", + description: "Tres ediciones consecutivas construyendo.", + tone: "bitcoin", + icon: "flame", + criteria: { type: "streak", count: 3 }, + }, + { + id: "streak-4", + category: "streaks", + name: "4 hackatons al hilo", + description: "Cuatro hackatones seguidos sin cortar la racha.", + tone: "nostr", + icon: "rocket", + criteria: { type: "streak", count: 4 }, + }, + { + id: "streak-5", + category: "streaks", + name: "5 hackatons al hilo", + description: "Cinco ediciones consecutivas: constancia desbloqueada.", + tone: "gold", + icon: "gem", + criteria: { type: "streak", count: 5 }, + }, +]; + +export function hackathonBadgeCatalogDTag(hackathonId: string): string { + return `lacrypta.dev:hackathon-badges:${hackathonId}`; +} + +export function hackathonBadgeDefinitionDTag( + hackathonId: string, + badgeId: string, +): string { + return `lacrypta.dev:badge:${hackathonId}:${badgeId}`; +} + +export function hackathonBadgeDefinitionATag( + issuerPubkey: string, + hackathonId: string, + badgeId: string, +): string { + return `${HACKATHON_BADGE_DEFINITION_KIND}:${issuerPubkey}:${hackathonBadgeDefinitionDTag(hackathonId, badgeId)}`; +} + +export function buildHackathonBadgeCatalogContent( + issuerPubkey: string, + hackathonId: string, + hackathonName: string, + badges: HackathonBadgeTemplate[] = DEFAULT_HACKATHON_BADGES, +): HackathonBadgeCatalog { + return { + version: HACKATHON_BADGE_SCHEMA_VERSION, + hackathon: hackathonId, + title: `Badges ${hackathonName}`, + categories: HACKATHON_BADGE_CATEGORIES, + badges: badges.map((badge) => ({ + ...badge, + definition: hackathonBadgeDefinitionATag( + issuerPubkey, + hackathonId, + badge.id, + ), + image: `https://lacrypta.dev/badges/${hackathonId}/${badge.id}.png`, + thumb: `https://lacrypta.dev/badges/${hackathonId}/${badge.id}-thumb.png`, + })), + }; +} + +export function buildHackathonBadgeDefinitionEvents( + issuerPubkey: string, + hackathonId: string, + badges: HackathonBadgeTemplate[] = DEFAULT_HACKATHON_BADGES, + createdAt = Math.floor(Date.now() / 1000), +): UnsignedEvent[] { + return badges.map((badge) => ({ + kind: HACKATHON_BADGE_DEFINITION_KIND, + pubkey: issuerPubkey, + created_at: createdAt, + content: "", + tags: [ + ["d", hackathonBadgeDefinitionDTag(hackathonId, badge.id)], + ["name", badge.name], + ["description", badge.description], + ["image", `https://lacrypta.dev/badges/${hackathonId}/${badge.id}.png`], + ["thumb", `https://lacrypta.dev/badges/${hackathonId}/${badge.id}-thumb.png`], + ["t", HACKATHON_BADGE_DEFINITION_TAG], + ["hackathon", hackathonId], + ["badge", badge.id], + ["category", badge.category], + ], + })); +} + +export function buildHackathonBadgeCatalogEvent( + issuerPubkey: string, + hackathonId: string, + hackathonName: string, + badges: HackathonBadgeTemplate[] = DEFAULT_HACKATHON_BADGES, + createdAt = Math.floor(Date.now() / 1000), +): UnsignedEvent { + const catalog = buildHackathonBadgeCatalogContent( + issuerPubkey, + hackathonId, + hackathonName, + badges, + ); + + return { + kind: HACKATHON_BADGE_CATALOG_KIND, + pubkey: issuerPubkey, + created_at: createdAt, + content: JSON.stringify(catalog), + tags: [ + ["d", hackathonBadgeCatalogDTag(hackathonId)], + ["client", "lacrypta.dev"], + ["hackathon", hackathonId], + ["schema", HACKATHON_BADGE_SCHEMA, String(HACKATHON_BADGE_SCHEMA_VERSION)], + ["title", catalog.title], + ["t", HACKATHON_BADGE_CATALOG_TAG], + ...catalog.badges.map((badge) => ["a", badge.definition]), + ], + }; +} + +export function parseHackathonBadgeCatalogEvent( + event: SignedEvent, +): HackathonBadgeCatalogEvent | null { + if (event.kind !== HACKATHON_BADGE_CATALOG_KIND) return null; + try { + const catalog = JSON.parse(event.content) as HackathonBadgeCatalog; + if (catalog.version !== HACKATHON_BADGE_SCHEMA_VERSION) return null; + if (!catalog.hackathon || !Array.isArray(catalog.categories)) return null; + if (!Array.isArray(catalog.badges)) return null; + const categories = new Set(catalog.categories.map((category) => category.id)); + const valid = catalog.badges.every( + (badge) => + badge.id && + badge.name && + badge.definition && + categories.has(badge.category), + ); + if (!valid) return null; + return { event, catalog }; + } catch { + return null; + } +} diff --git a/lib/nostrReports.ts b/lib/nostrReports.ts index 748a126..3dadc79 100644 --- a/lib/nostrReports.ts +++ b/lib/nostrReports.ts @@ -186,14 +186,7 @@ export async function fetchHackathonResults( // ─── hooks ─────────────────────────────────────────────────────────────────── function getLacryptaPubkeyHex(): string { - const npub = - typeof process !== "undefined" - ? process.env.NEXT_PUBLIC_LACRYPTA_NPUB ?? "" - : ""; - if (!npub) return ""; try { - // Dynamic decode at runtime — nip19 is client-only - // We cache the hex so the import only happens once per session const cached = (window as unknown as Record)[ "__lcpk__" ]; @@ -204,18 +197,17 @@ function getLacryptaPubkeyHex(): string { } } -/** Resolves npub → hex once and caches it on window.__lcpk__ */ +/** Resolves La Crypta's official publisher pubkey from the server. */ async function resolveLacryptaPubkey(): Promise { if (typeof window === "undefined") return ""; const win = window as unknown as Record; if (win.__lcpk__) return win.__lcpk__; - const npub = process.env.NEXT_PUBLIC_LACRYPTA_NPUB ?? ""; - if (!npub) return ""; try { - const { decode } = await import("nostr-tools/nip19"); - const decoded = decode(npub); - if (decoded.type !== "npub") return ""; - win.__lcpk__ = decoded.data as string; + const res = await fetch("/api/lacrypta-pubkeys"); + if (!res.ok) return ""; + const data = (await res.json()) as { publisherPubkey?: string }; + if (!data.publisherPubkey) return ""; + win.__lcpk__ = data.publisherPubkey; return win.__lcpk__; } catch { return "";