From 6e5d2e03831d098be0ba9604a9d163a5b89ef37c Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Wed, 1 Jul 2026 16:03:34 -0300 Subject: [PATCH] feat: auto-publish soldiers ranking + prize zaps from results podium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ranking automation: - scripts/publish-soldiers-ranking.mjs: headless version of the /soldados "Recrear ranking" admin action — self-signs with LACRYPTA_NSEC and republishes the ranking Nostr snapshot. No-ops without the secret; never fails the reports build. Chained into build-hackathon-reports.mjs (skip with SKIP_RANKING_PUBLISH=1) and exposed as `pnpm run ranking:publish`. - build-hackathon-reports.mjs: stop silently dropping hackathons whose report dir has no .md source (identity/commerce data predates the script) — preserve their existing reports.json entry instead of wiping it. Prize zaps on the results podium: - Wire the existing PrizeZapButton into VotingHero's ClosedHero podium + runner-up rows, keyed by each winner's recipientPubkey (frozen at close in results.winners). Moved PrizeZapButton to components/voting/ since it's now shared by the home and hackathon pages. - New GET /api/prize-recipient: resolves the recipient's Lightning address from their kind-0 profile lud16 ONLY (never nip05 — that's an identity handle, not a payout address). Server-side cached lookup, which is reliable where the client-side single-shot relay fetch was flaky. - PrizeZapButton gates on the admin pubkey (NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB), matching every other admin control, instead of the publisher key. - Resolve the destination in handlePay before paying so it no longer depends on the flaky client-side findZapEndpoint (the "No encontré el perfil" error). - Fix stuck-disabled pay buttons: the receipt-check effect depended on the inline `target` object, re-running every render and pinning `checking`. Depend on target's primitive fields instead. Signer probe dedupe: - lib/auth.ts: dedupe probeSignerAvailable per session so many useAuth consumers (one PrizeZapButton per podium slot) share a single window.nostr.getPublicKey() call instead of flooding the extension. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 4 + app/api/prize-recipient/route.ts | 50 +++++++ app/hackathons/[id]/page.tsx | 2 +- .../voting}/PrizeZapButton.tsx | 139 +++++++++++++++--- components/voting/VotingHero.tsx | 64 +++++++- lib/auth.ts | 48 ++++-- package.json | 2 + scripts/build-hackathon-reports.mjs | 41 ++++++ scripts/publish-soldiers-ranking.mjs | 96 ++++++++++++ 9 files changed, 410 insertions(+), 36 deletions(-) create mode 100644 app/api/prize-recipient/route.ts rename {app/hackathons/[id] => components/voting}/PrizeZapButton.tsx (91%) create mode 100644 scripts/publish-soldiers-ranking.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 0117f50..37a6625 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,10 @@ node scripts/build-hackathon-reports.mjs The parser is regex-based and depends on specific markdown structure (`**Posición:** N°`, `**Score final: X**`, `### Name (Model) — score`, `## 💡 Feedback Consolidado` with `### Fortalezas` / `### Áreas de Mejora` lists). New reports must follow that shape or fields will silently parse as `null`/empty. +`build-hackathon-reports.mjs` regenerates a hackathon's entry strictly from its `.md` files — but if a hackathon's report dir has zero `.md` files (or doesn't exist at all, e.g. `identity`/`commerce`, whose `reports.json` data predates this script and has no `.md` source in-tree), it preserves that hackathon's existing `reports.json` entry as-is with a warning, rather than dropping it. Deleting a report's `.md` file on purpose still removes it from the hackathon's own regenerated set. + +Report positions feed the soldiers' score on `/soldados` (`lib/soldiers.ts`), so after regenerating `reports.json` the script also runs `scripts/publish-soldiers-ranking.mjs` — a headless version of the `/soldados` "Recrear ranking" admin action that self-signs with `LACRYPTA_NSEC` and republishes the ranking Nostr snapshot (see `lib/soldiersRanking.ts`, `app/api/soldiers/ranking/route.ts`). It targets `NEXT_PUBLIC_SITE_URL`, no-ops when `LACRYPTA_NSEC` isn't set, and never fails the reports build (network/secret issues just print a warning). Skip it explicitly with `SKIP_RANKING_PUBLISH=1`, or run it standalone via `pnpm run ranking:publish`. **This publishes a real event to public Nostr relays** — never run it (or the reports script without `SKIP_RANKING_PUBLISH=1`) against production secrets from an environment you don't intend to publish from. + ## Two-source content model Project content comes from **both** curated JSON and community Nostr events, merged at view time: diff --git a/app/api/prize-recipient/route.ts b/app/api/prize-recipient/route.ts new file mode 100644 index 0000000..715adc1 --- /dev/null +++ b/app/api/prize-recipient/route.ts @@ -0,0 +1,50 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { getCachedNostrProfile } from "@/lib/nostrProfileCache"; + +/** + * Resolves a prize recipient's Lightning payment destination from their pubkey, + * for the podium "Pagar premio" flow (components/voting/PrizeZapButton). + * + * ONLY the recipient's kind-0 profile `lud16` counts — that's the actual + * Lightning address. `nip05` is an identity/verification handle, NOT a payment + * address (even when it looks similar), so it must never be used as a payout + * destination. If there's no `lud16`, we resolve nothing and the prize simply + * isn't payable until the winner publishes one. + * + * Read-only, no secrets; reuses the shared cached profile round-trip. + */ + +function lightningAddressToLnurlpEndpoint(address: string): string | null { + const [name, domain] = address.split("@"); + if (!name || !domain) return null; + return `https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`; +} + +export async function GET(req: NextRequest) { + const pubkey = (req.nextUrl.searchParams.get("pubkey") || "") + .toLowerCase() + .trim(); + if (!/^[0-9a-f]{64}$/.test(pubkey)) { + return NextResponse.json({ error: "pubkey invalido." }, { status: 400 }); + } + + let lightningAddress: string | null = null; + let source: "lud16" | "none" = "none"; + + try { + const profile = await getCachedNostrProfile(pubkey); + const lud16 = profile?.lud16?.trim(); + if (lud16 && lud16.includes("@")) { + lightningAddress = lud16; + source = "lud16"; + } + } catch { + /* leave unresolved */ + } + + const zapEndpoint = lightningAddress + ? lightningAddressToLnurlpEndpoint(lightningAddress) + : null; + + return NextResponse.json({ lightningAddress, zapEndpoint, source }); +} diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index dd30193..1ba0d6f 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -56,7 +56,7 @@ import VotingHero from "@/components/voting/VotingHero"; import HackathonResultsClient from "./HackathonResultsClient"; import AdminBadgesLink from "./AdminBadgesLink"; import PrizeBadgeButton, { type PrizeBadgeTask } from "./PrizeBadgeButton"; -import PrizeZapButton from "./PrizeZapButton"; +import PrizeZapButton from "@/components/voting/PrizeZapButton"; import HackathonInscripcionButton from "@/components/HackathonInscripcionButton"; export function generateStaticParams() { diff --git a/app/hackathons/[id]/PrizeZapButton.tsx b/components/voting/PrizeZapButton.tsx similarity index 91% rename from app/hackathons/[id]/PrizeZapButton.tsx rename to components/voting/PrizeZapButton.tsx index c1fb1dc..11f6369 100644 --- a/app/hackathons/[id]/PrizeZapButton.tsx +++ b/components/voting/PrizeZapButton.tsx @@ -29,7 +29,6 @@ import { type PrizeZapTarget, } from "@/lib/prizeZaps"; import { formatSats, hackathonSlugForId } from "@/lib/hackathons"; -import { resolveLacryptaPubkey } from "@/lib/nostrReports"; import { useToast } from "@/components/Toast"; import { cn } from "@/lib/cn"; @@ -74,10 +73,20 @@ export default function PrizeZapButton({ target }: { target: PrizeZapTarget }) { }, [checking, paid, paying, target.sats]); useEffect(() => { + // Gate on the ADMIN pubkey (NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB), matching + // every other admin control (PrizeBadgeButton, VotingSection, ...) — and + // matching payPrizeZap, which keys the whole flow on the logged-in + // signer (the admin). The publisher key (LACRYPTA_NSEC) is a different + // identity and must not gate this button. let cancelled = false; - resolveLacryptaPubkey().then((pk) => { - if (!cancelled) setAdminPubkey(pk || null); - }); + fetch("/api/lacrypta-pubkeys") + .then((res) => (res.ok ? res.json() : null)) + .then((data: { adminPubkey?: string } | null) => { + if (!cancelled) setAdminPubkey(data?.adminPubkey ?? null); + }) + .catch(() => { + if (!cancelled) setAdminPubkey(null); + }); return () => { cancelled = true; }; @@ -115,25 +124,78 @@ export default function PrizeZapButton({ target }: { target: PrizeZapTarget }) { return () => { cancelled = true; }; - }, [adminPubkey, auth, isAdmin, target]); + // Depend on target's identifying fields, NOT the object reference — callers + // (e.g. VotingHero) build `target` inline, so a new object every render + // would re-run this probe endlessly and keep `checking` (→ disabled) stuck. + }, [ + adminPubkey, + auth, + isAdmin, + target.hackathonId, + target.projectId, + target.recipientPubkey, + target.sats, + target.position, + ]); useEffect(() => { - setRecipientPaymentInfo( - target.recipientLightningAddress - ? { - lightningAddress: target.recipientLightningAddress, - zapEndpoint: target.recipientZapEndpoint ?? null, - source: "lud16", - } - : target.recipientZapEndpoint - ? { - lightningAddress: null, - zapEndpoint: target.recipientZapEndpoint, - source: "zap-endpoint", - } - : null, - ); - }, [target.recipientLightningAddress, target.recipientZapEndpoint]); + // When the caller pre-resolves the address (the hackathon page fetches the + // winner's lud16 server-side), use it directly. + if (target.recipientLightningAddress) { + setRecipientPaymentInfo({ + lightningAddress: target.recipientLightningAddress, + zapEndpoint: target.recipientZapEndpoint ?? null, + source: "lud16", + }); + return; + } + if (target.recipientZapEndpoint) { + setRecipientPaymentInfo({ + lightningAddress: null, + zapEndpoint: target.recipientZapEndpoint, + source: "zap-endpoint", + }); + return; + } + // Otherwise (e.g. the results podium only knows the pubkey), resolve it + // server-side: profile lud16 → submission nip05 fallback (see + // /api/prize-recipient). Fills "Destino" and gives the pay step an endpoint. + if (!target.recipientPubkey) { + setRecipientPaymentInfo(null); + return; + } + let cancelled = false; + setRecipientPaymentInfo(null); + fetch(`/api/prize-recipient?pubkey=${target.recipientPubkey}`) + .then((res) => (res.ok ? res.json() : null)) + .then((info: PrizeZapRecipientPaymentInfo | null) => { + if (cancelled) return; + setRecipientPaymentInfo( + info?.lightningAddress + ? { + lightningAddress: info.lightningAddress, + zapEndpoint: info.zapEndpoint ?? null, + source: info.source, + } + : { lightningAddress: null, zapEndpoint: null, source: "none" }, + ); + }) + .catch(() => { + if (!cancelled) + setRecipientPaymentInfo({ + lightningAddress: null, + zapEndpoint: null, + source: "none", + }); + }); + return () => { + cancelled = true; + }; + }, [ + target.recipientLightningAddress, + target.recipientZapEndpoint, + target.recipientPubkey, + ]); async function handlePay(e: React.MouseEvent) { e.preventDefault(); @@ -149,7 +211,40 @@ export default function PrizeZapButton({ target }: { target: PrizeZapTarget }) { setProgressLog([]); try { const signer = await getSigner(auth); - const record = await payPrizeZap(target, signer, (progress) => { + // Resolve the destination reliably from the server (profile lud16 → + // submission nip05) instead of depending on the display effect having + // finished. This keeps payPrizeZap off its flaky client-side profile + // lookup (the source of "No encontré el perfil Nostr del ganador"). + let lightningAddress = + target.recipientLightningAddress ?? + recipientPaymentInfo?.lightningAddress ?? + null; + let zapEndpoint = + target.recipientZapEndpoint ?? + recipientPaymentInfo?.zapEndpoint ?? + null; + if (!zapEndpoint && target.recipientPubkey) { + try { + const res = await fetch( + `/api/prize-recipient?pubkey=${target.recipientPubkey}`, + ); + if (res.ok) { + const info = (await res.json()) as PrizeZapRecipientPaymentInfo; + if (info?.zapEndpoint) { + zapEndpoint = info.zapEndpoint; + lightningAddress = info.lightningAddress ?? lightningAddress; + } + } + } catch { + /* fall back to payPrizeZap's own lookup below */ + } + } + const effectiveTarget: PrizeZapTarget = { + ...target, + recipientLightningAddress: lightningAddress, + recipientZapEndpoint: zapEndpoint, + }; + const record = await payPrizeZap(effectiveTarget, signer, (progress) => { setProgressLog((prev) => [...prev, progress]); }); setLocalRecord(record); diff --git a/components/voting/VotingHero.tsx b/components/voting/VotingHero.tsx index b4f03e0..b7d3d4f 100644 --- a/components/voting/VotingHero.tsx +++ b/components/voting/VotingHero.tsx @@ -29,6 +29,7 @@ import { import { cn } from "@/lib/cn"; import LiveTally from "@/components/voting/LiveTally"; import FinalResultsTable from "@/components/voting/FinalResultsTable"; +import PrizeZapButton from "@/components/voting/PrizeZapButton"; /** * Gamified, live "community voting" hero. Reused on the home page and the @@ -614,6 +615,13 @@ function ClosedHero({ // popular vote (metric = votes). The detailed breakdown table renders below. const hasFinal = !!period.results?.final && period.results.final.length > 0; + // `winners` is always computed at close (see computeVotingRanking), even + // when `final` (judges-merged) also is — it's the only place the prize + // recipient's pubkey is resolved, frozen at close time. Keyed by projectId + // so it stays correct regardless of which ranking `entries` uses. + const recipientByProject = new Map( + (period.results?.winners ?? []).map((w) => [w.projectId, w.recipientPubkey]), + ); // Up to 6 prize positions; the top 3 go on the podium, 4–6 in the list below. const entries: PodiumEntry[] = hasFinal ? period.results!.final!.slice(0, 6).map((r) => ({ @@ -678,6 +686,8 @@ function ClosedHero({ key={podium[idx].projectId} entry={podium[idx]} slug={slug} + hackathonId={hackathonId} + recipientPubkey={recipientByProject.get(podium[idx].projectId) ?? null} /> ))} @@ -686,7 +696,14 @@ function ClosedHero({ {runnersUp.length > 0 && (
    {runnersUp.map((e, i) => ( - + ))}
)} @@ -716,7 +733,17 @@ type PodiumEntry = { metric: string; }; -function PodiumCard({ entry, slug }: { entry: PodiumEntry; slug: string }) { +function PodiumCard({ + entry, + slug, + hackathonId, + recipientPubkey, +}: { + entry: PodiumEntry; + slug: string; + hackathonId: string; + recipientPubkey: string | null; +}) { const pos = entry.position; const isFirst = pos === 1; const prize = prizeForPosition(pos); @@ -779,6 +806,20 @@ function PodiumCard({ entry, slug }: { entry: PodiumEntry; slug: string }) { {formatSats(prize)} sats )} + {prize != null && recipientPubkey && ( +
+ +
+ )} ); } @@ -787,10 +828,14 @@ function RunnerRow({ entry, slug, index, + hackathonId, + recipientPubkey, }: { entry: PodiumEntry; slug: string; index: number; + hackathonId: string; + recipientPubkey: string | null; }) { const prize = prizeForPosition(entry.position); @@ -799,10 +844,11 @@ function RunnerRow({ initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.32 + index * 0.07, duration: 0.4 }} + className="flex items-center gap-2" > {entry.position} @@ -820,6 +866,18 @@ function RunnerRow({ {entry.metric} + {prize != null && recipientPubkey && ( + + )} ); } diff --git a/lib/auth.ts b/lib/auth.ts index 02c3c9b..2782474 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -52,6 +52,7 @@ export function setAuth(auth: Auth) { if (typeof window === "undefined") return; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(auth)); + signerProbeCache = null; window.dispatchEvent(new CustomEvent(EVENT)); } catch { /* quota */ @@ -61,6 +62,7 @@ export function setAuth(auth: Auth) { export function clearAuth(reason: LogoutReason = "user") { if (typeof window === "undefined") return; window.localStorage.removeItem(STORAGE_KEY); + signerProbeCache = null; try { window.localStorage.setItem(LOGOUT_REASON_KEY, reason); } catch { @@ -112,6 +114,14 @@ export function waitForNostrSigner(timeoutMs = 3000): Promise { * browser. NIP-46 is trusted without a probe — the bunker is remote, and * unavailability surfaces as a signing error rather than a missing signer. * `local` is trusted because the secret key is already in-hand. */ +// Dedupe the NIP-07 probe across every `useAuth()` consumer. Each mounted +// component that calls useAuth runs this probe, and for NIP-07 that hits +// `window.nostr.getPublicKey()` — with many components (e.g. one PrizeZapButton +// per podium slot) the extension gets flooded with identical getPublicKey +// calls. Cache the in-flight/resolved probe per session key so they share a +// single round-trip; invalidated on any auth change (setAuth/clearAuth). +let signerProbeCache: { key: string; promise: Promise } | null = null; + export async function probeSignerAvailable( auth: Auth, timeoutMs = 3000, @@ -120,17 +130,35 @@ export async function probeSignerAvailable( if (auth.method === "local") { return Array.isArray(auth.localSecret) && auth.localSecret.length === 32; } - const present = await waitForNostrSigner(timeoutMs); - if (!present) return false; - try { - // Some extensions expose window.nostr but a different pubkey (e.g. user - // switched account in Alby). Treat that as a mismatch → auto-logout so - // the app doesn't sign with the wrong key. - const pk = await window.nostr!.getPublicKey(); - return pk === auth.pubkey; - } catch { - return false; + const key = `${auth.method}:${auth.pubkey}`; + if (signerProbeCache && signerProbeCache.key === key) { + return signerProbeCache.promise; } + const promise = (async () => { + const present = await waitForNostrSigner(timeoutMs); + if (!present) return false; + try { + // Some extensions expose window.nostr but a different pubkey (e.g. user + // switched account in Alby). Treat that as a mismatch → auto-logout so + // the app doesn't sign with the wrong key. + const pk = await window.nostr!.getPublicKey(); + return pk === auth.pubkey; + } catch { + return false; + } + })(); + // Don't cache a rejected/false probe forever — a false result triggers + // auto-logout (which clears the cache anyway), and transient failures should + // be re-probeable on the next mount. + signerProbeCache = { key, promise }; + promise + .then((ok) => { + if (!ok && signerProbeCache?.key === key) signerProbeCache = null; + }) + .catch(() => { + if (signerProbeCache?.key === key) signerProbeCache = null; + }); + return promise; } export function useAuth(): { diff --git a/package.json b/package.json index aa87f4b..6c282e9 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "start": "next start", "test:email-login": "node --import tsx --test tests/emailLogin.test.ts", "gen:dev-keys": "node scripts/gen-dev-keys.mjs", + "reports:build": "node scripts/build-hackathon-reports.mjs", + "ranking:publish": "node scripts/publish-soldiers-ranking.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" diff --git a/scripts/build-hackathon-reports.mjs b/scripts/build-hackathon-reports.mjs index 72b3034..51465c5 100644 --- a/scripts/build-hackathon-reports.mjs +++ b/scripts/build-hackathon-reports.mjs @@ -5,6 +5,7 @@ import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; import { join, basename } from "node:path"; import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; const root = fileURLToPath(new URL("..", import.meta.url)); const reportsDir = join(root, "data/hackathons/reports"); @@ -148,6 +149,13 @@ function parseReport(md) { return { ...header, judges, feedback }; } +// Some hackathons (e.g. "identity", "commerce") have no `.md` source at all +// yet reports.json still carries hand-maintained/legacy data for them — +// preserve those entries untouched instead of silently dropping them when a +// hackathon dir has zero `.md` files. Hackathons that DO have `.md` files are +// still regenerated strictly from source, so deleting a report's `.md` on +// purpose still removes it. +const previous = existsSync(outFile) ? JSON.parse(readFileSync(outFile, "utf8")) : {}; const out = {}; for (const hackathonDir of readdirSync(reportsDir, { withFileTypes: true })) { @@ -163,6 +171,21 @@ for (const hackathonDir of readdirSync(reportsDir, { withFileTypes: true })) { } if (Object.keys(reports).length > 0) { out[hackathonId] = reports; + } else if (previous[hackathonId]) { + console.warn( + `warning: no .md files found for "${hackathonId}" — keeping its existing reports.json entry as-is.`, + ); + out[hackathonId] = previous[hackathonId]; + } +} +// Hackathons with no reports directory at all (no .md source ever existed +// in-tree for them) — preserve as-is too. +for (const hackathonId of Object.keys(previous)) { + if (!out[hackathonId] && !existsSync(join(reportsDir, hackathonId))) { + console.warn( + `warning: no reports directory for "${hackathonId}" — keeping its existing reports.json entry as-is.`, + ); + out[hackathonId] = previous[hackathonId]; } } @@ -174,3 +197,21 @@ const totalReports = Object.values(out).reduce( console.log( `parsed ${totalReports} reports across ${Object.keys(out).length} hackathon(s) → ${outFile}`, ); + +// Report positions feed the /soldados score (lib/soldiers.ts), so new +// results are stale there until the ranking snapshot is republished. +// Best-effort — network/secret issues here shouldn't fail the reports build. +if (process.env.SKIP_RANKING_PUBLISH !== "1") { + const publish = spawnSync( + process.execPath, + [join(root, "scripts/publish-soldiers-ranking.mjs")], + { stdio: "inherit" }, + ); + if (publish.status !== 0) { + console.warn( + "warning: failed to republish soldiers ranking — rerun `node scripts/publish-soldiers-ranking.mjs` manually, or set SKIP_RANKING_PUBLISH=1 to silence this.", + ); + } +} else { + console.log("SKIP_RANKING_PUBLISH=1 — not republishing the soldiers ranking."); +} diff --git a/scripts/publish-soldiers-ranking.mjs b/scripts/publish-soldiers-ranking.mjs new file mode 100644 index 0000000..45c6868 --- /dev/null +++ b/scripts/publish-soldiers-ranking.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// Recomputes and republishes the official /soldados ranking snapshot without +// a browser step. Mirrors the signing flow in +// app/soldados/AdminRepublishRanking.tsx, but self-signs the admin +// authorization with LACRYPTA_NSEC — the same keypair +// NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB must resolve to (see .env.example) — so it +// can run headless from build-hackathon-reports.mjs or a terminal. +// +// node scripts/publish-soldiers-ranking.mjs +// +// No-ops (exit 0) when LACRYPTA_NSEC isn't set, so local report editing +// without production secrets doesn't fail the reports build. + +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +function loadEnvLocal() { + const root = fileURLToPath(new URL("..", import.meta.url)); + const path = join(root, ".env.local"); + if (!existsSync(path)) return; + for (const line of readFileSync(path, "utf8").split("\n")) { + const match = line.match(/^\s*([\w.-]+)\s*=(.*)$/); + if (!match) continue; + const [, key, rawValue] = match; + if (process.env[key] !== undefined) continue; + let value = rawValue.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } +} + +loadEnvLocal(); + +const nsec = process.env.LACRYPTA_NSEC; +if (!nsec) { + console.log( + "[publish-soldiers-ranking] LACRYPTA_NSEC not set — skipping ranking republish.", + ); + process.exit(0); +} + +const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000").replace( + /\/+$/, + "", +); + +const { decode } = await import("nostr-tools/nip19"); +const { finalizeEvent, getPublicKey } = await import("nostr-tools/pure"); + +const decoded = decode(nsec); +if (decoded.type !== "nsec") { + console.error("[publish-soldiers-ranking] LACRYPTA_NSEC is not a valid nsec."); + process.exit(1); +} +const secret = decoded.data; +const pubkey = getPublicKey(secret); + +const request = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: "Recompute & publish soldiers ranking", + tags: [ + ["u", "/api/soldiers/ranking"], + ["method", "POST"], + ["action", "publish-soldiers-ranking"], + ], + }, + secret, +); + +console.log( + `[publish-soldiers-ranking] POST ${siteUrl}/api/soldiers/ranking as ${pubkey.slice(0, 8)}…`, +); + +const res = await fetch(`${siteUrl}/api/soldiers/ranking`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request }), +}); +const data = await res.json().catch(() => ({})); + +if (!res.ok || !data.ok) { + console.error(`[publish-soldiers-ranking] failed (${res.status}):`, data.error ?? data); + process.exit(1); +} + +console.log( + `[publish-soldiers-ranking] published — ${data.soldierCount ?? "?"} soldiers, generatedAt ${data.generatedAt ?? "?"}.`, +);