prometheus/service/admin-pages.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

338 lines
13 KiB
JavaScript

/**
* 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`;
}