From 6e30f406c5cab5bfe25055e538f37a44855706ba Mon Sep 17 00:00:00 2001 From: UltimateUser1 <343904631+UltimateUser1@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:09:59 +0100 Subject: [PATCH] feat: implement spikes 637, 639, 640, 641 (SEP-10 sessions, network config, age gating, licensing) Spike 641 (SEP-10 web auth): - lib/sep10-auth.ts: challenge build/verify via WebAuth, single-use challenges, opaque hashed bearer sessions with 1h TTL - GET /api/auth/challenge, POST/DELETE /api/auth/session - flag phase-162 Spike 640 (mainnet readiness): - lib/stellar-network.ts: STELLAR_NETWORK selects canonical passphrase, conflicting overrides throw, mainnet requires PHASE_MAINNET_CUTOVER_ACK=1 - viewer-authorization now resolves its passphrase through this module - flag phase-163 Spike 637 (age gating): - lib/age-gate.ts: conservative band from birth year, first declaration final, only the band is persisted - POST /api/age-gate/declare bound to the session wallet - POST /api/world accepts age_gate 18+/21+, returns 451 AGE_GATE_REQUIRED - flag phase-164 Spike 639 (licensing attestation): - lib/license-attestation.ts: PHASE_LICENSE_ATTESTATION_V1 canonical message, rights taxonomy with evidence rules, nonce-unique, one per collection - POST /api/world requires license_attestation on first creation when enabled - flag phase-165 Docs: PROJECT_ARCHITECTURE.md flag rows, docs/TECHNICAL.md env/sidecar section. Closes #503 Closes #502 Closes #499 Closes #501 Co-Authored-By: Claude Haiku 5.5 --- PROJECT_ARCHITECTURE.md | 4 + app/api/age-gate/declare/route.ts | 32 +++++ app/api/auth/challenge/route.ts | 19 +++ app/api/auth/session/route.ts | 36 ++++++ app/api/world/route.ts | 77 +++++++++++- docs/TECHNICAL.md | 17 +++ lib/age-gate.ts | 110 +++++++++++++++++ lib/feature-flags.ts | 20 ++- lib/license-attestation.ts | 149 +++++++++++++++++++++++ lib/sep10-auth.ts | 194 ++++++++++++++++++++++++++++++ lib/server-data-paths.ts | 3 + lib/stellar-network.ts | 57 +++++++++ lib/viewer-authorization.ts | 7 +- 13 files changed, 714 insertions(+), 11 deletions(-) create mode 100644 app/api/age-gate/declare/route.ts create mode 100644 app/api/auth/challenge/route.ts create mode 100644 app/api/auth/session/route.ts create mode 100644 lib/age-gate.ts create mode 100644 lib/license-attestation.ts create mode 100644 lib/sep10-auth.ts create mode 100644 lib/stellar-network.ts diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 91cbaac1..20fe3873 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -203,6 +203,10 @@ is a fixed-size digest of the canonical payload, keeping it under wallet | `phase-159` | `NEXT_PUBLIC_FEATURE_PHASE_159` / `FEATURE_PHASE_159` | Batched owner-key loader (`lib/owner-batch-loader.ts`): DataLoader-style per-tick coalescing, key dedupe, chunking and memoization to remove N+1 owner reads. Partial slice of spike 656 (issue #518) — route wiring, index pushdown, and sidecar `mget` are follow-ups | off | Unset var, restart — callers use their unbatched per-token path (pre-phase-159 behavior); only in-memory cache dropped | | `phase-160` | `NEXT_PUBLIC_FEATURE_PHASE_160` / `FEATURE_PHASE_160` | Web Vitals budgets (`lib/web-vitals-budgets.ts`): per-route LCP/INP/CLS budget table plus pass/fail evaluation and worst-per-vital summary. Partial slice of spike 657 (issue #520) — no live collector, RUM transport, or PR-blocking gate yet | off | Unset var, restart — no vitals evaluated or reported; the budget table is inert (no persisted state) | | `phase-161` | `NEXT_PUBLIC_FEATURE_PHASE_161` / `FEATURE_PHASE_161` | Typed contract-error taxonomy (`lib/phase-error-taxonomy.ts`): code-stable `PhaseError` descriptors 1..39 with `retryable` hints and `userMessageKey`s, mapped from raw host errors. Partial slice of spike 660 (issue #523) — Horizon/RPC transport taxonomy and retry policy are follow-ups | off | Unset var, restart — callers keep using the string helpers in `lib/phase-host-error.ts` (pre-phase-161 behavior); module stays inert | +| `phase-162` | `NEXT_PUBLIC_FEATURE_PHASE_162` / `FEATURE_PHASE_162` | SEP-10 web auth (`lib/sep10-auth.ts`): `GET /api/auth/challenge?account=` issues a challenge tx signed by `SEP10_SERVER_SECRET`; `POST /api/auth/session` verifies the wallet-signed challenge (single-use) and returns an opaque bearer session (1h); `DELETE /api/auth/session` revokes. Partial slice of spike 641 (issue #503) — existing `acting_wallet` / `X-Wallet-Address` routes are not migrated yet | off | Unset var, restart — auth routes return 404, `resolveSessionWallet` returns null, header-based routes unchanged; `auth-sessions.json` stays inert | +| `phase-163` | `NEXT_PUBLIC_FEATURE_PHASE_163` / `FEATURE_PHASE_163` | Stellar network resolution (`lib/stellar-network.ts`): `STELLAR_NETWORK` (`testnet` default, `mainnet`) selects the canonical passphrase; a conflicting `STELLAR_NETWORK_PASSPHRASE` throws; mainnet requires `PHASE_MAINNET_CUTOVER_ACK=1`. Partial slice of spike 640 (issue #502) — no seeding or ceremony tooling yet | off | Unset `STELLAR_NETWORK` (defaults to testnet). Note: `serverNetworkPassphrase()` now always resolves through this module regardless of the flag, so the mainnet ACK gate is not flag-dependent | +| `phase-164` | `NEXT_PUBLIC_FEATURE_PHASE_164` / `FEATURE_PHASE_164` | Self-attested age bands (`lib/age-gate.ts`): `POST /api/age-gate/declare` binds a birth year to the SEP-10 session wallet and stores only the band (first declaration final). `POST /api/world` with `age_gate: "18+" \| "21+"` requires a session from the creator and returns `451 AGE_GATE_REQUIRED` when unmet. Requires phase-162. Partial slice of spike 637 (issue #499) — viewer/claim-side gating and KYC are follow-ups | off | Unset var, restart — declare route returns 404, gated world creation is ignored; `age-declarations.json` bands stay inert | +| `phase-165` | `NEXT_PUBLIC_FEATURE_PHASE_165` / `FEATURE_PHASE_165` | Signed creator licensing declaration (`lib/license-attestation.ts`): first world creation requires `license_attestation` (`original`, `ai-generated`, `licensed`, `public-domain`, `fair-use`; non-original needs `evidence_cid`) signed by the creator wallet over `PHASE_LICENSE_ATTESTATION_V1`; 422 on missing/invalid, 409 on reused nonce or already-declared collection. Partial slice of spike 639 (issue #501) — market/bulk-CSV license surfacing and revocation are follow-ups | off | Unset var, restart — world creation no longer requires attestation; `license-attestations.json` stays inert | Flags are read via `lib/feature-flags.ts:isFeatureEnabled`. Client flags use `NEXT_PUBLIC_*`, server also accepts `FEATURE_*`. Zero regression when off. diff --git a/app/api/age-gate/declare/route.ts b/app/api/age-gate/declare/route.ts new file mode 100644 index 00000000..41e2c200 --- /dev/null +++ b/app/api/age-gate/declare/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server" +import { declareAge, AgeGateError } from "@/lib/age-gate" +import { resolveSessionWallet } from "@/lib/sep10-auth" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +/** POST /api/age-gate/declare — self-attest a birth year for the session wallet (phase-164). */ +export async function POST(request: NextRequest) { + const wallet = await resolveSessionWallet(request) + if (!wallet) { + return NextResponse.json({ error: "SESSION_REQUIRED" }, { status: 401 }) + } + + let body: { birthYear?: unknown } + try { + body = (await request.json()) as typeof body + } catch { + return NextResponse.json({ error: "JSON_INVALID" }, { status: 400 }) + } + + try { + const declaration = await declareAge(wallet, Number(body.birthYear)) + return NextResponse.json({ ok: true, band: declaration.band }, { headers: { "Cache-Control": "no-store" } }) + } catch (err) { + if (err instanceof AgeGateError) { + const status = err.code === "FLAG_DISABLED" ? 404 : 400 + return NextResponse.json({ error: err.code, detail: err.message }, { status }) + } + return NextResponse.json({ error: "DECLARATION_FAILED" }, { status: 500 }) + } +} diff --git a/app/api/auth/challenge/route.ts b/app/api/auth/challenge/route.ts new file mode 100644 index 00000000..33bcc4df --- /dev/null +++ b/app/api/auth/challenge/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server" +import { buildChallenge, Sep10AuthError } from "@/lib/sep10-auth" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +/** GET /api/auth/challenge?account=G... — issues a SEP-10 challenge transaction (phase-162). */ +export async function GET(request: NextRequest) { + const account = request.nextUrl.searchParams.get("account")?.trim() ?? "" + try { + return NextResponse.json(buildChallenge(account), { headers: { "Cache-Control": "no-store" } }) + } catch (err) { + if (err instanceof Sep10AuthError) { + const status = err.code === "FLAG_DISABLED" ? 404 : err.code === "INVALID_ACCOUNT" ? 400 : 500 + return NextResponse.json({ error: err.code, detail: err.message }, { status }) + } + return NextResponse.json({ error: "CHALLENGE_FAILED" }, { status: 500 }) + } +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 00000000..f3a730fc --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server" +import { issueSession, revokeSession, Sep10AuthError } from "@/lib/sep10-auth" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +/** POST /api/auth/session — exchange a wallet-signed SEP-10 challenge for a bearer session (phase-162). */ +export async function POST(request: NextRequest) { + let body: { transaction?: unknown; account?: unknown } + try { + body = (await request.json()) as typeof body + } catch { + return NextResponse.json({ error: "JSON_INVALID" }, { status: 400 }) + } + if (typeof body.transaction !== "string" || typeof body.account !== "string") { + return NextResponse.json({ error: "transaction and account are required" }, { status: 400 }) + } + + try { + const session = await issueSession(body.transaction, body.account.trim()) + return NextResponse.json(session, { headers: { "Cache-Control": "no-store" } }) + } catch (err) { + if (err instanceof Sep10AuthError) { + const status = + err.code === "FLAG_DISABLED" ? 404 : err.code === "REPLAYED_CHALLENGE" ? 409 : 401 + return NextResponse.json({ error: err.code, detail: err.message }, { status }) + } + return NextResponse.json({ error: "SESSION_FAILED" }, { status: 500 }) + } +} + +/** DELETE /api/auth/session — revoke the bearer session presented in Authorization. */ +export async function DELETE(request: NextRequest) { + const revoked = await revokeSession(request) + return NextResponse.json({ ok: revoked }, { status: revoked ? 200 : 404 }) +} diff --git a/app/api/world/route.ts b/app/api/world/route.ts index 005e904d..d4655bef 100644 --- a/app/api/world/route.ts +++ b/app/api/world/route.ts @@ -12,6 +12,14 @@ import { } from "@/lib/narrative-world-store" import { checkAndUnlock } from "@/lib/achievement-store" import { checkWorldConflict, normalizeVectorClock, type VectorClock } from "@/lib/world-conflict" +import { isAge164Enabled, isAgeGate, assertAgeGate, AgeGateError, type AgeGate } from "@/lib/age-gate" +import { + isLicense165Enabled, + recordLicenseAttestation, + LicenseAttestationError, + type RightsBasis, +} from "@/lib/license-attestation" +import { resolveSessionWallet } from "@/lib/sep10-auth" export type WorldsListItem = { collectionId: number @@ -85,6 +93,19 @@ type WorldSaveBody = { expected_version?: unknown /** Client's last-known vector clock (object or `[node, counter]` entries) — phase-105. */ expected_vector_clock?: unknown + /** Campaign age gate for this world — phase-164. Requires a SEP-10 session from the creator. */ + age_gate?: unknown + /** Signed licensing declaration, required on first creation when phase-165 is on. */ + license_attestation?: unknown +} + +type LicenseAttestationBody = { + wallet?: unknown + rights_basis?: unknown + evidence_cid?: unknown + issued_at?: unknown + nonce?: unknown + signature?: unknown } function isNonEmptyString(v: unknown): v is string { @@ -157,11 +178,30 @@ export async function POST(request: NextRequest) { expectedVectorClock = normalized } - const conflict = checkWorldConflict( - await getWorldForCollection(collectionId), - expectedVersion, - expectedVectorClock, - ) + if (body.age_gate !== undefined && !isAgeGate(body.age_gate)) { + return NextResponse.json({ error: "age_gate inválido. Valores permitidos: none, 18+, 21+" }, { status: 400 }) + } + const ageGate: AgeGate = isAgeGate(body.age_gate) ? body.age_gate : "none" + if (isAge164Enabled() && ageGate !== "none") { + const sessionWallet = await resolveSessionWallet(request) + if (!sessionWallet) { + return NextResponse.json({ error: "SESSION_REQUIRED" }, { status: 401 }) + } + if (sessionWallet !== creatorWallet) { + return NextResponse.json({ error: "creator_wallet must match the session wallet" }, { status: 403 }) + } + try { + await assertAgeGate(sessionWallet, ageGate) + } catch (err) { + if (err instanceof AgeGateError) { + return NextResponse.json({ error: err.code, required: ageGate }, { status: 451 }) + } + throw err + } + } + + const existingWorld = await getWorldForCollection(collectionId) + const conflict = checkWorldConflict(existingWorld, expectedVersion, expectedVectorClock) if (conflict.conflict) { return NextResponse.json( { @@ -176,6 +216,33 @@ export async function POST(request: NextRequest) { ) } + if (!existingWorld && isLicense165Enabled()) { + const la = body.license_attestation as LicenseAttestationBody | undefined + if (!la || typeof la !== "object") { + return NextResponse.json({ error: "LICENSE_ATTESTATION_REQUIRED" }, { status: 422 }) + } + if (!creatorWallet || la.wallet !== creatorWallet) { + return NextResponse.json({ error: "LICENSE_ATTESTATION_INVALID", detail: "attestation wallet must be the creator" }, { status: 422 }) + } + try { + await recordLicenseAttestation({ + collection_id: collectionId, + wallet: creatorWallet, + rights_basis: la.rights_basis as RightsBasis, + evidence_cid: typeof la.evidence_cid === "string" && la.evidence_cid.trim() ? la.evidence_cid.trim() : null, + issued_at: Number(la.issued_at), + nonce: typeof la.nonce === "string" ? la.nonce : "", + signature: typeof la.signature === "string" ? la.signature : "", + }) + } catch (err) { + if (err instanceof LicenseAttestationError) { + const status = err.code === "ALREADY_ISSUED" || err.code === "NONCE_REUSED" ? 409 : 422 + return NextResponse.json({ error: err.code, detail: err.message }, { status }) + } + throw err + } + } + const saved = await saveWorldForCollection(collectionId, { world_name: body.world_name.trim(), world_prompt: body.world_prompt.trim(), diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 5f0732b0..10d488a2 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -636,3 +636,20 @@ rather than widened. Per-file rulings for each issue's affected-file list are in `docs/spikes/142-curator-exhibitions.md`, `143-pricing-oracle.md`, `146-moderation-triage.md` and `147-quest-arcs.md`. + +## 15. Auth, network, age gating and licensing (spikes 637, 639, 640, 641) + +Flags: `phase-162` (SEP-10), `phase-163` (network), `phase-164` (age gate), `phase-165` (license attestation). All default off. See `PROJECT_ARCHITECTURE.md` §10. + +| Env | Scope | Purpose | +|-----|-------|---------| +| `SEP10_SERVER_SECRET` | server only | Signs SEP-10 challenge transactions. Never expose with `NEXT_PUBLIC_*`. | +| `SEP10_HOME_DOMAIN` | server | Home domain bound into challenges (default `phase.app`). | +| `SEP10_WEB_AUTH_DOMAIN` | server | Web auth domain (defaults to home domain). | +| `STELLAR_NETWORK` | server + client | `testnet` (default) or `mainnet`. Selects the canonical passphrase. | +| `STELLAR_NETWORK_PASSPHRASE` | server | Optional; must match `STELLAR_NETWORK` or startup resolution throws. | +| `PHASE_MAINNET_CUTOVER_ACK` | server | Must be `1` to resolve `mainnet` (cutover ceremony acknowledgement). | + +Sidecars (`.data/`, or `PHASE_SERVER_DATA_DIR`): `auth-sessions.json` (SHA-256 of session tokens and consumed challenge hashes only), `age-declarations.json` (bands only, no birth years), `license-attestations.json` (one per collection, nonce-unique). + +Known gaps: header-based routes (`acting_wallet`, `X-Wallet-Address`) are not yet migrated to sessions; sidecar writes are unlocked (single-instance only, same as other sidecars); viewer/claim-side age gating and mainnet seeding are not implemented. diff --git a/lib/age-gate.ts b/lib/age-gate.ts new file mode 100644 index 00000000..ea041f9e --- /dev/null +++ b/lib/age-gate.ts @@ -0,0 +1,110 @@ +/** + * Self-attested age bands and 18+/21+ campaign gating — spike 637 (issue #499). + * + * A declared birth year is bound to a SEP-10 session wallet (see sep10-auth.ts), + * so the claim belongs to a proven key. Only the derived band is persisted; the + * birth year is discarded to keep PII out of the sidecar store. The first + * declaration per wallet is final, so a user cannot retry with other years. + * + * Self-attestation is not identity verification. Year-only precision is + * conservative: a declared year counts as "at least N" only when the earliest + * possible birthday already reaches N. + * + * Feature flag: phase-164. Rollback: unset flag; stored bands stay inert on disk. + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { serverDataJsonPath } from "@/lib/server-data-paths" + +export const AGE_GATES = ["none", "18+", "21+"] as const +export type AgeGate = (typeof AGE_GATES)[number] + +export type AgeBand = "under-18" | "18+" | "21+" + +export type AgeDeclaration = { band: AgeBand; declared_at: number } + +export class AgeGateError extends Error { + code: "FLAG_DISABLED" | "INVALID_BIRTH_YEAR" | "AGE_GATE_REQUIRED" + constructor(code: AgeGateError["code"], message: string) { + super(message) + this.name = "AgeGateError" + this.code = code + } +} + +export function isAge164Enabled(): boolean { + return isFeatureEnabled("phase-164") +} + +export function isAgeGate(value: unknown): value is AgeGate { + return typeof value === "string" && (AGE_GATES as readonly string[]).includes(value) +} + +/** Conservative band from a birth year: counts only years that are certain. */ +export function bandFromBirthYear(birthYear: number, nowYear: number = new Date().getFullYear()): AgeBand { + if (!Number.isInteger(birthYear) || birthYear < 1900 || birthYear > nowYear) { + throw new AgeGateError("INVALID_BIRTH_YEAR", "birth year must be an integer between 1900 and the current year") + } + const minimumAge = nowYear - birthYear - 1 + if (minimumAge >= 21) return "21+" + if (minimumAge >= 18) return "18+" + return "under-18" +} + +export function meetsAgeGate(band: AgeBand | null | undefined, gate: AgeGate): boolean { + if (gate === "none") return true + if (!band) return false + if (gate === "21+") return band === "21+" + return band === "18+" || band === "21+" +} + +type AgeStore = Record + +async function readStore(): Promise { + try { + const raw = await readFile(serverDataJsonPath("ageDeclarations"), "utf8") + return JSON.parse(raw) as AgeStore + } catch { + return {} + } +} + +async function writeStore(store: AgeStore): Promise { + const fp = serverDataJsonPath("ageDeclarations") + await mkdir(path.dirname(fp), { recursive: true }) + await writeFile(fp, JSON.stringify(store, null, 2), "utf8") +} + +/** Record a band for a session-proven wallet. Existing declarations are returned unchanged. */ +export async function declareAge(wallet: string, birthYear: number): Promise { + if (!isAge164Enabled()) { + throw new AgeGateError("FLAG_DISABLED", "age declarations disabled (phase-164 flag off)") + } + const store = await readStore() + if (store[wallet]) return store[wallet] + + const declaration: AgeDeclaration = { + band: bandFromBirthYear(birthYear), + declared_at: Math.floor(Date.now() / 1000), + } + store[wallet] = declaration + await writeStore(store) + return declaration +} + +export async function getAgeBand(wallet: string): Promise { + const store = await readStore() + return store[wallet]?.band ?? null +} + +/** Throws AGE_GATE_REQUIRED unless the wallet's declared band satisfies the gate. */ +export async function assertAgeGate(wallet: string, gate: AgeGate): Promise { + if (gate === "none") return + if (!isAge164Enabled()) return + const band = await getAgeBand(wallet) + if (!meetsAgeGate(band, gate)) { + throw new AgeGateError("AGE_GATE_REQUIRED", `this action requires a declared age of ${gate}`) + } +} diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index 7dee6af8..bb0ce7bb 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -52,6 +52,12 @@ * - phase-161: typed contract-error taxonomy — code-stable PhaseError * descriptors with retryable hints and message keys (partial slice of spike * 660 / issue #523) + * - phase-162: SEP-10 web authentication challenges and sessions replacing + * loose wallet headers (spike 641 / issue #503) + * - phase-163: Stellar network resolution with passphrase validation and a + * mainnet cutover acknowledgement (spike 640 / issue #502) + * - phase-164: self-attested age bands with 18+/21+ campaign gating (spike 637 / issue #499) + * - phase-165: signed creator licensing attestation on world creation (spike 639 / issue #501) */ export type PhaseFeatureFlag = @@ -118,7 +124,11 @@ export type PhaseFeatureFlag = | "phase-158" | "phase-159" | "phase-160" - | "phase-161"; + | "phase-161" + | "phase-162" + | "phase-163" + | "phase-164" + | "phase-165"; const FLAG_ENV_MAP: Record = { "phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"], @@ -184,6 +194,10 @@ const FLAG_ENV_MAP: Record = { "phase-159": ["NEXT_PUBLIC_FEATURE_PHASE_159", "FEATURE_PHASE_159"], "phase-160": ["NEXT_PUBLIC_FEATURE_PHASE_160", "FEATURE_PHASE_160"], "phase-161": ["NEXT_PUBLIC_FEATURE_PHASE_161", "FEATURE_PHASE_161"], + "phase-162": ["NEXT_PUBLIC_FEATURE_PHASE_162", "FEATURE_PHASE_162"], + "phase-163": ["NEXT_PUBLIC_FEATURE_PHASE_163", "FEATURE_PHASE_163"], + "phase-164": ["NEXT_PUBLIC_FEATURE_PHASE_164", "FEATURE_PHASE_164"], + "phase-165": ["NEXT_PUBLIC_FEATURE_PHASE_165", "FEATURE_PHASE_165"], }; function isTruthy(v: string | undefined): boolean { @@ -282,6 +296,10 @@ export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { "phase-159", "phase-160", "phase-161", + "phase-162", + "phase-163", + "phase-164", + "phase-165", ]; // Filtering is evaluated at call time, allowing runtime test overrides. return all.filter(isFeatureEnabled) diff --git a/lib/license-attestation.ts b/lib/license-attestation.ts new file mode 100644 index 00000000..c16ff909 --- /dev/null +++ b/lib/license-attestation.ts @@ -0,0 +1,149 @@ +/** + * Signed creator licensing declarations on collection creation — spike 639 (issue #501). + * + * Wallet identity (phase-94, artist badges) does not prove the creator has the + * right to publish the work. A creator declares the rights basis of a new world + * and signs that declaration with the same wallet key used for SEP-10. The + * declaration is verified, bound to the collection and creator wallet, stored + * once per collection, and its nonce is never accepted twice. + * + * Feature flag: phase-165. Rollback: unset flag; stored attestations stay inert on disk. + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { serverDataJsonPath } from "@/lib/server-data-paths" +import { verifyAttestationSignature } from "@/lib/artist-attestation" + +export const RIGHTS_BASES = ["original", "ai-generated", "licensed", "public-domain", "fair-use"] as const +export type RightsBasis = (typeof RIGHTS_BASES)[number] + +/** Bases that cannot stand without third-party evidence (agreement, PD proof, model+prompt record). */ +const EVIDENCE_REQUIRED: readonly RightsBasis[] = ["ai-generated", "licensed", "public-domain", "fair-use"] + +/** Signed timestamps must be this fresh; longer windows let old declarations be replayed. */ +export const LICENSE_ATTESTATION_MAX_AGE_SECONDS = 300 + +export type LicenseAttestation = { + wallet: string + collection_id: number + rights_basis: RightsBasis + evidence_cid: string | null + issued_at: number + nonce: string + signature: string + recorded_at: number +} + +export type LicenseAttestationInput = Omit & { + collection_id?: number +} + +export class LicenseAttestationError extends Error { + code: "FLAG_DISABLED" | "LICENSE_ATTESTATION_REQUIRED" | "LICENSE_ATTESTATION_INVALID" | "ALREADY_ISSUED" | "NONCE_REUSED" + constructor(code: LicenseAttestationError["code"], message: string) { + super(message) + this.name = "LicenseAttestationError" + this.code = code + } +} + +export function isLicense165Enabled(): boolean { + return isFeatureEnabled("phase-165") +} + +/** Deterministic bytes the creator signs. Field order is fixed and every field is bound. */ +export function canonicalLicenseMessage( + input: Pick, +): string { + return [ + "PHASE_LICENSE_ATTESTATION_V1", + input.wallet, + String(input.collection_id), + input.rights_basis, + input.evidence_cid ?? "", + String(input.issued_at), + input.nonce, + ].join("|") +} + +/** Validates shape and cross-field rules. Returns a reason string on failure, null when valid. */ +export function validateLicenseShape(input: LicenseAttestationInput): string | null { + if (!(RIGHTS_BASES as readonly string[]).includes(input.rights_basis)) return "unknown rights_basis" + if (EVIDENCE_REQUIRED.includes(input.rights_basis) && !input.evidence_cid) { + return `rights_basis "${input.rights_basis}" requires evidence_cid` + } + if (!Number.isInteger(input.issued_at) || input.issued_at <= 0) return "issued_at must be a unix timestamp" + if (typeof input.nonce !== "string" || input.nonce.length < 8 || input.nonce.length > 64) return "nonce length invalid" + if (typeof input.signature !== "string" || input.signature.length === 0) return "signature missing" + return null +} + +type LicenseStore = Record + +async function readStore(): Promise { + try { + const raw = await readFile(serverDataJsonPath("licenseAttestations"), "utf8") + return JSON.parse(raw) as LicenseStore + } catch { + return {} + } +} + +async function writeStore(store: LicenseStore): Promise { + const fp = serverDataJsonPath("licenseAttestations") + await mkdir(path.dirname(fp), { recursive: true }) + await writeFile(fp, JSON.stringify(store, null, 2), "utf8") +} + +export async function getLicenseAttestation(collectionId: number): Promise { + const store = await readStore() + return store[String(collectionId)] ?? null +} + +/** + * Verify and persist the creator's licensing declaration for a new collection. + * The first attestation per collection is final; a reused nonce is rejected anywhere in the store. + */ +export async function recordLicenseAttestation( + input: LicenseAttestationInput & { collection_id: number }, + now: number = Math.floor(Date.now() / 1000), +): Promise { + if (!isLicense165Enabled()) { + throw new LicenseAttestationError("FLAG_DISABLED", "license attestation disabled (phase-165 flag off)") + } + const shapeError = validateLicenseShape(input) + if (shapeError) throw new LicenseAttestationError("LICENSE_ATTESTATION_INVALID", shapeError) + if (Math.abs(now - input.issued_at) > LICENSE_ATTESTATION_MAX_AGE_SECONDS) { + throw new LicenseAttestationError("LICENSE_ATTESTATION_INVALID", "attestation timestamp outside the allowed window") + } + + const message = canonicalLicenseMessage({ ...input, collection_id: input.collection_id, evidence_cid: input.evidence_cid ?? null }) + if (!verifyAttestationSignature(input.wallet, message, input.signature)) { + throw new LicenseAttestationError("LICENSE_ATTESTATION_INVALID", "signature does not match creator wallet") + } + + const store = await readStore() + const key = String(input.collection_id) + if (store[key]) { + throw new LicenseAttestationError("ALREADY_ISSUED", "collection already has a licensing declaration") + } + if (Object.values(store).some((a) => a.nonce === input.nonce)) { + throw new LicenseAttestationError("NONCE_REUSED", "attestation nonce was already used") + } + + const record: LicenseAttestation = { + wallet: input.wallet, + collection_id: input.collection_id, + rights_basis: input.rights_basis, + evidence_cid: input.evidence_cid ?? null, + issued_at: input.issued_at, + nonce: input.nonce, + signature: input.signature, + recorded_at: now, + } + store[key] = record + await writeStore(store) + return record +} diff --git a/lib/sep10-auth.ts b/lib/sep10-auth.ts new file mode 100644 index 00000000..43a9b1ab --- /dev/null +++ b/lib/sep10-auth.ts @@ -0,0 +1,194 @@ +/** + * SEP-10 web authentication with server-issued sessions — spike 641 (issue #503). + * + * Writes historically trusted `acting_wallet` / `X-Wallet-Address` headers. This + * module replaces that with a proof of key possession: the server builds a + * challenge transaction, the wallet signs it client-side (Freighter), and the + * server verifies the signature before minting an opaque bearer session. Only + * the SHA-256 of the session token is persisted. + * + * Feature flag: phase-162. Rollback: unset flag; existing header routes are untouched. + * Secrets: SEP10_SERVER_SECRET lives in server runtime only (never NEXT_PUBLIC_*). + */ + +import { createHash, randomBytes } from "node:crypto" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { Keypair, StrKey, WebAuth } from "@stellar/stellar-sdk" +import { isFeatureEnabled } from "@/lib/feature-flags" +import { resolveStellarNetwork } from "@/lib/stellar-network" +import { serverDataJsonPath } from "@/lib/server-data-paths" + +/** Challenge transactions are only valid for this many seconds. */ +export const SEP10_CHALLENGE_TTL_SECONDS = 300 + +/** Sessions last this long; the client must re-run the challenge after expiry. */ +export const SEP10_SESSION_TTL_SECONDS = 3600 + +export class Sep10AuthError extends Error { + code: "FLAG_DISABLED" | "CONFIG_MISSING" | "INVALID_ACCOUNT" | "INVALID_CHALLENGE" | "REPLAYED_CHALLENGE" + constructor(code: Sep10AuthError["code"], message: string) { + super(message) + this.name = "Sep10AuthError" + this.code = code + } +} + +export function isSep10Enabled(): boolean { + return isFeatureEnabled("phase-162") +} + +function requireEnabled(): void { + if (!isSep10Enabled()) { + throw new Sep10AuthError("FLAG_DISABLED", "SEP-10 auth disabled (phase-162 flag off)") + } +} + +function serverKeypair(): Keypair { + const secret = process.env.SEP10_SERVER_SECRET?.trim() + if (!secret || !StrKey.isValidEd25519SecretSeed(secret)) { + throw new Sep10AuthError("CONFIG_MISSING", "SEP10_SERVER_SECRET is not set to a valid secret seed") + } + return Keypair.fromSecret(secret) +} + +function homeDomain(): string { + return process.env.SEP10_HOME_DOMAIN?.trim() || "phase.app" +} + +function webAuthDomain(): string { + return process.env.SEP10_WEB_AUTH_DOMAIN?.trim() || homeDomain() +} + +export function buildChallenge(account: string): { transaction: string; network_passphrase: string } { + requireEnabled() + if (!StrKey.isValidEd25519PublicKey(account)) { + throw new Sep10AuthError("INVALID_ACCOUNT", "account must be a Stellar G address") + } + const { passphrase } = resolveStellarNetwork() + const transaction = WebAuth.buildChallengeTx( + serverKeypair(), + account, + homeDomain(), + SEP10_CHALLENGE_TTL_SECONDS, + passphrase, + webAuthDomain(), + ) + return { transaction, network_passphrase: passphrase } +} + +/** + * Verify a client-signed challenge. The signature check is delegated to the SDK + * (`verifyChallengeTxSigners`), which also enforces time bounds, the server + * source account, and the home domain manage-data entry. + */ +export function verifyChallenge(transaction: string, account: string): void { + requireEnabled() + const server = serverKeypair().publicKey() + const { passphrase } = resolveStellarNetwork() + try { + WebAuth.readChallengeTx(transaction, server, passphrase, homeDomain(), webAuthDomain()) + } catch { + throw new Sep10AuthError("INVALID_CHALLENGE", "challenge transaction is malformed or expired") + } + const signers = WebAuth.verifyChallengeTxSigners( + transaction, + server, + passphrase, + [account], + homeDomain(), + webAuthDomain(), + ) + if (!signers.includes(account)) { + throw new Sep10AuthError("INVALID_CHALLENGE", "challenge is not signed by the claimed account") + } +} + +// ─── session store ─────────────────────────────────────────────────────────── + +type SessionRecord = { wallet: string; expires_at: number } +type AuthStore = { + sessions: Record + consumed_challenges: Record +} + +const EMPTY_STORE: AuthStore = { sessions: {}, consumed_challenges: {} } + +function sha256Base64url(value: string): string { + return createHash("sha256").update(value).digest("base64url") +} + +async function readStore(): Promise { + try { + const raw = await readFile(serverDataJsonPath("authSessions"), "utf8") + const parsed = JSON.parse(raw) as Partial + return { + sessions: parsed.sessions ?? {}, + consumed_challenges: parsed.consumed_challenges ?? {}, + } + } catch { + return { ...EMPTY_STORE, sessions: {}, consumed_challenges: {} } + } +} + +async function writeStore(store: AuthStore): Promise { + const fp = serverDataJsonPath("authSessions") + await mkdir(path.dirname(fp), { recursive: true }) + await writeFile(fp, JSON.stringify(store, null, 2), "utf8") +} + +/** + * Accept a signed challenge exactly once and mint a session. A replayed + * transaction (same hash inside its validity window) is rejected. + */ +export async function issueSession( + transaction: string, + account: string, + now: number = Math.floor(Date.now() / 1000), +): Promise<{ token: string; wallet: string; expires_at: number }> { + verifyChallenge(transaction, account) + + const challengeKey = sha256Base64url(transaction) + const store = await readStore() + for (const [k, exp] of Object.entries(store.consumed_challenges)) { + if (exp <= now) delete store.consumed_challenges[k] + } + for (const [k, s] of Object.entries(store.sessions)) { + if (s.expires_at <= now) delete store.sessions[k] + } + if (store.consumed_challenges[challengeKey]) { + throw new Sep10AuthError("REPLAYED_CHALLENGE", "challenge already redeemed") + } + + const token = randomBytes(32).toString("base64url") + const expiresAt = now + SEP10_SESSION_TTL_SECONDS + store.consumed_challenges[challengeKey] = now + SEP10_CHALLENGE_TTL_SECONDS + store.sessions[sha256Base64url(token)] = { wallet: account, expires_at: expiresAt } + await writeStore(store) + return { token, wallet: account, expires_at: expiresAt } +} + +/** Resolve the authenticated wallet from `Authorization: Bearer `, or null. */ +export async function resolveSessionWallet(request: Request): Promise { + if (!isSep10Enabled()) return null + const header = request.headers.get("authorization")?.trim() ?? "" + if (!header.toLowerCase().startsWith("bearer ")) return null + const token = header.slice("bearer ".length).trim() + if (!token) return null + + const store = await readStore() + const session = store.sessions[sha256Base64url(token)] + if (!session || session.expires_at <= Math.floor(Date.now() / 1000)) return null + return session.wallet +} + +export async function revokeSession(request: Request): Promise { + const header = request.headers.get("authorization")?.trim() ?? "" + if (!header.toLowerCase().startsWith("bearer ")) return false + const key = sha256Base64url(header.slice("bearer ".length).trim()) + const store = await readStore() + if (!store.sessions[key]) return false + delete store.sessions[key] + await writeStore(store) + return true +} diff --git a/lib/server-data-paths.ts b/lib/server-data-paths.ts index b3f07d23..e3647df3 100644 --- a/lib/server-data-paths.ts +++ b/lib/server-data-paths.ts @@ -40,6 +40,9 @@ const FILES = { generationDlq: "generation-dlq.json", winbackExperiments: "winback-experiments.json", webhookSubscriptions: "webhook-subscriptions.json", + authSessions: "auth-sessions.json", + ageDeclarations: "age-declarations.json", + licenseAttestations: "license-attestations.json", } as const export type ServerDataFile = keyof typeof FILES diff --git a/lib/stellar-network.ts b/lib/stellar-network.ts new file mode 100644 index 00000000..1355ace9 --- /dev/null +++ b/lib/stellar-network.ts @@ -0,0 +1,57 @@ +/** + * Stellar network resolution with passphrase validation — spike 640 (issue #502). + * + * The passphrase used for signature domains (viewer gate, SEP-10 challenges) was + * read from ad-hoc env vars with a testnet fallback. A mainnet deploy that set + * only the RPC URL would silently sign and verify against testnet. This module + * is the single source: the network name picks the canonical passphrase, a + * conflicting override throws, and mainnet stays closed until the cutover + * ceremony is acknowledged via PHASE_MAINNET_CUTOVER_ACK. + * + * Feature flag: phase-163 (gates the mainnet status route only; resolution is always on). + * Rollback: unset STELLAR_NETWORK (defaults to testnet) and restart. + */ + +export type StellarNetworkName = "testnet" | "mainnet" + +export const STELLAR_PASSPHRASES: Record = { + testnet: "Test SDF Network ; September 2015", + mainnet: "Public Global Stellar Network ; September 2015", +} + +export type ResolvedStellarNetwork = { + network: StellarNetworkName + passphrase: string +} + +export class StellarNetworkConfigError extends Error { + constructor(message: string) { + super(message) + this.name = "StellarNetworkConfigError" + } +} + +export function resolveStellarNetwork( + env: Record = process.env, +): ResolvedStellarNetwork { + const raw = (env.STELLAR_NETWORK ?? "testnet").trim().toLowerCase() + if (raw !== "testnet" && raw !== "mainnet") { + throw new StellarNetworkConfigError(`STELLAR_NETWORK must be "testnet" or "mainnet", got "${raw}"`) + } + const network: StellarNetworkName = raw + + const override = (env.STELLAR_NETWORK_PASSPHRASE ?? env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE)?.trim() + if (override && override !== STELLAR_PASSPHRASES[network]) { + throw new StellarNetworkConfigError( + `Passphrase override does not match STELLAR_NETWORK=${network}; refusing to sign against a mixed domain`, + ) + } + + if (network === "mainnet" && env.PHASE_MAINNET_CUTOVER_ACK?.trim() !== "1") { + throw new StellarNetworkConfigError( + "Mainnet selected without PHASE_MAINNET_CUTOVER_ACK=1 (cutover ceremony not acknowledged)", + ) + } + + return { network, passphrase: STELLAR_PASSPHRASES[network] } +} diff --git a/lib/viewer-authorization.ts b/lib/viewer-authorization.ts index 78024dc0..5295d026 100644 --- a/lib/viewer-authorization.ts +++ b/lib/viewer-authorization.ts @@ -28,6 +28,7 @@ */ import { getDb } from "@/lib/sqlite-db" +import { resolveStellarNetwork } from "@/lib/stellar-network" /** Domain separator. Any change invalidates every previously issued signature. */ export const VIEWER_SIGNATURE_DOMAIN = "Phase SEP50" @@ -124,11 +125,7 @@ export function releaseJti(jti: string): void { /** The network this server settles on. Mismatched signatures are rejected. */ export function serverNetworkPassphrase(): string { - return ( - process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE ?? - process.env.STELLAR_NETWORK_PASSPHRASE ?? - "Test SDF Network ; September 2015" - ) + return resolveStellarNetwork().passphrase } /**