From f0d78a2fa77256fe867c7576bd6dd753942ac7e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 07:28:44 +0000 Subject: [PATCH 1/3] feat(admin): ADMIN_DB staging data layer + binding types (Phase-3 slice 1a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit worker/admin/staging.ts: the D1 layer for staged catalog/content edits over the separate ADMIN_DB (migrations-admin/0001) — list (metadata only, never draft bodies), get, create, update, abandon. Every mutation batches its row with an INSERT into admin_action_audit atomically; the audit detail column carries tokens derived from validated enums only, never free text. Rows age out via a 30-day TTL that slides on update and deliberately not on abandon; there is no hard DELETE. Like roster.ts, every DB failure throws so the routes surface an explicit 5xx. AdminEnv + the worker Env gain the optional ADMIN_DB binding (absent ⇒ the staging routes fail closed with 503). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- worker/admin/staging.ts | 139 ++++++++++++++++++++++++++++++++++++++++ worker/admin/types.ts | 5 ++ worker/index.ts | 1 + 3 files changed, 145 insertions(+) create mode 100644 worker/admin/staging.ts diff --git a/worker/admin/staging.ts b/worker/admin/staging.ts new file mode 100644 index 00000000..cc82d6f2 --- /dev/null +++ b/worker/admin/staging.ts @@ -0,0 +1,139 @@ +/** + * D1 data layer for staged catalog/content edits + the admin ACTION audit + * (migrations-admin/0001_admin_storage.sql — the separate ADMIN_DB, never the + * auth store). Used ONLY by the /api/admin/staging* routes in router.ts. + * + * Like roster.ts, every DB failure here THROWS — the routes surface it as an + * explicit 5xx. A management surface must never render a partial view as if it + * were complete. + * + * INSERT-ONLY AUDIT CONTRACT: admin_action_audit is written with INSERT and + * nothing else — no code path in this repository issues UPDATE or DELETE + * against it (pinned statically by worker/tests/admin-staging.test.ts, the + * same contract worker/tests/admin-roster.test.ts pins for admin_audit). + * Every mutation writes its row and its audit row in one db.batch() so + * neither can land without the other. + * + * Lifecycle this slice: draft → ready → abandoned. 'pr-opened' belongs to the + * future publish slice (which will also run the enforcement engine); nothing + * here can set it. There is no hard DELETE — staging is a workbench, not an + * archive: rows age out via expires_at (a 30-day TTL that slides on update), + * and list/get treat an expired row as absent. + */ +import type { D1Database } from '../auth/cf.ts'; + +export const STAGED_KINDS = ['catalog-entry', 'skill', 'guide'] as const; +export type StagedKind = (typeof STAGED_KINDS)[number]; +/** Everything the state COLUMN may hold. This slice writes only + * draft/ready/abandoned; 'pr-opened' is written by the future publish slice + * but must be readable (and treated as closed) the moment it exists. */ +export type StagedState = 'draft' | 'ready' | 'abandoned' | 'pr-opened'; + +/** 30 days, sliding on every update — abandoned work ages out. */ +export const STAGED_TTL_MS = 30 * 24 * 60 * 60 * 1000; +/** Generous for hand-written MDX, far under D1's row ceiling. */ +export const MAX_CONTENT_BYTES = 256 * 1024; +const SLUG = /^[a-z0-9][a-z0-9-]{0,127}$/; + +export function isValidStagedKind(v: unknown): v is StagedKind { + return typeof v === 'string' && (STAGED_KINDS as readonly string[]).includes(v); +} +export function isValidStagedSlug(v: unknown): v is string { + return typeof v === 'string' && SLUG.test(v); +} +export function contentWithinLimit(v: string): boolean { + return new TextEncoder().encode(v).length <= MAX_CONTENT_BYTES; +} + +/** The list projection deliberately EXCLUDES content — a listing never needs + * draft bodies, and omitting them keeps the response small by construction. */ +export interface StagedEditMeta { + id: string; + created_at: number; + updated_at: number; + author: string; // 'provider:subject' — a public identifier, never a token + kind: StagedKind; + slug: string; + enforcement_status: 'pending' | 'pass' | 'fail'; + state: StagedState; + expires_at: number; +} +export interface StagedEdit extends StagedEditMeta { + content: string; + enforcement_report: string | null; + pr_url: string | null; +} + +const META_COLS = 'id, created_at, updated_at, author, kind, slug, enforcement_status, state, expires_at'; + +/** Non-expired drafts, newest activity first. `author` narrows to one admin's + * own drafts (the role-'admin' view); omit it for the superadmin view. */ +export async function listStagedEdits(db: D1Database, now: number, author?: string): Promise { + const stmt = author + ? db.prepare(`SELECT ${META_COLS} FROM staged_edits WHERE expires_at > ? AND author = ? ORDER BY updated_at DESC`).bind(now, author) + : db.prepare(`SELECT ${META_COLS} FROM staged_edits WHERE expires_at > ? ORDER BY updated_at DESC`).bind(now); + const { results } = await stmt.all(); + return results; +} + +/** One full draft, or null if unknown OR expired (indistinguishable — an aged-out + * row is gone as far as the API is concerned). */ +export async function getStagedEdit(db: D1Database, now: number, id: string): Promise { + return await db + .prepare(`SELECT ${META_COLS}, content, enforcement_report, pr_url FROM staged_edits WHERE id = ? AND expires_at > ?`) + .bind(id, now) + .first(); +} + +/** The audit INSERT every mutation batches with its own write. detail carries a + * static token derived from validated enums only — never free text, never content. */ +function auditInsert( + db: D1Database, + a: { at: number; actor: string; action: string; target: string; detail: string | null }, +) { + return db + .prepare('INSERT INTO admin_action_audit (id, at, actor, action, target, detail) VALUES (?, ?, ?, ?, ?, ?)') + .bind(crypto.randomUUID(), a.at, a.actor, a.action, a.target, a.detail); +} + +/** Insert a new draft + its 'staging.create' audit row atomically. The caller + * has already validated kind/slug/content through the exported validators. */ +export async function createStagedEdit( + db: D1Database, + e: { id: string; author: string; kind: StagedKind; slug: string; content: string; now: number }, +): Promise { + await db.batch([ + db.prepare( + 'INSERT INTO staged_edits (id, created_at, updated_at, author, kind, slug, content, enforcement_status, enforcement_report, state, pr_url, expires_at) ' + + "VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', NULL, 'draft', NULL, ?)", + ).bind(e.id, e.now, e.now, e.author, e.kind, e.slug, e.content, e.now + STAGED_TTL_MS), + auditInsert(db, { at: e.now, actor: e.author, action: 'staging.create', target: e.id, detail: `kind:${e.kind}` }), + ]); +} + +/** Overwrite a draft's mutable fields + its 'staging.update' audit row + * atomically, sliding the TTL. The caller has fetched the row, enforced + * scoping, rejected abandoned drafts, and validated the merged values. */ +export async function updateStagedEdit( + db: D1Database, + e: { id: string; slug: string; content: string; state: 'draft' | 'ready'; actor: string; now: number }, +): Promise { + await db.batch([ + db.prepare('UPDATE staged_edits SET slug = ?, content = ?, state = ?, updated_at = ?, expires_at = ? WHERE id = ?') + .bind(e.slug, e.content, e.state, e.now, e.now + STAGED_TTL_MS, e.id), + auditInsert(db, { at: e.now, actor: e.actor, action: 'staging.update', target: e.id, detail: `state:${e.state}` }), + ]); +} + +/** Mark a draft abandoned + its 'staging.abandon' audit row atomically. The + * row stays until expires_at passes — soft-close, never a hard delete — and + * the TTL deliberately does NOT slide: abandonment starts the clock running out. */ +export async function abandonStagedEdit( + db: D1Database, + e: { id: string; actor: string; now: number }, +): Promise { + await db.batch([ + db.prepare("UPDATE staged_edits SET state = 'abandoned', updated_at = ? WHERE id = ?").bind(e.now, e.id), + auditInsert(db, { at: e.now, actor: e.actor, action: 'staging.abandon', target: e.id, detail: null }), + ]); +} diff --git a/worker/admin/types.ts b/worker/admin/types.ts index 862a4fa5..86dca522 100644 --- a/worker/admin/types.ts +++ b/worker/admin/types.ts @@ -23,6 +23,11 @@ export interface AdminEnv { * AUTH path ⇒ roster treated as empty (file principals unaffected); absent on * the MANAGEMENT routes ⇒ explicit 503. */ DB?: D1Database; + /** Phase-3 admin storage (migrations-admin/0001_admin_storage.sql): staged + * catalog/content edits + the insert-only admin_action_audit. A SEPARATE + * database from the auth store by design. Absent ⇒ the staging routes fail + * closed with an explicit 503. */ + ADMIN_DB?: D1Database; /** Canonical origin: pins the NIP-98 `u` tag + origin checks to config, not Host. */ SITE_URL?: string; /** Shared native rate limiter; admin uses its own buckets (admin-login / admin-elevate). */ diff --git a/worker/index.ts b/worker/index.ts index b1a6bfff..46508d4c 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -69,6 +69,7 @@ export interface Env { // closed when unprovisioned; see worker/admin/* and docs/admin-panel-spec.md. ADMIN_COORD?: DurableObjectNamespace; // per-identity coordinator DO (CSRF + rate-limit) ADMIN_SESSIONS?: KVNamespace; // opaque admin sessions (separate from SESSIONS) + ADMIN_DB?: D1Database; // Phase-3 admin storage (staging + action audit) } const UA = 'wecanjustbuildthings/1.0 (+https://wecanjustbuildthings.dev)'; From 04412511db67a74da79182c1ba9bebf25f48cb22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 07:29:09 +0000 Subject: [PATCH 2/3] feat(admin): /api/admin/staging* routes behind the two-tier gate (Phase-3 slice 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five routes: list (GET), get (GET ?id), create/update/abandon (POST). The superadmin-only management gate generalizes to requireActor(minRole) — one gate, both auth paths (cookie + per-request NIP-98), per-request role re-derivation, CSRF on every mutation, its own admin-staging rate bucket; requireSuperadmin stays as a thin wrapper so the roster routes are unchanged. Scoping: admins operate on their OWN drafts — another author's draft reads as 404, indistinguishable from nonexistent (no oracle). Superadmins list/read/ abandon any draft (janitorial) but can never update another author's words (explicit 403; they can already see the row, so honesty beats a fake 404). Closed drafts (abandoned / future pr-opened) reject mutation with 409. The NIP-98 u-tag for get covers the query string — the id is part of what was signed. Missing ADMIN_DB ⇒ explicit 503; unreachable ⇒ 503, never an empty 200 pretending the store is fine. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- worker/admin/router.ts | 214 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 201 insertions(+), 13 deletions(-) diff --git a/worker/admin/router.ts b/worker/admin/router.ts index 7cdaf39d..e74bd85b 100644 --- a/worker/admin/router.ts +++ b/worker/admin/router.ts @@ -64,6 +64,10 @@ import { resolveAdminSessionId, adminSessionCookie, clearAdminSessionCookie, purgeAdminSessions, type AdminMethod, } from './session.ts'; +import { + listStagedEdits, getStagedEdit, createStagedEdit, updateStagedEdit, abandonStagedEdit, + isValidStagedKind, isValidStagedSlug, contentWithinLimit, +} from './staging.ts'; /** How long a used per-request NIP-98 event id stays burned. Must exceed the * verifier's ±60s acceptance window so an exact replay can never slip in after @@ -299,48 +303,65 @@ function managementConfigured(env: AdminEnv): env is AdminEnv & { return adminConfigured(env) && Boolean(env.DB); } -interface SuperadminActor { actor: string; method: AuditMethod } +interface ActorGate { actor: string; method: AuditMethod; role: AdminRole } + +/** With exactly two tiers, 'superadmin' satisfies everything and 'admin' + * satisfies only an 'admin' floor. */ +const roleSatisfies = (role: AdminRole, minRole: AdminRole) => role === 'superadmin' || role === minRole; /** - * The single auth gate for the management routes: cookie session (role + * The single auth gate for every privileged admin route: cookie session (role * re-derived from file ∪ roster on THIS request; coordinator CSRF for - * mutations) OR per-request NIP-98 (Nostr file superadmins; single-use event - * id — Bluesky superadmins manage via cookie+CSRF only, NIP-98 has no AT-Proto - * analogue). Only a CURRENT 'superadmin' passes; everything short of that gets - * the same generic 401 as any failed authentication — no role oracle. Returns - * a ready-to-send failure Response otherwise. + * mutations) OR per-request NIP-98 (Nostr principals; single-use event id — + * Bluesky principals manage via cookie+CSRF only, NIP-98 has no AT-Proto + * analogue). Only an identity whose CURRENT role meets `minRole` passes; + * everything short of that gets the same generic 401 as any failed + * authentication — no role oracle. Returns a ready-to-send failure Response + * otherwise. */ -async function requireSuperadmin( +async function requireActor( request: Request, env: AdminEnv & { ADMIN_SESSIONS: KVNamespace; ADMIN_COORD: DurableObjectNamespace }, deps: AdminDeps, opts: { path: string; httpMethod: 'GET' | 'POST'; rawBody: string; mutation: boolean }, -): Promise { + minRole: AdminRole, +): Promise { const cookieId = readCookie(request, ADMIN_SESSION_COOKIE); if (cookieId) { const session = await resolveRoledAdmin(request, env, deps); if (!session) return authError(); - if (session.role !== 'superadmin') return authError(); // same generic 401 — no role oracle + if (!roleSatisfies(session.role, minRole)) return authError(); // same generic 401 — no role oracle if (opts.mutation) { const token = request.headers.get('x-admin-csrf') ?? ''; const check = await coord(env.ADMIN_COORD, session.record.method, session.record.subject, '/check', { sessionId: session.id, token }); if (!check || check.status !== 200 || check.data.ok !== true) return authError(403, 'invalid csrf token'); } - return { actor: `${session.record.method}:${session.record.subject}`, method: 'cookie' }; + return { actor: `${session.record.method}:${session.record.subject}`, method: 'cookie', role: session.role }; } const authHeader = request.headers.get('authorization'); if (authHeader) { const proven = await verifyNip98Event(authHeader, opts.rawBody, adminUrl(request, env, opts.path), opts.httpMethod); if (!proven) return authError(); - if ((await resolveAdminRole('nostr', proven.pubkey, env, deps)) !== 'superadmin') return authError(); + const role = await resolveAdminRole('nostr', proven.pubkey, env, deps); + if (!role || !roleSatisfies(role, minRole)) return authError(); if (!(await burnNip98EventId(env.ADMIN_SESSIONS, proven.eventId))) return authError(); - return { actor: `nostr:${proven.pubkey}`, method: 'nip98' }; + return { actor: `nostr:${proven.pubkey}`, method: 'nip98', role }; } return authError(); } +/** The roster-management gate: superadmins only. */ +function requireSuperadmin( + request: Request, + env: AdminEnv & { ADMIN_SESSIONS: KVNamespace; ADMIN_COORD: DurableObjectNamespace }, + deps: AdminDeps, + opts: { path: string; httpMethod: 'GET' | 'POST'; rawBody: string; mutation: boolean }, +): Promise { + return requireActor(request, env, deps, opts, 'superadmin'); +} + /** Parse + normalize a mutation's target principal through the SHARED * validator (allowlist.ts normalizeSubject/isValidSubject — one validator, * not two). Runs only AFTER successful superadmin auth, so there is no @@ -457,6 +478,168 @@ async function adminsRemove(request: Request, env: AdminEnv, deps: AdminDeps): P return authJson({ ok: true, provider: target.provider, subject: target.subject }); } +// ---- Phase 3: staged catalog/content edits (ADMIN_DB) ---- + +/** Env narrowing for the staging routes: core admin bindings PLUS the separate + * Phase-3 ADMIN_DB. Missing ⇒ explicit 503 — a staging surface must never + * pretend to be empty when its store is simply unbound. */ +function stagingConfigured(env: AdminEnv): env is AdminEnv & { + ADMIN_SESSIONS: KVNamespace; ADMIN_COORD: DurableObjectNamespace; ADMIN_DB: D1Database; +} { + return adminConfigured(env) && Boolean(env.ADMIN_DB); +} + +/** GET /api/admin/staging — non-expired drafts: an admin sees their OWN, a + * superadmin sees all (governance view). List rows carry no content bodies. */ +async function stagingList(request: Request, env: AdminEnv, deps: AdminDeps): Promise { + if (!stagingConfigured(env)) return authError(503, 'admin not configured'); + if (await overRateLimit(env.AUTH_RATE_LIMITER, request, 'admin-staging')) return authError(429, 'too many requests'); + const gate = await requireActor(request, env, deps, { + path: '/api/admin/staging', httpMethod: 'GET', rawBody: '', mutation: false, + }, 'admin'); + if (gate instanceof Response) return gate; + try { + const drafts = await listStagedEdits(env.ADMIN_DB, Date.now(), gate.role === 'superadmin' ? undefined : gate.actor); + return authJson({ drafts }); + } catch { + return authError(503, 'staging unavailable'); + } +} + +/** GET /api/admin/staging/get?id=… — one full draft. Another author's draft + * reads as 404 for an admin (no existence oracle); superadmins can read any. */ +async function stagingGet(request: Request, env: AdminEnv, deps: AdminDeps): Promise { + if (!stagingConfigured(env)) return authError(503, 'admin not configured'); + if (await overRateLimit(env.AUTH_RATE_LIMITER, request, 'admin-staging')) return authError(429, 'too many requests'); + const url = new URL(request.url); + // The NIP-98 `u` tag must cover the query string — the id is part of what was signed. + const gate = await requireActor(request, env, deps, { + path: `/api/admin/staging/get${url.search}`, httpMethod: 'GET', rawBody: '', mutation: false, + }, 'admin'); + if (gate instanceof Response) return gate; + const id = url.searchParams.get('id') ?? ''; + if (!id) return authJson({ error: 'invalid id' }, 400); + try { + const draft = await getStagedEdit(env.ADMIN_DB, Date.now(), id); + if (!draft || (gate.role !== 'superadmin' && draft.author !== gate.actor)) return authJson({ error: 'not found' }, 404); + return authJson({ draft }); + } catch { + return authError(503, 'staging unavailable'); + } +} + +/** POST /api/admin/staging/create — new draft owned by the acting admin. + * enforcement_status starts 'pending'; the engine runs in the publish slice. */ +async function stagingCreate(request: Request, env: AdminEnv, deps: AdminDeps): Promise { + if (!stagingConfigured(env)) return authError(503, 'admin not configured'); + if (crossSiteRequest(request, env.SITE_URL)) return authError(403, 'cross-site request rejected'); + if (await overRateLimit(env.AUTH_RATE_LIMITER, request, 'admin-staging')) return authError(429, 'too many requests'); + const rawBody = await request.text(); + const gate = await requireActor(request, env, deps, { + path: '/api/admin/staging/create', httpMethod: 'POST', rawBody, mutation: true, + }, 'admin'); + if (gate instanceof Response) return gate; + let parsed: { kind?: unknown; slug?: unknown; content?: unknown }; + try { + parsed = JSON.parse(rawBody) as typeof parsed; + } catch { + return authJson({ error: 'invalid body' }, 400); + } + if (!isValidStagedKind(parsed.kind)) return authJson({ error: 'invalid kind' }, 400); + if (!isValidStagedSlug(parsed.slug)) return authJson({ error: 'invalid slug' }, 400); + if (typeof parsed.content !== 'string' || parsed.content.trim() === '' || !contentWithinLimit(parsed.content)) { + return authJson({ error: 'invalid content' }, 400); + } + const id = crypto.randomUUID(); + try { + await createStagedEdit(env.ADMIN_DB, { + id, author: gate.actor, kind: parsed.kind, slug: parsed.slug, content: parsed.content, now: Date.now(), + }); + } catch { + return authError(503, 'staging unavailable'); + } + return authJson({ ok: true, id }); +} + +/** POST /api/admin/staging/update — AUTHOR-only, whatever the role: a + * superadmin janitors (list/read/abandon) but never writes someone else's + * words. Closed drafts (abandoned / pr-opened) reject with 409. */ +async function stagingUpdate(request: Request, env: AdminEnv, deps: AdminDeps): Promise { + if (!stagingConfigured(env)) return authError(503, 'admin not configured'); + if (crossSiteRequest(request, env.SITE_URL)) return authError(403, 'cross-site request rejected'); + if (await overRateLimit(env.AUTH_RATE_LIMITER, request, 'admin-staging')) return authError(429, 'too many requests'); + const rawBody = await request.text(); + const gate = await requireActor(request, env, deps, { + path: '/api/admin/staging/update', httpMethod: 'POST', rawBody, mutation: true, + }, 'admin'); + if (gate instanceof Response) return gate; + let parsed: { id?: unknown; slug?: unknown; content?: unknown; state?: unknown }; + try { + parsed = JSON.parse(rawBody) as typeof parsed; + } catch { + return authJson({ error: 'invalid body' }, 400); + } + if (typeof parsed.id !== 'string' || parsed.id === '') return authJson({ error: 'invalid id' }, 400); + if (parsed.slug !== undefined && !isValidStagedSlug(parsed.slug)) return authJson({ error: 'invalid slug' }, 400); + if (parsed.content !== undefined + && (typeof parsed.content !== 'string' || parsed.content.trim() === '' || !contentWithinLimit(parsed.content))) { + return authJson({ error: 'invalid content' }, 400); + } + if (parsed.state !== undefined && parsed.state !== 'draft' && parsed.state !== 'ready') { + return authJson({ error: 'invalid state' }, 400); // abandon has its own route; pr-opened is the publish slice's + } + try { + const existing = await getStagedEdit(env.ADMIN_DB, Date.now(), parsed.id); + if (!existing) return authJson({ error: 'not found' }, 404); + if (existing.author !== gate.actor) { + // Admins get the same 404 as a nonexistent draft (no oracle); a superadmin + // can already see the row, so the honest refusal is explicit. + return gate.role === 'superadmin' ? authJson({ error: 'author-only' }, 403) : authJson({ error: 'not found' }, 404); + } + if (existing.state === 'abandoned' || existing.state === 'pr-opened') return authJson({ error: 'draft is closed' }, 409); + await updateStagedEdit(env.ADMIN_DB, { + id: existing.id, + slug: (parsed.slug as string | undefined) ?? existing.slug, + content: (parsed.content as string | undefined) ?? existing.content, + state: (parsed.state as 'draft' | 'ready' | undefined) ?? (existing.state === 'ready' ? 'ready' : 'draft'), + actor: gate.actor, + now: Date.now(), + }); + } catch { + return authError(503, 'staging unavailable'); + } + return authJson({ ok: true }); +} + +/** POST /api/admin/staging/abandon — soft-close by the author, or by a + * superadmin (janitorial). The row ages out; nothing is hard-deleted. */ +async function stagingAbandon(request: Request, env: AdminEnv, deps: AdminDeps): Promise { + if (!stagingConfigured(env)) return authError(503, 'admin not configured'); + if (crossSiteRequest(request, env.SITE_URL)) return authError(403, 'cross-site request rejected'); + if (await overRateLimit(env.AUTH_RATE_LIMITER, request, 'admin-staging')) return authError(429, 'too many requests'); + const rawBody = await request.text(); + const gate = await requireActor(request, env, deps, { + path: '/api/admin/staging/abandon', httpMethod: 'POST', rawBody, mutation: true, + }, 'admin'); + if (gate instanceof Response) return gate; + let parsed: { id?: unknown }; + try { + parsed = JSON.parse(rawBody) as typeof parsed; + } catch { + return authJson({ error: 'invalid body' }, 400); + } + if (typeof parsed.id !== 'string' || parsed.id === '') return authJson({ error: 'invalid id' }, 400); + try { + const existing = await getStagedEdit(env.ADMIN_DB, Date.now(), parsed.id); + if (!existing || (gate.role !== 'superadmin' && existing.author !== gate.actor)) return authJson({ error: 'not found' }, 404); + if (existing.state === 'abandoned' || existing.state === 'pr-opened') return authJson({ error: 'draft is closed' }, 409); + await abandonStagedEdit(env.ADMIN_DB, { id: existing.id, actor: gate.actor, now: Date.now() }); + } catch { + return authError(503, 'staging unavailable'); + } + return authJson({ ok: true }); +} + export async function routeAdmin( request: Request, env: AdminEnv, @@ -472,6 +655,11 @@ export async function routeAdmin( if (path === '/api/admin/admins' && method === 'GET') return adminsList(request, env, deps); if (path === '/api/admin/admins/add' && method === 'POST') return adminsAdd(request, env, deps); if (path === '/api/admin/admins/remove' && method === 'POST') return adminsRemove(request, env, deps); + if (path === '/api/admin/staging' && method === 'GET') return stagingList(request, env, deps); + if (path === '/api/admin/staging/get' && method === 'GET') return stagingGet(request, env, deps); + if (path === '/api/admin/staging/create' && method === 'POST') return stagingCreate(request, env, deps); + if (path === '/api/admin/staging/update' && method === 'POST') return stagingUpdate(request, env, deps); + if (path === '/api/admin/staging/abandon' && method === 'POST') return stagingAbandon(request, env, deps); // Deny-by-default: unknown paths and wrong methods fail closed. return authJson({ error: 'not found' }, 404); } From e8d703bb0d9a45da55115640a51a01d5f6a84629 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 07:29:09 +0000 Subject: [PATCH 3/3] test(admin): Phase-3 staging journey matrix + insert-only action-audit pin 17 tests pinning the LOOP.md journeys by name: J1 cookie happy path; J2 fails-closed matrix (401 non-member == 401 bad signature, 503 missing AND unreachable ADMIN_DB, 403 CSRF, 403 cross-site, 429 rate-limited, 404 wrong method); J3 author scoping incl. the superadmin update-others 403; J4 validation + lifecycle (400s write zero audit rows; closed drafts 409; expired rows invisible); J5 exact audit rows per mutation + the static tripwire extending the 0002 contract to admin_action_audit across every worker/admin module; J6 per-request NIP-98 with replay burn + u-tag mismatch. Same harness discipline as the other admin suites: in-memory fakes, real schnorr, the real AdminCoordinator, the compile-time deps seam. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LVPyHAtQ1cdK76RpFasafP --- worker/tests/admin-staging.test.ts | 468 +++++++++++++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 worker/tests/admin-staging.test.ts diff --git a/worker/tests/admin-staging.test.ts b/worker/tests/admin-staging.test.ts new file mode 100644 index 00000000..23f4c8ce --- /dev/null +++ b/worker/tests/admin-staging.test.ts @@ -0,0 +1,468 @@ +/** + * Phase-3 staged-edits contract (/api/admin/staging*). Same harness discipline + * as the other admin tests — plain in-memory fakes + node:test, real schnorr + * signatures, the REAL AdminCoordinator behind a hand-fake namespace, and the + * compile-time deps seam for non-empty file fixtures. Journeys pinned by name + * (LOOP.md): + * J1 — cookie happy path: create → list → get → update → ready. + * J2 — fails closed: no auth 401; non-member 401; ADMIN_DB missing 503 / + * unreachable 503; CSRF 403; cross-site 403; rate-limited 429; + * wrong-method 404. + * J3 — scoping: an admin sees/touches only their OWN drafts (cross-author → + * 404, no existence oracle); a superadmin lists/reads/abandons any but + * NEVER updates another author's words (explicit 403). + * J4 — validation + lifecycle: bad kind/slug/content/JSON 400; unknown id + * 404; expired rows invisible; closed drafts reject mutation 409. + * J5 — audit: each mutation appends EXACTLY one admin_action_audit row, + * atomically; admin_action_audit is insert-only (pinned statically). + * J6 — per-request NIP-98: signed create works; exact replay is burned. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { routeAdmin } from '../admin/router.ts'; +import { AdminCoordinator } from '../admin/coordinator.ts'; +import type { AdminAllowlist } from '../admin/allowlist.ts'; +import { MAX_CONTENT_BYTES } from '../admin/staging.ts'; +import { ADMIN_SESSION_COOKIE } from '../admin/session.ts'; +import { sha256Hex } from '../auth/nostr.ts'; +import type { + KVNamespace, D1Database, D1PreparedStatement, D1Result, + DurableObjectNamespace, DurableObjectState, RateLimit, +} from '../auth/cf.ts'; +import type { AdminEnv } from '../admin/types.ts'; + +const ORIGIN = 'https://wecanjustbuildthings.dev'; + +// ---- fakes (same shapes as admin-roster.test.ts, plus the staging SQL) ---- + +function fakeKV(): KVNamespace { + const store = new Map(); + return { + async get(k) { return store.has(k) ? store.get(k)! : null; }, + async put(k, v) { store.set(k, v); }, + async delete(k) { store.delete(k); }, + async list(options) { + const prefix = options?.prefix ?? ''; + return { keys: [...store.keys()].filter((k) => k.startsWith(prefix)).map((name) => ({ name })), list_complete: true }; + }, + }; +} + +interface DraftRow { + id: string; created_at: number; updated_at: number; author: string; + kind: string; slug: string; content: string; + enforcement_status: string; enforcement_report: string | null; + state: string; pr_url: string | null; expires_at: number; +} +interface ActionAuditRow { id: string; at: number; actor: string; action: string; target: string; detail: string | null } + +/** In-memory ADMIN_DB covering the staged_edits + admin_action_audit surface + * staging.ts uses. `drafts`/`audit` are exposed so tests can assert exact rows + * (and force expiry by editing expires_at directly). */ +function fakeStagingD1(): D1Database & { drafts: Map; audit: ActionAuditRow[] } { + const drafts = new Map(); + const audit: ActionAuditRow[] = []; + const metaOf = (r: DraftRow) => ({ + id: r.id, created_at: r.created_at, updated_at: r.updated_at, author: r.author, + kind: r.kind, slug: r.slug, enforcement_status: r.enforcement_status, state: r.state, expires_at: r.expires_at, + }); + const apply = (sql: string, a: unknown[]) => { + if (sql.includes('INTO staged_edits')) { + drafts.set(a[0] as string, { + id: a[0] as string, created_at: a[1] as number, updated_at: a[2] as number, author: a[3] as string, + kind: a[4] as string, slug: a[5] as string, content: a[6] as string, + enforcement_status: 'pending', enforcement_report: null, state: 'draft', pr_url: null, + expires_at: a[7] as number, + }); + return; + } + if (sql.includes("SET state = 'abandoned'")) { + const row = drafts.get(a[1] as string); + if (row) { row.state = 'abandoned'; row.updated_at = a[0] as number; } + return; + } + if (sql.includes('UPDATE staged_edits SET slug')) { + const row = drafts.get(a[5] as string); + if (row) { + row.slug = a[0] as string; row.content = a[1] as string; row.state = a[2] as string; + row.updated_at = a[3] as number; row.expires_at = a[4] as number; + } + return; + } + if (sql.includes('INTO admin_action_audit')) { + audit.push({ + id: a[0] as string, at: a[1] as number, actor: a[2] as string, + action: a[3] as string, target: a[4] as string, detail: (a[5] ?? null) as string | null, + }); + } + }; + const make = (sql: string): D1PreparedStatement => { + let args: unknown[] = []; + const stmt: D1PreparedStatement = { + bind(...a) { args = a; return stmt; }, + async first() { + if (sql.includes('FROM staged_edits WHERE id')) { + const row = drafts.get(args[0] as string); + return ((row && row.expires_at > (args[1] as number) ? row : null) as T | null); + } + return null; + }, + async run(): Promise { apply(sql, args); return { results: [], success: true }; }, + async all(): Promise> { + if (sql.includes('FROM staged_edits WHERE expires_at')) { + let rows = [...drafts.values()].filter((r) => r.expires_at > (args[0] as number)); + if (sql.includes('AND author')) rows = rows.filter((r) => r.author === (args[1] as string)); + rows.sort((x, y) => y.updated_at - x.updated_at); + return { results: rows.map(metaOf) as T[], success: true }; + } + return { results: [], success: true }; + }, + }; + return stmt; + }; + return { prepare: make, async batch(stmts) { for (const s of stmts) await s.run(); return []; }, drafts, audit }; +} + +/** Every call fails — the "ADMIN_DB unreachable" fixture. */ +function throwingD1(): D1Database { + const stmt: D1PreparedStatement = { + bind() { return stmt; }, + async first() { throw new Error('D1 unreachable'); }, + async run(): Promise { throw new Error('D1 unreachable'); }, + async all(): Promise> { throw new Error('D1 unreachable'); }, + }; + return { prepare: () => stmt, async batch() { throw new Error('D1 unreachable'); } }; +} + +function fakeCoordNamespace(): DurableObjectNamespace { + const instances = new Map(); + const instanceFor = (name: string) => { + let inst = instances.get(name); + if (!inst) { + const map = new Map(); + const state: DurableObjectState = { + storage: { + async get(key: string) { return map.get(key) as T | undefined; }, + async put(key: string, value: T) { map.set(key, value); }, + async delete(key: string) { return map.delete(key); }, + }, + async blockConcurrencyWhile(fn: () => Promise) { return fn(); }, + }; + inst = new AdminCoordinator(state, {}); + instances.set(name, inst); + } + return inst; + }; + return { + idFromName: (name: string) => ({ toString: () => name }), + get: (id) => ({ fetch: (request: Request) => instanceFor(id.toString()).fetch(request) }), + }; +} + +const allowAllLimiter: RateLimit = { async limit() { return { success: true }; } }; +const denyAllLimiter: RateLimit = { async limit() { return { success: false }; } }; + +type Env = AdminEnv & { ADMIN_SESSIONS: KVNamespace; ADMIN_DB: ReturnType }; +function fakeEnv(overrides: Partial = {}): Env { + return { + ADMIN_SESSIONS: fakeKV(), + ADMIN_COORD: fakeCoordNamespace(), + ADMIN_DB: fakeStagingD1(), + SITE_URL: ORIGIN, + AUTH_RATE_LIMITER: allowAllLimiter, + ...overrides, + } as Env; +} + +const req = (path: string, init?: RequestInit) => new Request(`${ORIGIN}${path}`, init); +const nowS = () => Math.floor(Date.now() / 1000); + +function tokenFor( + sk: Uint8Array, + o: { url: string; method?: string; payload?: string; createdAt?: number }, +): string { + const tags: string[][] = [['u', o.url], ['method', o.method ?? 'POST']]; + if (o.payload !== undefined) tags.push(['payload', o.payload]); + const event = finalizeEvent({ kind: 27235, created_at: o.createdAt ?? nowS(), content: '', tags }, sk); + return 'Nostr ' + Buffer.from(JSON.stringify(event), 'utf8').toString('base64'); +} + +async function nostrLogin(env: AdminEnv, sk: Uint8Array, deps: Parameters[2]) { + const cRes = await routeAdmin(req('/api/admin/login/nostr/challenge', { method: 'POST' }), env, deps); + assert.equal(cRes.status, 200); + const { challenge } = (await cRes.json()) as { challenge: string }; + const body = JSON.stringify({ challenge }); + const token = tokenFor(sk, { url: `${ORIGIN}/api/admin/login/nostr/verify`, payload: await sha256Hex(body) }); + return routeAdmin(req('/api/admin/login/nostr/verify', { + method: 'POST', body, headers: { authorization: token, 'content-type': 'application/json' }, + }), env, deps); +} + +const sessionIdOf = (res: Response) => /__Host-wcjbt_admin=([0-9a-f]{64})/.exec(res.headers.get('set-cookie') ?? '')?.[1] ?? ''; +const withAdminCookie = (path: string, id: string, init?: RequestInit) => + req(path, { ...init, headers: { ...(init?.headers as Record ?? {}), cookie: `${ADMIN_SESSION_COOKIE}=${id}` } }); + +interface Session { id: string; csrf: string; sk: Uint8Array; pk: string } +async function loginAs(env: AdminEnv, sk: Uint8Array, deps: Parameters[2]): Promise { + const res = await nostrLogin(env, sk, deps); + assert.equal(res.status, 200); + const { csrf } = (await res.json()) as { csrf: string }; + return { id: sessionIdOf(res), csrf, sk, pk: getPublicKey(sk) }; +} + +const mutate = (path: string, s: Session, body: unknown, env: AdminEnv, deps: Parameters[2]) => + routeAdmin(withAdminCookie(path, s.id, { + method: 'POST', body: JSON.stringify(body), headers: { 'x-admin-csrf': s.csrf }, + }), env, deps); +const listVia = (s: Session, env: AdminEnv, deps: Parameters[2]) => + routeAdmin(withAdminCookie('/api/admin/staging', s.id), env, deps); +const getVia = (id: string, s: Session, env: AdminEnv, deps: Parameters[2]) => + routeAdmin(withAdminCookie(`/api/admin/staging/get?id=${encodeURIComponent(id)}`, s.id), env, deps); + +/** File fixture: one superadmin + two file admins (A authors, B is the other author). */ +const SK_SUPER = generateSecretKey(); +const SK_A = generateSecretKey(); +const SK_B = generateSecretKey(); +const SK_OUTSIDER = generateSecretKey(); +const DEPS = { + allowlist: { + nostr: [ + { pubkey: getPublicKey(SK_SUPER), role: 'superadmin' }, + { pubkey: getPublicKey(SK_A), role: 'admin' }, + { pubkey: getPublicKey(SK_B), role: 'admin' }, + ], + bluesky: [], + } as AdminAllowlist, +}; + +const DRAFT = { kind: 'skill', slug: 'my-method', content: '# My method\n\nStep one.' }; + +async function createDraft(s: Session, env: Env, body: unknown = DRAFT): Promise { + const res = await mutate('/api/admin/staging/create', s, body, env, DEPS); + assert.equal(res.status, 200); + const { id } = (await res.json()) as { id: string }; + assert.ok(id); + return id; +} + +// ---- J1: cookie happy path ---- + +test('J1 cookie happy path: create → list → get → update → ready', async () => { + const env = fakeEnv(); + const a = await loginAs(env, SK_A, DEPS); + const id = await createDraft(a, env); + + const list = await listVia(a, env, DEPS); + assert.equal(list.status, 200); + const { drafts } = (await list.json()) as { drafts: Array> }; + assert.equal(drafts.length, 1); + assert.equal(drafts[0]!.id, id); + assert.equal(drafts[0]!.state, 'draft'); + assert.equal(drafts[0]!.enforcement_status, 'pending'); + assert.equal('content' in drafts[0]!, false, 'list rows must not carry draft bodies'); + + const got = await getVia(id, a, env, DEPS); + assert.equal(got.status, 200); + const { draft } = (await got.json()) as { draft: Record }; + assert.equal(draft.content, DRAFT.content); + assert.equal(draft.author, `nostr:${a.pk}`); + + const upd = await mutate('/api/admin/staging/update', a, { id, content: '# v2', state: 'ready' }, env, DEPS); + assert.equal(upd.status, 200); + const after = (await (await getVia(id, a, env, DEPS)).json()) as { draft: Record }; + assert.equal(after.draft.state, 'ready'); + assert.equal(after.draft.content, '# v2'); +}); + +// ---- J2: fails closed ---- + +test('J2 unauthenticated and non-member requests are the same generic 401', async () => { + const env = fakeEnv(); + assert.equal((await routeAdmin(req('/api/admin/staging'), env, DEPS)).status, 401); + // valid signature, key not in the allowlist → same 401 (fails closed, no oracle) + const body = JSON.stringify(DRAFT); + const token = tokenFor(SK_OUTSIDER, { url: `${ORIGIN}/api/admin/staging/create`, payload: await sha256Hex(body) }); + const res = await routeAdmin(req('/api/admin/staging/create', { method: 'POST', body, headers: { authorization: token } }), env, DEPS); + assert.equal(res.status, 401); +}); + +test('J2 missing ADMIN_DB is an explicit 503, before any auth work', async () => { + const env = fakeEnv({ ADMIN_DB: undefined }); + assert.equal((await routeAdmin(req('/api/admin/staging'), env, DEPS)).status, 503); +}); + +test('J2 unreachable ADMIN_DB surfaces 503 on read AND write (never an empty 200)', async () => { + const env = fakeEnv(); + const a = await loginAs(env, SK_A, DEPS); + (env as { ADMIN_DB: D1Database }).ADMIN_DB = throwingD1(); + assert.equal((await listVia(a, env, DEPS)).status, 503); + assert.equal((await mutate('/api/admin/staging/create', a, DRAFT, env, DEPS)).status, 503); +}); + +test('J2 mutations without (or with a wrong) CSRF token are 403', async () => { + const env = fakeEnv(); + const a = await loginAs(env, SK_A, DEPS); + const noToken = await routeAdmin(withAdminCookie('/api/admin/staging/create', a.id, { + method: 'POST', body: JSON.stringify(DRAFT), + }), env, DEPS); + assert.equal(noToken.status, 403); + const badToken = await routeAdmin(withAdminCookie('/api/admin/staging/create', a.id, { + method: 'POST', body: JSON.stringify(DRAFT), headers: { 'x-admin-csrf': 'f'.repeat(64) }, + }), env, DEPS); + assert.equal(badToken.status, 403); +}); + +test('J2 cross-site mutations are rejected before auth', async () => { + const env = fakeEnv(); + const a = await loginAs(env, SK_A, DEPS); + const res = await routeAdmin(withAdminCookie('/api/admin/staging/create', a.id, { + method: 'POST', body: JSON.stringify(DRAFT), headers: { 'x-admin-csrf': a.csrf, origin: 'https://evil.example' }, + }), env, DEPS); + assert.equal(res.status, 403); +}); + +test('J2 rate-limited requests are 429', async () => { + const env = fakeEnv({ AUTH_RATE_LIMITER: denyAllLimiter }); + assert.equal((await routeAdmin(req('/api/admin/staging'), env, DEPS)).status, 429); +}); + +test('J2 wrong methods fail closed with 404', async () => { + const env = fakeEnv(); + assert.equal((await routeAdmin(req('/api/admin/staging', { method: 'POST' }), env, DEPS)).status, 404); + assert.equal((await routeAdmin(req('/api/admin/staging/create'), env, DEPS)).status, 404); + assert.equal((await routeAdmin(req('/api/admin/staging/get', { method: 'POST' }), env, DEPS)).status, 404); +}); + +// ---- J3: scoping ---- + +test('J3 an admin cannot see or touch another author\'s draft — 404, no existence oracle', async () => { + const env = fakeEnv(); + const a = await loginAs(env, SK_A, DEPS); + const b = await loginAs(env, SK_B, DEPS); + const idA = await createDraft(a, env); + assert.equal((await getVia(idA, b, env, DEPS)).status, 404); + assert.equal((await mutate('/api/admin/staging/update', b, { id: idA, content: '# hijack' }, env, DEPS)).status, 404); + assert.equal((await mutate('/api/admin/staging/abandon', b, { id: idA }, env, DEPS)).status, 404); + // and B's own list shows none of A's work + const list = (await (await listVia(b, env, DEPS)).json()) as { drafts: unknown[] }; + assert.equal(list.drafts.length, 0); +}); + +test('J3 a superadmin lists/reads/abandons any draft but can never update another author\'s words', async () => { + const env = fakeEnv(); + const a = await loginAs(env, SK_A, DEPS); + const b = await loginAs(env, SK_B, DEPS); + const sup = await loginAs(env, SK_SUPER, DEPS); + const idA = await createDraft(a, env); + const idB = await createDraft(b, env, { ...DRAFT, slug: 'other-method' }); + + const supList = (await (await listVia(sup, env, DEPS)).json()) as { drafts: Array<{ id: string }> }; + assert.deepEqual(new Set(supList.drafts.map((d) => d.id)), new Set([idA, idB])); + const aList = (await (await listVia(a, env, DEPS)).json()) as { drafts: Array<{ id: string }> }; + assert.deepEqual(aList.drafts.map((d) => d.id), [idA]); + + assert.equal((await getVia(idA, sup, env, DEPS)).status, 200); + assert.equal((await mutate('/api/admin/staging/update', sup, { id: idA, content: '# edit' }, env, DEPS)).status, 403); + assert.equal((await mutate('/api/admin/staging/abandon', sup, { id: idA }, env, DEPS)).status, 200); +}); + +// ---- J4: validation + lifecycle ---- + +test('J4 create validation: kind, slug, content, and JSON shape all gate with 400', async () => { + const env = fakeEnv(); + const a = await loginAs(env, SK_A, DEPS); + const cases: Array> = [ + { ...DRAFT, kind: 'malware' }, + { ...DRAFT, slug: 'UPPER' }, + { ...DRAFT, slug: '../escape' }, + { ...DRAFT, slug: '' }, + { ...DRAFT, content: '' }, + { ...DRAFT, content: ' ' }, + { ...DRAFT, content: 'a'.repeat(MAX_CONTENT_BYTES + 1) }, + ]; + for (const c of cases) { + assert.equal((await mutate('/api/admin/staging/create', a, c, env, DEPS)).status, 400, JSON.stringify(Object.keys(c))); + } + const badJson = await routeAdmin(withAdminCookie('/api/admin/staging/create', a.id, { + method: 'POST', body: 'not json', headers: { 'x-admin-csrf': a.csrf }, + }), env, DEPS); + assert.equal(badJson.status, 400); + assert.equal(env.ADMIN_DB.audit.length, 0, 'no audit rows for rejected mutations'); +}); + +test('J4 update lifecycle: unknown id 404; abandon-via-update 400; closed drafts 409', async () => { + const env = fakeEnv(); + const a = await loginAs(env, SK_A, DEPS); + const id = await createDraft(a, env); + assert.equal((await mutate('/api/admin/staging/update', a, { id: 'nope', content: '# x' }, env, DEPS)).status, 404); + assert.equal((await mutate('/api/admin/staging/update', a, { id, state: 'abandoned' }, env, DEPS)).status, 400); + assert.equal((await mutate('/api/admin/staging/abandon', a, { id }, env, DEPS)).status, 200); + assert.equal((await mutate('/api/admin/staging/update', a, { id, content: '# x' }, env, DEPS)).status, 409); + assert.equal((await mutate('/api/admin/staging/abandon', a, { id }, env, DEPS)).status, 409); +}); + +test('J4 expired drafts are invisible to list AND get', async () => { + const env = fakeEnv(); + const a = await loginAs(env, SK_A, DEPS); + const id = await createDraft(a, env); + env.ADMIN_DB.drafts.get(id)!.expires_at = Date.now() - 1; + const list = (await (await listVia(a, env, DEPS)).json()) as { drafts: unknown[] }; + assert.equal(list.drafts.length, 0); + assert.equal((await getVia(id, a, env, DEPS)).status, 404); +}); + +// ---- J5: audit ---- + +test('J5 every mutation appends exactly one admin_action_audit row with actor, action, target', async () => { + const env = fakeEnv(); + const a = await loginAs(env, SK_A, DEPS); + const id = await createDraft(a, env); + await mutate('/api/admin/staging/update', a, { id, state: 'ready' }, env, DEPS); + await mutate('/api/admin/staging/abandon', a, { id }, env, DEPS); + const actor = `nostr:${a.pk}`; + assert.deepEqual( + env.ADMIN_DB.audit.map(({ actor: act, action, target, detail }) => ({ actor: act, action, target, detail })), + [ + { actor, action: 'staging.create', target: id, detail: 'kind:skill' }, + { actor, action: 'staging.update', target: id, detail: 'state:ready' }, + { actor, action: 'staging.abandon', target: id, detail: null }, + ], + ); + for (const row of env.ADMIN_DB.audit) assert.ok(row.id && row.at > 0); +}); + +test('J5 admin_action_audit is INSERT-only in code: no UPDATE or DELETE against it exists anywhere in the worker', () => { + for (const rel of ['../admin/roster.ts', '../admin/router.ts', '../admin/roles.ts', '../admin/session.ts', '../admin/staging.ts', '../index.ts']) { + const source = readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'); + assert.doesNotMatch(source, /UPDATE\s+admin_action_audit/i, `${rel} must never UPDATE admin_action_audit`); + assert.doesNotMatch(source, /DELETE\s+FROM\s+admin_action_audit/i, `${rel} must never DELETE from admin_action_audit`); + } + const staging = readFileSync(fileURLToPath(new URL('../admin/staging.ts', import.meta.url)), 'utf8'); + assert.match(staging, /INSERT INTO admin_action_audit/, 'the action audit is written by INSERT alone'); +}); + +// ---- J6: per-request NIP-98 ---- + +test('J6 a nostr admin can create via per-request NIP-98; an exact replay is burned', async () => { + const env = fakeEnv(); + const body = JSON.stringify(DRAFT); + const token = tokenFor(SK_A, { url: `${ORIGIN}/api/admin/staging/create`, payload: await sha256Hex(body) }); + const call = () => routeAdmin(req('/api/admin/staging/create', { + method: 'POST', body, headers: { authorization: token, 'content-type': 'application/json' }, + }), env, DEPS); + assert.equal((await call()).status, 200); + assert.equal((await call()).status, 401, 'the event id is single-use'); +}); + +test('J6 NIP-98 list: the signed URL must cover the exact route', async () => { + const env = fakeEnv(); + const token = tokenFor(SK_A, { url: `${ORIGIN}/api/admin/staging`, method: 'GET' }); + const res = await routeAdmin(req('/api/admin/staging', { headers: { authorization: token } }), env, DEPS); + assert.equal(res.status, 200); + // same signature presented against a different admin route → 401 (u-tag mismatch) + const wrong = await routeAdmin(req('/api/admin/whoami', { headers: { authorization: token } }), env, DEPS); + assert.equal(wrong.status, 401); +});