diff --git a/app/api/cache/warm/route.ts b/app/api/cache/warm/route.ts index 11a8b50..81dd2de 100644 --- a/app/api/cache/warm/route.ts +++ b/app/api/cache/warm/route.ts @@ -1,6 +1,15 @@ import { type NextRequest, NextResponse } from "next/server"; import { getFreshNostrSubmissionsSnapshot } from "@/lib/nostrCache"; -import { isUpstashEnabled } from "@/lib/upstashCache"; +import { + isUpstashEnabled, + UPSTASH_KEYS, + UPSTASH_TTL, + upstashSet, +} from "@/lib/upstashCache"; +import { + getProjectRegistryState, + registryEntryForProject, +} from "@/lib/projectRegistry"; /** * Cache warming endpoint. Rescans the relays and writes the snapshot through to @@ -44,10 +53,31 @@ export async function GET(req: NextRequest) { const startedAt = Date.now(); const snapshot = await getFreshNostrSubmissionsSnapshot(); + + // Refresh the durable per-project copies for REGISTERED projects straight from + // the snapshot we just scanned (no extra relay calls) so a registered project + // keeps a warm backend copy — the copy the resolver falls back to when a + // thinly-propagated event misses the broad scan. Only registered projects get + // one; unregistered ids resolve fine from the snapshot itself. + const registry = await getProjectRegistryState(); + let durableWrites = 0; + for (const project of snapshot.projects) { + if (registryEntryForProject(registry, project)) { + // Key on the lowercased id — the resolver reads durable copies lowercased. + await upstashSet( + UPSTASH_KEYS.projectDurable(project.id.toLowerCase()), + project, + UPSTASH_TTL.durable, + ); + durableWrites++; + } + } + return NextResponse.json({ ok: true, warmed: snapshot.projects.length > 0, projects: snapshot.projects.length, + durableWrites, generatedAt: snapshot.generatedAt, elapsedMs: Date.now() - startedAt, }); diff --git a/app/api/nostr/refresh/route.ts b/app/api/nostr/refresh/route.ts index 383e860..e745e46 100644 --- a/app/api/nostr/refresh/route.ts +++ b/app/api/nostr/refresh/route.ts @@ -4,6 +4,9 @@ import { after } from "next/server"; import { getFreshNostrSubmissionsSnapshot, getNostrSubmissionsSnapshot, + getProjectWithDurableFallback, + rawFetchProjectByDTag, + type CachedNostrProject, } from "@/lib/nostrCache"; import { getProjectRegistryState, syncProjectRegistry } from "@/lib/projectRegistry"; import { attachProjectSlugs } from "@/lib/projectResolver"; @@ -16,15 +19,20 @@ import { nostrHackathonBadgesTag, nostrBadgesTag, nostrProfileTag, + nostrProjectByIdTag, nostrRelayListTag, nostrReportsTag, } from "@/lib/nostrCacheTags"; +import { + UPSTASH_KEYS, + UPSTASH_TTL, + upstashSet, +} from "@/lib/upstashCache"; import { getCachedHackathonBadgeCatalogSnapshot, getCachedHackathonBadgeDefinitionsSnapshot, getCachedHackathonBadgeOwnersSnapshot, } from "@/lib/hackathonBadgeCache"; -import { projectMatchesIdentifier } from "@/lib/projectIdentity"; type RefreshScope = | "projects" @@ -122,71 +130,148 @@ export async function POST(req: NextRequest) { if (scopes.includes("projects")) { 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. Scan the relays FIRST — that call writes the - // fresh snapshot through to Upstash — and only then drop the Next cache - // entry. The reverse order would let the regeneration read the *stale* - // Upstash entry back and silently undo the refresh. Note we expire the - // tag rather than `expireNostrTag`: the Upstash key is already fresh, so - // dropping it would only buy a redundant ~6s rescan. One blocking refetch - // per candidate event; repeats serve the cache (the client shows - // "sincronizando" and retries via router.refresh). - lastFreshScan = now; - snapshot = await getFreshNostrSubmissionsSnapshot(); - expireNextOnly(NOSTR_PROJECTS_TAG); - expireNextOnly(NOSTR_LEGACY_SUBMISSIONS_TAG); + const registry = await getProjectRegistryState(); + + if (body.projectId) { + // Targeted per-project refetch+recache. The frontend detected a newer + // event (or just wants this project warm): fetch ONLY this project by its + // `#d` (~4.5s) instead of the ~6s broad snapshot scan, write its durable + + // short KV copies so it always resolves, and return the cached snapshot + // with the fresh project merged in (never shrink the client's community + // cache to a single item). + // Lowercase so the KV keys we write match the lowercased ids the resolver + // reads back (getProjectWithDurableFallback / getNostrProjectByIdDirect). + const pid = body.projectId.toLowerCase(); + // This endpoint is UNAUTHENTICATED, so it must never persist an + // attacker-chosen event as a project's authoritative copy. The durable + // key is only for REGISTERED projects, and only their registry-recorded + // author's event: filter the fetch by that author, and write the durable + // key only when the project is registered. Unregistered ids get at most a + // short-lived lookup copy (30s), never the 1-year durable one. + const registeredAuthor = registry.byIdLc.get(pid)?.author; + const scanAllowed = now - lastFreshScan > FRESH_SCAN_MIN_INTERVAL_MS; + const wantsFresh = + body.blocking !== false && + !!body.candidateEventId && + scanAllowed && + !candidateAlreadyServed(body.candidateEventId, now); + + let project: CachedNostrProject | null = null; + if (wantsFresh) { + lastFreshScan = now; + const fetched = await rawFetchProjectByDTag( + pid, + 4500, + registeredAuthor, + ); + if (fetched) { + project = { ...fetched, id: pid }; + if (registeredAuthor) { + await upstashSet( + UPSTASH_KEYS.projectDurable(pid), + project, + UPSTASH_TTL.durable, + ); + } + await upstashSet( + UPSTASH_KEYS.projectById(pid), + project, + UPSTASH_TTL.lookup, + ); + // Refresh the per-id Next entry ONLY — the Upstash keys we just wrote + // must survive (expireNostrTag would delete them). + expireNextOnly(nostrProjectByIdTag(pid)); + } + } + if (!project) { + // Throttled, no candidate, or a relay miss → serve the durable/cached + // copy so a registered project never regresses to "not found". Guard by + // the registered author so a poisoned copy is never served. + project = await getProjectWithDurableFallback(pid, registeredAuthor); + } + + const cached = await getNostrSubmissionsSnapshot(); + let list: CachedNostrProject[]; + if (project) { + const fresh = project; + list = [ + fresh, + ...cached.projects.filter( + (p) => + !( + p.author === fresh.author && + p.id.toLowerCase() === fresh.id.toLowerCase() + ), + ), + ]; + } else { + list = cached.projects; + } + refreshed.projects = { + projects: attachProjectSlugs(list, registry), + generatedAt: new Date().toISOString(), + relays: cached.relays, + }; } 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. The background regeneration reads Upstash rather - // than the relays, so relay freshness on this path is bounded by the - // Upstash TTL (and by the warming cron that refreshes it). - snapshot = await getNostrSubmissionsSnapshot(); - if (now - lastProjectsInvalidation > PROJECTS_INVALIDATION_WINDOW_MS) { - lastProjectsInvalidation = 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. Scan the relays FIRST — that call writes the + // fresh snapshot through to Upstash — and only then drop the Next cache + // entry. The reverse order would let the regeneration read the *stale* + // Upstash entry back and silently undo the refresh. Note we expire the + // tag rather than `expireNostrTag`: the Upstash key is already fresh, so + // dropping it would only buy a redundant ~6s rescan. One blocking + // refetch per candidate event; repeats serve the cache (the client + // shows "sincronizando" and retries via router.refresh). + lastFreshScan = now; + snapshot = await getFreshNostrSubmissionsSnapshot(); + expireNextOnly(NOSTR_PROJECTS_TAG); + expireNextOnly(NOSTR_LEGACY_SUBMISSIONS_TAG); + } 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. The background regeneration reads Upstash rather + // than the relays, so relay freshness on this path is bounded by the + // Upstash TTL (and by the warming cron that refreshes it). + 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: 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, - ), - }; + refreshed.projects = { + ...snapshot, + projects: attachProjectSlugs( + snapshot.projects.filter((project) => { + if (body.hackathonId && project.hackathon !== body.hackathonId) { + return false; + } + if (body.author && project.author !== body.author) 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. diff --git a/app/api/projects/registry/route.ts b/app/api/projects/registry/route.ts new file mode 100644 index 0000000..bf1f4ed --- /dev/null +++ b/app/api/projects/registry/route.ts @@ -0,0 +1,206 @@ +import { NextRequest, NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import type { SignedEvent } from "@/lib/nostrSigner"; +import { + getProjectRegistryState, + isCuratedProjectId, + registerUserProjectSlug, +} from "@/lib/projectRegistry"; +import { normalizeRequestedSlug } from "@/lib/projectRegistryContract"; +import { + getNostrSubmissionsSnapshot, + rawFetchProjectByDTag, +} from "@/lib/nostrCache"; +import { + UPSTASH_KEYS, + UPSTASH_TTL, + upstashSet, +} from "@/lib/upstashCache"; +import { nostrProjectByIdTag } from "@/lib/nostrCacheTags"; +import { projectSlugHref } from "@/lib/projectLinks"; + +/** + * User-initiated project-slug registry. + * + * GET ?slug=[&projectId=] → availability check (format + uniqueness). + * POST { request: SignedEvent } → claim/change a slug. `request` is a + * kind-27235 NIP-98 event signed by the project's author carrying + * ["action","register-project-slug"], ["project",], ["slug",]. + * The backend verifies ownership against the project's own relay event, + * La-Crypta-signs an updated registry event, publishes it, caches a durable + * copy of the project, and returns the event for the client to republish. + * + * Mirrors the auth+sign pattern of `app/api/soldiers/ranking/route.ts`. + */ + +const ACTION = "register-project-slug"; +const REQUEST_MAX_AGE_SECONDS = 10 * 60; + +function tagValue(ev: SignedEvent, name: string): string | undefined { + return ev.tags.find((t) => t[0] === name)?.[1]; +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const rawSlug = url.searchParams.get("slug") ?? ""; + const projectId = url.searchParams.get("projectId") ?? undefined; + + const normalized = normalizeRequestedSlug(rawSlug); + if ("error" in normalized) { + return NextResponse.json({ + available: false, + reason: normalized.error, + slug: rawSlug.trim().toLowerCase(), + }); + } + + const slug = normalized.slug; + const state = await getProjectRegistryState(); + const owner = state.bySlug.get(slug); + + if (owner && owner.id.toLowerCase() !== projectId?.toLowerCase()) { + return NextResponse.json({ + available: false, + reason: "Esa URL ya está en uso.", + slug, + }); + } + + const mine = + owner && projectId && owner.id.toLowerCase() === projectId.toLowerCase(); + return NextResponse.json({ + available: true, + slug, + reason: mine ? "Es la URL actual de tu proyecto." : undefined, + }); +} + +export async function POST(req: NextRequest) { + let body: { request?: SignedEvent }; + try { + body = (await req.json()) as { request?: SignedEvent }; + } catch { + return NextResponse.json({ error: "Body JSON inválido." }, { status: 400 }); + } + const request = body.request; + if (!request) { + return NextResponse.json({ error: "Falta request firmado." }, { status: 400 }); + } + + try { + const { verifyEvent } = await import("nostr-tools/pure"); + + if (!verifyEvent(request)) { + return NextResponse.json({ error: "Request Nostr inválido." }, { status: 401 }); + } + if ( + Math.abs(Math.floor(Date.now() / 1000) - request.created_at) > + REQUEST_MAX_AGE_SECONDS + ) { + return NextResponse.json({ error: "Request expirado." }, { status: 401 }); + } + if (!request.tags.some((t) => t[0] === "action" && t[1] === ACTION)) { + return NextResponse.json( + { error: "Request no autorizado para registrar una URL." }, + { status: 401 }, + ); + } + + // Normalize the id to lowercase so the durable/short KV keys we write match + // the lowercased ids the resolver reads back (`getProjectWithDurableFallback`). + const projectId = (tagValue(request, "project") ?? "").trim().toLowerCase(); + if (!projectId) { + return NextResponse.json( + { error: "Falta el id del proyecto." }, + { status: 400 }, + ); + } + const normalized = normalizeRequestedSlug(tagValue(request, "slug") ?? ""); + if ("error" in normalized) { + return NextResponse.json({ error: normalized.error }, { status: 400 }); + } + const slug = normalized.slug; + + // Curated (in-tree) projects are La Crypta-owned; their canonical URL is + // already their id and they're auto-registered by sync. Never claimable — + // otherwise a community-project owner could shadow a curated project's URL. + if (isCuratedProjectId(projectId)) { + return NextResponse.json( + { error: "Este proyecto no se puede registrar manualmente." }, + { status: 403 }, + ); + } + + // Recognized-author gate: if the system already knows this project (from the + // La-Crypta-signed registry or the cached snapshot), only that author may + // register/change its URL. This is what stops a stranger from hijacking a + // known project by publishing a forged event under a colliding `#d` tag + // (a `#d` is not owner-exclusive for NIP-33 replaceable events). + const [registry, snapshot] = await Promise.all([ + getProjectRegistryState(), + getNostrSubmissionsSnapshot(), + ]); + const recognizedAuthor = + registry.byIdLc.get(projectId)?.author ?? + snapshot.projects.find((p) => p.id.toLowerCase() === projectId)?.author; + if (recognizedAuthor && recognizedAuthor !== request.pubkey) { + return NextResponse.json( + { error: "Solo el autor del proyecto puede registrar su URL." }, + { status: 403 }, + ); + } + + // Fetch the requester's OWN event for this id (authors-filtered): confirms + // they actually published the project AND yields authentic content to cache + // durably — a forger's colliding event can never be the one returned here. + const project = await rawFetchProjectByDTag(projectId, 4500, request.pubkey); + if (!project) { + return NextResponse.json( + { error: "No encontramos un proyecto tuyo con ese id en los relays." }, + { status: 404 }, + ); + } + + const result = await registerUserProjectSlug({ + projectId, + slug, + requesterPubkey: request.pubkey, + project: { name: project.name, hackathon: project.hackathon }, + }); + if (result.status === "error") { + return NextResponse.json({ error: result.message }, { status: result.code }); + } + + // Durable backend copy so this project ALWAYS resolves server-side, even if + // the broad snapshot never captured its (thinly-propagated) event — the + // original `/projects/` "Proyecto no encontrado" class of bug. Pin the + // queried id. Then refresh the per-id Next cache entry ONLY (not + // `expireNostrTag`, which would delete the durable key we just wrote). + const pinned = { ...project, id: projectId }; + await upstashSet( + UPSTASH_KEYS.projectDurable(projectId), + pinned, + UPSTASH_TTL.durable, + ); + await upstashSet( + UPSTASH_KEYS.projectById(projectId), + pinned, + UPSTASH_TTL.lookup, + ); + revalidateTag(nostrProjectByIdTag(projectId), { expire: 0 }); + + return NextResponse.json({ + ok: true, + event: result.event, + slug: result.slug, + changed: result.changed, + canonicalUrl: projectSlugHref(result.slug), + }); + } catch (error) { + console.error("[api/projects/registry] failed", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Error interno." }, + { status: 500 }, + ); + } +} diff --git a/app/projects/[slug]/NostrProjectPageClient.tsx b/app/projects/[slug]/NostrProjectPageClient.tsx index 2dbd048..6bb8f52 100644 --- a/app/projects/[slug]/NostrProjectPageClient.tsx +++ b/app/projects/[slug]/NostrProjectPageClient.tsx @@ -43,6 +43,7 @@ import { cn } from "@/lib/cn"; import { projectMatchesIdentifier } from "@/lib/projectIdentity"; import { mergeDataRelays } from "@/lib/nostrRelayConfig"; import { seedProjectEntities, seedProfileEntities, useProjectEntity } from "@/lib/entityStore"; +import { projectSlugHref } from "@/lib/projectLinks"; import { ProjectDetailView } from "@/components/ProjectDetailView"; // The edit modal is the largest client component in the tree and only opens for @@ -51,6 +52,11 @@ const NewProjectModal = dynamic(() => import("@/components/NewProjectModal"), { ssr: false, }); +// Owner-only URL registration card — kept out of the first load like the modal. +const ProjectSlugCard = dynamic(() => import("@/components/ProjectSlugCard"), { + ssr: false, +}); + type SearchPhase = "cache" | "snapshot" | "relays"; function ProjectRelaySearchLoading({ @@ -762,6 +768,44 @@ export default function NostrProjectPage({ project.hackathon && report ? : undefined; const isAuthor = auth?.pubkey === project.author; + // `canonicalSlug` equals the project id when unregistered; a different value + // means the project already has a registered slug. + const registeredSlug = + canonicalSlug && canonicalSlug.toLowerCase() !== project.id.toLowerCase() + ? canonicalSlug + : project.slug && project.slug.toLowerCase() !== project.id.toLowerCase() + ? project.slug + : undefined; + + const slugSlot = isAuthor ? ( + { + seedProjectEntities([ + { + id: project.id, + slug, + name: project.name, + description: project.description, + logo: project.logo, + cover: project.cover, + status: project.status, + hackathon: project.hackathon, + author: project.author, + tech: project.tech, + updatedAt: project.eventCreatedAt, + }, + ]); + // Navigate to the new canonical URL; the resolver now has the slug. + router.replace(projectSlugHref(slug)); + }} + /> + ) : undefined; + return ( <> setEditOpen(true) : undefined} archiveState={archiveStep} onArchive={ diff --git a/components/ProjectDetailView.tsx b/components/ProjectDetailView.tsx index 9d58e23..3f8db75 100644 --- a/components/ProjectDetailView.tsx +++ b/components/ProjectDetailView.tsx @@ -75,6 +75,7 @@ export function ProjectDetailView({ onArchive, onCancelArchive, reportSlot, + slugSlot, }: { project: ProjectLike; authorPubkey: string; @@ -89,6 +90,8 @@ export function ProjectDetailView({ onArchive?: () => void; onCancelArchive?: () => void; reportSlot?: ReactNode; + /** Owner-only URL/slug registration card, rendered in the actions column. */ + slugSlot?: ReactNode; }) { const galleryImages = project.images ?? []; const galleryThumbs = project.thumbs ?? []; @@ -333,6 +336,8 @@ export function ProjectDetailView({ /> )} + {slugSlot} + {(project.repo || project.demo || isAuthor) && (
diff --git a/components/ProjectSlugCard.tsx b/components/ProjectSlugCard.tsx new file mode 100644 index 0000000..d94614f --- /dev/null +++ b/components/ProjectSlugCard.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Check, Link2, Loader2, Pencil, AlertTriangle, X } from "lucide-react"; +import type { Auth } from "@/lib/auth"; +import { getSigner } from "@/lib/nostrSigner"; +import { useToast } from "@/components/Toast"; +import { cn } from "@/lib/cn"; +import { slugifyProjectName } from "@/lib/projectIdentity"; +import { projectSlugHref } from "@/lib/projectLinks"; +import { + checkSlugAvailability, + requestProjectSlug, + type SlugAvailability, +} from "@/lib/projectRegistryClient"; +import { publishSignedEventsToRelays } from "@/lib/hackathonBadgeClient"; + +/** + * Owner-only card to register (or change) the project's canonical URL slug. + * Prefills a suggestion from the project name, checks availability live, then + * signs → posts → republishes the La-Crypta-signed registry event and navigates + * to the new canonical URL. + */ +export default function ProjectSlugCard({ + auth, + projectId, + projectName, + currentSlug, + relays, + onRegistered, +}: { + auth: Auth | null; + projectId: string; + projectName: string; + /** Canonical slug when the project is already registered. */ + currentSlug?: string; + relays: string[]; + onRegistered: (slug: string) => void; +}) { + const { push: pushToast } = useToast(); + const suggestion = useMemo( + () => slugifyProjectName(projectName) || projectId, + [projectName, projectId], + ); + const [editing, setEditing] = useState(!currentSlug); + const [value, setValue] = useState(currentSlug ?? suggestion); + const [availability, setAvailability] = useState(null); + const [checking, setChecking] = useState(false); + const [submitting, setSubmitting] = useState(false); + + // Debounced availability check whenever the input changes (while editing). + useEffect(() => { + if (!editing) return; + const slug = value.trim().toLowerCase(); + if (!slug) { + setAvailability(null); + return; + } + setChecking(true); + const ctrl = new AbortController(); + const t = setTimeout(async () => { + try { + const result = await checkSlugAvailability(slug, projectId, ctrl.signal); + setAvailability(result); + } catch { + /* aborted or offline — leave the last state */ + } finally { + setChecking(false); + } + }, 400); + return () => { + ctrl.abort(); + clearTimeout(t); + }; + }, [value, editing, projectId]); + + const normalized = value.trim().toLowerCase(); + const isCurrent = !!currentSlug && normalized === currentSlug.toLowerCase(); + const canSubmit = + !submitting && + !checking && + !!availability?.available && + normalized.length >= 2 && + !isCurrent; + + async function submit() { + if (!auth) { + pushToast({ kind: "error", title: "Iniciá sesión para registrar la URL." }); + return; + } + setSubmitting(true); + // Declared outside the try so the bunker/NIP-46 signer (a live WebSocket + // connection) is torn down on EVERY exit path, not just success. + let signer: Awaited> | null = null; + try { + signer = await getSigner(auth, { + onAuthUrl: (url) => { + pushToast({ + kind: "info", + title: "Autorizá la firma en tu bunker", + description: url, + duration: 20000, + }); + try { + window.open(url, "_blank", "noopener,noreferrer"); + } catch { + /* popup blocked */ + } + }, + }); + const result = await requestProjectSlug(signer, { + projectId, + slug: normalized, + }); + // Republish the La-Crypta-signed registry event from the browser too. + await publishSignedEventsToRelays([result.event], relays); + pushToast({ + kind: "success", + title: "URL registrada", + description: projectSlugHref(result.slug), + }); + onRegistered(result.slug); + } catch (e) { + pushToast({ + kind: "error", + title: "No se pudo registrar la URL", + description: e instanceof Error ? e.message : String(e), + duration: 10000, + }); + } finally { + signer?.close?.().catch(() => {}); + setSubmitting(false); + } + } + + const inputRef = useRef(null); + + return ( +
+
+ +

+ URL del proyecto +

+
+ + {currentSlug && !editing ? ( +
+

+ lacrypta.dev{projectSlugHref(currentSlug)} +

+ +
+ ) : ( +
+ + + {availability?.reason && ( +

+ {!availability.available && ( + + )} + {availability.reason} +

+ )} + +
+ + {currentSlug && ( + + )} +
+

+ La firma la hace La Crypta. Vos publicás el registro con tu firma + para propagarlo en los relays. +

+
+ )} +
+ ); +} diff --git a/lib/nostrCache.ts b/lib/nostrCache.ts index 7793bea..63db7e2 100644 --- a/lib/nostrCache.ts +++ b/lib/nostrCache.ts @@ -20,6 +20,7 @@ import { import { UPSTASH_KEYS, UPSTASH_TTL, + upstashGet, upstashReadThrough, upstashSet, } from "./upstashCache"; @@ -240,13 +241,28 @@ async function rawFetchAllProjects( * possibly-newer replica, and the browser reconciles to the newest event * after paint. The hard timeout only bounds the not-found case. */ -async function rawFetchProjectByDTag( +export async function rawFetchProjectByDTag( projectId: string, timeoutMs = 4500, + /** + * When set, restrict the query to events signed by this pubkey. Ownership + * checks pass it so a stranger who republishes a colliding `#d` under their + * own key can't masquerade as the project (a `#d` is NOT owner-exclusive for + * a NIP-33 replaceable event — one exists per (pubkey, d-tag)). + */ + author?: string, ): Promise { const { SimplePool } = await import("nostr-tools/pool"); const pool = new SimplePool(); const dTag = `${PROJECT_D_PREFIX}${projectId}`; + const filter = author + ? { + kinds: [PROJECT_KIND], + "#d": [dTag], + "#t": [PROJECT_TAG], + authors: [author], + } + : { kinds: [PROJECT_KIND], "#d": [dTag], "#t": [PROJECT_TAG] }; let best: CachedNostrProject | null = null; await new Promise((resolve) => { @@ -267,14 +283,16 @@ async function rawFetchProjectByDTag( closer = pool.subscribe( TOP10_RELAYS, - { kinds: [PROJECT_KIND], "#d": [dTag], "#t": [PROJECT_TAG] }, + filter, { onevent(ev: IncomingEvent) { const parsed = parseEvent(ev); // Ignore unparseable/archived events (never let a malformed first // responder mask a valid one) — keep listening until a valid event - // or the timeout. + // or the timeout. Defense in depth: also drop an event whose pubkey + // doesn't match a requested author (relays should honor the filter). if (!parsed || parsed.status === "archived") return; + if (author && parsed.author !== author) return; if (!best || parsed.eventCreatedAt > best.eventCreatedAt) { best = parsed; } @@ -333,6 +351,45 @@ export async function getNostrProjectByIdDirect( ); } +/** + * Resolve a project by id and NEVER return `null` for one we've cached durably. + * + * The guarantee the `/projects/` page needs: a registered project must + * always render, even when the broad snapshot missed it and a live `#d` scan + * times out (thin relay propagation — the original "Proyecto no encontrado" + * class of bug). Order: + * 1. `getNostrProjectByIdDirect` — cached read-through → live `#d` relay scan + * on a miss. This is the freshness path; relays only ever bring newer data. + * 2. On a miss, fall back to the durable Upstash copy written at registration + * / targeted refetch / cache warm. + * + * The read path deliberately does NOT write the durable copy — that would add a + * Redis write to every resolve. Durable copies are (re)written only where we + * KNOW we hold an authoritative project (see the registry route, the targeted + * refresh branch, and the warm cron), and the year-long TTL bridges the gaps. + */ +export async function getProjectWithDurableFallback( + projectId: string, + /** + * When the caller knows who the project SHOULD belong to (a registered + * project's registry-recorded author), pass it: any cached copy whose author + * doesn't match is discarded. This stops a poisoned copy — e.g. a stranger's + * colliding `#d` event that slipped into the short-lived lookup key or a + * durable key — from being served at the victim's canonical URL. + */ + expectedAuthor?: string, +): Promise { + const authorOk = (p: CachedNostrProject | null): p is CachedNostrProject => + !!p && (!expectedAuthor || p.author === expectedAuthor); + + const direct = await getNostrProjectByIdDirect(projectId); + if (authorOk(direct)) return direct; + const durable = await upstashGet( + UPSTASH_KEYS.projectDurable(projectId), + ); + return authorOk(durable) ? durable : null; +} + async function buildSubmissionsSnapshot(): Promise< CachedNostrSubmissionsSnapshot > { diff --git a/lib/nostrRevalidate.ts b/lib/nostrRevalidate.ts index 66f58d7..d69bd21 100644 --- a/lib/nostrRevalidate.ts +++ b/lib/nostrRevalidate.ts @@ -33,7 +33,14 @@ function upstashKeysForTag(tag: string): string[] { return [UPSTASH_KEYS.soldiersRanking]; } if (tag.startsWith(PROJECT_TAG_PREFIX)) { - return [UPSTASH_KEYS.projectById(tag.slice(PROJECT_TAG_PREFIX.length))]; + const id = tag.slice(PROJECT_TAG_PREFIX.length); + // A hard-expire is an intentional invalidation (project genuinely changed), + // so drop the durable copy too — the next read re-fetches. This is distinct + // from the read-path fallback, which never overwrites the durable copy with + // a transient relay miss. Callers that hold fresh data must NOT hard-expire + // right after a write-through (that would delete what they just cached); + // they expire the Next tier only (see the refresh/registry routes). + return [UPSTASH_KEYS.projectById(id), UPSTASH_KEYS.projectDurable(id)]; } if (tag.startsWith(PROFILE_TAG_PREFIX)) { return [UPSTASH_KEYS.profile(tag.slice(PROFILE_TAG_PREFIX.length))]; diff --git a/lib/projectRegistry.ts b/lib/projectRegistry.ts index 92a2f14..a733435 100644 --- a/lib/projectRegistry.ts +++ b/lib/projectRegistry.ts @@ -20,6 +20,9 @@ import { cacheLife, cacheTag } from "next/cache"; import { DEFAULT_RELAYS } from "./nostrRelayConfig"; import { NOSTR_PROJECT_REGISTRY_TAG } from "./nostrCacheTags"; +import { expireNostrTag } from "./nostrRevalidate"; +import { upstashAcquireLock, upstashReleaseLock } from "./upstashCache"; +import type { SignedEvent } from "./nostrSigner"; import { PROJECT_REGISTRY_D_TAG, PROJECT_REGISTRY_KIND, @@ -68,12 +71,21 @@ export function buildRegistryState( const bySlug = new Map(); const byIdLc = new Map(); const byName = new Map(); - for (const entry of entries) { - if (!bySlug.has(entry.slug)) bySlug.set(entry.slug, entry); - const idLc = entry.id.toLowerCase(); - if (!byIdLc.has(idLc)) byIdLc.set(idLc, entry); + // Slugs are permanent (append-only), but a project MAY change which slug is + // canonical by publishing a newer entry for the same id. So resolve id/name + // to the LATEST entry (canonical) while `bySlug` keeps every slug — including + // a project's earlier slugs, which stay resolvable as owner-locked redirect + // aliases. Process oldest-first so the newest write wins the id/name maps. + const ordered = [...entries].sort( + (a, b) => (a.registeredAt || 0) - (b.registeredAt || 0), + ); + for (const entry of ordered) { + // Each slug maps to exactly one project (uniqueness is enforced at write + // time); a re-registration of the same slug just refreshes the entry. + bySlug.set(entry.slug, entry); + byIdLc.set(entry.id.toLowerCase(), entry); const nameKey = comparableProjectName(entry.name); - if (nameKey && !byName.has(nameKey)) byName.set(nameKey, entry); + if (nameKey) byName.set(nameKey, entry); } return { entries, bySlug, byIdLc, byName }; } @@ -407,9 +419,84 @@ async function publishToRelays( return results.some(Boolean); } +/* ─────────────────────────── sign helpers ──────────────────────────────── */ + +async function getBackendSecretBytes(): Promise { + const nsec = process.env.LACRYPTA_NSEC; + if (!nsec) return null; + try { + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(nsec); + if (decoded.type !== "nsec") return null; + return decoded.data as Uint8Array; + } catch { + return null; + } +} + +/** + * Build + sign the single replaceable registry event from a full entry list. + * Shared by the automatic sync and the user-initiated claim so both apply the + * same tags and the same monotonic `created_at` floor (a lost race can never + * shadow a newer registry someone else published). + */ +async function buildAndSignRegistryEvent( + entries: ProjectRegistryEntry[], + prevCreatedAt: number, + nowMs: number, + secret: Uint8Array, +): Promise { + const { finalizeEvent } = await import("nostr-tools/pure"); + const nowUnix = Math.floor(nowMs / 1000); + const snapshot: ProjectRegistrySnapshot = { + version: PROJECT_REGISTRY_SCHEMA_VERSION, + updatedAt: new Date(nowMs).toISOString(), + entries, + }; + return finalizeEvent( + { + kind: PROJECT_REGISTRY_KIND, + created_at: Math.max(nowUnix, prevCreatedAt + 1), + content: serializeProjectRegistry(snapshot), + tags: [ + ["d", PROJECT_REGISTRY_D_TAG], + ["t", PROJECT_REGISTRY_T_TAG], + ["client", "La Crypta Dev"], + ["projects", String(entries.length)], + ], + }, + secret, + ) as unknown as SignedEvent; +} + let lastSyncAttempt = 0; const SYNC_THROTTLE_MS = 5 * 60 * 1000; +/** Serializes ALL registry writers (auto-sync + user claims) so concurrent + * read-modify-writes can't lost-update the single replaceable event. */ +const REGISTRY_WRITE_LOCK = "lock:project-registry"; +const REGISTRY_WRITE_LOCK_TTL = 15; + +/** Max distinct projects one author may register (cheap sybil/DoS brake). */ +const MAX_SLUGS_PER_AUTHOR = 30; + +let curatedIdSet: Set | null = null; +/** True when `id` is a curated (in-tree) project id — these are La Crypta-owned + * and auto-registered by sync; users must never claim them (their canonical URL + * is already the id). */ +export function isCuratedProjectId(id: string): boolean { + if (!curatedIdSet) { + curatedIdSet = new Set(); + for (const h of HACKATHONS) { + for (const p of getHackathonProjects(h.id)) { + curatedIdSet.add(p.id.toLowerCase()); + } + } + for (const p of HOME_PROJECTS) curatedIdSet.add(p.id.toLowerCase()); + } + return curatedIdSet.has(id.toLowerCase()); +} + /** * Register any not-yet-registered projects by publishing an updated registry * event. Call via `after(() => syncProjectRegistry())` from a route handler — @@ -426,6 +513,15 @@ export async function syncProjectRegistry(): Promise { if (now - lastSyncAttempt < SYNC_THROTTLE_MS) return; lastSyncAttempt = now; + // Take the shared registry write lock so a concurrent user slug claim (or a + // sync on another instance) can't lost-update the single replaceable event. + // If someone else holds it, skip — the next throttle window retries. + const locked = await upstashAcquireLock( + REGISTRY_WRITE_LOCK, + REGISTRY_WRITE_LOCK_TTL, + ); + if (!locked) return; + try { // Read-modify-write against the FRESH relay state, never the cached copy: // the cache is stale by design (SWR) and merging over it would drop // entries a concurrent instance just published. @@ -497,30 +593,14 @@ export async function syncProjectRegistry(): Promise { const snapshot = makeSnapshot(additions); const entries = snapshot.entries; - const { decode } = await import("nostr-tools/nip19"); - const { finalizeEvent } = await import("nostr-tools/pure"); - const decoded = decode(nsec); - if (decoded.type !== "nsec") return; - - const signed = finalizeEvent( - { - kind: PROJECT_REGISTRY_KIND, - // Monotonic floor: a replay of this publish can never shadow a newer - // registry someone else published while we were computing. - created_at: Math.max( - nowUnix, - (currentRead.status === "ok" ? currentRead.createdAt : 0) + 1, - ), - content, - tags: [ - ["d", PROJECT_REGISTRY_D_TAG], - ["t", PROJECT_REGISTRY_T_TAG], - ["client", "La Crypta Dev"], - ["projects", String(entries.length)], - ], - }, - decoded.data as Uint8Array, - ) as IncomingEvent; + const secret = await getBackendSecretBytes(); + if (!secret) return; + const signed = await buildAndSignRegistryEvent( + entries, + currentRead.status === "ok" ? currentRead.createdAt : 0, + now, + secret, + ); const ok = await publishToRelays(signed, DEFAULT_RELAYS); if (!ok) { @@ -535,7 +615,199 @@ export async function syncProjectRegistry(): Promise { .map((a) => a.slug) .join(", ")}`, ); + } finally { + await upstashReleaseLock(REGISTRY_WRITE_LOCK); + } } catch (error) { console.warn("[projectRegistry] sync failed", error); } } + +/* ──────────────────── user-initiated slug registration ─────────────────── */ + +export type RegisterSlugResult = + | { status: "ok"; event: SignedEvent; slug: string; changed: boolean } + | { status: "error"; code: number; message: string }; + +/** + * User-initiated slug registration / change. The API route has already + * authenticated the requester and verified they OWN the project; this function + * owns the registry read-modify-write: + * - serialize concurrent writers with a best-effort Upstash lock, + * - fresh-read the registry from relays (never the cached copy), + * - reject a slug already owned by a DIFFERENT project (append-only, no hijack), + * - append the new entry — which is also how a slug CHANGES: the newer entry + * becomes canonical (`buildRegistryState` is latest-wins per id) and the old + * slug stays a redirect alias, + * - sign with LACRYPTA_NSEC, publish server-side, and return the event for the + * client to also republish. + * + * Never throws — returns a typed error the route maps to an HTTP status. + */ +export async function registerUserProjectSlug(input: { + projectId: string; + /** Pre-normalized/validated by the route (see `normalizeRequestedSlug`). */ + slug: string; + requesterPubkey: string; + project: { name: string; hackathon: string | null }; +}): Promise { + const secret = await getBackendSecretBytes(); + if (!secret) { + return { status: "error", code: 503, message: "Registro de URLs no disponible." }; + } + + const locked = await upstashAcquireLock( + REGISTRY_WRITE_LOCK, + REGISTRY_WRITE_LOCK_TTL, + ); + if (!locked) { + return { + status: "error", + code: 409, + message: "Hay otro registro en curso. Probá de nuevo en unos segundos.", + }; + } + + try { + // Fresh relay read + the cached high-water copy. Both are needed to enforce + // the append-only invariant: a stale/partial/empty relay read must NEVER be + // used as the base, or we'd sign a NEWER (monotonic created_at) but SHORTER + // registry that replaces the real one and drops everyone else's entries. + const [currentRead, cachedRegistry] = await Promise.all([ + rawFetchRegistry(), + getProjectRegistryCached(), + ]); + if (currentRead.status === "no-response") { + return { + status: "error", + code: 503, + message: "No se pudo leer el registro (relays sin respuesta).", + }; + } + const currentEntries = + currentRead.status === "ok" ? currentRead.snapshot.entries : []; + if (currentEntries.length < cachedRegistry.entries.length) { + // Fresh base smaller than the high-water copy ⇒ a bad/partial read (or an + // `empty-confirmed` from relays that just don't hold the newest event). + // Bail rather than publish a shrunken registry over the real one. + return { + status: "error", + code: 503, + message: "El registro no está sincronizado. Probá de nuevo en unos segundos.", + }; + } + const prevCreatedAt = + currentRead.status === "ok" ? currentRead.createdAt : 0; + const state = buildRegistryState(currentEntries); + + const idLc = input.projectId.toLowerCase(); + const owner = state.bySlug.get(input.slug); + if (owner && owner.id.toLowerCase() !== idLc) { + return { status: "error", code: 409, message: "Esa URL ya está en uso." }; + } + + const canonical = state.byIdLc.get(idLc); + // Ownership binding: once a project is registered, only its recorded author + // may re-point it. Prevents a stranger who forged a colliding project event + // from re-registering someone else's already-registered slug. + if ( + canonical?.author && + canonical.author !== input.requesterPubkey + ) { + return { + status: "error", + code: 403, + message: "Solo el autor registrado puede cambiar la URL de este proyecto.", + }; + } + + // Cheap per-author brake: cap the number of DISTINCT projects one key may + // register (slug changes to an already-registered project don't count). + const myIds = new Set( + currentEntries + .filter((e) => e.author === input.requesterPubkey) + .map((e) => e.id.toLowerCase()), + ); + if (!myIds.has(idLc) && myIds.size >= MAX_SLUGS_PER_AUTHOR) { + return { + status: "error", + code: 429, + message: "Alcanzaste el máximo de URLs registradas.", + }; + } + + const alreadyCanonical = canonical?.slug === input.slug; + + let entries = currentEntries; + let changed = false; + if (!alreadyCanonical) { + const entry: ProjectRegistryEntry = { + slug: input.slug, + id: input.projectId, + author: input.requesterPubkey, + name: input.project.name.slice(0, MAX_ENTRY_NAME_CHARS), + hackathon: input.project.hackathon ?? null, + registeredAt: Math.floor(Date.now() / 1000), + }; + const candidate = [...currentEntries, entry]; + if (candidate.length > PROJECT_REGISTRY_MAX_ENTRIES) { + return { status: "error", code: 507, message: "El registro está lleno." }; + } + const content = serializeProjectRegistry({ + version: PROJECT_REGISTRY_SCHEMA_VERSION, + updatedAt: new Date().toISOString(), + entries: candidate, + }); + if ( + new TextEncoder().encode(content).length > + PROJECT_REGISTRY_MAX_CONTENT_BYTES + ) { + return { + status: "error", + code: 507, + message: "El registro alcanzó su límite de tamaño.", + }; + } + entries = candidate; + changed = true; + } + // else: the project already has this exact slug as canonical — we still + // re-sign the current entries and republish, which re-propagates the + // registry (useful when the prior event thinly propagated). + + const signed = await buildAndSignRegistryEvent( + entries, + prevCreatedAt, + Date.now(), + secret, + ); + + // Belt-and-suspenders: publish server-side too (the client also + // republishes). Respect the kill switch; a publish failure is non-fatal + // because the client broadcasts the returned event. + if (process.env.REGISTRY_PUBLISH_DISABLED !== "1") { + const ok = await publishToRelays(signed, DEFAULT_RELAYS); + if (!ok) { + console.warn( + "[projectRegistry] user slug claim: no relay accepted the event", + ); + } + } + + // Drop the registry cache so the resolver re-reads the new slug at once. + // The registry is not Upstash-backed, so this is a Next-tier expiry only. + await expireNostrTag(NOSTR_PROJECT_REGISTRY_TAG); + + return { status: "ok", event: signed, slug: input.slug, changed }; + } catch (error) { + console.warn("[projectRegistry] user slug claim failed", error); + return { + status: "error", + code: 500, + message: + error instanceof Error ? error.message : "No se pudo registrar la URL.", + }; + } finally { + await upstashReleaseLock(REGISTRY_WRITE_LOCK); + } +} diff --git a/lib/projectRegistryClient.ts b/lib/projectRegistryClient.ts new file mode 100644 index 0000000..216fecb --- /dev/null +++ b/lib/projectRegistryClient.ts @@ -0,0 +1,100 @@ +"use client"; + +/** + * Client helpers for the user-initiated project-slug registry. Mirrors the + * badge bootstrap flow (`lib/hackathonBadgeClient.ts`): the user signs a + * kind-27235 NIP-98 authorization event, the backend verifies ownership + + * La-Crypta-signs the updated registry event, and the client republishes the + * returned event to the relays with `publishSignedEventsToRelays`. + */ + +import type { SignedEvent } from "./nostrSigner"; +import { projectSlugHref } from "./projectLinks"; + +type SlugRequestSigner = { + pubkey: string; + signEvent: (event: { + kind: number; + created_at: number; + tags: string[][]; + content: string; + pubkey?: string; + }) => Promise; +}; + +export type SlugAvailability = { + available: boolean; + slug: string; + reason?: string; +}; + +/** Check whether `slug` is free (or already owned by `projectId`). */ +export async function checkSlugAvailability( + slug: string, + projectId?: string, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ slug }); + if (projectId) params.set("projectId", projectId); + const res = await fetch(`/api/projects/registry?${params.toString()}`, { + cache: "no-store", + signal, + }); + if (!res.ok) { + return { available: false, slug, reason: "No se pudo verificar la URL." }; + } + return (await res.json()) as SlugAvailability; +} + +export type SlugClaimResult = { + event: SignedEvent; + slug: string; + changed: boolean; + canonicalUrl: string; +}; + +/** + * Request registration of `slug` for `projectId`. Signs the authorization event + * with the user's signer, posts it, and returns the La-Crypta-signed registry + * event for the caller to republish. + */ +export async function requestProjectSlug( + signer: SlugRequestSigner, + { projectId, slug }: { projectId: string; slug: string }, +): Promise { + const request = await signer.signEvent({ + kind: 27235, + pubkey: signer.pubkey, + created_at: Math.floor(Date.now() / 1000), + content: `Registrar URL ${slug} para el proyecto ${projectId}`, + tags: [ + ["u", "/api/projects/registry"], + ["method", "POST"], + ["action", "register-project-slug"], + ["project", projectId], + ["slug", slug], + ], + }); + + const res = await fetch("/api/projects/registry", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request }), + }); + const data = (await res.json().catch(() => ({}))) as { + event?: SignedEvent; + slug?: string; + changed?: boolean; + canonicalUrl?: string; + error?: string; + }; + if (!res.ok || !data.event || !data.slug) { + throw new Error(data.error || "No se pudo registrar la URL."); + } + return { + event: data.event, + slug: data.slug, + changed: data.changed ?? false, + canonicalUrl: data.canonicalUrl ?? projectSlugHref(data.slug), + }; +} diff --git a/lib/projectRegistryContract.ts b/lib/projectRegistryContract.ts index 3545cc2..b174001 100644 --- a/lib/projectRegistryContract.ts +++ b/lib/projectRegistryContract.ts @@ -23,6 +23,72 @@ export const PROJECT_REGISTRY_SCHEMA_VERSION = 1; * size caps (~128 KB on nostr-rs-relay) and silently stop publishing. */ export const PROJECT_REGISTRY_MAX_ENTRIES = 1500; export const PROJECT_REGISTRY_MAX_CONTENT_BYTES = 100_000; +export const PROJECT_REGISTRY_MAX_SLUG_CHARS = 80; +export const PROJECT_REGISTRY_MIN_SLUG_CHARS = 2; + +/** + * Slugs that would collide with a top-level route segment or a reserved word. + * A user-chosen slug is rejected against this set; auto-minted slugs come from + * project names and effectively never hit these, but the guard is cheap. + */ +export const RESERVED_SLUGS = new Set([ + "dashboard", + "api", + "projects", + "project", + "hackathons", + "hackathon", + "hackathones", + "soldados", + "soldado", + "badges", + "badge", + "skills", + "infra", + "dev", + "sitemap", + "robots", + "login", + "logout", + "admin", + "settings", + "new", + "edit", + "proyecto", + "proyectos", +]); + +/** Kebab-case: lowercase alphanumeric segments joined by single hyphens. */ +const SLUG_SHAPE_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * Validate + normalize a user-requested slug. Returns `{ slug }` on success or + * `{ error }` (a Spanish, user-facing reason). Both the availability GET and the + * claim POST go through this so their answers can never diverge. + */ +export function normalizeRequestedSlug( + raw: string, +): { slug: string } | { error: string } { + const slug = (raw ?? "").trim().toLowerCase(); + if (slug.length < PROJECT_REGISTRY_MIN_SLUG_CHARS) { + return { error: "La URL debe tener al menos 2 caracteres." }; + } + if (slug.length > PROJECT_REGISTRY_MAX_SLUG_CHARS) { + return { error: `La URL no puede superar ${PROJECT_REGISTRY_MAX_SLUG_CHARS} caracteres.` }; + } + if (!SLUG_SHAPE_RE.test(slug)) { + return { + error: "Solo minúsculas, números y guiones (sin empezar/terminar en guión).", + }; + } + if (isReservedSlugShape(slug)) { + return { error: "Esa URL tiene una forma reservada." }; + } + if (RESERVED_SLUGS.has(slug)) { + return { error: "Esa URL está reservada." }; + } + return { slug }; +} export type ProjectRegistryEntry = { /** Permanent URL slug (lowercase). Never reassigned to another project. */ diff --git a/lib/projectResolver.ts b/lib/projectResolver.ts index 901caea..6b7243e 100644 --- a/lib/projectResolver.ts +++ b/lib/projectResolver.ts @@ -22,8 +22,8 @@ import { import { PROJECTS as HOME_PROJECTS, type Project as HomeProject } from "./projects"; import { projectMatchesIdentifier } from "./projectIdentity"; import { - getNostrProjectByIdDirect, getNostrSubmissionsSnapshot, + getProjectWithDurableFallback, type CachedNostrProject, } from "./nostrCache"; import { @@ -216,9 +216,14 @@ export async function resolveProjectParam( getNostrSubmissionsSnapshot(), ]); - // 1. Registry slug (the canonical namespace). - const entry = registry.bySlug.get(paramLc) ?? null; - if (entry) { + // 1. Registry slug (the canonical namespace). The matched slug may be an + // alias (a project's earlier slug, kept as a redirect after the owner changed + // it) — canonicalize to the CURRENT entry for that id so old-slug URLs 308 to + // the new slug (`byIdLc` is latest-wins per id; see `buildRegistryState`). + const aliasEntry = registry.bySlug.get(paramLc) ?? null; + if (aliasEntry) { + const entry = + registry.byIdLc.get(aliasEntry.id.toLowerCase()) ?? aliasEntry; const curated = curatedForEntry(entry); const home = homeForEntry(entry); let nostr = entry.author @@ -226,10 +231,16 @@ export async function resolveProjectParam( : findNostrProject(snapshot.projects, entry.id); // A registered community project whose event slipped through the broad // snapshot (thin relay propagation) would otherwise render an empty - // client-scan shell. Resolve it directly by its `#d` id so the canonical - // page renders server-side. Curated-backed entries never need this. + // client-scan shell. Resolve it by its `#d` id — with a durable backend + // fallback so a registered project ALWAYS renders server-side even when the + // live relay scan also misses it. Curated-backed entries never need this. if (!nostr && !curated && !home && UUID_RE.test(entry.id.toLowerCase())) { - nostr = await getNostrProjectByIdDirect(entry.id.toLowerCase()); + // Pass the registry-recorded author so a poisoned cache copy (a stranger's + // colliding event) can never be served at this project's canonical URL. + nostr = await getProjectWithDurableFallback( + entry.id.toLowerCase(), + entry.author, + ); } return resolvedProject(param, registry, entry, curated, home, nostr); } @@ -252,7 +263,7 @@ export async function resolveProjectParam( // `#d` lookup by that id resolves it server-side, deterministically. Skipped // for name-slug / event-id params (a `#d` query would never match them). if (UUID_RE.test(paramLc)) { - const direct = await getNostrProjectByIdDirect(paramLc); + const direct = await getProjectWithDurableFallback(paramLc); if (direct) { return resolvedProject(param, registry, null, null, null, direct); } diff --git a/lib/upstashCache.ts b/lib/upstashCache.ts index 744b6bd..5627af2 100644 --- a/lib/upstashCache.ts +++ b/lib/upstashCache.ts @@ -33,6 +33,15 @@ export const UPSTASH_KEYS = { submissionsSnapshot: "nostr:snapshot:v1", soldiersRanking: "nostr:ranking:v1", projectById: (projectId: string) => `nostr:project:${projectId}:v1`, + /** + * Durable per-project copy for *registered* projects. Unlike `projectById` + * (a short-lived lookup cache that heals not-found fast), this is written + * only when we hold a real project in hand (slug registration, targeted + * refetch, cache warm) and kept for a very long TTL so a thinly-propagated + * registered project (the `/projects/` "not found" class of bug) always + * has a backend copy to render — the relay scan only ever supplies newer data. + */ + projectDurable: (projectId: string) => `nostr:project-durable:${projectId}:v1`, profile: (pubkey: string) => `nostr:profile:${pubkey}:v1`, } as const; @@ -49,6 +58,14 @@ export const UPSTASH_TTL = { /** `days` profiles. */ profile: 60 * 60 * 24, ranking: 60 * 60 * 24, + /** + * Durable per-project copy: a year. Registrations are rare and each entry is + * ~1 KB, so this is effectively permanent — refreshed on every update + * (targeted refetch) and by the warm cron, and only ever overwritten with + * newer data. The long TTL is what guarantees a registered project never + * silently falls back to "not found". + */ + durable: 60 * 60 * 24 * 365, } as const; function usingLocalRelays(): boolean { @@ -172,6 +189,23 @@ export async function upstashReadThrough( return value; } +/** + * Plain read (no producer fallback). Returns the cached value or `null` — a + * miss and a genuinely-absent key are indistinguishable, which is exactly what + * the durable-fallback path wants: "serve the last copy we ever saw, else null". + * Best-effort: any error (or unconfigured cache) reads as a miss. + */ +export async function upstashGet(key: string): Promise { + const redis = getRedis(); + if (!redis) return null; + try { + const hit = await redis.get(namespacedKey(key)); + return hit ?? null; + } catch { + return null; + } +} + /** * Overwrite a key outright. Backs the write-through paths: a route that just * scanned the relays for read-your-writes should refresh the shared cache so @@ -191,6 +225,39 @@ export async function upstashSet( } } +/** + * Best-effort distributed lock (SET NX EX). Serializes the registry + * read-modify-write across serverless instances so two near-simultaneous slug + * claims can't each read the registry, append, and clobber the other's entry. + * + * Returns `true` when the lock was taken OR the cache is unconfigured — without + * Upstash there is no cross-instance contention to guard (single-process dev / + * self-host), and a cache hiccup must never block a legitimate write. So this + * narrows the race window; the fresh-read-before-sign remains the correctness + * backstop. + */ +export async function upstashAcquireLock( + key: string, + ttlSeconds: number, +): Promise { + const redis = getRedis(); + if (!redis) return true; + try { + const res = await redis.set(namespacedKey(key), "1", { + nx: true, + ex: ttlSeconds, + }); + return res === "OK"; + } catch { + return true; + } +} + +/** Release a lock taken with {@link upstashAcquireLock}. */ +export async function upstashReleaseLock(key: string): Promise { + await upstashDelete(key); +} + /** Drop keys so the next read re-produces them. No-op when unconfigured. */ export async function upstashDelete(...keys: string[]): Promise { const redis = getRedis();