This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md
Next.js 16 with cacheComponents: true (see next.config.ts), React 19, Tailwind 4, TypeScript strict. The cacheComponents flag and the "use cache" directive used in lib/nostrCache.ts are Next 16-only — do not "fix" them with older patterns. When unsure about Next API surface, read node_modules/next/dist/docs/01-app/**/*.md (the AGENTS.md note above is real — APIs differ from earlier versions).
There is no test runner, linter, or CI script wired up. Only pnpm dev, pnpm build, pnpm start. Package manager is pinned via packageManager in package.json — use pnpm, not npm; there is no package-lock.json.
Per-project markdown reviews live in data/hackathons/reports/<hackathonId>/<projectSlug>.md. They are parsed into a single data/hackathons/reports.json consumed by the hackathon routes and the useProjectReport hook. After editing any markdown report:
node scripts/build-hackathon-reports.mjs
The parser is regex-based and depends on specific markdown structure (**Posición:** N°, **Score final: X**, ### <emoji> 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.
Project content comes from both curated JSON and community Nostr events, merged at view time:
- Curated —
data/hackathons/projects-<id>.json(typed inlib/hackathons.ts) andlib/projects.ts(homepage list). Static, in-tree, edited by maintainers. - Community — NIP-78 parameterized replaceable events (
kind 30078) carrying thettaglacrypta-dev-projectand adtaglacrypta.dev:project:<id>. Published from the dashboard, signed with the user's own key.
lib/hackathons.ts:mergeWithSubmissions() is the canonical merge: curated wins on id collisions, ordered by report rank; Nostr submissions follow, freshest first. When adding a new project field, update both the curated JSON shape (HackathonProject) and the Nostr serialization in lib/userProjects.ts:buildProjectEvent + parseProjectContent — they must round-trip.
Every project has one canonical URL: /projects/<slug>. Slugs are pinned by a La Crypta-signed kind-30078 registry event (d tag lacrypta.dev:projects:registry; contract in lib/projectRegistryContract.ts, server-only reader/publisher in lib/projectRegistry.ts). Unregistered projects canonicalize to /projects/<project id>. Legacy /hackathons/<slug>/<id> and /projects/<pubkey>/<id> URLs 308-redirect via route handlers. The registry auto-syncs from POST /api/nostr/refresh via after(); the publish no-ops without LACRYPTA_NSEC or with REGISTRY_PUBLISH_DISABLED=1. Build project URLs only with lib/projectLinks.ts:projectHref (curatedProjectHref for curated ids) — never hand-roll them. lib/entityStore.ts is the client-side project/profile summary cache that list pages seed so detail navigation paints instantly.
lib/nostrCache.ts and lib/userProjects.ts look like duplicates but are not interchangeable:
lib/nostrCache.tsis server-only (no"use client"). It uses"use cache"+ the customcacheLife("nostr")profile (stale 300 / revalidate 300 / expire 7d, defined innext.config.ts— background revalidation on user load) +cacheTag("nostr:hackathon-submissions")so a single relay round-trip backs the sitemap, dynamic OG images, and SSR project pages. Revalidate viaPOST /api/revalidate-nostrwith headerx-revalidate-secret: $REVALIDATE_SECRET(defaults to the global submissions tag if no body).lib/userProjects.tsis"use client". It owns publish/sign, localStorage caching (labs:user-projects-v2:<pubkey>,labs:community-projects:v1), and the live community-scan progress UI.
Don't import the client module from server code; don't add "use cache" to the client one. If a parser changes, update both.
lib/upstashCache.ts is a read-through Redis cache that sits beneath "use cache", not beside it. The relay scans it fronts are expensive (rawFetchAllProjects ≈ 6s, rawFetchProjectByDTag ≈ 4.5s), and "use cache" only hides that while its entry is warm — every cold start, deploy and tag expiry otherwise re-runs the scan inside a user (often crawler) request. With Upstash, only a true miss reaches the relays.
It is not a Next cacheHandlers implementation on purpose: custom cache handlers are not invoked on Vercel (its managed Data Cache backs "use cache" there). Plain HTTPS to Upstash REST behaves identically on Vercel, Docker and next start. Without UPSTASH_REDIS_REST_URL/_TOKEN the whole layer no-ops — an unconfigured environment just pays the scan.
Two rules keep the tiers consistent:
- Hard expiry must clear both tiers.
revalidateTag(tag, { expire: 0 })alone leaves the Upstash key intact and the next render resurrects exactly what you invalidated. Uselib/nostrRevalidate.ts:expireNostrTag(it maps tag → key and does both). This does not apply to stale-marking (revalidateTag(tag, "max")), which wants the cached value served while it regenerates. - If you already hold fresh data, scan first, expire second.
getFreshNostrSubmissionsSnapshot()bypasses the read and writes through to Upstash, so callers doing read-your-writes (/api/nostr/refresh, registry sync, ranking publish) should call it and then expire the Next tag only — dropping the key they just warmed buys a redundant 6s rescan.
Keys are namespaced lacrypta:<dev|preview|prod>:… off VERCEL_ENV, with NEXT_PUBLIC_DEV_MODE or a localhost relay forcing dev: regardless. Production and preview deployments share one Upstash database, so this split is what stops a preview branch that changes the CachedNostrProject shape from writing it into the keys production reads back — and what stops pnpm dev (dummy data, local relay) from ever serving fake projects to the public site. Empty snapshots and null lookups are never persisted (a relay timeout must not pin "no projects" fleet-wide). app/api/cache/warm/route.ts + vercel.json keep the snapshot hot on a cron so the scan lands there, never in a page request.
Three signer methods, all wired through lib/nostrSigner.ts:getSigner(auth):
nip07— browser extension (window.nostr).lib/auth.ts:waitForNostrSignerpolls because extensions inject async;probeSignerAvailablealso confirms the extension's pubkey still matches the stored session and auto-logouts on mismatch.nip46— remote bunker. Two transports coexist:- QR-originated sessions persist
auth.bunker.encryption("nip44"or"nip04") and use the in-treelib/nip46Client.ts. This exists becausenostr-tools/nip46'sBunkerSigneris NIP-44 only, which breaks against Amber (defaults to NIP-04). The custom client auto-detects on first decrypt and locks the version for the session. - Legacy paste-flow sessions (no
encryptionfield) keep usingBunkerSignerdirectly.
- QR-originated sessions persist
local— nsec stored inlocalStorageasauth.localSecret(32-byte array). Throwaway/dev only.
Auth state lives in localStorage under labs:auth. Mutations dispatch a labs:auth:changed event so useAuth (and other tabs via storage) re-render. Use clearAuth(reason) not localStorage.removeItem — the reason is read by the login modal to show contextual messages.
LACRYPTA_NSEC— server-only signing key for La Crypta's official events (reports, results). Never prefix withNEXT_PUBLIC_.NEXT_PUBLIC_LACRYPTA_NPUB— public key, used for Nostr filterauthors, admin guard, and signature verification. Decoded to hex on demand and cached onwindow.__lcpk__(seelib/nostrReports.ts:resolveLacryptaPubkey).REVALIDATE_SECRET— gates/api/revalidate-nostr.UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN— server-only. Enable the read-through cache; unset means it no-ops. On Vercel the Upstash marketplace integration injects the same credentials asKV_REST_API_URL/KV_REST_API_TOKEN, andlib/upstashCache.tsaccepts either pair.UPSTASH_CACHE_DISABLED=1forces it off with the credentials still present.CRON_SECRET— gatesGET /api/cache/warm(Authorization: Bearer …, which Vercel Cron sends automatically).
- User-facing copy is Spanish (
lang="es", localees_AR). Identifiers and most code comments are English; some error messages thrown to users are Spanish on purpose — keep them Spanish. - Path alias
@/*resolves to the repo root (e.g.@/lib/hackathons,@/components/ui/PageHero). - Theme tokens live in
app/globals.cssas CSS custom properties exposed through Tailwind 4's@theme inlineblock. Use semantic names (bg-background-card,text-foreground-muted,text-bitcoin,text-nostr,text-lightning,text-cyan) — they're the contract, not raw hex. - Fonts are exposed as
--font-sans(Inter),--font-display(Space Grotesk),--font-mono(JetBrains Mono). Headings already default to display. - Modals must use
lib/useScrollLock.ts— it setsdata-scroll-lockon<html>and a--sbwCSS var to compensate for the scrollbar gutter on the body andheader.fixed. Bypassing it causes layout shift. - All Nostr events La Crypta publishes carry a
["client", "La Crypta Dev"]tag — match it on new event types. - Sitemap is split:
sitemap/static.xml(curated) andsitemap/nostr.xml(community submissions, deduped against curated). Both are declared inapp/sitemap.ts:generateSitemaps. The Nostr sitemap shares the cached relay round-trip with project pages — don't add a second uncached fetch. /dashboardand/apiare noindex (app/robots.ts). Per-pagemetadata.robots: { index: false }exists for the same reason on dashboard subroutes.