prometheus: signed listings — BIP-137 publisher sign, SQLite index
POST /api/listing accepts a JSON body with the manifest fields, a
reference to a signed scan report from /api/upload, and a BIP-137
signature. Before the row lands in SQLite the service verifies three
things:
1. the attached scan_report was signed by this service, is for the
exact (hash, size) the listing claims, and the verdict is clean —
a publisher cannot swap in a different file's clean report;
2. the publisher's signature recovers to a valid cashaddress, and
that address matches the on-chain owner of the publisher's BNS
name (gateway /api/name/<name>) — recovery alone is never enough
per [[recovery-is-not-verification]];
3. the usual shape checks: category in the allow-list, sha256 is 64
hex, download URL is http(s), slug matches [a-z0-9-]{1..64}, body
under 1 MiB.
GET /api/listings and GET /api/listings/<publisher>/<slug> are read-only
endpoints the browse milestone will render from.
MOCK_BNS_OWNER=<cashaddr> short-circuits the gateway call for local
testing without a chipnet gateway reachable — same pattern as MOCK_SCAN.
Dependencies:
- @bitauth/libauth, for the secp256k1 primitives behind BIP-137 recovery
and the cashaddress encoder. Same primitive Argus's registrar uses, so
signatures from Argus-compatible wallets verify unchanged.
- node:sqlite (ships with Node 22+) for the listings DB — one file next
to the service, WAL journaling, no native build step. engines bumped
to ">=22".
Lifted service/lib/sign-message.js from Argus so Prometheus does not
have to pull the resolver's whole dependency tree in for one verifier.
Removed its NETWORK import; the default address prefix comes from
BNS_NETWORK=chipnet|mainnet instead.
INTERIM: no APS1 record on chain yet. The signed row IS the listing;
when APS1 is defined cross-session the service will add an on-chain
publish step that points at the same signed manifest. The browse and
admin milestones can proceed against this DB in the meantime.
scripts/test-listing-roundtrip.mjs drives the whole flow end-to-end
with a deterministic test key, so a sanity check is a two-command
sequence: start the server with MOCK_SCAN=clean + MOCK_BNS_OWNER=<the
test address>, then run the script. Negative paths (fake scan report,
bad category, duplicate slug) also verified by hand.
2026-10-08 01:48:12 +02:00
|
|
|
/**
|
|
|
|
|
* 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);
|
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
|
|
|
|
|
|
|
|
-- 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)
|
|
|
|
|
);
|
prometheus: signed listings — BIP-137 publisher sign, SQLite index
POST /api/listing accepts a JSON body with the manifest fields, a
reference to a signed scan report from /api/upload, and a BIP-137
signature. Before the row lands in SQLite the service verifies three
things:
1. the attached scan_report was signed by this service, is for the
exact (hash, size) the listing claims, and the verdict is clean —
a publisher cannot swap in a different file's clean report;
2. the publisher's signature recovers to a valid cashaddress, and
that address matches the on-chain owner of the publisher's BNS
name (gateway /api/name/<name>) — recovery alone is never enough
per [[recovery-is-not-verification]];
3. the usual shape checks: category in the allow-list, sha256 is 64
hex, download URL is http(s), slug matches [a-z0-9-]{1..64}, body
under 1 MiB.
GET /api/listings and GET /api/listings/<publisher>/<slug> are read-only
endpoints the browse milestone will render from.
MOCK_BNS_OWNER=<cashaddr> short-circuits the gateway call for local
testing without a chipnet gateway reachable — same pattern as MOCK_SCAN.
Dependencies:
- @bitauth/libauth, for the secp256k1 primitives behind BIP-137 recovery
and the cashaddress encoder. Same primitive Argus's registrar uses, so
signatures from Argus-compatible wallets verify unchanged.
- node:sqlite (ships with Node 22+) for the listings DB — one file next
to the service, WAL journaling, no native build step. engines bumped
to ">=22".
Lifted service/lib/sign-message.js from Argus so Prometheus does not
have to pull the resolver's whole dependency tree in for one verifier.
Removed its NETWORK import; the default address prefix comes from
BNS_NETWORK=chipnet|mainnet instead.
INTERIM: no APS1 record on chain yet. The signed row IS the listing;
when APS1 is defined cross-session the service will add an on-chain
publish step that points at the same signed manifest. The browse and
admin milestones can proceed against this DB in the meantime.
scripts/test-listing-roundtrip.mjs drives the whole flow end-to-end
with a deterministic test key, so a sanity check is a two-command
sequence: start the server with MOCK_SCAN=clean + MOCK_BNS_OWNER=<the
test address>, then run the script. Negative paths (fake scan report,
bad category, duplicate slug) also verified by hand.
2026-10-08 01:48:12 +02:00
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
/* ----------------------- 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;
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
/* ----------------------- 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);
|
|
|
|
|
}
|