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.
131 lines
4 KiB
JavaScript
131 lines
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);
|
|
`);
|
|
|
|
/* ----------------------- 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;
|
|
}
|