prometheus/service/db.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

228 lines
7.4 KiB
JavaScript

/**
* The listings database — a SQLite file next to the service.
*
* The service is small enough that one file + prepared statements beats a
* server-side DB, and node:sqlite (Node 22+) keeps that free from a native
* build step. Everything synchronous — fine for the volume we expect at
* launch, and the schema migrates easily to Postgres if volume forces it.
*
* The schema deliberately duplicates the publisher BNS name into the row:
* a listing is identified by `<publisher>/<slug>`, and that composite is
* what browse URLs point at. Changing a publisher name later would mean a
* new row, not an UPDATE — which matches reality (ownership transfers of a
* BNS name do not transfer the apps published under it).
*/
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const DB_PATH = process.env.PROMETHEUS_DB
|| resolve(dirname(fileURLToPath(import.meta.url)), '..', 'db.sqlite');
mkdirSync(dirname(DB_PATH), { recursive: true });
export const db = new DatabaseSync(DB_PATH);
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS listings (
id TEXT PRIMARY KEY,
publisher TEXT NOT NULL,
slug TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
version TEXT NOT NULL,
homepage TEXT,
icon TEXT,
download_url TEXT NOT NULL,
download_sha256 TEXT NOT NULL,
download_size INTEGER NOT NULL,
scan_report TEXT NOT NULL, -- the base64url signed envelope from /api/upload
signer_address TEXT NOT NULL,
signature TEXT NOT NULL, -- the publisher's BIP-137 signature
signed_message TEXT NOT NULL, -- exact bytes the publisher signed
created_at TEXT NOT NULL,
withdrawn_at TEXT,
UNIQUE (publisher, slug)
);
CREATE INDEX IF NOT EXISTS idx_listings_category
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 ----------------------- */
const stmtInsert = db.prepare(`
INSERT INTO listings (
id, publisher, slug, name, description, category, version,
homepage, icon, download_url, download_sha256, download_size,
scan_report, signer_address, signature, signed_message, created_at
) VALUES (
@id, @publisher, @slug, @name, @description, @category, @version,
@homepage, @icon, @download_url, @download_sha256, @download_size,
@scan_report, @signer_address, @signature, @signed_message, @created_at
)
`);
const stmtGet = db.prepare(`SELECT * FROM listings WHERE id = ?`);
const stmtAllActive = db.prepare(`
SELECT * FROM listings WHERE withdrawn_at IS NULL ORDER BY created_at DESC
`);
const stmtByCategory = db.prepare(`
SELECT * FROM listings
WHERE category = ? AND withdrawn_at IS NULL
ORDER BY created_at DESC
`);
const stmtByPublisher = db.prepare(`
SELECT * FROM listings WHERE publisher = ? ORDER BY created_at DESC
`);
const stmtCountActive = db.prepare(`
SELECT COUNT(*) AS n FROM listings WHERE withdrawn_at IS NULL
`);
const stmtCountByCategory = db.prepare(`
SELECT category, COUNT(*) AS n FROM listings
WHERE withdrawn_at IS NULL
GROUP BY category
`);
/* ----------------------- public helpers ----------------------- */
export function insertListing(row) {
stmtInsert.run(row);
}
export function getListing(id) {
return stmtGet.get(id) || null;
}
export function allActiveListings() {
return stmtAllActive.all();
}
export function listingsByCategory(category) {
return stmtByCategory.all(category);
}
export function listingsByPublisher(publisher) {
return stmtByPublisher.all(publisher);
}
export function countActive() {
return stmtCountActive.get().n;
}
export function countByCategory() {
const rows = stmtCountByCategory.all();
const out = {};
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);
}