Skip to content
107 changes: 107 additions & 0 deletions migrations-admin/0001_admin_storage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
-- 0001_admin_storage.sql — DRAFT (admin-panel Phase 3): the ADMIN_DB schema.
--
-- ⚠ NOT YET PROVISIONED OR BOUND. This file is the reviewable spec for the
-- separate ADMIN_DB database the admin-panel spec calls for. Nothing reads it
-- yet: there is no ADMIN_DB binding in wrangler.jsonc and no code path touches
-- these tables. It lands first so the schema is reviewed as code BEFORE any
-- resource exists. To bring it live (operator, after review):
--
-- 1. npx wrangler d1 create wcjbt-admin
-- 2. add to wrangler.jsonc d1_databases:
-- { "binding": "ADMIN_DB", "database_name": "wcjbt-admin",
-- "database_id": "<from step 1>", "migrations_dir": "migrations-admin" }
-- 3. npx wrangler d1 migrations apply wcjbt-admin --remote
--
-- WHY A SEPARATE DATABASE (not more tables in wcjbt-auth): the runtime roster +
-- roster audit (migrations/0002) are auth-adjacent — WHO may sign in — and
-- correctly live with auth. Everything here is a different domain with a
-- different retention and legal posture: moderation casework and content
-- staging. Separation keeps the auth store small and auditable, and lets this
-- data carry its own lifecycle without ever touching sign-in state.
--
-- VALUES ENCODED IN THE SHAPE (empower + protect the builder/operator):
-- - Data minimization: rows hold public identifiers ('provider:subject' —
-- pubkeys/DIDs) and reference tokens ONLY. Never a session token, never
-- key material, never more of a report than the case needs.
-- - admin_action_audit is INSERT-ONLY by contract, mirroring admin_audit in
-- migrations/0002: no code path may UPDATE or DELETE it (pin statically in
-- tests, as worker/tests/admin-roster.test.ts does for 0002). An editable
-- audit trail is theater.
-- - Staged edits EXPIRE (expires_at is NOT NULL): staging is a workbench,
-- not an archive. Abandoned drafts age out instead of accumulating.
-- - CSAM/NCMEC evidence NEVER lives here. Phase 6 puts evidence in the
-- locked-down ADMIN_EVIDENCE R2 bucket with its own access logging; this
-- database holds only case coordination.
-- - Portability without lock-in (Phase 4): mod_labels maps 1:1 onto NIP-32
-- kind-1985 label events (MOD/X-MOD vocab per NIP-69), so Cloudflare stays
-- primary and the protocol layer is a switchable, default-off mirror.

-- ---- Catalog / content staging (admin-panel Phase 5 reads/writes this) ----
-- A draft catalog entry, skill, or guide edit being prepared in the console.
-- Publishing NEVER writes content directly to the site: the publish action runs
-- the enforcement engine, then opens a repository PR (the existing contribution
-- path) — state marks where in that flow the draft sits.
CREATE TABLE staged_edits (
id TEXT PRIMARY KEY, -- opaque random id
created_at INTEGER NOT NULL, -- epoch ms
updated_at INTEGER NOT NULL,
author TEXT NOT NULL, -- acting admin, 'provider:subject'
kind TEXT NOT NULL, -- 'catalog-entry' | 'skill' | 'guide'
slug TEXT NOT NULL, -- target slug/path being created or edited
content TEXT NOT NULL, -- the full draft body (MDX/YAML)
enforcement_status TEXT NOT NULL, -- 'pending' | 'pass' | 'fail'
enforcement_report TEXT, -- engine findings (JSON), shown to the author
state TEXT NOT NULL, -- 'draft' | 'ready' | 'pr-opened' | 'abandoned'
pr_url TEXT, -- once opened, the PR is the source of truth
expires_at INTEGER NOT NULL -- drafts age out; staging is not archival
);
CREATE INDEX idx_staged_edits_author ON staged_edits(author);
CREATE INDEX idx_staged_edits_expires ON staged_edits(expires_at);

