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,
|
prometheus: BCH payments — invoices, price oracle, admin reconcile
Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
|
|
|
-- payment_status is one of: 'free' | 'pending' | 'paid'. Free is the opt-in
|
|
|
|
|
-- default (the launch wave underwrites hosting). When PAYMENT_REQUIRED is
|
|
|
|
|
-- set, new listings land as 'pending' and browse hides them until the
|
|
|
|
|
-- matching invoice settles.
|
|
|
|
|
payment_status TEXT NOT NULL DEFAULT 'free',
|
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
|
|
|
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
|
|
|
|
prometheus: BCH payments — invoices, price oracle, admin reconcile
Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
|
|
|
-- Invoices for the paid wave. One receiving address serves every invoice;
|
|
|
|
|
-- we distinguish them by amount — each invoice has a random few-sat jitter
|
|
|
|
|
-- on top of the base USD-equivalent, so no two in-flight invoices expect
|
|
|
|
|
-- the same number of sats to the same address at the same time.
|
|
|
|
|
CREATE TABLE IF NOT EXISTS invoices (
|
|
|
|
|
id TEXT PRIMARY KEY, -- opaque base32 token
|
|
|
|
|
listing_id TEXT NOT NULL REFERENCES listings(id) ON DELETE CASCADE,
|
|
|
|
|
address TEXT NOT NULL, -- the operator receive address
|
|
|
|
|
amount_sat INTEGER NOT NULL, -- exact sats expected
|
|
|
|
|
amount_usd TEXT NOT NULL, -- the USD figure the quote was based on
|
|
|
|
|
rate_sat_per_usd INTEGER NOT NULL, -- the price snapshot used for amount_sat
|
|
|
|
|
issued_at TEXT NOT NULL,
|
|
|
|
|
expires_at TEXT NOT NULL, -- 20 min after issued_at
|
|
|
|
|
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'paid' | 'expired'
|
|
|
|
|
paid_at TEXT,
|
|
|
|
|
paid_txid TEXT,
|
|
|
|
|
paid_by TEXT -- 'manual' | 'watcher'
|
|
|
|
|
);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_invoices_listing ON invoices(listing_id);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
|
|
|
|
|
|
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 = ?`);
|
|
|
|
|
|
prometheus: BCH payments — invoices, price oracle, admin reconcile
Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
|
|
|
// "Visible" rows are those that would be shown on browse — not withdrawn, not
|
|
|
|
|
// awaiting payment. 'free' and 'paid' both qualify as settled.
|
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
|
|
|
const stmtAllActive = db.prepare(`
|
prometheus: BCH payments — invoices, price oracle, admin reconcile
Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
|
|
|
SELECT * FROM listings
|
|
|
|
|
WHERE withdrawn_at IS NULL AND payment_status IN ('free', 'paid')
|
|
|
|
|
ORDER BY created_at DESC
|
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
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const stmtByCategory = db.prepare(`
|
|
|
|
|
SELECT * FROM listings
|
prometheus: BCH payments — invoices, price oracle, admin reconcile
Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
|
|
|
WHERE category = ?
|
|
|
|
|
AND withdrawn_at IS NULL
|
|
|
|
|
AND payment_status IN ('free', 'paid')
|
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
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const stmtByPublisher = db.prepare(`
|
|
|
|
|
SELECT * FROM listings WHERE publisher = ? ORDER BY created_at DESC
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const stmtCountActive = db.prepare(`
|
prometheus: BCH payments — invoices, price oracle, admin reconcile
Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
|
|
|
SELECT COUNT(*) AS n FROM listings
|
|
|
|
|
WHERE withdrawn_at IS NULL AND payment_status IN ('free', 'paid')
|
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
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const stmtCountByCategory = db.prepare(`
|
|
|
|
|
SELECT category, COUNT(*) AS n FROM listings
|
prometheus: BCH payments — invoices, price oracle, admin reconcile
Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
|
|
|
WHERE withdrawn_at IS NULL AND payment_status IN ('free', 'paid')
|
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
|
|
|
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);
|
|
|
|
|
}
|
prometheus: BCH payments — invoices, price oracle, admin reconcile
Payment flow for the paid wave (the free wave is still the default):
1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
listing lands with payment_status='pending' and is hidden from
browse. If not set, the listing goes straight to 'free' and is
visible immediately (today's behaviour, unchanged).
2. Anyone (publisher, their script, their wallet) POSTs
/api/invoice {listing_id} and gets back a one-shot invoice:
operator receive address, exact sat amount (USD-pegged + random
1..256 sat jitter so no two in-flight invoices collide),
bitcoincash: URI for a wallet, 20-min expiry.
3. Reader pays. The amount matches exactly one pending invoice, and
when the mempool watcher reports (address, amount, txid) to
tryMatchPayment, both the invoice and the listing flip to paid.
Browse starts showing the listing.
The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.
Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
BitPay used this for years; it is simple, auditable, and doesn't
require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
(address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
fallback). If both the fresh fetch and the stale fallback fail,
/api/invoice refuses — issuing invoices on a stale price is worse
than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
to 'expired' as a side effect, so there's no sweeper to run.
Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.
Config:
PAYMENT_REQUIRED=1 — turn paid mode on
PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address
PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD)
PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL
End-to-end verified:
PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
returns correct sats at live BCH price → admin login + mark-paid →
listing becomes visible → /api/invoice/<id> returns status 'paid'
with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
|
|
|
|
|
|
|
|
/* ----------------------- invoices ----------------------- */
|
|
|
|
|
|
|
|
|
|
const stmtInsertListingWithStatus = 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,
|
|
|
|
|
payment_status
|
|
|
|
|
) 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,
|
|
|
|
|
@payment_status
|
|
|
|
|
)
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
/** Insert a listing while explicitly setting payment_status (for paid mode). */
|
|
|
|
|
export function insertListingWithPaymentStatus(row) {
|
|
|
|
|
stmtInsertListingWithStatus.run(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stmtListingSetPaid = db.prepare(`
|
|
|
|
|
UPDATE listings SET payment_status = 'paid'
|
|
|
|
|
WHERE id = ? AND payment_status != 'paid'
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
export function markListingPaid(id) {
|
|
|
|
|
const info = stmtListingSetPaid.run(id);
|
|
|
|
|
return info.changes > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stmtInvInsert = db.prepare(`
|
|
|
|
|
INSERT INTO invoices (
|
|
|
|
|
id, listing_id, address, amount_sat, amount_usd, rate_sat_per_usd,
|
|
|
|
|
issued_at, expires_at
|
|
|
|
|
) VALUES (
|
|
|
|
|
@id, @listing_id, @address, @amount_sat, @amount_usd, @rate_sat_per_usd,
|
|
|
|
|
@issued_at, @expires_at
|
|
|
|
|
)
|
|
|
|
|
`);
|
|
|
|
|
const stmtInvGet = db.prepare(`SELECT * FROM invoices WHERE id = ?`);
|
|
|
|
|
const stmtInvByListing = db.prepare(`
|
|
|
|
|
SELECT * FROM invoices WHERE listing_id = ? ORDER BY issued_at DESC
|
|
|
|
|
`);
|
|
|
|
|
const stmtInvAll = db.prepare(`SELECT * FROM invoices ORDER BY issued_at DESC`);
|
|
|
|
|
const stmtInvMarkPaid = db.prepare(`
|
|
|
|
|
UPDATE invoices
|
|
|
|
|
SET status = 'paid', paid_at = @paid_at, paid_txid = @paid_txid, paid_by = @paid_by
|
|
|
|
|
WHERE id = @id AND status = 'pending'
|
|
|
|
|
`);
|
|
|
|
|
const stmtInvMarkExpired = db.prepare(`
|
|
|
|
|
UPDATE invoices SET status = 'expired'
|
|
|
|
|
WHERE id = ? AND status = 'pending' AND datetime(expires_at) < datetime('now')
|
|
|
|
|
`);
|
|
|
|
|
const stmtInvPendingByAddr = db.prepare(`
|
|
|
|
|
SELECT * FROM invoices
|
|
|
|
|
WHERE address = ? AND amount_sat = ? AND status = 'pending'
|
|
|
|
|
AND datetime(expires_at) >= datetime('now')
|
|
|
|
|
ORDER BY issued_at ASC
|
|
|
|
|
LIMIT 1
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
export function insertInvoice(row) { stmtInvInsert.run(row); }
|
|
|
|
|
export function getInvoice(id) { return stmtInvGet.get(id) || null; }
|
|
|
|
|
export function invoicesForListing(id) { return stmtInvByListing.all(id); }
|
|
|
|
|
export function allInvoices() { return stmtInvAll.all(); }
|
|
|
|
|
export function markInvoicePaid(args) { const info = stmtInvMarkPaid.run(args); return info.changes > 0; }
|
|
|
|
|
export function markInvoiceExpired(id) { const info = stmtInvMarkExpired.run(id); return info.changes > 0; }
|
|
|
|
|
export function matchPendingInvoice(address, amount_sat) {
|
|
|
|
|
return stmtInvPendingByAddr.get(address, amount_sat) || null;
|
|
|
|
|
}
|