From 2eb584087b6bcf49af068de2f07615db4afa3611 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:03:12 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(admin):=200002=20migration=20=E2=80=94?= =?UTF-8?q?=20runtime=20admin=5Froster=20+=20insert-only=20admin=5Faudit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-tier schema: the committed allowlist stays the file-rooted tier (PR- governed, immutable at runtime); admin_roster holds the runtime-managed ADMIN tier, mutable only by file superadmins via /api/admin/admins (Phase 3); admin_audit records every mutation and is insert-only by contract. Applied locally via migrate:local; remote apply is deliberately deferred (management routes 503 between code deploy and remote apply — fails closed). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc --- migrations/0002_admin_roster.sql | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 migrations/0002_admin_roster.sql diff --git a/migrations/0002_admin_roster.sql b/migrations/0002_admin_roster.sql new file mode 100644 index 00000000..1713937d --- /dev/null +++ b/migrations/0002_admin_roster.sql @@ -0,0 +1,36 @@ +-- 0002_admin_roster.sql — runtime-managed ADMIN tier + insert-only audit. +-- +-- Two-tier model: the committed allowlist (worker/admin/allowlist.ts) is the +-- file-rooted tier — PR-governed, immutable at runtime, and a COMPLETE +-- governance surface on its own (a fork may run its entire admin set through +-- PRs with this roster empty forever). This table holds the runtime-managed +-- ADMIN tier: mutable ONLY by file entries recorded role 'superadmin', via +-- /api/admin/admins, every mutation audit-logged. A subject present in the +-- file must never appear here (the API refuses); at role-resolution time the +-- file always wins. Roster membership grants role 'admin' — NEVER superadmin: +-- there is no runtime write-path to the superadmin set. +-- +-- admin_audit is INSERT-ONLY by contract: no code path issues UPDATE or +-- DELETE against it (worker/tests/admin-roster.test.ts pins this statically). + +CREATE TABLE admin_roster ( + provider TEXT NOT NULL, -- 'nostr' | 'bluesky' + subject TEXT NOT NULL, -- nostr: 64-hex pubkey (lowercase); bluesky: did + added_by TEXT NOT NULL, -- acting superadmin, 'provider:subject' + added_at INTEGER NOT NULL, -- epoch ms + note TEXT, -- optional operator note ("who is this") + PRIMARY KEY (provider, subject) +); + +CREATE TABLE admin_audit ( + id TEXT PRIMARY KEY, -- opaque random id + at INTEGER NOT NULL, -- epoch ms + actor TEXT NOT NULL, -- acting superadmin, 'provider:subject' + action TEXT NOT NULL, -- 'admin.add' | 'admin.remove' + provider TEXT NOT NULL, -- target principal + subject TEXT NOT NULL, + method TEXT NOT NULL, -- 'cookie' | 'nip98' + note TEXT +); + +CREATE INDEX idx_admin_audit_at ON admin_audit(at); From 3c2e357b0f0205b7269f96ab067934404d7e95a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:06:41 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(admin):=20single=20per-request=20role?= =?UTF-8?q?=20resolver=20over=20file=20=E2=88=AA=20roster=20(two=20tiers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveAdminRole (worker/admin/roles.ts) is now the ONE authority every authenticated admin route authorizes through: the committed file first (role honored as recorded — only file 'superadmin' entries will hold roster-mutation rights; the file always wins), then the D1 admin_roster ('admin' only — the roster can never yield superadmin). DB absent/unreachable ⇒ roster treated as EMPTY on this auth path: file principals unaffected, roster admins denied — fails closed, never degraded-open. Rewired login/verify, elevate, whoami (both paths), and logout — closing the landed gap where logout resolved the session without a membership re-check. A valid session id reaching logout is now ALWAYS destroyed; membership failure changes the status code, never leaves the record alive. allowlist.ts: header contract updated to the two-tier model; two additive exports (normalizeSubject — Unicode trim + strip all Cf format chars + lowercase hex, DIDs exact; isValidSubject — the same HEX_PUBKEY/DID rules the matchers enforce). Existing exports untouched; one validator, not two. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc --- worker/admin/allowlist.ts | 56 ++++++++++++++++------- worker/admin/roles.ts | 71 +++++++++++++++++++++++++++++ worker/admin/router.ts | 94 +++++++++++++++++++++------------------ 3 files changed, 162 insertions(+), 59 deletions(-) create mode 100644 worker/admin/roles.ts diff --git a/worker/admin/allowlist.ts b/worker/admin/allowlist.ts index 338fb481..bb8f1920 100644 --- a/worker/admin/allowlist.ts +++ b/worker/admin/allowlist.ts @@ -1,22 +1,29 @@ /** - * The server-side admin allowlist — the single authority on WHO may hold an admin - * session. Checked on every admin request, after (never instead of) cryptographic - * verification of the identity. + * The committed FILE tier of the two-tier admin model — the single authority on + * WHO may govern the admin set itself. Checked on every admin request, after + * (never instead of) cryptographic verification of the identity. * - * FAILS CLOSED. There is no self-registration, no environment override, and no - * dev/test bypass. An identity absent from this list is rejected — including a - * request carrying a perfectly valid signature — and removal revokes any live - * session on its next request (per-request enforcement in worker/admin/router.ts). + * TWO TIERS (role resolution: worker/admin/roles.ts — file first, file wins): + * - THIS FILE: file-rooted principals, PR-governed (add/remove = commit + + * review + deploy) and IMMUTABLE AT RUNTIME — there is NO runtime write-path + * to this set, and the management API refuses file-resident subjects (add → + * 409, remove → 403). An entry's `role` is honored as recorded: only + * 'superadmin' entries may mutate the runtime roster below; a file entry + * recorded 'admin' is an admin with no roster-mutation rights. The file is a + * COMPLETE governance surface on its own — a fork may run its entire admin + * set through PRs with the roster empty forever. + * - RUNTIME ROSTER (D1 `admin_roster`, migrations/0002_admin_roster.sql): + * role 'admin' only — the roster can never yield superadmin. Mutable + * exclusively by file superadmins via /api/admin/admins, every mutation + * written to the insert-only `admin_audit`. * - * GOVERNANCE (the role field is genesis DATA, not a runtime capability switch): - * - role: "superadmin" — governance authority over THIS list, exercised through - * repository merge rights (add/remove admin = PR; revocation = commit + - * deploy). Never an in-app power: there is NO runtime write-path to the admin - * set. Also the highest capability tier when Phases 5–6 (catalog management, - * moderation) differentiate capabilities. - * - role: "admin" — assigned by superadmins via PR. - * TODAY all allowlisted identities pass the same gate; differentiated - * enforcement lands with the features that need it — nothing speculative here. + * FAILS CLOSED. There is no self-registration, no environment override, and no + * dev/test bypass. An identity absent from file ∪ roster is rejected — including + * a request carrying a perfectly valid signature — and removal (commit + deploy, + * or a roster remove) revokes any live session on its next request (per-request + * role re-derivation in worker/admin/router.ts). An absent or unreachable DB + * treats the ROSTER as empty: file principals keep working, roster admins are + * denied — never degraded-open. * * Downstream deployers (this is AGPL software — run your own instance): * constitute your own admin set by editing the two arrays below in your fork. @@ -107,3 +114,20 @@ export function adminRoleFor( if (!isAllowedBlueskyDid(list, subject)) return null; return list.bluesky.find((e) => e.did === subject)?.role ?? null; } + +/** Strip ALL Unicode format characters (category Cf — bidi overrides like U+202A, + * zero-width joiners, word joiners… the invisibles a copy-paste smuggles around + * an identifier), then Unicode-trim; hex pubkeys are lowercased, DIDs are exact + * after the strip. THE single normalizer for admin-principal input — the roster + * management API reuses it verbatim; never write a second implementation. */ +export function normalizeSubject(provider: 'nostr' | 'bluesky', subject: string): string { + const stripped = subject.replace(/\p{Cf}/gu, '').trim(); + return provider === 'nostr' ? stripped.toLowerCase() : stripped; +} + +/** True only for a NORMALIZED subject (see normalizeSubject) that satisfies the + * same rules the file matchers above enforce — HEX_PUBKEY / DID, one validator + * shared by the file tier and the roster management API, never two. */ +export function isValidSubject(provider: 'nostr' | 'bluesky', subject: string): boolean { + return provider === 'nostr' ? HEX_PUBKEY.test(subject) : DID.test(subject); +} diff --git a/worker/admin/roles.ts b/worker/admin/roles.ts new file mode 100644 index 00000000..3f86e340 --- /dev/null +++ b/worker/admin/roles.ts @@ -0,0 +1,71 @@ +/** + * Per-request admin role resolution — THE single authority every authenticated + * admin route authorizes through (worker/admin/router.ts calls nothing else). + * + * Two tiers, resolved in a fixed order: + * 1. The committed file (worker/admin/allowlist.ts) — role honored as + * recorded ('superadmin' | 'admin'). The file is consulted FIRST and + * always wins; runtime state can never change or shadow a file + * principal's role. + * 2. The D1 runtime roster (admin_roster, migrations/0002_admin_roster.sql) + * — role 'admin' only. The roster can NEVER yield 'superadmin': there is + * no runtime path into the superadmin tier, by construction. + * + * Availability asymmetry (deliberate): on this AUTH path a missing or + * unreachable DB binding treats the roster as EMPTY — file principals keep + * working (a D1 outage can't lock the operator out), roster admins are denied. + * Fails closed, never degraded-open. The MANAGEMENT routes do the opposite and + * surface DB unavailability as an explicit 5xx (worker/admin/roster.ts). + * + * The role is DERIVED here on every request and never stored: a session record + * carries only the proven subject + method, so removal — a file commit+deploy + * or a roster remove — is effective on the principal's next request. + */ +import type { AdminEnv } from './types.ts'; +import { + ADMIN_ALLOWLIST, adminRoleFor, normalizeSubject, isValidSubject, + type AdminAllowlist, type AdminRole, +} from './allowlist.ts'; + +/** Compile-time-only test seam (same contract as routeAdmin's deps parameter): + * tests must exercise non-empty file tiers without committing real identities. + * Runtime callers never pass it; no environment value reaches it. */ +export interface AdminDeps { + allowlist: AdminAllowlist; +} + +export const DEFAULT_ADMIN_DEPS: AdminDeps = { allowlist: ADMIN_ALLOWLIST }; + +/** + * The role a proven principal holds RIGHT NOW, or null for a non-member. + * `subject` is normalized (shared normalizer) and validated before either tier + * is consulted, so a malformed subject can never match anything — the same + * malformed-entry immunity the file matchers enforce. + */ +export async function resolveAdminRole( + provider: 'nostr' | 'bluesky', + subject: string, + env: AdminEnv, + deps: AdminDeps, +): Promise { + const normalized = normalizeSubject(provider, subject); + if (!isValidSubject(provider, normalized)) return null; + + // Tier 1 — the committed file, role honored as recorded. Always wins. + const fileRole = adminRoleFor(deps.allowlist, provider, normalized); + if (fileRole) return fileRole; + + // Tier 2 — the runtime roster: 'admin' only, and silently EMPTY when the DB + // binding is absent or the query fails (roster principals fail closed while + // file principals stay unaffected — see the module header). + if (!env.DB) return null; + try { + const row = await env.DB + .prepare('SELECT subject FROM admin_roster WHERE provider = ? AND subject = ?') + .bind(provider, normalized) + .first<{ subject: string }>(); + return row ? 'admin' : null; + } catch { + return null; + } +} diff --git a/worker/admin/router.ts b/worker/admin/router.ts index 2da47d93..739d4d62 100644 --- a/worker/admin/router.ts +++ b/worker/admin/router.ts @@ -12,10 +12,14 @@ * Everything else FAILS CLOSED with 404; missing bindings fail closed with 503. * * Security model: - * - The committed EMPTY allowlist (worker/admin/allowlist.ts) is checked - * server-side after every cryptographic verification. Allowlist rejection and - * verification failure return the SAME generic 401 — an attacker learns - * nothing about which gate stopped them. There is no bypass, no env override. + * - TWO-TIER membership, re-derived per request (worker/admin/roles.ts): the + * committed file (worker/admin/allowlist.ts — superadmins + file admins, + * immutable at runtime) first, then the D1 runtime roster (admins only). + * Checked server-side after every cryptographic verification. Membership + * rejection and verification failure return the SAME generic 401 — an + * attacker learns nothing about which gate stopped them (and no role + * oracle: a non-superadmin probing a management route sees the same 401). + * There is no bypass, no env override. * - Pre-auth throttling: native rate limiter, buckets admin-login/admin-elevate, * keyed on client IP. Post-auth velocity + CSRF: the per-identity ADMIN_COORD * Durable Object, reachable only after verification + allowlist (decision 4). @@ -39,9 +43,8 @@ import { getIdentitySubject } from '../auth/db.ts'; import { overRateLimit, crossSiteRequest } from '../auth/guards.ts'; import type { KVNamespace, DurableObjectNamespace } from '../auth/cf.ts'; import type { AdminEnv } from './types.ts'; -import { - ADMIN_ALLOWLIST, isAllowedNostrPubkey, isAllowedBlueskyDid, type AdminAllowlist, -} from './allowlist.ts'; +import type { AdminRole } from './allowlist.ts'; +import { resolveAdminRole, DEFAULT_ADMIN_DEPS, type AdminDeps } from './roles.ts'; import { ADMIN_SESSION_COOKIE, newAdminSessionId, putAdminSession, destroyAdminSession, resolveAdminSessionId, adminSessionCookie, clearAdminSessionCookie, type AdminMethod, @@ -52,9 +55,7 @@ import { * the guard expires but still inside the window. */ const NIP98_REPLAY_TTL_SECONDS = 150; -export interface AdminDeps { - allowlist: AdminAllowlist; -} +export type { AdminDeps } from './roles.ts'; /** The two bindings every admin auth route needs. SESSIONS/DB are additionally * required by the elevation route and checked there. */ @@ -116,8 +117,9 @@ async function nostrVerify(request: Request, env: AdminEnv, deps: AdminDeps): Pr parsed.challenge ?? '', ); if (!proven) return authError(); - // Allowlist AFTER proof, same generic 401 as a failed proof (no oracle). - if (!isAllowedNostrPubkey(deps.allowlist, proven.pubkey)) return authError(); + // Membership (file ∪ roster) AFTER proof, same generic 401 as a failed proof + // (no oracle). + if (!(await resolveAdminRole('nostr', proven.pubkey, env, deps))) return authError(); return openAdminSession(env, 'nostr', proven.pubkey); } @@ -137,7 +139,7 @@ async function elevateBluesky(request: Request, env: AdminEnv, deps: AdminDeps): } catch { return authError(); } - if (!did || !isAllowedBlueskyDid(deps.allowlist, did)) return authError(); + if (!did || !(await resolveAdminRole('bluesky', did, env, deps))) return authError(); return openAdminSession(env, 'bluesky', did); } @@ -162,51 +164,47 @@ async function openAdminSession( ); } -/** True when a proven subject is STILL a member of the allowlist for its method. */ -function isSubjectAllowed(allowlist: AdminAllowlist, method: AdminMethod, subject: string): boolean { - return method === 'nostr' - ? isAllowedNostrPubkey(allowlist, subject) - : isAllowedBlueskyDid(allowlist, subject); -} - /** * The single per-request enforcement choke point for every COOKIE-authenticated - * admin route. Resolves the session (idle/absolute bounds) AND re-checks that its - * identity is STILL allowlisted — so removing an identity from the committed - * allowlist revokes every live session on its NEXT request, not merely at expiry - * (matching the governance model: revocation = commit + deploy, effective next - * request). A session whose identity has been de-listed is destroyed, not just - * rejected. The NIP-98 API path enforces membership inline (see whoami Path 2); - * this covers the browser path and any future privileged cookie route — route - * privileged reads/writes through this, never through resolveAdminSessionId alone. - * Returns null → caller responds 401. + * admin route. Resolves the session (idle/absolute bounds) AND re-derives the + * identity's role from the CURRENT effective set (file ∪ roster) — so removing a + * principal (a file commit+deploy, or a roster remove) revokes every live + * session on its NEXT request, not merely at expiry. A session whose identity + * has been de-listed is destroyed, not just rejected. The role is derived here + * per request and never stored in the session record. The NIP-98 API path + * enforces membership inline (see whoami Path 2); this covers the browser path + * and every privileged cookie route — route privileged reads/writes through + * this, never through resolveAdminSessionId alone. Returns null → caller + * responds 401. */ -async function resolveAllowlistedAdmin( +async function resolveRoledAdmin( request: Request, env: AdminEnv & { ADMIN_SESSIONS: KVNamespace }, deps: AdminDeps, -): Promise<{ id: string; record: { subject: string; method: AdminMethod } } | null> { +): Promise<{ id: string; record: { subject: string; method: AdminMethod }; role: AdminRole } | null> { const session = await resolveAdminSessionId(env.ADMIN_SESSIONS, readCookie(request, ADMIN_SESSION_COOKIE)); if (!session) return null; const { record } = session; - if (!isSubjectAllowed(deps.allowlist, record.method, record.subject)) { + const role = await resolveAdminRole(record.method, record.subject, env, deps); + if (!role) { await destroyAdminSession(env.ADMIN_SESSIONS, session.id); // de-listed → kill the session return null; } - return session; + return { ...session, role }; } async function whoami(request: Request, env: AdminEnv, deps: AdminDeps): Promise { if (!adminConfigured(env)) return authError(503, 'admin not configured'); - // Path 1 — browser: the admin cookie session, re-checked against the allowlist on - // EVERY request (resolveAllowlistedAdmin) so a revoked identity loses access on its - // next call, not at expiry. The CSRF token rides along so a reloaded tab can still - // make its next state-changing call; the response is same-origin-readable only - // (authJson sets no CORS) and the token is useless without the HttpOnly cookie. + // Path 1 — browser: the admin cookie session, its role re-derived from the + // effective set (file ∪ roster) on EVERY request (resolveRoledAdmin) so a + // revoked identity loses access on its next call, not at expiry. The CSRF token + // rides along so a reloaded tab can still make its next state-changing call; + // the response is same-origin-readable only (authJson sets no CORS) and the + // token is useless without the HttpOnly cookie. const cookieId = readCookie(request, ADMIN_SESSION_COOKIE); if (cookieId) { - const session = await resolveAllowlistedAdmin(request, env, deps); + const session = await resolveRoledAdmin(request, env, deps); if (!session) return authError(); const { record } = session; const csrfRes = await coord(env.ADMIN_COORD, record.method, record.subject, '/csrf', { sessionId: session.id }); @@ -221,7 +219,7 @@ async function whoami(request: Request, env: AdminEnv, deps: AdminDeps): Promise if (authHeader) { const proven = await verifyNip98Event(authHeader, '', adminUrl(request, env, '/api/admin/whoami'), 'GET'); if (!proven) return authError(); - if (!isAllowedNostrPubkey(deps.allowlist, proven.pubkey)) return authError(); + if (!(await resolveAdminRole('nostr', proven.pubkey, env, deps))) return authError(); const replayKey = `nip98:${proven.eventId}`; if ((await env.ADMIN_SESSIONS.get(replayKey)) !== null) return authError(); await env.ADMIN_SESSIONS.put(replayKey, '1', { expirationTtl: NIP98_REPLAY_TTL_SECONDS }); @@ -231,12 +229,22 @@ async function whoami(request: Request, env: AdminEnv, deps: AdminDeps): Promise return authError(); } -async function logout(request: Request, env: AdminEnv): Promise { +async function logout(request: Request, env: AdminEnv, deps: AdminDeps): Promise { if (!adminConfigured(env)) return authError(503, 'admin not configured'); if (crossSiteRequest(request, env.SITE_URL)) return authError(403, 'cross-site request rejected'); const session = await resolveAdminSessionId(env.ADMIN_SESSIONS, readCookie(request, ADMIN_SESSION_COOKIE)); if (!session) return authError(); const { record } = session; + // Per-request role re-derivation, cookie path included — logout is not exempt. + // A valid session id that reaches logout ALWAYS ends destroyed: membership + // failure changes the status code, never leaves the record alive. The + // coordinator is NOT consulted for a de-listed principal (it is only ever + // reachable post-verification + membership); its orphaned CSRF entry is inert + // once the session record is gone. + if (!(await resolveAdminRole(record.method, record.subject, env, deps))) { + await destroyAdminSession(env.ADMIN_SESSIONS, session.id); + return authError(); + } // CSRF: the token minted at login (x-admin-csrf header) must validate against // the per-identity coordinator before the state change. const token = request.headers.get('x-admin-csrf') ?? ''; @@ -250,7 +258,7 @@ async function logout(request: Request, env: AdminEnv): Promise { export async function routeAdmin( request: Request, env: AdminEnv, - deps: AdminDeps = { allowlist: ADMIN_ALLOWLIST }, + deps: AdminDeps = DEFAULT_ADMIN_DEPS, ): Promise { const path = new URL(request.url).pathname; const method = request.method.toUpperCase(); @@ -258,7 +266,7 @@ export async function routeAdmin( if (path === '/api/admin/login/nostr/verify' && method === 'POST') return nostrVerify(request, env, deps); if (path === '/api/admin/elevate/bluesky' && method === 'POST') return elevateBluesky(request, env, deps); if (path === '/api/admin/whoami' && method === 'GET') return whoami(request, env, deps); - if (path === '/api/admin/logout' && method === 'POST') return logout(request, env); + if (path === '/api/admin/logout' && method === 'POST') return logout(request, env, deps); // Deny-by-default: unknown paths and wrong methods fail closed. return authJson({ error: 'not found' }, 404); } From faea1382b586e091155130d092343ab7b63a579d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:11:36 +0000 Subject: [PATCH 3/4] feat(admin): superadmin-only roster management API + insert-only audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three exact-path routes (deny-by-default preserved): GET /api/admin/admins (file principals marked immutable ∪ D1 roster — DB failure is an explicit 503, never a file-only list served as complete), POST /api/admin/admins/add (409 file-resident or duplicate), POST /api/admin/admins/remove (403 file-resident, 404 unknown, best-effort purge of the removed admin's live sessions — the per-request role re-derivation stays authoritative). One auth gate (requireSuperadmin): cookie session with per-request role re-derivation + coordinator CSRF on mutations, or per-request NIP-98 with single-use event ids (Nostr superadmins; Bluesky superadmins manage via cookie+CSRF only). Non-superadmins get the same generic 401 — no role oracle; target validation runs only after successful auth — no pre-auth format oracle. Targets go through the SHARED normalizer/validator (allowlist.ts), npub input decoded via the in-tree nostr-tools nip19 with hex echoed back; notes reuse sanitizeDisplayName. Every mutation writes the roster row and its admin_audit row ('provider:subject' actor, cookie|nip98 method) in one atomic batch; audit is INSERT-only — no UPDATE/DELETE path exists. New AUTH_RATE_LIMITER bucket 'admin-manage' mirrors the existing admin buckets. KVNamespace deliberately widened with cursor-paged list() for the purge; all test fakes extended to match. Dependency delta: zero. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc --- worker/admin/roster.ts | 72 ++++++++++ worker/admin/router.ts | 210 +++++++++++++++++++++++++++++- worker/admin/session.ts | 30 +++++ worker/admin/types.ts | 5 +- worker/auth/cf.ts | 8 ++ worker/tests/admin-auth.test.ts | 4 + worker/tests/auth-bluesky.test.ts | 4 + worker/tests/auth-nostr.test.ts | 4 + worker/tests/auth.test.ts | 4 + 9 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 worker/admin/roster.ts diff --git a/worker/admin/roster.ts b/worker/admin/roster.ts new file mode 100644 index 00000000..45db958e --- /dev/null +++ b/worker/admin/roster.ts @@ -0,0 +1,72 @@ +/** + * D1 data layer for the runtime admin roster + its insert-only audit log + * (migrations/0002_admin_roster.sql). Used ONLY by the superadmin management + * routes in worker/admin/router.ts; per-request AUTH reads go through + * worker/admin/roles.ts instead, which treats a failing DB as an empty roster. + * HERE every DB failure THROWS — the management routes surface it as an + * explicit 5xx and must never render a file-only view as if it were complete + * (the deliberate availability asymmetry of the two-tier design). + * + * INSERT-ONLY AUDIT CONTRACT: admin_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-roster.test.ts). Mutations write + * the roster row and its audit row in one db.batch() so neither can land + * without the other. + */ +import type { D1Database } from '../auth/cf.ts'; + +export type AdminProvider = 'nostr' | 'bluesky'; +/** How the acting superadmin authenticated the mutation (audit column). */ +export type AuditMethod = 'cookie' | 'nip98'; + +export interface RosterEntry { + provider: AdminProvider; + subject: string; + added_by: string; // acting superadmin, 'provider:subject' + added_at: number; // epoch ms + note: string | null; +} + +export async function listRosterEntries(db: D1Database): Promise { + const { results } = await db + .prepare('SELECT provider, subject, added_by, added_at, note FROM admin_roster ORDER BY added_at, provider, subject') + .all(); + return results; +} + +export async function rosterHas(db: D1Database, provider: AdminProvider, subject: string): Promise { + const row = await db + .prepare('SELECT subject FROM admin_roster WHERE provider = ? AND subject = ?') + .bind(provider, subject) + .first<{ subject: string }>(); + return row !== null; +} + +/** Insert a roster row + its 'admin.add' audit row atomically. The caller has + * already normalized/validated the subject (shared validator), verified the + * actor is a file superadmin, and rejected file-resident/duplicate targets. */ +export async function addRosterEntry( + db: D1Database, + entry: { provider: AdminProvider; subject: string; actor: string; method: AuditMethod; note: string | null; now: number }, +): Promise { + await db.batch([ + db.prepare('INSERT INTO admin_roster (provider, subject, added_by, added_at, note) VALUES (?, ?, ?, ?, ?)') + .bind(entry.provider, entry.subject, entry.actor, entry.now, entry.note), + db.prepare('INSERT INTO admin_audit (id, at, actor, action, provider, subject, method, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + .bind(crypto.randomUUID(), entry.now, entry.actor, 'admin.add', entry.provider, entry.subject, entry.method, entry.note), + ]); +} + +/** Delete a roster row + write its 'admin.remove' audit row atomically. Only + * the roster row is ever deleted — the audit trail is append-only history. */ +export async function removeRosterEntry( + db: D1Database, + entry: { provider: AdminProvider; subject: string; actor: string; method: AuditMethod; now: number }, +): Promise { + await db.batch([ + db.prepare('DELETE FROM admin_roster WHERE provider = ? AND subject = ?') + .bind(entry.provider, entry.subject), + db.prepare('INSERT INTO admin_audit (id, at, actor, action, provider, subject, method, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + .bind(crypto.randomUUID(), entry.now, entry.actor, 'admin.remove', entry.provider, entry.subject, entry.method, null), + ]); +} diff --git a/worker/admin/router.ts b/worker/admin/router.ts index 739d4d62..4b76cc15 100644 --- a/worker/admin/router.ts +++ b/worker/admin/router.ts @@ -9,6 +9,13 @@ * GET /api/admin/whoami → { identity, method } via cookie session OR * per-request NIP-98 (API clients) * POST /api/admin/logout → CSRF-checked session destruction + * Phase 3 (two-tier roster management — SUPERADMIN only, cookie+CSRF or + * per-request NIP-98; Bluesky superadmins manage via cookie+CSRF only): + * GET /api/admin/admins → file principals (immutable) ∪ D1 roster + * POST /api/admin/admins/add → roster add (409 file-resident/duplicate) + * POST /api/admin/admins/remove → roster remove (403 file-resident, + * 404 unknown) + best-effort session purge + * Every roster mutation writes the insert-only admin_audit with actor + method. * Everything else FAILS CLOSED with 404; missing bindings fail closed with 503. * * Security model: @@ -36,18 +43,26 @@ * never pass it and no environment value reaches it, so it cannot act as a * runtime bypass. */ +import { decode as nip19Decode } from 'nostr-tools/nip19'; import { authJson, authError } from '../auth/respond.ts'; -import { issueChallenge, verifyNostrAuth, verifyNip98Event } from '../auth/nostr.ts'; +import { issueChallenge, verifyNostrAuth, verifyNip98Event, sanitizeDisplayName } from '../auth/nostr.ts'; import { resolveSession, readCookie } from '../auth/session.ts'; import { getIdentitySubject } from '../auth/db.ts'; import { overRateLimit, crossSiteRequest } from '../auth/guards.ts'; -import type { KVNamespace, DurableObjectNamespace } from '../auth/cf.ts'; +import type { KVNamespace, D1Database, DurableObjectNamespace } from '../auth/cf.ts'; import type { AdminEnv } from './types.ts'; -import type { AdminRole } from './allowlist.ts'; +import { + adminRoleFor, normalizeSubject, isValidSubject, + isWellFormedNostrEntry, isWellFormedBlueskyEntry, type AdminRole, +} from './allowlist.ts'; import { resolveAdminRole, DEFAULT_ADMIN_DEPS, type AdminDeps } from './roles.ts'; +import { + listRosterEntries, rosterHas, addRosterEntry, removeRosterEntry, type AdminProvider, type AuditMethod, +} from './roster.ts'; import { ADMIN_SESSION_COOKIE, newAdminSessionId, putAdminSession, destroyAdminSession, - resolveAdminSessionId, adminSessionCookie, clearAdminSessionCookie, type AdminMethod, + resolveAdminSessionId, adminSessionCookie, clearAdminSessionCookie, purgeAdminSessions, + type AdminMethod, } from './session.ts'; /** How long a used per-request NIP-98 event id stays burned. Must exceed the @@ -72,6 +87,16 @@ function adminUrl(request: Request, env: AdminEnv, path: string): string { return `${origin}${path}`; } +/** Single-use NIP-98 event ids (THE replay gate for every per-request-signed + * route): false when the id was already seen, else burns it for longer than + * the verifier's ±60s acceptance window and returns true. */ +async function burnNip98EventId(kv: KVNamespace, eventId: string): Promise { + const replayKey = `nip98:${eventId}`; + if ((await kv.get(replayKey)) !== null) return false; + await kv.put(replayKey, '1', { expirationTtl: NIP98_REPLAY_TTL_SECONDS }); + return true; +} + // ---- per-identity coordinator RPC (post-verification only; see coordinator.ts) ---- async function coord( @@ -220,9 +245,7 @@ async function whoami(request: Request, env: AdminEnv, deps: AdminDeps): Promise const proven = await verifyNip98Event(authHeader, '', adminUrl(request, env, '/api/admin/whoami'), 'GET'); if (!proven) return authError(); if (!(await resolveAdminRole('nostr', proven.pubkey, env, deps))) return authError(); - const replayKey = `nip98:${proven.eventId}`; - if ((await env.ADMIN_SESSIONS.get(replayKey)) !== null) return authError(); - await env.ADMIN_SESSIONS.put(replayKey, '1', { expirationTtl: NIP98_REPLAY_TTL_SECONDS }); + if (!(await burnNip98EventId(env.ADMIN_SESSIONS, proven.eventId))) return authError(); return authJson({ identity: proven.pubkey, method: 'nostr' }); } @@ -255,6 +278,176 @@ async function logout(request: Request, env: AdminEnv, deps: AdminDeps): Promise return authJson({ ok: true }, 200, { 'set-cookie': clearAdminSessionCookie() }); } +// ---- Phase 3: superadmin roster management ---- + +/** Env narrowing for the management routes: the core admin bindings PLUS the D1 + * DB the roster lives in. Here a missing DB is an explicit 503 — the inverse + * of the auth path's silent-empty roster (worker/admin/roles.ts) — because a + * management surface must never render a file-only view as if complete. */ +function managementConfigured(env: AdminEnv): env is AdminEnv & { + ADMIN_SESSIONS: KVNamespace; ADMIN_COORD: DurableObjectNamespace; DB: D1Database; +} { + return adminConfigured(env) && Boolean(env.DB); +} + +interface SuperadminActor { actor: string; method: AuditMethod } + +/** + * The single auth gate for the management routes: 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. + */ +async 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 { + 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 (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' }; + } + + 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(); + if (!(await burnNip98EventId(env.ADMIN_SESSIONS, proven.eventId))) return authError(); + return { actor: `nostr:${proven.pubkey}`, method: 'nip98' }; + } + + return authError(); +} + +/** 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 + * pre-auth format oracle. Accepts npub input for Nostr (decoded with the + * in-tree nostr-tools nip19) — the success response echoes the hex. */ +function parseTargetPrincipal(body: { provider?: unknown; subject?: unknown }): + { provider: AdminProvider; subject: string } | null { + const provider = body.provider; + if (provider !== 'nostr' && provider !== 'bluesky') return null; + if (typeof body.subject !== 'string') return null; + let subject = normalizeSubject(provider, body.subject); + if (provider === 'nostr' && subject.startsWith('npub1')) { + try { + const decoded = nip19Decode(subject); + if (decoded.type !== 'npub') return null; + subject = normalizeSubject('nostr', decoded.data); + } catch { + return null; // malformed npub + } + } + return isValidSubject(provider, subject) ? { provider, subject } : null; +} + +async function adminsList(request: Request, env: AdminEnv, deps: AdminDeps): Promise { + if (!managementConfigured(env)) return authError(503, 'admin not configured'); + if (await overRateLimit(env.AUTH_RATE_LIMITER, request, 'admin-manage')) return authError(429, 'too many requests'); + const gate = await requireSuperadmin(request, env, deps, { + path: '/api/admin/admins', httpMethod: 'GET', rawBody: '', mutation: false, + }); + if (gate instanceof Response) return gate; + let roster; + try { + roster = await listRosterEntries(env.DB); + } catch { + return authError(503, 'roster unavailable'); // explicit — never a file-only list served as complete + } + const file = [ + ...deps.allowlist.nostr.filter(isWellFormedNostrEntry).map((e) => ({ + provider: 'nostr' as const, subject: e.pubkey.toLowerCase(), role: e.role, source: 'file' as const, immutable: true, + })), + ...deps.allowlist.bluesky.filter(isWellFormedBlueskyEntry).map((e) => ({ + provider: 'bluesky' as const, subject: e.did, role: e.role, source: 'file' as const, immutable: true, + })), + ]; + const runtime = roster.map((e) => ({ + provider: e.provider, subject: e.subject, role: 'admin' as const, source: 'roster' as const, + added_by: e.added_by, added_at: e.added_at, ...(e.note ? { note: e.note } : {}), + })); + return authJson({ admins: [...file, ...runtime] }); +} + +async function adminsAdd(request: Request, env: AdminEnv, deps: AdminDeps): Promise { + if (!managementConfigured(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-manage')) return authError(429, 'too many requests'); + const rawBody = await request.text(); + const gate = await requireSuperadmin(request, env, deps, { + path: '/api/admin/admins/add', httpMethod: 'POST', rawBody, mutation: true, + }); + if (gate instanceof Response) return gate; + let parsed: { provider?: unknown; subject?: unknown; note?: unknown }; + try { + parsed = JSON.parse(rawBody) as typeof parsed; + } catch { + return authJson({ error: 'invalid body' }, 400); + } + const target = parseTargetPrincipal(parsed); + if (!target) return authJson({ error: 'invalid principal' }, 400); + // File principals are IMMUTABLE at runtime: adding one (whatever its recorded + // role) is a conflict with the PR-governed tier, never a roster write. + if (adminRoleFor(deps.allowlist, target.provider, target.subject)) { + return authJson({ error: 'file-resident principal (PR-governed)' }, 409); + } + const note = sanitizeDisplayName(typeof parsed.note === 'string' ? parsed.note : null); + try { + if (await rosterHas(env.DB, target.provider, target.subject)) return authJson({ error: 'already an admin' }, 409); + await addRosterEntry(env.DB, { ...target, actor: gate.actor, method: gate.method, note, now: Date.now() }); + } catch { + return authError(503, 'roster unavailable'); + } + return authJson({ ok: true, provider: target.provider, subject: target.subject, role: 'admin', ...(note ? { note } : {}) }); +} + +async function adminsRemove(request: Request, env: AdminEnv, deps: AdminDeps): Promise { + if (!managementConfigured(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-manage')) return authError(429, 'too many requests'); + const rawBody = await request.text(); + const gate = await requireSuperadmin(request, env, deps, { + path: '/api/admin/admins/remove', httpMethod: 'POST', rawBody, mutation: true, + }); + if (gate instanceof Response) return gate; + let parsed: { provider?: unknown; subject?: unknown }; + try { + parsed = JSON.parse(rawBody) as typeof parsed; + } catch { + return authJson({ error: 'invalid body' }, 400); + } + const target = parseTargetPrincipal(parsed); + if (!target) return authJson({ error: 'invalid principal' }, 400); + // Runtime can NEVER revoke a file principal — superadmin and file admin alike. + if (adminRoleFor(deps.allowlist, target.provider, target.subject)) { + return authJson({ error: 'file-resident principal is immutable at runtime' }, 403); + } + try { + if (!(await rosterHas(env.DB, target.provider, target.subject))) return authJson({ error: 'not found' }, 404); + await removeRosterEntry(env.DB, { ...target, actor: gate.actor, method: gate.method, now: Date.now() }); + } catch { + return authError(503, 'roster unavailable'); + } + // Best-effort purge of the removed admin's live sessions; the per-request + // role re-derivation above stays authoritative either way. + await purgeAdminSessions(env.ADMIN_SESSIONS, target.provider, target.subject); + return authJson({ ok: true, provider: target.provider, subject: target.subject }); +} + export async function routeAdmin( request: Request, env: AdminEnv, @@ -267,6 +460,9 @@ export async function routeAdmin( if (path === '/api/admin/elevate/bluesky' && method === 'POST') return elevateBluesky(request, env, deps); if (path === '/api/admin/whoami' && method === 'GET') return whoami(request, env, deps); if (path === '/api/admin/logout' && method === 'POST') return logout(request, env, deps); + 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); // Deny-by-default: unknown paths and wrong methods fail closed. return authJson({ error: 'not found' }, 404); } diff --git a/worker/admin/session.ts b/worker/admin/session.ts index 82624dac..5c6e1692 100644 --- a/worker/admin/session.ts +++ b/worker/admin/session.ts @@ -78,6 +78,36 @@ export async function destroyAdminSession(kv: KVNamespace, id: string): Promise< await kv.delete(key(id)); } +/** + * Best-effort purge of every live admin session bound to a removed principal + * (called by the roster-remove route). The per-request role re-derivation in + * the router remains AUTHORITATIVE — a session this scan misses (KV list is + * eventually consistent) or fails to delete is still rejected AND destroyed on + * its very next request — so every error here is swallowed: a removal must + * never fail because eviction hygiene did. + */ +export async function purgeAdminSessions( + kv: KVNamespace, + method: AdminMethod, + subject: string, +): Promise { + try { + let cursor: string | undefined; + do { + const page = await kv.list({ prefix: 'adm:', cursor }); + for (const { name } of page.keys) { + const raw = await kv.get(name); + if (!raw) continue; + try { + const record = JSON.parse(raw) as AdminSessionRecord; + if (record.method === method && record.subject === subject) await kv.delete(name); + } catch { /* malformed record — expiry/eviction owns it */ } + } + cursor = page.list_complete ? undefined : page.cursor; + } while (cursor); + } catch { /* best-effort by design; the per-request guard is authoritative */ } +} + /** * Resolve + validate a session id against both bounds, refreshing `lastSeen` per * the coalescing rules above. Returns null for any break (unknown id, malformed diff --git a/worker/admin/types.ts b/worker/admin/types.ts index f691776d..862a4fa5 100644 --- a/worker/admin/types.ts +++ b/worker/admin/types.ts @@ -18,7 +18,10 @@ export interface AdminEnv { ADMIN_SESSIONS?: KVNamespace; /** USER sessions — read-only here, for the Bluesky reuse-then-elevate flow. */ SESSIONS?: KVNamespace; - /** Identity model — resolves a user session to its proven DID for elevation. */ + /** Identity model (elevation reads the proven DID) + the runtime admin roster + * and its insert-only audit (migrations/0002_admin_roster.sql). Absent on the + * AUTH path ⇒ roster treated as empty (file principals unaffected); absent on + * the MANAGEMENT routes ⇒ explicit 503. */ DB?: D1Database; /** Canonical origin: pins the NIP-98 `u` tag + origin checks to config, not Host. */ SITE_URL?: string; diff --git a/worker/auth/cf.ts b/worker/auth/cf.ts index 2e0923ac..62da03f0 100644 --- a/worker/auth/cf.ts +++ b/worker/auth/cf.ts @@ -12,6 +12,14 @@ export interface KVNamespace { // way a fresh relative `expirationTtl` would. put(key: string, value: string, options?: { expirationTtl?: number; expiration?: number }): Promise; delete(key: string): Promise; + // Cursor-paged key listing — widened deliberately for the admin session purge + // (best-effort revocation hygiene, worker/admin/session.ts). Only the fields + // we consume are typed. + list(options?: { prefix?: string; cursor?: string }): Promise<{ + keys: { name: string }[]; + list_complete: boolean; + cursor?: string; + }>; } export interface D1Result { diff --git a/worker/tests/admin-auth.test.ts b/worker/tests/admin-auth.test.ts index 481aa36b..73ca9a9e 100644 --- a/worker/tests/admin-auth.test.ts +++ b/worker/tests/admin-auth.test.ts @@ -44,6 +44,10 @@ function fakeKV(): KVNamespace & { puts: RecordedPut[] } { async get(k) { return store.has(k) ? store.get(k)! : null; }, async put(k, v, options) { store.set(k, v); puts.push({ key: k, value: v, options }); }, 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 }; + }, }; } diff --git a/worker/tests/auth-bluesky.test.ts b/worker/tests/auth-bluesky.test.ts index 01923510..cc80aeab 100644 --- a/worker/tests/auth-bluesky.test.ts +++ b/worker/tests/auth-bluesky.test.ts @@ -13,6 +13,10 @@ function fakeKV(): KVNamespace { 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 }; + }, }; } diff --git a/worker/tests/auth-nostr.test.ts b/worker/tests/auth-nostr.test.ts index ca7f12b8..c9766e76 100644 --- a/worker/tests/auth-nostr.test.ts +++ b/worker/tests/auth-nostr.test.ts @@ -12,6 +12,10 @@ function fakeKV(): KVNamespace { 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 }; + }, }; } const storeOf = () => ({ SESSIONS: fakeKV() }); diff --git a/worker/tests/auth.test.ts b/worker/tests/auth.test.ts index a959485f..bec0fc7c 100644 --- a/worker/tests/auth.test.ts +++ b/worker/tests/auth.test.ts @@ -15,6 +15,10 @@ function fakeKV(): KVNamespace { 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 }; + }, }; } From 9c2325308ac55f7f051655a99befcf5c3bbe236e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:16:05 +0000 Subject: [PATCH 4/4] test(admin): pin every P2B two-tier invariant by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24 tests in worker/tests/admin-roster.test.ts, same harness discipline (real schnorr, real AdminCoordinator behind hand fakes, DI'd deps): I1 runtime can never grant/revoke superadmin (file-resident add 409 / remove 403, nothing audited; roster yields only 'admin'; Ruling 1 — a FILE admin logs in but is refused management with the same generic 401); I2 fails closed (empty file rejects management even with a valid signature; DB absent/unreachable ⇒ roster empty on auth — superadmins work, roster admins don't — and an explicit 503 on management, never a file-only list); I3 per-request role re-derivation cookie path included (data-layer removal kills the live session next request; route removal purges KV immediately; purge failure tolerated; logout destroy-on-null); I4 insert-only audit pinned statically (no UPDATE/DELETE against admin_audit in any worker module); I5 the ONE shared normalizer/validator incl. the U+202A bidi case. Plus: exact audit rows per auth path, npub input echoed as hex, no pre-auth format oracle, CSRF + cross-site on cookie mutations, NIP-98 payload-tag binding + replay burn on management calls, Bluesky superadmin via cookie+CSRF, admin-manage 429s, deny-by-default 404s around the new surface. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc --- worker/tests/admin-roster.test.ts | 664 ++++++++++++++++++++++++++++++ 1 file changed, 664 insertions(+) create mode 100644 worker/tests/admin-roster.test.ts diff --git a/worker/tests/admin-roster.test.ts b/worker/tests/admin-roster.test.ts new file mode 100644 index 00000000..9cf5c37d --- /dev/null +++ b/worker/tests/admin-roster.test.ts @@ -0,0 +1,664 @@ +/** + * Two-tier role model contract (P2B): file-rooted superadmins + runtime-managed + * roster admins. 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. Every INVARIANT of the P2B block is pinned here + * by name: + * I1 — runtime can NEVER grant or revoke superadmin; file principals cannot + * be added to or removed from the roster (4xx, and no audit row). + * I2 — fails closed: empty file ⇒ every admin route rejects, management + * included; DB absent/unreachable ⇒ roster treated as EMPTY (superadmins + * work, roster admins don't) — management surfaces an explicit 5xx. + * I3 — every authenticated request (cookie path INCLUDED) re-derives the role + * from the current effective set; removal is effective next request. + * I4 — no self-registration; admin_audit is insert-only (no UPDATE/DELETE in + * code — pinned statically below). + * I5 — allowlist parsing/validation reused verbatim: ONE normalizer/validator + * (allowlist.ts), Cf-stripping included. + */ +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 { npubEncode } from 'nostr-tools/nip19'; +import { routeAdmin } from '../admin/router.ts'; +import { AdminCoordinator } from '../admin/coordinator.ts'; +import { normalizeSubject, isValidSubject, type AdminAllowlist } from '../admin/allowlist.ts'; +import { resolveAdminRole } from '../admin/roles.ts'; +import { removeRosterEntry } from '../admin/roster.ts'; +import { ADMIN_SESSION_COOKIE, purgeAdminSessions, putAdminSession, newAdminSessionId } from '../admin/session.ts'; +import { sha256Hex } from '../auth/nostr.ts'; +import { SESSION_COOKIE, createSession } from '../auth/session.ts'; +import { getOrCreateUserByIdentity } from '../auth/db.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-auth.test.ts, plus roster/audit 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 RosterRow { provider: string; subject: string; added_by: string; added_at: number; note: string | null } +interface AuditRow { + id: string; at: number; actor: string; action: string; + provider: string; subject: string; method: string; note: string | null; +} + +/** In-memory D1 covering BOTH the identity model (for Bluesky elevation) and + * the roster/audit tables of migrations/0002. `audit` is exposed so tests can + * assert exact rows. */ +function fakeD1(): D1Database & { audit: AuditRow[]; roster: Map } { + const users = new Map(); + const identities = new Map(); // key: provider|subject + const roster = new Map(); // key: provider|subject + const audit: AuditRow[] = []; + const apply = (sql: string, a: unknown[]) => { + if (sql.includes('DELETE FROM users')) { users.delete(a[0] as string); return; } + if (sql.includes('INTO users')) { + const id = a[0] as string; + if (sql.includes('OR IGNORE') && users.has(id)) return; + users.set(id, { id, created_at: a[1] as number, display_name: (a[2] ?? null) as string | null }); + return; + } + if (sql.includes('INTO identities')) { + const key = `${a[0]}|${a[1]}`; + if (sql.includes('OR IGNORE') && identities.has(key)) return; + identities.set(key, { user_id: a[2] as string }); + return; + } + if (sql.includes('INTO admin_roster')) { + const key = `${a[0]}|${a[1]}`; + if (roster.has(key)) throw new Error('UNIQUE constraint failed: admin_roster'); + roster.set(key, { + provider: a[0] as string, subject: a[1] as string, + added_by: a[2] as string, added_at: a[3] as number, note: (a[4] ?? null) as string | null, + }); + return; + } + if (sql.includes('DELETE FROM admin_roster')) { roster.delete(`${a[0]}|${a[1]}`); return; } + if (sql.includes('INTO admin_audit')) { + audit.push({ + id: a[0] as string, at: a[1] as number, actor: a[2] as string, action: a[3] as string, + provider: a[4] as string, subject: a[5] as string, method: a[6] as string, note: (a[7] ?? 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 admin_roster WHERE provider')) { + return ((roster.get(`${args[0]}|${args[1]}`) ?? null) as T | null); + } + if (sql.includes('SELECT subject FROM identities')) { + for (const [key, v] of identities) { + const [provider, ...rest] = key.split('|'); + if (v.user_id === args[0] && provider === args[1]) return ({ subject: rest.join('|') } as T); + } + return null; + } + if (sql.includes('JOIN users')) { + const idn = identities.get(`${args[0]}|${args[1]}`); + return (((idn && users.get(idn.user_id)) ?? null) as T | null); + } + if (sql.includes('FROM users WHERE id')) return ((users.get(args[0] as string) ?? null) as T | null); + if (sql.includes('FROM identities WHERE provider')) { const r = identities.get(`${args[0]}|${args[1]}`); return ((r ?? null) as T | null); } + return null; + }, + async run(): Promise { apply(sql, args); return { results: [], success: true }; }, + async all(): Promise> { + if (sql.includes('FROM admin_roster')) { + const results = [...roster.values()].sort((x, y) => x.added_at - y.added_at) as T[]; + return { results, success: true }; + } + return { results: [], success: true }; + }, + }; + return stmt; + }; + return { prepare: make, async batch(stmts) { for (const s of stmts) await s.run(); return []; }, audit, roster }; +} + +/** Every call fails — the "D1 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; DB: ReturnType }; +function fakeEnv(overrides: Partial = {}): Env { + return { + ADMIN_SESSIONS: fakeKV(), + ADMIN_COORD: fakeCoordNamespace(), + SESSIONS: fakeKV(), + DB: fakeD1(), + 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); +const EMPTY: AdminAllowlist = { nostr: [], bluesky: [] }; + +function tokenFor( + sk: Uint8Array, + o: { kind?: number; 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: o.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 cookieOf = (res: Response) => res.headers.get('set-cookie') ?? ''; +const sessionIdOf = (res: Response) => /__Host-wcjbt_admin=([0-9a-f]{64})/.exec(cookieOf(res))?.[1] ?? ''; +const withAdminCookie = (path: string, id: string, init?: RequestInit) => + req(path, { ...init, headers: { ...(init?.headers as Record ?? {}), cookie: `${ADMIN_SESSION_COOKIE}=${id}` } }); + +/** A logged-in principal ready to call routes on both auth paths. */ +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) }; +} + +/** Cookie-path mutation call (same-site, CSRF header riding along). */ +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); + +/** File fixture: one superadmin + one file-resident 'admin' (Ruling 1). */ +function fileFixture(superPk: string, fileAdminPk: string): { allowlist: AdminAllowlist } { + return { + allowlist: { + nostr: [ + { pubkey: superPk, role: 'superadmin' }, + { pubkey: fileAdminPk, role: 'admin' }, + ], + bluesky: [{ did: 'did:plc:filesuper1', role: 'superadmin' }], + }, + }; +} + +// ---- I5: the ONE shared normalizer/validator ---- + +test('I5 normalizeSubject strips ALL Cf format chars (U+202A bidi included), Unicode-trims, lowercases hex', () => { + const pk = 'A'.repeat(64); + assert.equal(normalizeSubject('nostr', `‪${pk}‬`), 'a'.repeat(64)); // bidi override wrap + assert.equal(normalizeSubject('nostr', `​ ${pk}⁠ `), 'a'.repeat(64)); // zero-width + word joiner + assert.equal(normalizeSubject('nostr', ` ${pk} `), 'a'.repeat(64)); // Unicode whitespace trim + assert.equal(normalizeSubject('bluesky', ' ‪did:plc:MiXeD‬ '), 'did:plc:MiXeD'); // DIDs exact after strip +}); + +test('I5 isValidSubject enforces the SAME rules as the file matchers (hex64 / did:plc|web)', () => { + assert.equal(isValidSubject('nostr', 'f'.repeat(64)), true); + assert.equal(isValidSubject('nostr', 'F'.repeat(64)), false); // validate AFTER normalize — uppercase is not normalized input + assert.equal(isValidSubject('nostr', 'f'.repeat(63)), false); + assert.equal(isValidSubject('nostr', `npub1${'q'.repeat(58)}`), false); // npub is input sugar, never a stored subject + assert.equal(isValidSubject('bluesky', 'did:plc:ok123'), true); + assert.equal(isValidSubject('bluesky', 'did:key:z6Mk'), false); + assert.equal(isValidSubject('bluesky', 'alice.bsky.social'), false); // handles are mutable pointers, never subjects +}); + +// ---- I1: runtime can NEVER grant or revoke superadmin ---- + +test('I1 file principals cannot be ADDED to the roster (409) and nothing lands in the audit', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const fileAdminSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), getPublicKey(fileAdminSk)); + const s = await loginAs(env, superSk, deps); + for (const subject of [getPublicKey(superSk), getPublicKey(fileAdminSk)]) { + const res = await mutate('/api/admin/admins/add', s, { provider: 'nostr', subject }, env, deps); + assert.equal(res.status, 409, 'file-resident add must conflict'); + } + const resDid = await mutate('/api/admin/admins/add', s, { provider: 'bluesky', subject: 'did:plc:filesuper1' }, env, deps); + assert.equal(resDid.status, 409); + assert.equal(env.DB.audit.length, 0, 'refused mutations must not be audited as roster changes'); + assert.equal(env.DB.roster.size, 0); +}); + +test('I1 file principals cannot be REMOVED at runtime (403, no audit) — superadmin and file admin alike', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const fileAdminSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), getPublicKey(fileAdminSk)); + const s = await loginAs(env, superSk, deps); + for (const [provider, subject] of [ + ['nostr', getPublicKey(superSk)], ['nostr', getPublicKey(fileAdminSk)], ['bluesky', 'did:plc:filesuper1'], + ] as const) { + const res = await mutate('/api/admin/admins/remove', s, { provider, subject }, env, deps); + assert.equal(res.status, 403, `${provider} file principal must be immutable`); + } + assert.equal(env.DB.audit.length, 0); +}); + +test('I1 the roster can only ever yield role admin — a roster add never grants superadmin', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + const s = await loginAs(env, superSk, deps); + const newPk = getPublicKey(generateSecretKey()); + assert.equal((await mutate('/api/admin/admins/add', s, { provider: 'nostr', subject: newPk }, env, deps)).status, 200); + assert.equal(await resolveAdminRole('nostr', newPk, env, deps), 'admin'); + // …and the file always wins: the file superadmin resolves from the file even + // with a roster present. + assert.equal(await resolveAdminRole('nostr', getPublicKey(superSk), env, deps), 'superadmin'); +}); + +test('I1 (Ruling 1) a FILE admin holds no roster-mutation rights: management routes reject it like any non-superadmin', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const fileAdminSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), getPublicKey(fileAdminSk)); + const s = await loginAs(env, fileAdminSk, deps); // file admin CAN log in… + assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', s.id), env, deps)).status, 200); + // …but sees the same generic 401 on every management route (no role oracle). + const list = await routeAdmin(withAdminCookie('/api/admin/admins', s.id), env, deps); + assert.equal(list.status, 401); + assert.deepEqual(await list.json(), { error: 'authentication failed' }); + assert.equal((await mutate('/api/admin/admins/add', s, { provider: 'nostr', subject: 'a'.repeat(64) }, env, deps)).status, 401); + assert.equal((await mutate('/api/admin/admins/remove', s, { provider: 'nostr', subject: 'a'.repeat(64) }, env, deps)).status, 401); +}); + +// ---- I2: fails closed ---- + +test('I2 EMPTY file ⇒ management endpoints reject even a valid signature: no superadmin, no roster mutations, ever', async () => { + const env = fakeEnv(); + const sk = generateSecretKey(); + const deps = { allowlist: EMPTY }; + // Login is impossible (allowlist reject)… + assert.equal((await nostrLogin(env, sk, deps)).status, 401); + // …and so is per-request NIP-98 management with a perfectly valid signature. + const url = `${ORIGIN}/api/admin/admins`; + const res = await routeAdmin(req('/api/admin/admins', { + headers: { authorization: tokenFor(sk, { url, method: 'GET' }) }, + }), env, deps); + assert.equal(res.status, 401); + const body = JSON.stringify({ provider: 'nostr', subject: 'a'.repeat(64) }); + const add = await routeAdmin(req('/api/admin/admins/add', { + method: 'POST', body, + headers: { authorization: tokenFor(sk, { url: `${ORIGIN}/api/admin/admins/add`, payload: await sha256Hex(body) }) }, + }), env, deps); + assert.equal(add.status, 401); + assert.equal(env.DB.roster.size, 0); +}); + +test('I2 DB binding ABSENT: file superadmin logs in and works; roster admin is denied; management is an explicit 503', async () => { + const superSk = generateSecretKey(); + const rosterSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + // Seed a roster admin while the DB is up, and keep their live session. + const envUp = fakeEnv(); + const superSession = await loginAs(envUp, superSk, deps); + assert.equal((await mutate('/api/admin/admins/add', superSession, { provider: 'nostr', subject: getPublicKey(rosterSk) }, envUp, deps)).status, 200); + const rosterSession = await loginAs(envUp, rosterSk, deps); + + // Same stores, DB gone. + const envDown = { ...envUp, DB: undefined } as AdminEnv; + assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', superSession.id), envDown, deps)).status, 200, 'file superadmin unaffected'); + assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', rosterSession.id), envDown, deps)).status, 401, 'roster admin fails closed'); + assert.equal((await nostrLogin(envDown, rosterSk, deps)).status, 401, 'roster login fails closed'); + assert.equal((await routeAdmin(withAdminCookie('/api/admin/admins', superSession.id), envDown, deps)).status, 503, 'management surfaces missing DB'); +}); + +test('I2 DB UNREACHABLE (throws): roster treated as empty on auth; management list is an explicit 503, never file-only', async () => { + const superSk = generateSecretKey(); + const rosterSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + const envUp = fakeEnv(); + const superSession = await loginAs(envUp, superSk, deps); + assert.equal((await mutate('/api/admin/admins/add', superSession, { provider: 'nostr', subject: getPublicKey(rosterSk) }, envUp, deps)).status, 200); + const rosterSession = await loginAs(envUp, rosterSk, deps); + + const envDown = { ...envUp, DB: throwingD1() } as AdminEnv; + assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', superSession.id), envDown, deps)).status, 200); + assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', rosterSession.id), envDown, deps)).status, 401); + const list = await routeAdmin(withAdminCookie('/api/admin/admins', superSession.id), envDown, deps); + assert.equal(list.status, 503, 'a file-only list must never be served as complete'); + assert.deepEqual(await list.json(), { error: 'roster unavailable' }); + // Mutations against an unreachable roster are 5xx too — never silently dropped. + const add = await mutate('/api/admin/admins/add', superSession, { provider: 'nostr', subject: 'b'.repeat(64) }, envDown, deps); + assert.equal(add.status, 503); +}); + +// ---- I3: per-request re-derivation, cookie path included ---- + +test('I3 a roster admin can log in and use whoami; REMOVAL (data layer, no purge) kills the live cookie session on its next request', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const rosterSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + const superSession = await loginAs(env, superSk, deps); + const rosterPk = getPublicKey(rosterSk); + assert.equal((await mutate('/api/admin/admins/add', superSession, { provider: 'nostr', subject: rosterPk }, env, deps)).status, 200); + + const rosterSession = await loginAs(env, rosterSk, deps); + assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', rosterSession.id), env, deps)).status, 200); + + // Remove straight at the data layer — deliberately bypassing the route and its + // best-effort purge — so what this proves is the PER-REQUEST role re-check. + await removeRosterEntry(env.DB, { provider: 'nostr', subject: rosterPk, actor: 'test', method: 'nip98', now: Date.now() }); + assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', rosterSession.id), env, deps)).status, 401); + // …and the session was destroyed, not merely rejected. + assert.equal(await env.ADMIN_SESSIONS.get(`adm:${rosterSession.id}`), null); +}); + +test('I3 removal via the ROUTE additionally purges the removed admin\'s live sessions from KV (best-effort hygiene)', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const rosterSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + const superSession = await loginAs(env, superSk, deps); + const rosterPk = getPublicKey(rosterSk); + assert.equal((await mutate('/api/admin/admins/add', superSession, { provider: 'nostr', subject: rosterPk }, env, deps)).status, 200); + const rosterSession = await loginAs(env, rosterSk, deps); + assert.ok(await env.ADMIN_SESSIONS.get(`adm:${rosterSession.id}`), 'live before removal'); + + assert.equal((await mutate('/api/admin/admins/remove', superSession, { provider: 'nostr', subject: rosterPk }, env, deps)).status, 200); + // The record is gone from KV immediately — no next-request round-trip needed… + assert.equal(await env.ADMIN_SESSIONS.get(`adm:${rosterSession.id}`), null); + // …and the superadmin's own session survived the purge untouched. + assert.equal((await routeAdmin(withAdminCookie('/api/admin/whoami', superSession.id), env, deps)).status, 200); +}); + +test('I3 purge is BEST-EFFORT: a KV that cannot list() never fails the removal (the guard stays authoritative)', async () => { + const kv = fakeKV(); + const id = newAdminSessionId(); + await putAdminSession(kv, id, 'f'.repeat(64), 'nostr'); + const broken: KVNamespace = { ...kv, async list() { throw new Error('KV list unavailable'); } }; + await purgeAdminSessions(broken, 'nostr', 'f'.repeat(64)); // must not throw + assert.ok(await kv.get(`adm:${id}`), 'record intact — next-request re-check owns revocation'); + // …and a working purge only deletes the matching principal. + const other = newAdminSessionId(); + await putAdminSession(kv, other, 'a'.repeat(64), 'nostr'); + await purgeAdminSessions(kv, 'nostr', 'f'.repeat(64)); + assert.equal(await kv.get(`adm:${id}`), null); + assert.ok(await kv.get(`adm:${other}`)); +}); + +test('I3 LOGOUT destroy-on-null: a de-listed principal\'s logout destroys the record and 401s — never leaves it alive', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const rosterSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + const superSession = await loginAs(env, superSk, deps); + const rosterPk = getPublicKey(rosterSk); + assert.equal((await mutate('/api/admin/admins/add', superSession, { provider: 'nostr', subject: rosterPk }, env, deps)).status, 200); + const rosterSession = await loginAs(env, rosterSk, deps); + await removeRosterEntry(env.DB, { provider: 'nostr', subject: rosterPk, actor: 'test', method: 'nip98', now: Date.now() }); + + // No CSRF header at all: membership fails first, the record must still die. + const out = await routeAdmin(withAdminCookie('/api/admin/logout', rosterSession.id, { method: 'POST' }), env, deps); + assert.equal(out.status, 401); + assert.equal(await env.ADMIN_SESSIONS.get(`adm:${rosterSession.id}`), null, 'destroyed, not merely rejected'); +}); + +// ---- management API surface ---- + +test('list returns file principals marked immutable ∪ roster entries with provenance', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const fileAdminSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), getPublicKey(fileAdminSk)); + const s = await loginAs(env, superSk, deps); + const rosterPk = getPublicKey(generateSecretKey()); + assert.equal((await mutate('/api/admin/admins/add', s, { provider: 'nostr', subject: rosterPk, note: 'Ops teammate' }, env, deps)).status, 200); + + const res = await routeAdmin(withAdminCookie('/api/admin/admins', s.id), env, deps); + assert.equal(res.status, 200); + const { admins } = (await res.json()) as { admins: Record[] }; + const file = admins.filter((a) => a.source === 'file'); + const roster = admins.filter((a) => a.source === 'roster'); + assert.equal(file.length, 3); // two nostr file entries + one bluesky + assert.ok(file.every((a) => a.immutable === true)); + assert.equal(file.filter((a) => a.role === 'superadmin').length, 2); + assert.equal(file.filter((a) => a.role === 'admin').length, 1); // Ruling 1: recorded role honored + assert.equal(roster.length, 1); + assert.deepEqual( + { provider: roster[0].provider, subject: roster[0].subject, role: roster[0].role, note: roster[0].note, added_by: roster[0].added_by }, + { provider: 'nostr', subject: rosterPk, role: 'admin', note: 'Ops teammate', added_by: `nostr:${s.pk}` }, + ); + assert.equal(typeof roster[0].added_at, 'number'); +}); + +test('audit rows are EXACT: add + remove, actor provider:subject, method recorded per auth path', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + const s = await loginAs(env, superSk, deps); + const target = getPublicKey(generateSecretKey()); + assert.equal((await mutate('/api/admin/admins/add', s, { provider: 'nostr', subject: target, note: 'Temp' }, env, deps)).status, 200); + assert.equal((await mutate('/api/admin/admins/remove', s, { provider: 'nostr', subject: target }, env, deps)).status, 200); + + assert.equal(env.DB.audit.length, 2); + const [add, remove] = env.DB.audit; + assert.deepEqual( + { action: add.action, actor: add.actor, provider: add.provider, subject: add.subject, method: add.method, note: add.note }, + { action: 'admin.add', actor: `nostr:${s.pk}`, provider: 'nostr', subject: target, method: 'cookie', note: 'Temp' }, + ); + assert.deepEqual( + { action: remove.action, actor: remove.actor, provider: remove.provider, subject: remove.subject, method: remove.method, note: remove.note }, + { action: 'admin.remove', actor: `nostr:${s.pk}`, provider: 'nostr', subject: target, method: 'cookie', note: null }, + ); // the nip98 method value is pinned by the NIP-98 management test below + assert.ok(add.id && remove.id && add.id !== remove.id); + assert.equal(typeof add.at, 'number'); +}); + +test('duplicate add → 409; remove of a non-member → 404 (Ruling 4)', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + const s = await loginAs(env, superSk, deps); + const target = getPublicKey(generateSecretKey()); + assert.equal((await mutate('/api/admin/admins/add', s, { provider: 'nostr', subject: target }, env, deps)).status, 200); + assert.equal((await mutate('/api/admin/admins/add', s, { provider: 'nostr', subject: target }, env, deps)).status, 409); + assert.equal((await mutate('/api/admin/admins/remove', s, { provider: 'nostr', subject: 'c'.repeat(64) }, env, deps)).status, 404); + assert.equal(env.DB.audit.length, 1, 'only the successful add is audited'); +}); + +test('npub input is accepted (Ruling 2, in-tree nip19) and the success response echoes HEX', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + const s = await loginAs(env, superSk, deps); + const targetPk = getPublicKey(generateSecretKey()); + const res = await mutate('/api/admin/admins/add', s, { provider: 'nostr', subject: ` ‪${npubEncode(targetPk)}‬ ` }, env, deps); + assert.equal(res.status, 200); + const body = (await res.json()) as { subject: string }; + assert.equal(body.subject, targetPk); // hex echoed, invisibles stripped + assert.ok(env.DB.roster.has(`nostr|${targetPk}`), 'stored as hex, never bech32'); +}); + +test('validation errors surface only AFTER superadmin auth (no pre-auth format oracle); garbage is 400 post-auth', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + // Unauthenticated malformed body: generic 401, never a validation message. + const anon = await routeAdmin(req('/api/admin/admins/add', { method: 'POST', body: 'not json{' }), env, deps); + assert.equal(anon.status, 401); + assert.deepEqual(await anon.json(), { error: 'authentication failed' }); + // Authenticated superadmin: malformed body / bad provider / bad subject → 400. + const s = await loginAs(env, superSk, deps); + assert.equal((await routeAdmin(withAdminCookie('/api/admin/admins/add', s.id, { + method: 'POST', body: 'not json{', headers: { 'x-admin-csrf': s.csrf }, + }), env, deps)).status, 400); + assert.equal((await mutate('/api/admin/admins/add', s, { provider: 'github', subject: 'x' }, env, deps)).status, 400); + assert.equal((await mutate('/api/admin/admins/add', s, { provider: 'nostr', subject: 'not-hex' }, env, deps)).status, 400); + assert.equal((await mutate('/api/admin/admins/add', s, { provider: 'bluesky', subject: 'alice.bsky.social' }, env, deps)).status, 400); +}); + +test('cookie mutations demand the coordinator CSRF token and a same-site request', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + const s = await loginAs(env, superSk, deps); + const body = JSON.stringify({ provider: 'nostr', subject: 'a'.repeat(64) }); + // cross-site → 403 before anything else + assert.equal((await routeAdmin(withAdminCookie('/api/admin/admins/add', s.id, { + method: 'POST', body, headers: { 'sec-fetch-site': 'cross-site', 'x-admin-csrf': s.csrf }, + }), env, deps)).status, 403); + // missing / wrong token → 403, nothing written + assert.equal((await routeAdmin(withAdminCookie('/api/admin/admins/add', s.id, { method: 'POST', body }), env, deps)).status, 403); + assert.equal((await routeAdmin(withAdminCookie('/api/admin/admins/add', s.id, { + method: 'POST', body, headers: { 'x-admin-csrf': 'f'.repeat(64) }, + }), env, deps)).status, 403); + assert.equal(env.DB.roster.size, 0); +}); + +test('per-request NIP-98 management: payload-tag bound to the exact body, single-use event id (burned)', async () => { + const env = fakeEnv(); + const superSk = generateSecretKey(); + const deps = fileFixture(getPublicKey(superSk), 'e'.repeat(64)); + const target = getPublicKey(generateSecretKey()); + const body = JSON.stringify({ provider: 'nostr', subject: target }); + const url = `${ORIGIN}/api/admin/admins/add`; + const token = tokenFor(superSk, { url, payload: await sha256Hex(body) }); + // A TAMPERED body under the same signature dies on the payload tag… + const tampered = JSON.stringify({ provider: 'nostr', subject: 'd'.repeat(64) }); + assert.equal((await routeAdmin(req('/api/admin/admins/add', { + method: 'POST', body: tampered, headers: { authorization: token }, + }), env, deps)).status, 401); + // …the honest body succeeds and audits method nip98… + const ok = await routeAdmin(req('/api/admin/admins/add', { + method: 'POST', body, headers: { authorization: token }, + }), env, deps); + assert.equal(ok.status, 200); + assert.equal(env.DB.audit[0].method, 'nip98'); + assert.equal(env.DB.audit[0].actor, `nostr:${getPublicKey(superSk)}`); + // …and an exact replay of the same event id is burned (409 dup would leak + // state through a replayed credential — it must die at auth, as 401). + assert.equal((await routeAdmin(req('/api/admin/admins/add', { + method: 'POST', body, headers: { authorization: token }, + }), env, deps)).status, 401); + // GET list via NIP-98: works once, replay dies. + const listToken = tokenFor(superSk, { url: `${ORIGIN}/api/admin/admins`, method: 'GET' }); + const call = () => routeAdmin(req('/api/admin/admins', { headers: { authorization: listToken } }), env, deps); + assert.equal((await call()).status, 200); + assert.equal((await call()).status, 401); +}); + +test('a Bluesky file superadmin manages via cookie+CSRF (elevate path); roster Bluesky admins resolve via elevate too', async () => { + const env = fakeEnv(); + const deps = fileFixture('f'.repeat(64), 'e'.repeat(64)); // bluesky superadmin: did:plc:filesuper1 + const user = await getOrCreateUserByIdentity(env.DB, 'bluesky', 'did:plc:filesuper1', 'Owner'); + const sid = await createSession({ SESSIONS: env.SESSIONS!, DB: env.DB }, user); + const elevated = await routeAdmin(req('/api/admin/elevate/bluesky', { + method: 'POST', headers: { cookie: `${SESSION_COOKIE}=${sid}` }, + }), env, deps); + assert.equal(elevated.status, 200); + const { csrf } = (await elevated.json()) as { csrf: string }; + const adminId = sessionIdOf(elevated); + const res = await routeAdmin(withAdminCookie('/api/admin/admins/add', adminId, { + method: 'POST', body: JSON.stringify({ provider: 'bluesky', subject: 'did:plc:teammate9' }), + headers: { 'x-admin-csrf': csrf }, + }), env, deps); + assert.equal(res.status, 200); + assert.equal(env.DB.audit[0].actor, 'bluesky:did:plc:filesuper1'); + // The roster DID elevates like any admin: proven user session + roster membership. + const tUser = await getOrCreateUserByIdentity(env.DB, 'bluesky', 'did:plc:teammate9', null); + const tSid = await createSession({ SESSIONS: env.SESSIONS!, DB: env.DB }, tUser); + const tElevated = await routeAdmin(req('/api/admin/elevate/bluesky', { + method: 'POST', headers: { cookie: `${SESSION_COOKIE}=${tSid}` }, + }), env, deps); + assert.equal(tElevated.status, 200); + // …and, as a plain admin, is refused management (same generic 401). + assert.equal((await routeAdmin(withAdminCookie('/api/admin/admins', sessionIdOf(tElevated)), env, deps)).status, 401); +}); + +test('admin-manage bucket: all three management routes 429 when the limiter trips', async () => { + const env = fakeEnv({ AUTH_RATE_LIMITER: denyAllLimiter }); + for (const [path, method] of [ + ['/api/admin/admins', 'GET'], ['/api/admin/admins/add', 'POST'], ['/api/admin/admins/remove', 'POST'], + ] as const) { + assert.equal((await routeAdmin(req(path, { method }), env)).status, 429, `${method} ${path}`); + } +}); + +test('deny-by-default holds around the new surface: wrong verbs and near-miss paths are 404', async () => { + const env = fakeEnv(); + assert.equal((await routeAdmin(req('/api/admin/admins', { method: 'POST' }), env)).status, 404); + assert.equal((await routeAdmin(req('/api/admin/admins', { method: 'DELETE' }), env)).status, 404); + assert.equal((await routeAdmin(req('/api/admin/admins/add', { method: 'GET' }), env)).status, 404); + assert.equal((await routeAdmin(req('/api/admin/admins/remove', { method: 'GET' }), env)).status, 404); + assert.equal((await routeAdmin(req('/api/admin/admins/anything'), env)).status, 404); +}); + +// ---- I4: insert-only audit, pinned statically ---- + +test('I4 admin_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']) { + const source = readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'); + assert.doesNotMatch(source, /UPDATE\s+admin_audit/i, `${rel} must never UPDATE admin_audit`); + assert.doesNotMatch(source, /DELETE\s+FROM\s+admin_audit/i, `${rel} must never DELETE from admin_audit`); + } + const roster = readFileSync(fileURLToPath(new URL('../admin/roster.ts', import.meta.url)), 'utf8'); + assert.match(roster, /INSERT INTO admin_audit/, 'the audit is written by INSERT alone'); +});