prometheus/service/modes.js
Local Dev cb1589cf36 prometheus: admin panel — login, listings, 3-mode toggle, audit log
/admin is wallet-gated. The configured ADMIN_PUBLISHER is a BNS name;
only the on-chain owner of that name can sign in. The sign-in flow is
BIP-137 (same primitive publishers use): the login page shows a fresh
one-shot challenge, the admin signs it in their wallet, pastes the
signature, and the service verifies that it recovers to the current
on-chain owner. Session is a 24-hour ed25519-signed cookie minted by
the service key.

Pages:
  /admin             dashboard — totals, mode state, recent listings + audit
  /admin/login       BIP-137 challenge sign-in
  /admin/listings    all listings, with a Withdraw button per row
  /admin/modes       global + per-category publish-mode toggle
  /admin/audit       the hash-chained audit log

The three publish modes exist as toggles:
  `append`     — any valid listing goes in (launch default)
  `scan-first` — infected uploads are rejected before they land
  `review`     — clean listings wait for human approval
Global mode is the fallback; a per-category override takes precedence
when set. The toggle persists; the review-queue enforcement lands in a
later milestone (the enforcement sketch is in modes.js's header).

Audit log:
  Every admin action (login, set_mode, clear_mode, withdraw_listing) is
  an append-only row with a sha256 chain over the previous row's hash.
  The chain does not prevent edits; it makes retroactive ones loud —
  row N's `prev_hash` disagreeing with row N-1's `this_hash` is the
  tamper signal.

Withdrawal:
  Soft — `withdrawn_at` on the listing row. Browse hides withdrawn
  listings (already the case) and the detail page returns 404 for them.
  Nothing in the chain is undone; this is Prometheus's own view only.

Session design:
  Cookie is `prom_admin=<payload b64url>.<signature b64url>`, signed with
  the ed25519 service key, HttpOnly, SameSite=Strict, Path=/admin,
  24-hour expiry. Reconfiguring ADMIN_PUBLISHER invalidates in-flight
  sessions by name mismatch. Challenges live in a 10-min TTL map and
  are tied to a specific name so one admin's challenge cannot authenticate
  another.

Added:
  service/admin.js         route handlers + dispatch
  service/admin-pages.js   HTML templates for the four pages
  service/admin-session.js challenge + cookie mint/verify + readSession
  service/audit.js         hash-chained append helper
  service/modes.js         publish-mode read/write with override semantics
  schema: settings + audit tables

scripts/test-admin-roundtrip.mjs drives the whole flow (unauth redirect
→ challenge scrape → sign → login → dashboard → set mode → audit
inspection) with the same deterministic test key the listing-roundtrip
script uses. Withdraw-flow spot-check: create a listing, log in as
admin, withdraw it, confirm browse hides it and the detail page returns
404. All seven checks pass.
2026-10-08 21:35:48 +02:00

93 lines
3.4 KiB
JavaScript

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