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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions migrations/0002_admin_roster.sql
Original file line number Diff line number Diff line change
@@ -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);
56 changes: 40 additions & 16 deletions worker/admin/allowlist.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
}
71 changes: 71 additions & 0 deletions worker/admin/roles.ts
Original file line number Diff line number Diff line change
@@ -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<AdminRole | null> {
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;
}
}
72 changes: 72 additions & 0 deletions worker/admin/roster.ts
Original file line number Diff line number Diff line change
@@ -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<RosterEntry[]> {
const { results } = await db
.prepare('SELECT provider, subject, added_by, added_at, note FROM admin_roster ORDER BY added_at, provider, subject')
.all<RosterEntry>();
return results;
}

export async function rosterHas(db: D1Database, provider: AdminProvider, subject: string): Promise<boolean> {
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<void> {
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<void> {
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),
]);
}
Loading
Loading