prometheus/service/server.js
Local Dev 34efb319cc 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

286 lines
9.3 KiB
JavaScript

/**
* Prometheus HTTP server.
*
* One `node:http` listener, a map-based router for page routes, and a tiny
* static-file handler for /static/*. No framework, no templating engine —
* the response-rendering path for a page is: look up the handler, call it,
* wrap the result in `layout()`, flush. If any step of that stops reading
* at a glance, we have gotten too clever.
*
* Dynamic routes land here later: /api/upload, /api/manifest, /api/publish,
* /api/pay, /admin/*. For now the server is only serving pages, so the
* router is only pages.
*/
import { createServer } from 'node:http';
import { createReadStream, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, normalize, resolve } from 'node:path';
import { layout } from './layout.js';
import * as pages from './pages.js';
import { handleUpload } from './uploads.js';
import { clamdPing } from './clamd.js';
import { publicKeyBase64, loadServiceKey } from './signing.js';
import {
handleListingCreate,
handleListingIndex,
handleListingGet,
} from './listings.js';
import { categoryPage, appPage, CATEGORIES } from './pages-browse.js';
import { handleAdmin } from './admin.js';
import { renderSitemap, renderRobots } from './seo.js';
import { allActiveListings } from './db.js';
import { handleInvoiceCreate, handleInvoiceGet } from './invoice-api.js';
const PORT = Number(process.env.PORT) || 3700;
const BIND = process.env.BIND || '127.0.0.1';
// Load the service key on startup so a missing-key problem shows up in the
// terminal when the operator reads the launch log, not under a POST later.
loadServiceKey();
const SELF = fileURLToPath(import.meta.url);
const SERVICE_DIR = dirname(SELF);
const PUBLIC_DIR = resolve(SERVICE_DIR, '..', 'public');
/**
* The page router.
*
* Exact-match paths first. Patterns (category pages, listing detail) will
* attach here when the browse milestone lands.
*/
const PAGE_ROUTES = {
'/': pages.home,
'/publish': pages.publish,
'/browse': pages.browse,
'/submit': pages.submit,
'/boundaries': pages.boundaries,
};
/**
* Content types for static files. Deliberately short — if the server
* learns to serve a type that is not here, add it here rather than
* reaching for `mime-types`.
*/
const MIME = {
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.js': 'text/javascript; charset=utf-8',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.txt': 'text/plain; charset=utf-8',
};
/* ============ server ============ */
const server = createServer(async (req, res) => {
const started = Date.now();
const url = normalizeRequestUrl(req.url);
try {
if (url.startsWith('/static/')) {
await serveStatic(url.slice('/static/'.length), res);
} else if (url === '/api/upload') {
await handleUpload(req, res);
} else if (url === '/api/listing') {
await handleListingCreate(req, res);
} else if (url === '/api/listings') {
handleListingIndex(req, res, req.url);
} else if (url.startsWith('/api/listings/')) {
handleListingGet(req, res, req.url);
} else if (url === '/api/invoice') {
await handleInvoiceCreate(req, res);
} else if (url.startsWith('/api/invoice/')) {
handleInvoiceGet(req, res, req.url);
} else if (url === '/healthz') {
await serveHealth(res);
} else if (url === '/.well-known/prometheus-service-key.json') {
serveServiceKey(res);
} else if (url === '/sitemap.xml') {
serveSitemap(res);
} else if (url === '/robots.txt') {
serveRobots(res);
} else if (url === '/admin' || url.startsWith('/admin/')) {
await handleAdmin(req, res, url);
} else if (url.startsWith('/c/')) {
const slug = url.slice('/c/'.length);
servePageFn(() => categoryPage(req, slug), res);
} else if (url.startsWith('/app/')) {
// /app/<publisher>/<slug>
const parts = url.slice('/app/'.length).split('/').filter(Boolean);
if (parts.length === 2) {
servePageFn(() => appPage(req, parts[0], parts[1]), res);
} else {
servePage(pages.notFound, res, 404, req);
}
} else if (PAGE_ROUTES[url]) {
servePage(PAGE_ROUTES[url], res);
} else {
servePage(pages.notFound, res, 404, req);
}
} catch (err) {
// The response-level error log is intentionally short — a stack trace
// in the terminal is for the operator, not every reader.
console.error('[prometheus] 500', req.method, url, err?.message || err);
if (!res.headersSent) {
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
}
res.end('internal error\n');
}
res.on('close', () => {
const ms = Date.now() - started;
console.log(`[prometheus] ${res.statusCode} ${req.method} ${url} ${ms}ms`);
});
});
server.listen(PORT, BIND, () => {
console.log(`[prometheus] listening on http://${BIND}:${PORT}`);
});
/* ============ helpers ============ */
/**
* Trim query/hash, decode once, collapse any `..` to its parent.
* Returns a leading-slash path.
*/
function normalizeRequestUrl(raw) {
const [path] = String(raw || '/').split('?');
const decoded = decodeURIComponent(path);
// normalize + leading-slash enforcement means `/foo/..//bar` → `/bar`.
// On Windows, node:path/normalize uses backslashes; convert back.
const normalized = normalize(decoded).replaceAll('\\', '/');
return normalized.startsWith('/') ? normalized : '/' + normalized;
}
/**
* Render a page function to the response.
*/
function servePage(fn, res, status = 200, req = null) {
const page = fn(req);
renderPage(page, res, status);
}
/**
* Render a page object (not a function). Used by routes that take extra
* arguments to produce the page (category slug, app publisher/slug) and
* therefore need to call the generator themselves rather than hand a bare
* reference to servePage.
*/
function servePageFn(produce, res) {
const page = produce();
// A `notFound()` return has this exact title; routes use it as the convention
// for "the thing you asked for does not exist" so we don't need a parallel
// status plumbing path.
const status = page.title === 'Not here' ? 404 : 200;
renderPage(page, res, status);
}
function renderPage(page, res, status) {
const html = layout(page);
res.writeHead(status, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'public, max-age=60',
});
res.end(html);
}
/**
* `/healthz` — JSON probe for operators and uptime checks. Says whether
* clamd (or the mock) is reachable; the HTTP status alone is enough for
* an opinionated monitor.
*/
async function serveHealth(res) {
const scanner = await clamdPing();
const ok = scanner.ok === true;
const body = JSON.stringify({ ok, scanner });
res.writeHead(ok ? 200 : 503, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
'content-length': Buffer.byteLength(body),
});
res.end(body);
}
/**
* `/.well-known/prometheus-service-key.json` — the public key callers use to
* verify scan reports. Lives at a well-known path so clients can cache it by
* origin.
*/
function serveSitemap(res) {
const listings = allActiveListings().map((r) => ({
publisher: r.publisher, slug: r.slug, lastmod: r.created_at,
}));
const xml = renderSitemap({ categories: CATEGORIES.map((c) => c.slug), listings });
res.writeHead(200, {
'content-type': 'application/xml; charset=utf-8',
'cache-control': 'public, max-age=600',
'content-length': Buffer.byteLength(xml),
});
res.end(xml);
}
function serveRobots(res) {
const body = renderRobots();
res.writeHead(200, {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'public, max-age=600',
'content-length': Buffer.byteLength(body),
});
res.end(body);
}
function serveServiceKey(res) {
const body = JSON.stringify({
service: 'prometheus',
alg: 'ed25519',
pubkey: publicKeyBase64(),
issued_by: 'https://prometheus.x',
}, null, 2);
res.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'public, max-age=3600',
'content-length': Buffer.byteLength(body),
});
res.end(body);
}
/**
* Serve a file from PUBLIC_DIR by relative path. The caller passes a
* path with no leading slash and no `..`; this double-checks via
* path.resolve against PUBLIC_DIR and refuses anything that escapes.
*/
async function serveStatic(relPath, res) {
const full = resolve(PUBLIC_DIR, relPath);
if (!full.startsWith(PUBLIC_DIR + '\\') && !full.startsWith(PUBLIC_DIR + '/')) {
res.writeHead(400, { 'content-type': 'text/plain' });
res.end('bad path\n');
return;
}
let st;
try {
st = statSync(full);
} catch {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found\n');
return;
}
if (!st.isFile()) {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found\n');
return;
}
const ext = '.' + relPath.split('.').pop();
const mime = MIME[ext] || 'application/octet-stream';
res.writeHead(200, {
'content-type': mime,
// Static assets are versioned in the URL (`?v=1`), so a long cache is
// safe and the only way to make the viewport snappy on repeat visits.
'cache-control': 'public, max-age=31536000, immutable',
'content-length': st.size,
});
createReadStream(full).pipe(res);
}