The three modes describe how new listings get accepted. The
+ global default applies everywhere unless a category has its own override.
+ Clear an override by picking "inherits global" from the per-category dropdown.
+
+
+ append — any valid listing goes in; readers see the signed scan report and decide.
+ scan-first — infected uploads are rejected before they land. Clean ones publish.
+ review — clean listings sit in a human-review queue before browse shows them. Toggle persists; enforcement lands in a later milestone.
+
Append-only. Each row's this_hash
+ is computed over its own fields plus the previous row's hash — a retroactive
+ edit anywhere in the chain shows up as a mismatch in the next row.
+
+ ${entries.length === 0
+ ? `
No admin actions yet.
`
+ : `
+
+
seq
at
actor
action
+
target
details
this_hash
+
+
+ ${entries.map((e) => `
+
${e.seq}
+
${escapeHtml(e.at)}
+
${escapeHtml(e.actor)}
+
${escapeHtml(e.action)}
+
${escapeHtml(e.target || '')}
+
${escapeHtml(e.details || '')}
+
${escapeHtml(e.this_hash.slice(0, 10))}…
+
`).join('')}
+
+
`}
+
+
+
`,
+ };
+}
+
+/* ------------------------------- helpers ------------------------------- */
+
+function formatBytes(n) {
+ const num = Number(n) || 0;
+ if (num < 1024) return `${num} B`;
+ if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KiB`;
+ if (num < 1024 * 1024 * 1024) return `${(num / 1024 / 1024).toFixed(1)} MiB`;
+ return `${(num / 1024 / 1024 / 1024).toFixed(2)} GiB`;
+}
diff --git a/service/admin-session.js b/service/admin-session.js
new file mode 100644
index 0000000..d1336ef
--- /dev/null
+++ b/service/admin-session.js
@@ -0,0 +1,209 @@
+/**
+ * Admin sign-in via BIP-137, plus the cookie-based session the admin pages
+ * verify on every request.
+ *
+ * Flow:
+ * 1. GET /admin/login → the login page renders a fresh challenge string
+ * (random 24 bytes base64url). The challenge is stored in memory with a
+ * 10-minute TTL and the admin's BNS name baked in — tying it to one name
+ * stops a challenge from being used to log in as somebody else later.
+ * 2. The admin signs the challenge with the key for the address that owns
+ * the admin BNS name, pastes the signature, and POSTs.
+ * 3. The server recovers the signer, looks up the current on-chain owner of
+ * the admin name, and compares (per [[recovery-is-not-verification]]). On
+ * match, we mint a signed session cookie — payload has {name, address,
+ * exp}, signed with the service's ed25519 key.
+ * 4. Every /admin/* page reads the cookie, verifies it, and gates on the
+ * expiration + the name matching the configured admin.
+ *
+ * No cross-request state apart from the challenge map, which is tiny and
+ * single-process — fine for the operator-only admin.
+ */
+
+import { randomBytes, sign as nodeSign, verify as nodeVerify, createPublicKey, createPrivateKey } from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { verifyControl, DEFAULT_PREFIX } from './lib/sign-message.js';
+import { lookupName } from './bns.js';
+
+const CHALLENGE_TTL_MS = 10 * 60 * 1000; // 10 min
+const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
+const COOKIE_NAME = 'prom_admin';
+
+// The one admin, by BNS name. The on-chain owner of this name is who can log in.
+const ADMIN_PUBLISHER = (process.env.ADMIN_PUBLISHER || '').toLowerCase();
+
+/* ------------------------------- key ------------------------------- */
+
+const KEY_DIR = process.env.PROMETHEUS_KEY_DIR
+ || resolve(dirname(fileURLToPath(import.meta.url)), '..', '.keys');
+const KEY_PATH = resolve(KEY_DIR, 'service.key');
+
+let cachedPrivate = null;
+let cachedPublic = null;
+function key() {
+ if (cachedPrivate) return { priv: cachedPrivate, pub: cachedPublic };
+ const pem = readFileSync(KEY_PATH, 'utf8');
+ cachedPrivate = createPrivateKey(pem);
+ cachedPublic = createPublicKey(cachedPrivate);
+ return { priv: cachedPrivate, pub: cachedPublic };
+}
+
+/* ------------------------------- challenges ------------------------------- */
+
+/** `{challenge: {created_at, name}}` — one-shot, 10 min TTL. */
+const challenges = new Map();
+
+/** Return a fresh challenge for `name`. Pulls old entries while we're here. */
+export function newChallenge(name) {
+ const challenge = randomBytes(24).toString('base64url');
+ challenges.set(challenge, { created_at: Date.now(), name });
+ pruneChallenges();
+ return challenge;
+}
+
+function pruneChallenges() {
+ const cutoff = Date.now() - CHALLENGE_TTL_MS;
+ for (const [k, v] of challenges) {
+ if (v.created_at < cutoff) challenges.delete(k);
+ }
+}
+
+/* ------------------------------- login verify ------------------------------- */
+
+/**
+ * Verify a signed login challenge and, on success, return the session cookie
+ * string to set.
+ *
+ * @returns {{ok: true, cookie: string, expiresAt: number, admin: {name, address}}
+ * | {ok: false, reason: string}}
+ */
+export async function verifyLogin({ challenge, signature }) {
+ if (!ADMIN_PUBLISHER) {
+ return { ok: false, reason: 'admin is not configured (set ADMIN_PUBLISHER)' };
+ }
+ const slot = challenges.get(challenge);
+ if (!slot) return { ok: false, reason: 'challenge expired or unknown' };
+ challenges.delete(challenge); // one-shot
+ if (Date.now() - slot.created_at > CHALLENGE_TTL_MS) {
+ return { ok: false, reason: 'challenge expired' };
+ }
+ if (slot.name !== ADMIN_PUBLISHER) {
+ return { ok: false, reason: 'challenge was minted for a different name' };
+ }
+
+ const nameInfo = await lookupName(ADMIN_PUBLISHER);
+ if (!nameInfo.ok) {
+ return { ok: false, reason: `could not resolve admin name: ${nameInfo.reason}` };
+ }
+
+ const message = loginMessage(challenge, ADMIN_PUBLISHER);
+ const verify = verifyControl({
+ message,
+ signature,
+ expectedAddress: nameInfo.owner,
+ prefix: DEFAULT_PREFIX,
+ });
+ if (!verify.ok) {
+ return { ok: false, reason: verify.reason };
+ }
+
+ const expiresAt = Date.now() + SESSION_TTL_MS;
+ const cookie = mintCookie({
+ name: ADMIN_PUBLISHER,
+ address: nameInfo.owner,
+ exp: expiresAt,
+ });
+
+ return { ok: true, cookie, expiresAt, admin: { name: ADMIN_PUBLISHER, address: nameInfo.owner } };
+}
+
+/**
+ * The exact bytes the admin signs. Includes the admin name and the challenge
+ * so a signature valid in one admin's context never transfers to another.
+ */
+export function loginMessage(challenge, name) {
+ return `PROMETHEUS-ADMIN-LOGIN\nname=${name}\nchallenge=${challenge}`;
+}
+
+/* ------------------------------- cookie mint + verify ------------------------------- */
+
+function mintCookie(payload) {
+ const canonical = canonicalJson(payload);
+ const sig = nodeSign(null, Buffer.from(canonical, 'utf8'), key().priv);
+ const token = Buffer.from(canonical, 'utf8').toString('base64url') + '.' + sig.toString('base64url');
+
+ const maxAge = Math.max(1, Math.floor((payload.exp - Date.now()) / 1000));
+ return `${COOKIE_NAME}=${token}; Path=/admin; Max-Age=${maxAge}; HttpOnly; SameSite=Strict`;
+}
+
+export function clearCookie() {
+ return `${COOKIE_NAME}=; Path=/admin; Max-Age=0; HttpOnly; SameSite=Strict`;
+}
+
+/**
+ * Verify the admin cookie on a request. Returns the admin payload or null.
+ */
+export function readSession(req) {
+ const header = req.headers['cookie'];
+ if (!header) return null;
+ const token = pickCookie(header, COOKIE_NAME);
+ if (!token) return null;
+
+ const dot = token.indexOf('.');
+ if (dot < 0) return null;
+ const payloadB64 = token.slice(0, dot);
+ const sigB64 = token.slice(dot + 1);
+
+ let canonical;
+ try {
+ canonical = Buffer.from(payloadB64, 'base64url').toString('utf8');
+ } catch {
+ return null;
+ }
+
+ let sig;
+ try {
+ sig = Buffer.from(sigB64, 'base64url');
+ } catch {
+ return null;
+ }
+
+ const ok = nodeVerify(null, Buffer.from(canonical, 'utf8'), key().pub, sig);
+ if (!ok) return null;
+
+ let payload;
+ try {
+ payload = JSON.parse(canonical);
+ } catch {
+ return null;
+ }
+ if (!payload || typeof payload !== 'object') return null;
+ if (!payload.exp || Date.now() > Number(payload.exp)) return null;
+ if (payload.name !== ADMIN_PUBLISHER) return null; // reconfig invalidates sessions
+ return payload;
+}
+
+export function adminPublisher() {
+ return ADMIN_PUBLISHER;
+}
+
+/* ------------------------------- helpers ------------------------------- */
+
+function pickCookie(header, name) {
+ const parts = String(header).split(/;\s*/);
+ for (const p of parts) {
+ const eq = p.indexOf('=');
+ if (eq < 0) continue;
+ if (p.slice(0, eq) === name) return p.slice(eq + 1);
+ }
+ return null;
+}
+
+function canonicalJson(value) {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
+ if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';
+ const keys = Object.keys(value).sort();
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}';
+}
diff --git a/service/admin.js b/service/admin.js
new file mode 100644
index 0000000..e8c4c91
--- /dev/null
+++ b/service/admin.js
@@ -0,0 +1,199 @@
+/**
+ * Admin route handlers.
+ *
+ * Everything here is form-based so a JS-off browser still works. The one GET
+ * that mutates state (the login POST → session cookie) is a form submission;
+ * every other mutation POSTs and redirects to the GET it changed.
+ *
+ * Pages come from admin-pages.js; this file is the glue between HTTP and the
+ * DB / session.
+ */
+
+import { layout } from './layout.js';
+import {
+ newChallenge, verifyLogin, readSession, clearCookie, adminPublisher, loginMessage,
+} from './admin-session.js';
+import {
+ allListingsIncludingWithdrawn, allActiveListings, withdrawListing, getListing,
+ countActive, recentAudit as recentAuditRows,
+} from './db.js';
+import { allModes, setMode, MODES } from './modes.js';
+import { CATEGORIES, countsByCategory } from './pages-browse.js';
+import { logAudit } from './audit.js';
+import {
+ loginPage, dashboardPage, listingsPage, modesPage, auditPage,
+} from './admin-pages.js';
+
+/* ----------------- dispatch (called from server.js) ----------------- */
+
+/**
+ * Admin router. Returns true if it handled the request. The caller falls
+ * through to the regular router if we return false.
+ */
+export async function handleAdmin(req, res, url) {
+ // /admin/login (GET + POST) is the only path accessible without a session.
+ if (url === '/admin/login') {
+ if (req.method === 'POST') return doLogin(req, res);
+ return renderPage(res, loginPage({
+ challenge: newChallenge(adminPublisher()),
+ name: adminPublisher() || '(ADMIN_PUBLISHER env not set)',
+ }));
+ }
+ if (url === '/admin/logout' && req.method === 'POST') {
+ return doLogout(res);
+ }
+
+ // Everything else requires a session.
+ const session = readSession(req);
+ if (!session) {
+ res.writeHead(302, { location: '/admin/login' });
+ return res.end();
+ }
+
+ if (url === '/admin' || url === '/admin/') return showDashboard(res, session);
+ if (url === '/admin/listings') return showListings(res, session);
+ if (url === '/admin/modes') {
+ if (req.method === 'POST') return saveModes(req, res, session);
+ return showModes(res, session);
+ }
+ if (url === '/admin/audit') return showAudit(res, session);
+
+ const withdrawMatch = url.match(/^\/admin\/listings\/([^/]+)\/([^/]+)\/withdraw$/);
+ if (withdrawMatch && req.method === 'POST') {
+ return doWithdraw(req, res, session, `${decodeURIComponent(withdrawMatch[1])}/${decodeURIComponent(withdrawMatch[2])}`);
+ }
+
+ return notFoundHtml(res);
+}
+
+/* ----------------- login + logout ----------------- */
+
+async function doLogin(req, res) {
+ const form = await readForm(req);
+ const result = await verifyLogin({
+ challenge: String(form.challenge || ''),
+ signature: String(form.signature || ''),
+ });
+ if (!result.ok) {
+ return renderPage(res, loginPage({
+ challenge: newChallenge(adminPublisher()),
+ name: adminPublisher(),
+ error: result.reason,
+ }), 400);
+ }
+
+ logAudit({ actor: result.admin.name, action: 'login' });
+
+ res.writeHead(302, {
+ location: '/admin',
+ 'set-cookie': result.cookie,
+ });
+ res.end();
+}
+
+function doLogout(res) {
+ res.writeHead(302, {
+ location: '/admin/login',
+ 'set-cookie': clearCookie(),
+ });
+ res.end();
+}
+
+/* ----------------- GET pages ----------------- */
+
+function showDashboard(res, session) {
+ const counts = countsByCategory();
+ const total = countActive();
+ const modes = allModes(CATEGORIES.map((c) => c.slug));
+ const recentListings = allActiveListings().slice(0, 10);
+ const recentAudit = recentAuditRows(20);
+ renderPage(res, dashboardPage({ session, counts, total, modes, recentListings, recentAudit }));
+}
+
+function showListings(res, session) {
+ const rows = allListingsIncludingWithdrawn();
+ renderPage(res, listingsPage({ session, rows }));
+}
+
+function showModes(res, session) {
+ const modes = allModes(CATEGORIES.map((c) => c.slug));
+ renderPage(res, modesPage({ session, modes }));
+}
+
+function showAudit(res, session) {
+ const entries = recentAuditRows(200);
+ renderPage(res, auditPage({ session, entries }));
+}
+
+/* ----------------- POST actions ----------------- */
+
+async function doWithdraw(req, res, session, id) {
+ const row = getListing(id);
+ if (!row) return notFoundHtml(res);
+ const changed = withdrawListing(id, new Date().toISOString());
+ if (changed) {
+ logAudit({
+ actor: session.name,
+ action: 'withdraw_listing',
+ target: id,
+ details: { category: row.category, publisher: row.publisher },
+ });
+ }
+ res.writeHead(302, { location: '/admin/listings' });
+ res.end();
+}
+
+async function saveModes(req, res, session) {
+ const form = await readForm(req);
+
+ // Global first — the per-category reads depend on the current global.
+ const g = String(form.global || '');
+ if (MODES.includes(g)) {
+ setMode({ category: null, value: g, actor: session.name });
+ }
+
+ for (const c of CATEGORIES) {
+ const v = String(form[`cat_${c.slug}`] || '');
+ // v === '' means "inherits global" — pass empty value to clear override.
+ setMode({ category: c.slug, value: v, actor: session.name });
+ }
+
+ res.writeHead(302, { location: '/admin/modes' });
+ res.end();
+}
+
+/* ----------------- helpers ----------------- */
+
+function renderPage(res, page, status = 200) {
+ const html = layout(page);
+ res.writeHead(status, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'no-store',
+ });
+ res.end(html);
+}
+
+function notFoundHtml(res) {
+ const page = {
+ title: 'Not here',
+ description: 'The requested admin page does not exist.',
+ body: `