/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.
338 lines
13 KiB
JavaScript
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`;
|
|
}
|