prometheus/service/audit.js

53 lines
1.9 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
/**
* 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(',') + '}';
}