Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions PROJECT_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
32 changes: 32 additions & 0 deletions app/api/age-gate/declare/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
19 changes: 19 additions & 0 deletions app/api/auth/challenge/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
36 changes: 36 additions & 0 deletions app/api/auth/session/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
77 changes: 72 additions & 5 deletions app/api/world/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
{
Expand All @@ -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(),
Expand Down
17 changes: 17 additions & 0 deletions docs/TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
110 changes: 110 additions & 0 deletions lib/age-gate.ts
Original file line number Diff line number Diff line change
@@ -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<string, AgeDeclaration>

async function readStore(): Promise<AgeStore> {
try {
const raw = await readFile(serverDataJsonPath("ageDeclarations"), "utf8")
return JSON.parse(raw) as AgeStore
} catch {
return {}
}
}

async function writeStore(store: AgeStore): Promise<void> {
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<AgeDeclaration> {
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<AgeBand | null> {
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<void> {
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}`)
}
}
20 changes: 19 additions & 1 deletion lib/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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<PhaseFeatureFlag, string[]> = {
"phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"],
Expand Down Expand Up @@ -184,6 +194,10 @@ const FLAG_ENV_MAP: Record<PhaseFeatureFlag, string[]> = {
"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 {
Expand Down Expand Up @@ -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)
Expand Down
Loading