diff --git a/CLAUDE.md b/CLAUDE.md index 37a6625..310ca02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,11 +33,13 @@ Project content comes from **both** curated JSON and community Nostr events, mer `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/`. 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/`. Legacy `/hackathons//` and `/projects//` 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"` + `cacheLife("hours")` + `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/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:`, `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. diff --git a/app/api/dev/revalidate/route.ts b/app/api/dev/revalidate/route.ts index 32e174b..82019af 100644 --- a/app/api/dev/revalidate/route.ts +++ b/app/api/dev/revalidate/route.ts @@ -4,6 +4,7 @@ import { isDevMode } from "@/lib/devMode"; import { NOSTR_PROJECTS_TAG, NOSTR_LEGACY_SUBMISSIONS_TAG, + NOSTR_PROJECT_REGISTRY_TAG, NOSTR_SOLDIERS_RANKING_TAG, } from "@/lib/nostrCacheTags"; @@ -20,5 +21,6 @@ export async function POST() { revalidateTag(NOSTR_PROJECTS_TAG, { expire: 0 }); revalidateTag(NOSTR_LEGACY_SUBMISSIONS_TAG, { expire: 0 }); revalidateTag(NOSTR_SOLDIERS_RANKING_TAG, { expire: 0 }); + revalidateTag(NOSTR_PROJECT_REGISTRY_TAG, { expire: 0 }); return NextResponse.json({ ok: true }); } diff --git a/app/api/nostr-projects/route.ts b/app/api/nostr-projects/route.ts deleted file mode 100644 index 3ef7f67..0000000 --- a/app/api/nostr-projects/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { revalidateTag } from "next/cache"; -import { NextResponse } from "next/server"; -import { - getNostrSubmissionsSnapshot, - NOSTR_PROJECTS_TAG, - NOSTR_SUBMISSIONS_TAG, -} from "@/lib/nostrCache"; - -export async function GET() { - const snapshot = await getNostrSubmissionsSnapshot(); - return NextResponse.json(snapshot); -} - -export async function POST() { - revalidateTag(NOSTR_PROJECTS_TAG, { expire: 0 }); - revalidateTag(NOSTR_SUBMISSIONS_TAG, { expire: 0 }); - const snapshot = await getNostrSubmissionsSnapshot(); - return NextResponse.json({ - ok: true, - revalidated: [NOSTR_PROJECTS_TAG, NOSTR_SUBMISSIONS_TAG], - snapshot, - }); -} diff --git a/app/api/nostr/projects/route.ts b/app/api/nostr/projects/route.ts index d42d0e9..6e8232c 100644 --- a/app/api/nostr/projects/route.ts +++ b/app/api/nostr/projects/route.ts @@ -1,18 +1,26 @@ import { NextRequest, NextResponse } from "next/server"; import { getNostrSubmissionsSnapshot } from "@/lib/nostrCache"; import { projectMatchesIdentifier } from "@/lib/projectIdentity"; +import { getProjectRegistryState } from "@/lib/projectRegistry"; +import { attachProjectSlugs } from "@/lib/projectResolver"; export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const hackathonId = searchParams.get("hackathonId"); const projectId = searchParams.get("projectId"); const author = searchParams.get("author"); - const snapshot = await getNostrSubmissionsSnapshot(); - const projects = snapshot.projects.filter((project) => { - if (hackathonId && project.hackathon !== hackathonId) return false; - if (author && project.author !== author) return false; - if (projectId && !projectMatchesIdentifier(project, projectId)) return false; - return true; - }); + const [snapshot, registry] = await Promise.all([ + getNostrSubmissionsSnapshot(), + getProjectRegistryState(), + ]); + const projects = attachProjectSlugs( + snapshot.projects.filter((project) => { + if (hackathonId && project.hackathon !== hackathonId) return false; + if (author && project.author !== author) return false; + if (projectId && !projectMatchesIdentifier(project, projectId)) return false; + return true; + }), + registry, + ); return NextResponse.json({ ...snapshot, projects }); } diff --git a/app/api/nostr/refresh/route.ts b/app/api/nostr/refresh/route.ts index 0349948..f1b6094 100644 --- a/app/api/nostr/refresh/route.ts +++ b/app/api/nostr/refresh/route.ts @@ -1,9 +1,12 @@ import { revalidateTag } from "next/cache"; import { NextRequest, NextResponse } from "next/server"; +import { after } from "next/server"; import { getFreshNostrSubmissionsSnapshot, getNostrSubmissionsSnapshot, } from "@/lib/nostrCache"; +import { getProjectRegistryState, syncProjectRegistry } from "@/lib/projectRegistry"; +import { attachProjectSlugs } from "@/lib/projectResolver"; import { NOSTR_LEGACY_SUBMISSIONS_TAG, NOSTR_PROJECTS_TAG, @@ -52,6 +55,29 @@ function expireTag(tag: string) { revalidateTag(tag, { expire: 0 }); } +// Throttles: this endpoint is unauthenticated and its expensive paths run a +// ~6s multi-relay scan. Best-effort per-instance guards (serverless instances +// each keep their own) so anonymous callers can't hammer the relays or keep +// the shared snapshot permanently cold. Within a window, callers still get +// the cached snapshot. +let lastProjectsInvalidation = 0; +const PROJECTS_INVALIDATION_WINDOW_MS = 30_000; +// Blocking relay scans (candidate read-your-writes + blocking:false bypass). +let lastFreshScan = 0; +const FRESH_SCAN_MIN_INTERVAL_MS = 5_000; +// Each published event gets ONE read-your-writes expiry; retries serve cache. +const seenCandidates = new Map(); +const CANDIDATE_WINDOW_MS = 60_000; + +function candidateAlreadyServed(eventId: string, now: number): boolean { + for (const [id, ts] of seenCandidates) { + if (now - ts > CANDIDATE_WINDOW_MS) seenCandidates.delete(id); + } + if (seenCandidates.has(eventId)) return true; + seenCandidates.set(eventId, now); + return false; +} + function parseRefreshATags(value: unknown): string[] { if (!Array.isArray(value)) return []; return [ @@ -84,27 +110,69 @@ export async function POST(req: NextRequest) { }; if (scopes.includes("projects")) { - expire(NOSTR_PROJECTS_TAG); - expire(NOSTR_LEGACY_SUBMISSIONS_TAG); - const snapshot = body.blocking === false - ? await getFreshNostrSubmissionsSnapshot() - : await getNostrSubmissionsSnapshot(); + const now = Date.now(); + const scanAllowed = now - lastFreshScan > FRESH_SCAN_MIN_INTERVAL_MS; + let snapshot; + if (body.blocking === false && scanAllowed) { + // Explicit bypass: fetch straight from relays, mark the cache stale. + lastFreshScan = now; + snapshot = await getFreshNostrSubmissionsSnapshot(); + revalidateTag(NOSTR_PROJECTS_TAG, "max"); + revalidateTag(NOSTR_LEGACY_SUBMISSIONS_TAG, "max"); + expiredTags.push(NOSTR_PROJECTS_TAG, NOSTR_LEGACY_SUBMISSIONS_TAG); + } else if ( + body.blocking !== false && + body.candidateEventId && + scanAllowed && + !candidateAlreadyServed(body.candidateEventId, now) + ) { + // Read-your-writes: the caller just published an event and needs the + // snapshot to contain it — expire and re-fetch synchronously. One + // blocking refetch per candidate event; repeats serve the cache (the + // client shows "sincronizando" and retries via router.refresh). + lastFreshScan = now; + expire(NOSTR_PROJECTS_TAG); + expire(NOSTR_LEGACY_SUBMISSIONS_TAG); + snapshot = await getNostrSubmissionsSnapshot(); + } else { + // Stale-while-revalidate: serve the cached snapshot NOW, then mark the + // tags stale so the next visit regenerates in the background. Reading + // BEFORE revalidateTag is what keeps this request non-blocking — the + // pending tag would otherwise discard the entry and re-run the ~6s + // relay scan inline. + snapshot = await getNostrSubmissionsSnapshot(); + if (now - lastProjectsInvalidation > PROJECTS_INVALIDATION_WINDOW_MS) { + lastProjectsInvalidation = now; + revalidateTag(NOSTR_PROJECTS_TAG, "max"); + revalidateTag(NOSTR_LEGACY_SUBMISSIONS_TAG, "max"); + expiredTags.push(NOSTR_PROJECTS_TAG, NOSTR_LEGACY_SUBMISSIONS_TAG); + } + } + + const registry = await getProjectRegistryState(); refreshed.projects = { ...snapshot, - projects: snapshot.projects.filter((project) => { - if (body.hackathonId && project.hackathon !== body.hackathonId) { - return false; - } - if (body.author && project.author !== body.author) return false; - if ( - body.projectId && - !projectMatchesIdentifier(project, body.projectId) - ) { - return false; - } - return true; - }), + projects: attachProjectSlugs( + snapshot.projects.filter((project) => { + if (body.hackathonId && project.hackathon !== body.hackathonId) { + return false; + } + if (body.author && project.author !== body.author) return false; + if ( + body.projectId && + !projectMatchesIdentifier(project, body.projectId) + ) { + return false; + } + return true; + }), + registry, + ), }; + + // Register any new projects in the La Crypta-signed slug registry. + // after() keeps the work (and its revalidateTag) alive past the response. + after(() => syncProjectRegistry()); } if (body.pubkey) { diff --git a/app/dashboard/hackathones/MisHackatonesClient.tsx b/app/dashboard/hackathones/MisHackatonesClient.tsx index 2c48999..d1217a2 100644 --- a/app/dashboard/hackathones/MisHackatonesClient.tsx +++ b/app/dashboard/hackathones/MisHackatonesClient.tsx @@ -12,6 +12,7 @@ import { type UserProject, } from "@/lib/userProjects"; import { HACKATHONS, hackathonSlugForId } from "@/lib/hackathons"; +import { projectHref } from "@/lib/projectLinks"; import { cn } from "@/lib/cn"; const HACKATHON_NAME = new Map(HACKATHONS.map((h) => [h.id, h.name])); @@ -140,7 +141,7 @@ export default function MisHackatonesClient() { {g.projects.map((p) => (
  • diff --git a/app/dashboard/projects/UserProjectsClient.tsx b/app/dashboard/projects/UserProjectsClient.tsx index c8ce1d8..de529af 100644 --- a/app/dashboard/projects/UserProjectsClient.tsx +++ b/app/dashboard/projects/UserProjectsClient.tsx @@ -42,8 +42,9 @@ import { type TeamMember, type UserProject, } from "@/lib/userProjects"; -import { HACKATHONS, hackathonSlugForId } from "@/lib/hackathons"; +import { HACKATHONS } from "@/lib/hackathons"; import { useNostrProfile } from "@/lib/nostrProfile"; +import { projectHref } from "@/lib/projectLinks"; import { mergeDataRelays } from "@/lib/nostrRelayConfig"; type RelayResult = { relay: string; ok: boolean; error?: string }; @@ -744,7 +745,6 @@ export default function UserProjectsClient() { openEdit(p)} onDelete={() => setDeleteId(p.id)} disabled={publishing} @@ -843,22 +843,16 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { function ProjectCard({ project, - pubkey, onEdit, onDelete, disabled, }: { project: UserProject; - pubkey?: string; onEdit: () => void; onDelete: () => void; disabled: boolean; }) { - const detailHref = project.hackathon - ? `/hackathons/${hackathonSlugForId(project.hackathon)}/${project.id}` - : pubkey - ? `/projects/${pubkey}/${project.id}` - : null; + const detailHref = projectHref(project); return (
    - {detailHref ? ( - - {project.name} - - ) : ( - - {project.name} - - )} + + {project.name} +