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

218 lines
6.8 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';
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 === '/healthz') {
await serveHealth(res);
} else if (url === '/.well-known/prometheus-service-key.json') {
serveServiceKey(res);
} 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);
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 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);
}