prometheus/service/admin-pages.js

434 lines
17 KiB
JavaScript
Raw Permalink Normal View History

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
/**
* 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')}
prometheus: BCH payments — invoices, price oracle, admin reconcile Payment flow for the paid wave (the free wave is still the default): 1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the listing lands with payment_status='pending' and is hidden from browse. If not set, the listing goes straight to 'free' and is visible immediately (today's behaviour, unchanged). 2. Anyone (publisher, their script, their wallet) POSTs /api/invoice {listing_id} and gets back a one-shot invoice: operator receive address, exact sat amount (USD-pegged + random 1..256 sat jitter so no two in-flight invoices collide), bitcoincash: URI for a wallet, 20-min expiry. 3. Reader pays. The amount matches exactly one pending invoice, and when the mempool watcher reports (address, amount, txid) to tryMatchPayment, both the invoice and the listing flip to paid. Browse starts showing the listing. The mempool watcher that would do (3) automatically is deliberately NOT in this commit — it needs an electrum-cash subscription or a block explorer webhook, and that's a next milestone. In the meantime, admins reconcile by hand from /admin/payments, which shows every invoice with a Mark paid form (optional txid field). Same effect on the DB, same audit log entry, different `paid_by` string. Design decisions worth recording: - One receive address + amount matching, NOT an HD wallet per invoice. BitPay used this for years; it is simple, auditable, and doesn't require a seed on the server to spend from later. - Random few-sat jitter per invoice guarantees uniqueness of the (address, amount) pair across all in-flight invoices. - Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale fallback). If both the fresh fetch and the stale fallback fail, /api/invoice refuses — issuing invoices on a stale price is worse than refusing to quote until the oracle recovers. - Quote is locked at issue time; the 20-min deadline is the lock. - Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice to 'expired' as a side effect, so there's no sweeper to run. Admin UI /admin/payments shows every invoice (pending/paid/expired), plus a dashboard-ish header with the configured mode + receive address. The gating config appears in the UI even when payments are disabled, so an operator spinning up a new instance can see at a glance what's set. Config: PAYMENT_REQUIRED=1 — turn paid mode on PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD) PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL End-to-end verified: PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice returns correct sats at live BCH price → admin login + mark-paid → listing becomes visible → /api/invoice/<id> returns status 'paid' with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
${link('/admin/payments', 'Payments')}
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
${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>`;
}
prometheus: BCH payments — invoices, price oracle, admin reconcile Payment flow for the paid wave (the free wave is still the default): 1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the listing lands with payment_status='pending' and is hidden from browse. If not set, the listing goes straight to 'free' and is visible immediately (today's behaviour, unchanged). 2. Anyone (publisher, their script, their wallet) POSTs /api/invoice {listing_id} and gets back a one-shot invoice: operator receive address, exact sat amount (USD-pegged + random 1..256 sat jitter so no two in-flight invoices collide), bitcoincash: URI for a wallet, 20-min expiry. 3. Reader pays. The amount matches exactly one pending invoice, and when the mempool watcher reports (address, amount, txid) to tryMatchPayment, both the invoice and the listing flip to paid. Browse starts showing the listing. The mempool watcher that would do (3) automatically is deliberately NOT in this commit — it needs an electrum-cash subscription or a block explorer webhook, and that's a next milestone. In the meantime, admins reconcile by hand from /admin/payments, which shows every invoice with a Mark paid form (optional txid field). Same effect on the DB, same audit log entry, different `paid_by` string. Design decisions worth recording: - One receive address + amount matching, NOT an HD wallet per invoice. BitPay used this for years; it is simple, auditable, and doesn't require a seed on the server to spend from later. - Random few-sat jitter per invoice guarantees uniqueness of the (address, amount) pair across all in-flight invoices. - Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale fallback). If both the fresh fetch and the stale fallback fail, /api/invoice refuses — issuing invoices on a stale price is worse than refusing to quote until the oracle recovers. - Quote is locked at issue time; the 20-min deadline is the lock. - Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice to 'expired' as a side effect, so there's no sweeper to run. Admin UI /admin/payments shows every invoice (pending/paid/expired), plus a dashboard-ish header with the configured mode + receive address. The gating config appears in the UI even when payments are disabled, so an operator spinning up a new instance can see at a glance what's set. Config: PAYMENT_REQUIRED=1 — turn paid mode on PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD) PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL End-to-end verified: PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice returns correct sats at live BCH price → admin login + mark-paid → listing becomes visible → /api/invoice/<id> returns status 'paid' with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
/* ------------------------------- payments ------------------------------- */
export function paymentsPage({ session, invoices, config, notice = null }) {
return {
title: 'Admin — payments',
description: 'Invoices and payment status.',
here: '/admin/payments',
body: `
${adminNav(session, '/admin/payments')}
<div class="wrap">
${notice ? `<div class="note" style="margin-top:1rem"><b>${escapeHtml(notice)}</b></div>` : ''}
<section class="plain">
<h2>Payments</h2>
<div class="grid" style="margin-top:1rem">
<div class="card">
<h3>${escapeHtml(config.required ? 'required' : 'off')}</h3>
<p>payment gate — ${escapeHtml(config.required ? 'new listings land as pending' : 'new listings publish immediately')}</p>
</div>
<div class="card">
<h3>$${escapeHtml(String(config.fee_usd))}</h3>
<p>listing fee per publication</p>
</div>
<div class="card">
<h3 style="word-break:break-all;font-size:14px;font-family:ui-monospace,monospace">${escapeHtml(config.address || '(not set)')}</h3>
<p>operator receive address</p>
</div>
</div>
${!config.address ? `
<div class="note warn" style="margin-top:1.4rem">
<b>No receive address configured.</b> Set <code class="inline">PROMETHEUS_PAYMENT_ADDRESS</code>
in the service environment and restart. Until then, /api/invoice refuses to issue.
</div>` : ''}
${config.required && !config.address ? `
<div class="note danger" style="margin-top:1rem">
<b>Payments are required but no address is set.</b> New listings are being created as
<code class="inline">pending</code> but cannot be settled. Set the address and restart,
or unset <code class="inline">PAYMENT_REQUIRED</code>.
</div>` : ''}
<h3 style="margin-top:1.8rem">Invoices (${invoices.length})</h3>
${invoices.length === 0 ? `<p class="lede">No invoices issued yet.</p>` : `
<table>
<thead><tr>
<th>id</th><th>listing</th><th>sats</th><th>status</th>
<th>issued</th><th>expires / paid</th><th></th>
</tr></thead>
<tbody>
${invoices.map((i) => renderInvoiceRow(i)).join('\n')}
</tbody>
</table>`}
</section>
<section>
<h2>Mempool watcher</h2>
<p class="lede">The mempool watcher that would auto-reconcile incoming payments to pending
invoices is a follow-up milestone. For now, confirm a payment by hand once it lands —
paste the txid into the Mark paid form on an invoice row. The listing becomes visible
on browse the moment the invoice flips to paid.</p>
</section>
</div>`,
};
}
function renderInvoiceRow(i) {
const paidChip =
i.status === 'paid' ? `<span class="pill p-live">paid</span>` :
i.status === 'expired' ? `<span class="pill" style="background:rgba(246,118,138,.14);color:var(--taken)">expired</span>` :
`<span class="pill p-upcoming">pending</span>`;
const rightCell = i.status === 'paid'
? `<div style="color:var(--dim);font-size:12.5px">${escapeHtml(i.paid_at)}<br><code class="inline" style="font-size:11px">${escapeHtml((i.paid_txid || '').slice(0, 20))}…</code></div>`
: `<div style="color:var(--dim);font-size:12.5px">${escapeHtml(i.expires_at)}</div>`;
const action = i.status === 'pending'
? `<form method="post" action="/admin/payments/${encodeURIComponent(i.id)}/mark-paid" style="margin:0;display:flex;gap:4px">
<input name="txid" placeholder="txid (optional)" style="padding:4px 6px;background:var(--panel);border:1px solid var(--line);border-radius:6px;color:var(--ink);font-size:12px;width:140px">
<button class="btn ghost" type="submit" style="padding:4px 10px;font-size:12.5px">Mark paid</button>
</form>`
: `<span style="color:var(--dim);font-size:12px">${i.status === 'paid' ? 'by ' + escapeHtml(i.paid_by || '') : ''}</span>`;
return `<tr>
<td><code class="inline" style="font-size:11px">${escapeHtml(i.id)}</code></td>
<td><code class="inline" style="font-size:11px">${escapeHtml(i.listing_id)}</code></td>
<td style="font-family:ui-monospace,monospace;font-size:13px">${i.amount_sat.toLocaleString()}</td>
<td>${paidChip}</td>
<td style="color:var(--dim);font-size:12.5px">${escapeHtml(i.issued_at)}</td>
<td>${rightCell}</td>
<td>${action}</td>
</tr>`;
}
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
/* ------------------------------- 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`;
}