/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.
228 lines
7.4 KiB
JavaScript
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);
|
|
}
|