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.
This commit is contained in:
Local Dev 2026-10-08 21:35:48 +02:00
parent bb07c73aaf
commit cb1589cf36
8 changed files with 1113 additions and 0 deletions

View file

@ -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 (!/<option value="scan-first" selected>scan-first<\/option>[\s\S]*?Global/.test(html6)
&& !/Global[\s\S]*?<option value="scan-first" selected>scan-first<\/option>/.test(html6)) {
// Simpler check: just verify the string appears for the global row
if (!html6.includes(`<option value="scan-first" selected>scan-first</option>`)) {
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));

338
service/admin-pages.js Normal file
View file

@ -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: `
<header class="hero compact">
<h1>Admin</h1>
<p class="tag">Sign in with the key that owns <code class="inline">${escapeHtml(name)}</code>.</p>
</header>
<div class="wrap narrow">
${error ? `<div class="note danger"><b>Sign-in failed.</b> ${escapeHtml(error)}</div>` : ''}
<section class="plain">
<ol class="steps">
<li><b>1. Copy the challenge below into your wallet's "sign message" box.</b>
<span>The entire block, exactly as shown.</span></li>
<li><b>2. Sign with the key for the address that owns the admin name.</b>
<span>Theseus's Aegis panel, Electron Cash, Deviant — any BIP-137
wallet works.</span></li>
<li><b>3. Paste the signature below and submit.</b>
<span>The signature authenticates you as the admin for the next 24 hours.</span></li>
</ol>
<h3 style="margin-top:1.6rem">Message to sign</h3>
<pre id="msg">${escapeHtml(message)}</pre>
<button class="btn ghost" type="button" onclick="copy()">Copy message</button>
<form method="post" action="/admin/login" style="margin-top:1.6rem;display:grid;gap:12px">
<input type="hidden" name="challenge" value="${escapeHtml(challenge)}">
<label style="color:var(--mut);font-size:14px">
Signature (base64)
<textarea name="signature" required rows="3"
style="width:100%;margin-top:.3rem;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:10px;color:var(--ink);font-family:ui-monospace,monospace;font-size:13px"></textarea>
</label>
<button class="btn acid" type="submit">Sign in</button>
</form>
</section>
<script>
function copy() {
const t = document.getElementById('msg').textContent;
navigator.clipboard.writeText(t).then(
() => { const b = event.target; const old = b.textContent; b.textContent = 'copied'; setTimeout(() => b.textContent = old, 1200); },
() => alert('copy failed — select the message and ctrl+c')
);
}
</script>
</div>`,
};
}
/* ------------------------------- shared topnav ------------------------------- */
function adminNav(session, here = '') {
const link = (href, label) =>
`<a href="${href}" style="${here === href ? 'color:var(--acid);background:rgba(214,255,61,.08);' : ''}padding:6px 12px;border-radius:8px;text-decoration:none;color:var(--mut);font-size:13.5px">${label}</a>`;
return `<nav style="padding:12px 1.2rem;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:4px;align-items:center;flex-wrap:wrap">
<b style="color:var(--ink);margin-right:14px;font-size:14px">Admin</b>
${link('/admin', 'Dashboard')}
${link('/admin/listings', 'Listings')}
${link('/admin/modes', 'Modes')}
${link('/admin/audit', 'Audit')}
<span style="flex:1"></span>
<span style="color:var(--dim);font-size:12.5px">${escapeHtml(session.name)}</span>
<form method="post" action="/admin/logout" style="margin:0">
<button class="btn ghost" type="submit" style="padding:5px 10px;font-size:12.5px">Sign out</button>
</form>
</nav>`;
}
/* ------------------------------- dashboard ------------------------------- */
export function dashboardPage({ session, counts, total, modes, recentListings, recentAudit }) {
const modeChip = (m) => `<span class="pill p-${m === 'append' ? 'live' : m === 'scan-first' ? 'alpha' : 'designed'}">${escapeHtml(m)}</span>`;
return {
title: 'Admin — dashboard',
description: 'Prometheus admin dashboard.',
here: '/admin',
body: `
${adminNav(session, '/admin')}
<div class="wrap">
<section class="plain">
<h2>Dashboard</h2>
<div class="grid" style="margin-top:1rem">
<div class="card">
<h3>${total}</h3>
<p>active listings across ${Object.values(counts).filter((n) => n > 0).length} categories</p>
</div>
<div class="card">
<h3>${modeChip(modes.global)}</h3>
<p>global publish mode</p>
</div>
<div class="card">
<h3>${Object.values(modes.categories).filter(Boolean).length}</h3>
<p>per-category overrides in effect</p>
</div>
</div>
</section>
<section>
<h2>Recent listings</h2>
${recentListings.length === 0
? `<p class="lede">No listings yet.</p>`
: `<table>
<thead><tr><th>id</th><th>category</th><th>size</th><th>at</th><th></th></tr></thead>
<tbody>
${recentListings.map((r) => `<tr>
<td><code class="inline">${escapeHtml(r.id)}</code></td>
<td>${escapeHtml(r.category)}</td>
<td>${formatBytes(r.download_size)}</td>
<td style="color:var(--dim);font-size:12.5px">${escapeHtml(r.created_at)}</td>
<td><a href="/admin/listings#${escapeHtml(r.id)}" style="font-size:12.5px">manage →</a></td>
</tr>`).join('')}
</tbody>
</table>`}
</section>
<section>
<h2>Recent audit</h2>
${recentAudit.length === 0
? `<p class="lede">No admin actions yet.</p>`
: `<table>
<thead><tr><th>at</th><th>actor</th><th>action</th><th>target</th></tr></thead>
<tbody>
${recentAudit.slice(0, 10).map((r) => `<tr>
<td style="color:var(--dim);font-size:12.5px">${escapeHtml(r.at)}</td>
<td>${escapeHtml(r.actor)}</td>
<td>${escapeHtml(r.action)}</td>
<td style="color:var(--mut)">${escapeHtml(r.target || '')}</td>
</tr>`).join('')}
</tbody>
</table>
<p style="margin-top:.8rem"><a href="/admin/audit">Full audit log →</a></p>`}
</section>
</div>`,
};
}
/* ------------------------------- 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')}
<div class="wrap">
${notice ? `<div class="note" style="margin-top:1rem"><b>${escapeHtml(notice)}</b></div>` : ''}
<section class="plain">
<h2>Listings (${rows.length})</h2>
${rows.length === 0
? `<p class="lede">No listings yet.</p>`
: `<table>
<thead><tr>
<th>id</th><th>name</th><th>category</th><th>size</th>
<th>status</th><th>created</th><th></th>
</tr></thead>
<tbody>
${rows.map((r) => renderListingRow(r)).join('\n')}
</tbody>
</table>`}
</section>
</div>`,
};
}
function renderListingRow(r) {
const withdrawn = !!r.withdrawn_at;
const statusCell = withdrawn
? `<span class="pill" style="background:rgba(246,118,138,.14);color:var(--taken)">withdrawn</span>`
: `<span class="pill p-live">active</span>`;
const actions = withdrawn
? `<span style="color:var(--dim);font-size:12.5px">${escapeHtml(r.withdrawn_at)}</span>`
: `<form method="post" action="/admin/listings/${encodeURIComponent(r.id)}/withdraw" style="margin:0"
onsubmit="return confirm('Withdraw ' + ${JSON.stringify(r.id)} + '? Clients will stop showing it in browse.');">
<button class="btn ghost" type="submit" style="padding:5px 10px;font-size:12.5px">Withdraw</button>
</form>`;
return `<tr id="${escapeHtml(r.id)}">
<td><a href="/app/${escapeHtml(r.publisher)}/${escapeHtml(r.slug)}" target="_blank" rel="noopener"><code class="inline">${escapeHtml(r.id)}</code></a></td>
<td>${escapeHtml(r.name)}</td>
<td>${escapeHtml(r.category)}</td>
<td>${formatBytes(r.download_size)}</td>
<td>${statusCell}</td>
<td style="color:var(--dim);font-size:12.5px">${escapeHtml(r.created_at)}</td>
<td>${actions}</td>
</tr>`;
}
/* ------------------------------- modes ------------------------------- */
export function modesPage({ session, modes, notice = null }) {
const modeOptions = (selected) =>
MODES.map((m) => `<option value="${m}"${m === selected ? ' selected' : ''}>${m}</option>`).join('');
return {
title: 'Admin — modes',
description: 'Publish-mode policy per category.',
here: '/admin/modes',
body: `
${adminNav(session, '/admin/modes')}
<div class="wrap">
${notice ? `<div class="note" style="margin-top:1rem"><b>${escapeHtml(notice)}</b></div>` : ''}
<section class="plain">
<h2>Publish modes</h2>
<p class="lede">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.</p>
<div class="note">
<b>append</b> — any valid listing goes in; readers see the signed scan report and decide.<br>
<b>scan-first</b> — infected uploads are rejected before they land. Clean ones publish.<br>
<b>review</b> — clean listings sit in a human-review queue before browse shows them. <em>Toggle persists; enforcement lands in a later milestone.</em>
</div>
<form method="post" action="/admin/modes" style="margin-top:1.6rem">
<table>
<thead><tr><th>Scope</th><th>Current mode</th></tr></thead>
<tbody>
<tr>
<td><b>Global</b></td>
<td>
<select name="global" style="padding:6px 10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink)">
${modeOptions(modes.global)}
</select>
</td>
</tr>
${CATEGORIES.map((c) => {
const override = modes.categories[c.slug];
return `<tr>
<td>${c.icon} ${escapeHtml(c.title)} <code class="inline" style="font-size:11px;color:var(--dim)">${c.slug}</code></td>
<td>
<select name="cat_${c.slug}" style="padding:6px 10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink)">
<option value=""${override ? '' : ' selected'}>— inherits global —</option>
${MODES.map((m) => `<option value="${m}"${override === m ? ' selected' : ''}>${m}</option>`).join('')}
</select>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
<button class="btn acid" type="submit" style="margin-top:1.2rem">Save modes</button>
</form>
</section>
</div>`,
};
}
/* ------------------------------- 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')}
<div class="wrap">
<section class="plain">
<h2>Audit log</h2>
<p class="lede">Append-only. Each row's <code class="inline">this_hash</code>
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.</p>
${entries.length === 0
? `<p class="lede" style="margin-top:1rem">No admin actions yet.</p>`
: `<table>
<thead><tr>
<th>seq</th><th>at</th><th>actor</th><th>action</th>
<th>target</th><th>details</th><th>this_hash</th>
</tr></thead>
<tbody>
${entries.map((e) => `<tr>
<td>${e.seq}</td>
<td style="color:var(--dim);font-size:12px">${escapeHtml(e.at)}</td>
<td>${escapeHtml(e.actor)}</td>
<td>${escapeHtml(e.action)}</td>
<td><code class="inline">${escapeHtml(e.target || '')}</code></td>
<td style="font-size:12px;color:var(--mut);max-width:260px;word-break:break-all">${escapeHtml(e.details || '')}</td>
<td style="font-size:11px;color:var(--dim);font-family:ui-monospace,monospace">${escapeHtml(e.this_hash.slice(0, 10))}…</td>
</tr>`).join('')}
</tbody>
</table>`}
</section>
</div>`,
};
}
/* ------------------------------- 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`;
}

209
service/admin-session.js Normal file
View file

@ -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(',') + '}';
}

199
service/admin.js Normal file
View file

@ -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: `<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;
}

52
service/audit.js Normal file
View file

@ -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(',') + '}';
}

View file

@ -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);
}

93
service/modes.js Normal file
View file

@ -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 },
});
}

View file

@ -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);