Skip to content

Latest commit

 

History

History
89 lines (55 loc) · 11.3 KB

File metadata and controls

89 lines (55 loc) · 11.3 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

@AGENTS.md

Stack note

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.

Hackathon reports pipeline

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.

Two-source content model

Project content comes from both curated JSON and community Nostr events, merged at view time:

  • Curateddata/hackathons/projects-<id>.json (typed in lib/hackathons.ts) and lib/projects.ts (homepage list). Static, in-tree, edited by maintainers.
  • Community — NIP-78 parameterized replaceable events (kind 30078) carrying the t tag lacrypta-dev-project and a d tag lacrypta.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.

Server vs. client Nostr code

lib/nostrCache.ts and lib/userProjects.ts look like duplicates but are not interchangeable:

  • lib/nostrCache.ts is server-only (no "use client"). It uses "use cache" + the custom cacheLife("nostr") profile (stale 300 / revalidate 300 / expire 7d, defined in next.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 via POST /api/revalidate-nostr with header x-revalidate-secret: $REVALIDATE_SECRET (defaults to the global submissions tag if no body).
  • lib/userProjects.ts is "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.

The Upstash tier under "use cache"

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. Use lib/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.

Signing & auth

Three signer methods, all wired through lib/nostrSigner.ts:getSigner(auth):

  1. nip07 — browser extension (window.nostr). lib/auth.ts:waitForNostrSigner polls because extensions inject async; probeSignerAvailable also confirms the extension's pubkey still matches the stored session and auto-logouts on mismatch.
  2. nip46 — remote bunker. Two transports coexist:
    • QR-originated sessions persist auth.bunker.encryption ("nip44" or "nip04") and use the in-tree lib/nip46Client.ts. This exists because nostr-tools/nip46's BunkerSigner is 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 encryption field) keep using BunkerSigner directly.
  3. local — nsec stored in localStorage as auth.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.

Environment variables

  • LACRYPTA_NSECserver-only signing key for La Crypta's official events (reports, results). Never prefix with NEXT_PUBLIC_.
  • NEXT_PUBLIC_LACRYPTA_NPUB — public key, used for Nostr filter authors, admin guard, and signature verification. Decoded to hex on demand and cached on window.__lcpk__ (see lib/nostrReports.ts:resolveLacryptaPubkey).
  • REVALIDATE_SECRET — gates /api/revalidate-nostr.
  • UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKENserver-only. Enable the read-through cache; unset means it no-ops. On Vercel the Upstash marketplace integration injects the same credentials as KV_REST_API_URL / KV_REST_API_TOKEN, and lib/upstashCache.ts accepts either pair. UPSTASH_CACHE_DISABLED=1 forces it off with the credentials still present.
  • CRON_SECRET — gates GET /api/cache/warm (Authorization: Bearer …, which Vercel Cron sends automatically).

Conventions worth knowing

  • User-facing copy is Spanish (lang="es", locale es_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.css as CSS custom properties exposed through Tailwind 4's @theme inline block. 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 sets data-scroll-lock on <html> and a --sbw CSS var to compensate for the scrollbar gutter on the body and header.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) and sitemap/nostr.xml (community submissions, deduped against curated). Both are declared in app/sitemap.ts:generateSitemaps. The Nostr sitemap shares the cached relay round-trip with project pages — don't add a second uncached fetch.
  • /dashboard and /api are noindex (app/robots.ts). Per-page metadata.robots: { index: false } exists for the same reason on dashboard subroutes.