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:
parent
bb07c73aaf
commit
cb1589cf36
8 changed files with 1113 additions and 0 deletions
122
scripts/test-admin-roundtrip.mjs
Normal file
122
scripts/test-admin-roundtrip.mjs
Normal 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
338
service/admin-pages.js
Normal 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
209
service/admin-session.js
Normal 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
199
service/admin.js
Normal 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
52
service/audit.js
Normal 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(',') + '}';
|
||||
}
|
||||
|
|
@ -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
93
service/modes.js
Normal 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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue