Admin role tiers: file-rooted superadmins + runtime-managed roster (P2B)#51
Merged
Conversation
…n_audit 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc
… tiers)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
wecanjustbuildthings | 9c23253 | Jul 05 2026, 05:27 PM |
MartinMontero
marked this pull request as ready for review
July 5, 2026 18:47
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Extends the landed Phase-2 admin auth (
worker/admin/*) with the P2B two-tier role model: the committed allowlist (worker/admin/allowlist.ts) is the file-rooted tier (PR-governed, immutable at runtime); a new D1admin_rosterin the existingDBholds runtime-managed admins, mutable only by file superadmins via three new superadmin-only/api/admin/admins*endpoints, with every mutation written to an insert-onlyadmin_audit. One resolver (worker/admin/roles.ts) re-derives the role from the current effective set (file ∪ roster) on every authenticated request — cookie path included — so removal is effective on the principal's next request. Four commits, one phase each: migration → resolver/rewiring → management API → invariant tests.Phase 0 stale-brief corrections (on the record)
resolveAllowlistedAdmin); this PR generalizes it to role re-derivation (resolveRoledAdmin) and extends it tologout, which was still resolving sessions without a membership re-check — that gap is closed here (a valid session id reaching logout is now always destroyed; membership failure changes the status code, never leaves the record alive).normalizeSubjectintroduced here (allowlist.ts) is therefore THE single shared Cf-stripping normalizer, per the one-implementation ruling.The four rulings (applied)
role: 'admin'entries are honored as recorded — they log in like admins, hold no roster-mutation rights, and are immutable via the API (add → 409, remove → 403). The file is a complete governance surface: a fork may run its entire admin set through PRs with the roster empty forever.normalizeSubject(Unicode trim + strip all Cf format chars + lowercase hex; DIDs exact after strip) andisValidSubject(the same HEX_PUBKEY/DID rules the matchers enforce). Existing exports byte-untouched. npub input is accepted on the management API via the in-treenostr-tools/nip19and the hex is echoed back. Validation errors surface only after successful superadmin auth — no pre-auth format oracle.provider:subject.D1 availability asymmetry (deliberate)
On the auth path a missing/unreachable DB 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: DB unavailability is an explicit 503 (
roster unavailable), and a file-only list is never rendered as if complete.Migration sequencing note
Between merge (code deploys) and the remote migration apply, the management routes return 503 and existing auth is unaffected — fails closed by design. Remote apply is a hard stop reserved to the owner (see below).
What the owner does post-merge
npx wrangler d1 migrations apply wcjbt-auth --remotePOST /api/admin/admins/addwith{"provider":"nostr","subject":"<throwaway npub or hex>","note":"round-trip test"}(cookie +x-admin-csrf, or per-request NIP-98), confirm it appears inGET /api/admin/adminsassource: roster, thenPOST /api/admin/admins/removefor the same principal.npx wrangler d1 execute wcjbt-auth --remote --command "SELECT at, actor, action, provider, subject, method, note FROM admin_audit ORDER BY at;"— expect oneadmin.addand oneadmin.removefor the throwaway subject, actorprovider:subjectof the acting superadmin.Type of change
Required checks
npm run checkpasses (schema validation)npm run enforcepasses (three-layer exclusion policy + recipe contract)npm testpasses (315/315 — 291 baseline + 24 new invariant tests, each P2B invariant pinned by name)npm run buildsucceeds (npm run verify:allexit 0)Dependency delta: zero (
package.json/lockfile untouched; nip19 comes from the already-in-treenostr-tools). Migration applies cleanly on a fresh local D1 (0001 → 0002 verified from empty state). No workflow files touched;operational_advisoryuntouched.For catalog entries
Not applicable — no catalog entries in this PR.
For recipes
Not applicable — no recipes in this PR.
🤖 Generated with Claude Code
https://claude.ai/code/session_01U8EGtxYhdvUcvhCZLBrXFc
Generated by Claude Code