prometheus/service/db.js

132 lines
4 KiB
JavaScript
Raw Normal View History

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