prometheus/service/admin.js
Local Dev cb1589cf36 prometheus: admin panel — login, listings, 3-mode toggle, audit log
/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=<payload b64url>.<signature b64url>`, 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.
2026-10-08 21:35:48 +02:00

199 lines
6 KiB
JavaScript

/**
* 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: `<header class="hero compact"><h1>Not here</h1></header>
<div class="wrap"><section class="plain"><p>Go <a href="/admin">home</a>.</p></section></div>`,
};
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;
}