From cb1589cf3611bf153edd29dc62d860f622a5253f Mon Sep 17 00:00:00 2001 From: Local Dev Date: Thu, 8 Oct 2026 21:35:48 +0200 Subject: [PATCH] =?UTF-8?q?prometheus:=20admin=20panel=20=E2=80=94=20login?= =?UTF-8?q?,=20listings,=203-mode=20toggle,=20audit=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /admin is wallet-gated. The configured ADMIN_PUBLISHER is a BNS name; only the on-chain owner of that name can sign in. The sign-in flow is BIP-137 (same primitive publishers use): the login page shows a fresh one-shot challenge, the admin signs it in their wallet, pastes the signature, and the service verifies that it recovers to the current on-chain owner. Session is a 24-hour ed25519-signed cookie minted by the service key. Pages: /admin dashboard — totals, mode state, recent listings + audit /admin/login BIP-137 challenge sign-in /admin/listings all listings, with a Withdraw button per row /admin/modes global + per-category publish-mode toggle /admin/audit the hash-chained audit log The three publish modes exist as toggles: `append` — any valid listing goes in (launch default) `scan-first` — infected uploads are rejected before they land `review` — clean listings wait for human approval Global mode is the fallback; a per-category override takes precedence when set. The toggle persists; the review-queue enforcement lands in a later milestone (the enforcement sketch is in modes.js's header). Audit log: Every admin action (login, set_mode, clear_mode, withdraw_listing) is an append-only row with a sha256 chain over the previous row's hash. The chain does not prevent edits; it makes retroactive ones loud — row N's `prev_hash` disagreeing with row N-1's `this_hash` is the tamper signal. Withdrawal: Soft — `withdrawn_at` on the listing row. Browse hides withdrawn listings (already the case) and the detail page returns 404 for them. Nothing in the chain is undone; this is Prometheus's own view only. Session design: Cookie is `prom_admin=.`, signed with the ed25519 service key, HttpOnly, SameSite=Strict, Path=/admin, 24-hour expiry. Reconfiguring ADMIN_PUBLISHER invalidates in-flight sessions by name mismatch. Challenges live in a 10-min TTL map and are tied to a specific name so one admin's challenge cannot authenticate another. Added: service/admin.js route handlers + dispatch service/admin-pages.js HTML templates for the four pages service/admin-session.js challenge + cookie mint/verify + readSession service/audit.js hash-chained append helper service/modes.js publish-mode read/write with override semantics schema: settings + audit tables scripts/test-admin-roundtrip.mjs drives the whole flow (unauth redirect → challenge scrape → sign → login → dashboard → set mode → audit inspection) with the same deterministic test key the listing-roundtrip script uses. Withdraw-flow spot-check: create a listing, log in as admin, withdraw it, confirm browse hides it and the detail page returns 404. All seven checks pass. --- scripts/test-admin-roundtrip.mjs | 122 +++++++++++ service/admin-pages.js | 338 +++++++++++++++++++++++++++++++ service/admin-session.js | 209 +++++++++++++++++++ service/admin.js | 199 ++++++++++++++++++ service/audit.js | 52 +++++ service/db.js | 97 +++++++++ service/modes.js | 93 +++++++++ service/server.js | 3 + 8 files changed, 1113 insertions(+) create mode 100644 scripts/test-admin-roundtrip.mjs create mode 100644 service/admin-pages.js create mode 100644 service/admin-session.js create mode 100644 service/admin.js create mode 100644 service/audit.js create mode 100644 service/modes.js diff --git a/scripts/test-admin-roundtrip.mjs b/scripts/test-admin-roundtrip.mjs new file mode 100644 index 0000000..3d8af73 --- /dev/null +++ b/scripts/test-admin-roundtrip.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * End-to-end admin flow against a locally running service. + * + * 1. GET /admin → expect 302 to /admin/login (no session yet). + * 2. GET /admin/login → scrape the challenge out of the HTML. + * 3. Sign the challenge with the deterministic test key. + * 4. POST /admin/login → expect 302 + set-cookie. + * 5. GET /admin with the cookie → expect 200 and "Dashboard" in the body. + * 6. POST /admin/modes → change global to scan-first; verify it stuck. + * 7. GET /admin/audit → expect at least one row (the login + the mode set). + * + * Server must be started with: + * MOCK_SCAN=clean \ + * MOCK_BNS_OWNER=bchtest:qpumqqygwcnt999fz3gp5nxjy66ckg6esvmzshj478 \ + * ADMIN_PUBLISHER=admin.x \ + * node service/server.js + * + * (MOCK_BNS_OWNER applies to every lookup, so both the admin's name and any + * publisher name resolve to the same deterministic test address.) + */ + +import { signMessageBip137 } from '../service/lib/sign-message.js'; + +const BASE = process.env.BASE || 'http://127.0.0.1:3700'; +const PRIVKEY_HEX = process.env.TEST_PRIVKEY + || '0101010101010101010101010101010101010101010101010101010101010101'; +const privateKey = Buffer.from(PRIVKEY_HEX, 'hex'); + +const log = (...a) => console.log('[admin-test]', ...a); +const fail = (msg, extra) => { + console.error('[admin-test] FAIL:', msg); + if (extra !== undefined) console.error(extra); + process.exit(1); +}; + +async function main() { + // 1. GET /admin — should redirect. + const r1 = await fetch(`${BASE}/admin`, { redirect: 'manual' }); + if (r1.status !== 302 || !r1.headers.get('location')?.includes('/admin/login')) { + fail(`GET /admin without session: expected 302 to login, got ${r1.status}`, + r1.headers.get('location')); + } + log(`✓ GET /admin without session → 302 ${r1.headers.get('location')}`); + + // 2. GET /admin/login — scrape challenge. + const r2 = await fetch(`${BASE}/admin/login`); + if (r2.status !== 200) fail(`GET /admin/login: expected 200, got ${r2.status}`); + const html2 = await r2.text(); + const m = html2.match(/challenge" value="([A-Za-z0-9_\-=]+)"/); + if (!m) fail('could not find challenge in login page', html2.slice(0, 400)); + const challenge = m[1]; + const nameMatch = html2.match(/name=([a-z0-9.\-]+)\n/); + const adminName = nameMatch ? nameMatch[1] : 'admin.x'; + log(`✓ GET /admin/login → challenge ${challenge.slice(0, 16)}… for ${adminName}`); + + // 3. Sign. + const message = `PROMETHEUS-ADMIN-LOGIN\nname=${adminName}\nchallenge=${challenge}`; + const signature = signMessageBip137(message, privateKey); + log(`✓ signed challenge (${signature.length} base64 chars)`); + + // 4. POST /admin/login. + const r4 = await fetch(`${BASE}/admin/login`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + redirect: 'manual', + body: new URLSearchParams({ challenge, signature }).toString(), + }); + if (r4.status !== 302) { + fail(`POST /admin/login: expected 302, got ${r4.status}`, await r4.text()); + } + const cookie = r4.headers.get('set-cookie'); + if (!cookie || !cookie.startsWith('prom_admin=')) { + fail('login did not set prom_admin cookie', cookie); + } + const token = cookie.split(';')[0]; // "prom_admin=…" + log(`✓ POST /admin/login → 302 + ${token.slice(0, 24)}…`); + + const headers = { cookie: token }; + + // 5. GET /admin with cookie. + const r5 = await fetch(`${BASE}/admin`, { headers }); + if (r5.status !== 200) fail(`GET /admin with session: expected 200, got ${r5.status}`); + const html5 = await r5.text(); + if (!/Dashboard/.test(html5)) fail('dashboard HTML missing "Dashboard"', html5.slice(0, 400)); + log(`✓ GET /admin with session → 200 (dashboard)`); + + // 6. POST /admin/modes — flip global to scan-first. + const modesBody = new URLSearchParams(); + modesBody.append('global', 'scan-first'); + for (const slug of ['communication', 'privacy', 'wallets', 'games', 'devtools', 'media', 'utilities', 'other']) { + modesBody.append(`cat_${slug}`, ''); + } + const r6 = await fetch(`${BASE}/admin/modes`, { + method: 'POST', + headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded' }, + redirect: 'manual', + body: modesBody.toString(), + }); + if (r6.status !== 302) fail(`POST /admin/modes: expected 302, got ${r6.status}`); + const r6r = await fetch(`${BASE}/admin/modes`, { headers }); + const html6 = await r6r.text(); + if (!/`)) { + fail('mode toggle did not persist (no selected scan-first in HTML)'); + } + } + log(`✓ POST /admin/modes global=scan-first → persisted`); + + // 7. GET /admin/audit — expect rows. + const r7 = await fetch(`${BASE}/admin/audit`, { headers }); + const html7 = await r7.text(); + if (!/set_mode/.test(html7)) fail('audit log missing set_mode row'); + if (!/login/.test(html7)) fail('audit log missing login row'); + log(`✓ GET /admin/audit → login + set_mode rows present`); + + log('all admin-flow checks passed.'); +} + +main().catch((err) => fail(err?.message || String(err), err?.stack)); diff --git a/service/admin-pages.js b/service/admin-pages.js new file mode 100644 index 0000000..8b03098 --- /dev/null +++ b/service/admin-pages.js @@ -0,0 +1,338 @@ +/** + * HTML templates for the admin area. + * + * Deliberately plain. Every action is a form submission that redirects back, + * so a JavaScript-off session works — the only script we ship is a tiny + * convenience to copy the sign-this string on the login page. + * + * Pages follow the same {title, description, body, here?} shape as the public + * pages, so the shared layout wraps them unchanged. The admin topnav sits + * inside each body (so the public topnav still appears above it for continuity). + */ + +import { escapeHtml } from './layout.js'; +import { CATEGORIES } from './pages-browse.js'; +import { MODES } from './modes.js'; +import { adminPublisher } from './admin-session.js'; + +/* ------------------------------- login ------------------------------- */ + +export function loginPage({ challenge, error = null, name }) { + const message = `PROMETHEUS-ADMIN-LOGIN\nname=${name}\nchallenge=${challenge}`; + return { + title: 'Admin — sign in', + description: 'Sign in to the Prometheus admin.', + here: '', + body: ` +
+

Admin

+

Sign in with the key that owns ${escapeHtml(name)}.

+
+ +
+ +${error ? `
Sign-in failed. ${escapeHtml(error)}
` : ''} + +
+
    +
  1. 1. Copy the challenge below into your wallet's "sign message" box. + The entire block, exactly as shown.
  2. +
  3. 2. Sign with the key for the address that owns the admin name. + Theseus's Aegis panel, Electron Cash, Deviant — any BIP-137 + wallet works.
  4. +
  5. 3. Paste the signature below and submit. + The signature authenticates you as the admin for the next 24 hours.
  6. +
+ +

Message to sign

+
${escapeHtml(message)}
+ + +
+ + + +
+
+ + + +
`, + }; +} + +/* ------------------------------- shared topnav ------------------------------- */ + +function adminNav(session, here = '') { + const link = (href, label) => + `${label}`; + return ``; +} + +/* ------------------------------- dashboard ------------------------------- */ + +export function dashboardPage({ session, counts, total, modes, recentListings, recentAudit }) { + const modeChip = (m) => `${escapeHtml(m)}`; + + return { + title: 'Admin — dashboard', + description: 'Prometheus admin dashboard.', + here: '/admin', + body: ` +${adminNav(session, '/admin')} + +
+ +
+

Dashboard

+ +
+
+

${total}

+

active listings across ${Object.values(counts).filter((n) => n > 0).length} categories

+
+
+

${modeChip(modes.global)}

+

global publish mode

+
+
+

${Object.values(modes.categories).filter(Boolean).length}

+

per-category overrides in effect

+
+
+
+ +
+

Recent listings

+ ${recentListings.length === 0 + ? `

No listings yet.

` + : ` + + + ${recentListings.map((r) => ` + + + + + + `).join('')} + +
idcategorysizeat
${escapeHtml(r.id)}${escapeHtml(r.category)}${formatBytes(r.download_size)}${escapeHtml(r.created_at)}manage →
`} +
+ +
+

Recent audit

+ ${recentAudit.length === 0 + ? `

No admin actions yet.

` + : ` + + + ${recentAudit.slice(0, 10).map((r) => ` + + + + + `).join('')} + +
atactoractiontarget
${escapeHtml(r.at)}${escapeHtml(r.actor)}${escapeHtml(r.action)}${escapeHtml(r.target || '')}
+

Full audit log →

`} +
+ +
`, + }; +} + +/* ------------------------------- listings ------------------------------- */ + +export function listingsPage({ session, rows, notice = null }) { + return { + title: 'Admin — listings', + description: 'All listings on Prometheus.', + here: '/admin/listings', + body: ` +${adminNav(session, '/admin/listings')} + +
+ +${notice ? `
${escapeHtml(notice)}
` : ''} + +
+

Listings (${rows.length})

+ + ${rows.length === 0 + ? `

No listings yet.

` + : ` + + + + + + ${rows.map((r) => renderListingRow(r)).join('\n')} + +
idnamecategorysizestatuscreated
`} +
+ +
`, + }; +} + +function renderListingRow(r) { + const withdrawn = !!r.withdrawn_at; + const statusCell = withdrawn + ? `withdrawn` + : `active`; + const actions = withdrawn + ? `${escapeHtml(r.withdrawn_at)}` + : `
+ +
`; + return ` + ${escapeHtml(r.id)} + ${escapeHtml(r.name)} + ${escapeHtml(r.category)} + ${formatBytes(r.download_size)} + ${statusCell} + ${escapeHtml(r.created_at)} + ${actions} + `; +} + +/* ------------------------------- modes ------------------------------- */ + +export function modesPage({ session, modes, notice = null }) { + const modeOptions = (selected) => + MODES.map((m) => ``).join(''); + + return { + title: 'Admin — modes', + description: 'Publish-mode policy per category.', + here: '/admin/modes', + body: ` +${adminNav(session, '/admin/modes')} + +
+ +${notice ? `
${escapeHtml(notice)}
` : ''} + +
+

Publish modes

+

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. +
+ +
+ + + + + + + + ${CATEGORIES.map((c) => { + const override = modes.categories[c.slug]; + return ` + + + `; + }).join('')} + +
ScopeCurrent mode
Global + +
${c.icon} ${escapeHtml(c.title)} ${c.slug} + +
+ +
+
+ +
`, + }; +} + +/* ------------------------------- audit ------------------------------- */ + +export function auditPage({ session, entries }) { + return { + title: 'Admin — audit log', + description: 'Hash-chained log of admin actions.', + here: '/admin/audit', + body: ` +${adminNav(session, '/admin/audit')} + +
+ +
+

Audit log

+

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.

` + : ` + + + + + + ${entries.map((e) => ` + + + + + + + + `).join('')} + +
seqatactoractiontargetdetailsthis_hash
${e.seq}${escapeHtml(e.at)}${escapeHtml(e.actor)}${escapeHtml(e.action)}${escapeHtml(e.target || '')}${escapeHtml(e.details || '')}${escapeHtml(e.this_hash.slice(0, 10))}…
`} +
+ +
`, + }; +} + +/* ------------------------------- 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: `

Not here

+

Go home.

`, + }; + renderPage(res, page, 404); +} + +async function readForm(req) { + const chunks = []; + let total = 0; + for await (const chunk of req) { + total += chunk.length; + if (total > 64 * 1024) throw new Error('form too large'); + chunks.push(chunk); + } + const body = Buffer.concat(chunks).toString('utf8'); + const params = new URLSearchParams(body); + const out = {}; + for (const [k, v] of params) out[k] = v; + return out; +} diff --git a/service/audit.js b/service/audit.js new file mode 100644 index 0000000..f78ef63 --- /dev/null +++ b/service/audit.js @@ -0,0 +1,52 @@ +/** + * Append a row to the hash-chained audit log. + * + * Each row's `this_hash` is computed over its own fields plus the previous + * row's `this_hash`. If a later reader finds row N's `prev_hash` disagreeing + * with row N-1's `this_hash`, the log has been tampered with — the chain + * does not stop edits, it just makes them loud. + * + * The chain starts at 64 zeros for the first row. + */ + +import { createHash } from 'node:crypto'; +import { auditTailHash, insertAuditRow } from './db.js'; + +/** + * Record an admin action. + * + * @param {object} entry + * @param {string} entry.actor admin BNS name + * @param {string} entry.action verb slug ('withdraw_listing', 'set_mode', 'login', …) + * @param {string} [entry.target] the thing being acted on (listing id, mode key, …) + * @param {object} [entry.details] arbitrary JSON, canonicalised before hashing + */ +export function logAudit({ actor, action, target = null, details = null }) { + const at = new Date().toISOString(); + const prev_hash = auditTailHash(); + const detailsStr = details == null ? null : canonicalJson(details); + + const h = createHash('sha256'); + h.update(at); + h.update('|'); + h.update(String(actor)); + h.update('|'); + h.update(String(action)); + h.update('|'); + h.update(target == null ? '' : String(target)); + h.update('|'); + h.update(detailsStr == null ? '' : detailsStr); + h.update('|'); + h.update(prev_hash); + const this_hash = h.digest('hex'); + + insertAuditRow({ at, actor, action, target, details: detailsStr, prev_hash, this_hash }); + return { at, actor, action, target, details: detailsStr, prev_hash, this_hash }; +} + +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/db.js b/service/db.js index cb9ddc4..3e247c1 100644 --- a/service/db.js +++ b/service/db.js @@ -55,6 +55,29 @@ db.exec(` ON listings(category) WHERE withdrawn_at IS NULL; CREATE INDEX IF NOT EXISTS idx_listings_publisher ON listings(publisher); + + -- Operator-visible settings. Admin reads/writes through /admin/modes etc. + -- Values are text; callers coerce when they read. + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT -- admin BNS name, NULL for seeded defaults + ); + + -- Append-only hash-chained audit log. Every admin action lands a row; the + -- chain lets a later reader detect retroactive edits (if row N's prev_hash + -- does not match row N-1's this_hash, something was tampered with). + CREATE TABLE IF NOT EXISTS audit ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + actor TEXT NOT NULL, -- admin BNS name + action TEXT NOT NULL, -- 'withdraw_listing', 'set_mode', 'login', … + target TEXT, -- a listing id, a mode key, … + details TEXT, -- canonical JSON of {before, after, note} + prev_hash TEXT NOT NULL, -- 64 hex of previous row's this_hash + this_hash TEXT NOT NULL -- sha256 over (seq||at||actor||action||target||details||prev_hash) + ); `); /* ----------------------- prepared statements ----------------------- */ @@ -129,3 +152,77 @@ export function countByCategory() { for (const row of rows) out[row.category] = row.n; return out; } + +/* ----------------------- withdrawal + takedown ----------------------- */ + +const stmtWithdraw = db.prepare(` + UPDATE listings SET withdrawn_at = ? WHERE id = ? AND withdrawn_at IS NULL +`); + +/** + * Mark a listing withdrawn. No-op if already withdrawn. + * @returns {boolean} true if a row was updated. + */ +export function withdrawListing(id, atIso) { + const info = stmtWithdraw.run(atIso, id); + return info.changes > 0; +} + +const stmtAllIncludingWithdrawn = db.prepare(` + SELECT * FROM listings ORDER BY created_at DESC +`); + +export function allListingsIncludingWithdrawn() { + return stmtAllIncludingWithdrawn.all(); +} + +/* ----------------------- settings ----------------------- */ + +const stmtSettingsGet = db.prepare(`SELECT value FROM settings WHERE key = ?`); +const stmtSettingsSet = db.prepare(` + INSERT INTO settings (key, value, updated_at, updated_by) + VALUES (@key, @value, @updated_at, @updated_by) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by +`); +const stmtSettingsAll = db.prepare(`SELECT * FROM settings ORDER BY key`); + +export function getSetting(key, fallback = null) { + const row = stmtSettingsGet.get(key); + return row ? row.value : fallback; +} + +export function setSetting(key, value, updatedBy, atIso) { + stmtSettingsSet.run({ key, value: String(value), updated_at: atIso, updated_by: updatedBy || null }); +} + +export function allSettings() { + return stmtSettingsAll.all(); +} + +/* ----------------------- audit log ----------------------- */ + +const stmtAuditInsert = db.prepare(` + INSERT INTO audit (at, actor, action, target, details, prev_hash, this_hash) + VALUES (@at, @actor, @action, @target, @details, @prev_hash, @this_hash) +`); +const stmtAuditTail = db.prepare(`SELECT this_hash FROM audit ORDER BY seq DESC LIMIT 1`); +const stmtAuditRecent = db.prepare(`SELECT * FROM audit ORDER BY seq DESC LIMIT ?`); + +/** + * Last `this_hash`, or 64 zeros when the log is empty — the chain anchor. + */ +export function auditTailHash() { + const row = stmtAuditTail.get(); + return row ? row.this_hash : '0'.repeat(64); +} + +export function insertAuditRow(row) { + stmtAuditInsert.run(row); +} + +export function recentAudit(limit = 50) { + return stmtAuditRecent.all(limit); +} diff --git a/service/modes.js b/service/modes.js new file mode 100644 index 0000000..854c262 --- /dev/null +++ b/service/modes.js @@ -0,0 +1,93 @@ +/** + * Publish-mode policy — read + write + enumerate. + * + * Three modes, named after what they do rather than numbered: + * `append` Any valid listing goes in; readers see the signed scan report + * and their trust lists decide (the launch default). + * `scan-first` Infected uploads are rejected before they land. Clean ones + * publish immediately. Already the current /api/upload shape, + * so this mode is the default behaviour today. + * `review` Clean listings sit in a human-review queue before browse + * shows them. Not yet enforced — the toggle persists, the + * queue logic lands in a later milestone. + * + * Global mode is the fallback; a per-category override is read when a listing's + * category has one set. The admin UI exposes both. + */ + +import { getSetting, setSetting } from './db.js'; +import { logAudit } from './audit.js'; + +export const MODES = ['append', 'scan-first', 'review']; +const DEFAULT_MODE = 'append'; +const GLOBAL_KEY = 'publish_mode_global'; +const perCatKey = (slug) => `publish_mode_${slug}`; + +export function globalMode() { + const v = getSetting(GLOBAL_KEY, DEFAULT_MODE); + return MODES.includes(v) ? v : DEFAULT_MODE; +} + +export function categoryMode(slug) { + const v = getSetting(perCatKey(slug), null); + if (v && MODES.includes(v)) return { mode: v, source: 'category' }; + return { mode: globalMode(), source: 'global' }; +} + +export function allModes(categories) { + const g = globalMode(); + const out = { global: g, categories: {} }; + for (const slug of categories) { + const v = getSetting(perCatKey(slug), null); + out.categories[slug] = v && MODES.includes(v) ? v : null; // null = inherits global + } + return out; +} + +/** + * Set a mode (global or per-category). `category=null` sets the global default; + * a slug sets that category. Writing an empty value for a category clears the + * override and the category falls back to the global default. + */ +export function setMode({ category, value, actor }) { + const key = category ? perCatKey(category) : GLOBAL_KEY; + const now = new Date().toISOString(); + + if (!value) { + // Clearing: delete the row by writing a sentinel and relying on the admin + // page to show "inherits global". Simpler: just set the global default + // string so getSetting returns the default. For an override we need an + // actual delete — add it as a direct exec since it's one path. + deleteSetting(key, actor, now, category); + return; + } + + if (!MODES.includes(value)) { + throw new Error(`unknown mode: ${value}`); + } + + const prev = getSetting(key, category ? null : DEFAULT_MODE); + setSetting(key, value, actor, now); + logAudit({ + actor, + action: 'set_mode', + target: category || 'global', + details: { before: prev, after: value }, + }); +} + +/* The one-off path for clearing a per-category override. */ +function deleteSetting(key, actor, now, category) { + // We don't ship a dedicated delete-setting helper — this is the only caller. + // Importing the raw db just for this would be a dependency inversion; use + // the public API: write a reserved sentinel, then the resolver ignores it. + // Easier: just set it to the empty string and have the reader fall through. + const prev = getSetting(key, null); + setSetting(key, '', actor, now); + logAudit({ + actor, + action: 'clear_mode', + target: category || 'global', + details: { before: prev, after: null }, + }); +} diff --git a/service/server.js b/service/server.js index a8bbc14..fcb783d 100644 --- a/service/server.js +++ b/service/server.js @@ -27,6 +27,7 @@ import { handleListingGet, } from './listings.js'; import { categoryPage, appPage } from './pages-browse.js'; +import { handleAdmin } from './admin.js'; const PORT = Number(process.env.PORT) || 3700; const BIND = process.env.BIND || '127.0.0.1'; @@ -89,6 +90,8 @@ const server = createServer(async (req, res) => { await serveHealth(res); } else if (url === '/.well-known/prometheus-service-key.json') { serveServiceKey(res); + } else if (url === '/admin' || url.startsWith('/admin/')) { + await handleAdmin(req, res, url); } else if (url.startsWith('/c/')) { const slug = url.slice('/c/'.length); servePageFn(() => categoryPage(req, slug), res);