-- ---- Trust & Safety casework (admin-panel Phase 6 reads/writes this) ----
-- One row per report/incident under review. subject_ref is a minimal reference
-- (URL, event id, or slug) — the case holds coordinates, not copies of content.
CREATE TABLE mod_cases (
id TEXT PRIMARY KEY, -- opaque random id
opened_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL, -- 'report' | 'sweep' | 'manual'
subject_ref TEXT NOT NULL, -- what is under review (reference only)
reason TEXT NOT NULL, -- vocab token (NIP-56/NIP-69 style), not free prose
state TEXT NOT NULL, -- 'open' | 'reviewing' | 'actioned' | 'dismissed'
assignee TEXT, -- reviewing admin, 'provider:subject'
escalated INTEGER NOT NULL DEFAULT 0 -- 1 = routed to the restricted Phase-6 path
);
CREATE INDEX idx_mod_cases_state ON mod_cases(state, updated_at);

-- The deterministic moderation outcome: label rows shaped as NIP-32 kind-1985
-- events (namespace MOD/X-MOD per NIP-69) so the Phase-4 exporter can publish
-- them verbatim when portability is switched on. Per NIP-69, later moderation
-- supersedes earlier — superseded_by links forward instead of rewriting history.
CREATE TABLE mod_labels (
id TEXT PRIMARY KEY, -- opaque random id
case_id TEXT NOT NULL REFERENCES mod_cases(id),
created_at INTEGER NOT NULL,
actor TEXT NOT NULL, -- labeling admin, 'provider:subject'
namespace TEXT NOT NULL, -- 'MOD' | 'X-MOD'
label TEXT NOT NULL, -- vocabulary value
target TEXT NOT NULL, -- the labeled reference
published INTEGER NOT NULL DEFAULT 0, -- Phase-4 exporter state (default off)
superseded_by TEXT -- id of the label that replaces this one
);
CREATE INDEX idx_mod_labels_case ON mod_labels(case_id);

-- ---- Admin action audit (every phase writes this) ----
-- INSERT-ONLY. What an admin DID: staging publishes, case state changes, label
-- applications. detail carries static reason tokens only — never content bodies,
-- never session material. (Roster changes are audited in wcjbt-auth's
-- admin_audit, next to the roster itself.)
CREATE TABLE admin_action_audit (
id TEXT PRIMARY KEY, -- opaque random id
at INTEGER NOT NULL, -- epoch ms
actor TEXT NOT NULL, -- acting admin, 'provider:subject'
action TEXT NOT NULL, -- 'staging.create' | 'staging.publish' | 'case.open' | 'case.assign' | 'case.close' | 'label.apply' | 'label.supersede'
target TEXT NOT NULL, -- the acted-on id/ref
detail TEXT -- static reason token, optional
);
CREATE INDEX idx_admin_action_audit_at ON admin_action_audit(at);
60 changes: 47 additions & 13 deletions src/components/AdminConsole.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
* src/lib/security-headers.ts). The bunker connection string is pasted
* here; an ephemeral client key is generated in-memory for transport.
* NEVER an nsec/ncryptsec input — key-looking pastes are rejected.
* Web bunkers' auth_url approval step is supported (sanitized, http(s)
* only); every remote await is timeout-bounded and failures surface
* their real cause in the browser console (see ./bunker-login.ts).
* - Bluesky: reuse the ordinary site sign-in (header widget), then elevate;
* the server checks the session's proven DID against the allowlist.
*
Expand All @@ -27,6 +30,10 @@
* the authority.
*/
import { onMount } from 'svelte';
import {
looksLikeKeyMaterial, withTimeout, bunkerReason, safeApprovalUrl,
BUNKER_CONNECT_TIMEOUT_MS, BUNKER_SIGN_TIMEOUT_MS,
} from './bunker-login.ts';

type Role = 'superadmin' | 'admin';
type Who = { identity: string; method: 'nostr' | 'bluesky'; role: Role; csrf?: string };
Expand All @@ -47,6 +54,10 @@
let notice = $state('');
let busy = $state<'' | 'nip07' | 'nip46' | 'bluesky' | 'logout'>('');
let bunkerInput = $state('');
// Web bunkers (nsec.app-style) send a NIP-46 `auth_url` the user must open and
// approve; Amber and other push-approval signers never do. Sanitized before it
// may render — live only while the attempt is in flight.
let approvalUrl = $state('');

// ---- management panel state (superadmin only) ----
let admins = $state<AdminRow[] | null>(null);
Expand Down Expand Up @@ -133,15 +144,11 @@
}
}

/** Anything that looks like key material is refused outright — this input is
* ONLY for a bunker connection string (bunker://…) or a NIP-05 name. */
function looksLikeKeyMaterial(s: string): boolean {
const t = s.trim().toLowerCase();
return t.startsWith('nsec1') || t.startsWith('ncryptsec1') || /^[0-9a-f]{64}$/.test(t);
}
// The key-material refusal gate + all other pure NIP-46 logic live in
// ./bunker-login.ts, where they are unit-tested.

async function loginNip46() {
error = ''; notice = '';
error = ''; notice = ''; approvalUrl = '';
const input = bunkerInput.trim();
if (!input) { error = 'Paste your bunker:// connection string first.'; return; }
if (looksLikeKeyMaterial(input)) {
Expand All @@ -161,16 +168,36 @@
notice = 'Connecting to your bunker — approve the request in your signer app…';
// Ephemeral TRANSPORT key for this conversation only (never persisted,
// never the admin's identity key).
const s = BunkerSigner.fromBunker(generateSecretKey(), pointer);
const s = BunkerSigner.fromBunker(generateSecretKey(), pointer, {
// The auth_url handler web bunkers require: without it nostr-tools warns
// and the connect promise never settles. The URL is SIGNER-PROVIDED input:
// sanitize to http(s) before opening, open with no opener access, and keep
// a clickable fallback rendered for when the popup is blocked (this runs
// outside the click gesture, so blockers usually do block it).
onauth: (url: string) => {
const safe = safeApprovalUrl(url);
if (!safe) return;
approvalUrl = safe;
window.open(safe, '_blank', 'noopener,noreferrer');
},
});
signer = s;
await s.connect();
await loginWithSigner((tpl) => s.signEvent(tpl));
// Both remote awaits are BOUNDED: an unanswered on-phone approval or a dead
// relay becomes a visible, explained error — never a spinner that lives forever.
await withTimeout(s.connect(), BUNKER_CONNECT_TIMEOUT_MS, 'bunker connect');
await loginWithSigner((tpl) => withTimeout(s.signEvent(tpl), BUNKER_SIGN_TIMEOUT_MS, 'bunker signing'));
notice = '';
} catch {
error = 'Bunker sign-in didn’t complete. Check the connection string, approve the request in your signer, and make sure only allowlisted admin keys are used.';
} catch (e) {
// The real cause goes to the console for the operator — the error object
// only, never the pasted input and never key material. The UI gets a
// static explanation mapped from it (bunkerReason echoes nothing).
console.error('[admin] NIP-46 bunker login failed:', e);
error = bunkerReason(e);
notice = '';
} finally {
try { await signer?.close(); } catch { /* relay already closed */ }
// Fire-and-forget: close() itself must never be able to hang the UI.
signer?.close().catch(() => { /* relay already closed */ });
approvalUrl = ''; // dead once the signer is closed, whatever the outcome
busy = '';
}
}
Expand Down Expand Up @@ -420,6 +447,13 @@
{busy === 'nip46' ? 'Waiting for bunker…' : 'Connect'}
</button>
</div>
{#if approvalUrl}
<p class="notice" role="status">
Your bunker asks you to approve this connection:
<a href={approvalUrl} target="_blank" rel="noopener noreferrer">open the approval page</a>,
approve there, then return to this tab.
</p>
{/if}
</div>
<div class="card">
<h2>Bluesky</h2>
Expand Down
Loading
Loading