prometheus: scaffold the standalone service — pages, routing, static assets
New top-level Prometheus/ directory holds the Node service that will own
prometheus.x once the on-chain record flips from the silentmode.st
redirect to ip+tls. Nothing dynamic yet — this milestone delivers the
site skeleton and proves the family's visual conventions port cleanly to
a dynamic server.
Shape:
Prometheus/service/server.js node:http listener + map-based router
Prometheus/service/layout.js document shell, topnav, footer
Prometheus/service/pages.js home / browse / submit / publish / boundaries
Prometheus/public/style.css ported silentmode.st tokens + category grid
Prometheus/public/favicon.svg flame mark
Routes: /, /browse, /submit, /publish, /boundaries, /static/*.
Dynamic routes (/api/upload, /api/manifest, /api/publish, /api/pay,
/admin/*) land here in later milestones per the architecture doc.
Launch config .claude/launch.json gets a prometheus-service entry on
port 3700 so the preview harness can run it.
The pre-pivot WordPress-based scaffold at site-prometheus-x/ is left
in place for the next session to delete deliberately — the architecture
doc marks it as abandoned for prometheus.x but keeps the Sirius Press
infra fixes it carried in.
2026-10-05 22:22:47 +02:00
|
|
|
/**
|
|
|
|
|
* 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';
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
import { dirname, normalize, resolve } from 'node:path';
|
prometheus: scaffold the standalone service — pages, routing, static assets
New top-level Prometheus/ directory holds the Node service that will own
prometheus.x once the on-chain record flips from the silentmode.st
redirect to ip+tls. Nothing dynamic yet — this milestone delivers the
site skeleton and proves the family's visual conventions port cleanly to
a dynamic server.
Shape:
Prometheus/service/server.js node:http listener + map-based router
Prometheus/service/layout.js document shell, topnav, footer
Prometheus/service/pages.js home / browse / submit / publish / boundaries
Prometheus/public/style.css ported silentmode.st tokens + category grid
Prometheus/public/favicon.svg flame mark
Routes: /, /browse, /submit, /publish, /boundaries, /static/*.
Dynamic routes (/api/upload, /api/manifest, /api/publish, /api/pay,
/admin/*) land here in later milestones per the architecture doc.
Launch config .claude/launch.json gets a prometheus-service entry on
port 3700 so the preview harness can run it.
The pre-pivot WordPress-based scaffold at site-prometheus-x/ is left
in place for the next session to delete deliberately — the architecture
doc marks it as abandoned for prometheus.x but keeps the Sirius Press
infra fixes it carried in.
2026-10-05 22:22:47 +02:00
|
|
|
import { layout } from './layout.js';
|
|
|
|
|
import * as pages from './pages.js';
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
import { handleUpload } from './uploads.js';
|
|
|
|
|
import { clamdPing } from './clamd.js';
|
|
|
|
|
import { publicKeyBase64, loadServiceKey } from './signing.js';
|
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
|
|
|
import {
|
|
|
|
|
handleListingCreate,
|
|
|
|
|
handleListingIndex,
|
|
|
|
|
handleListingGet,
|
|
|
|
|
} from './listings.js';
|
prometheus: weekly s3 snapshot of the browse surface to bns/prometheus
scripts/snapshot.mjs renders every public page (home, browse, publish,
boundaries, 8 categories, every active listing) to <out>/<path>/index.html,
plus /sitemap.xml, /robots.txt, and the two static assets. Writing to a
directory next to the service AND uploading via `aws s3 sync` are two
flags on the same command: --out to pick the directory, --upload to push.
Live routes added for /sitemap.xml and /robots.txt so a crawler hitting
the live service sees the same enumeration the snapshot bakes into the
bucket. renderSitemap / renderRobots in service/seo.js are the single
rendering path both call sites use.
Scope deliberately limited to the browse surface — /submit and the API
routes depend on the live backend and were never going to work on a
static mirror. The browse surface IS what the fallback is for: readers
survive an outage, publishers can't.
systemd unit + timer under deploy/ run the snapshot weekly on Sunday
04:00 UTC with 30 min jitter and Persistent=yes so a missed run after a
reboot catches up. Unit uses the sia-storage AWS profile per
[[sia-storage-credentials-location]] and the per-process endpoint URL
baked in via Environment=.
Local dry-run against an empty DB produced 14 HTML files + sitemap +
robots + the two assets.
2026-10-08 21:42:54 +02:00
|
|
|
import { categoryPage, appPage, CATEGORIES } from './pages-browse.js';
|
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
|
|
|
import { handleAdmin } from './admin.js';
|
prometheus: weekly s3 snapshot of the browse surface to bns/prometheus
scripts/snapshot.mjs renders every public page (home, browse, publish,
boundaries, 8 categories, every active listing) to <out>/<path>/index.html,
plus /sitemap.xml, /robots.txt, and the two static assets. Writing to a
directory next to the service AND uploading via `aws s3 sync` are two
flags on the same command: --out to pick the directory, --upload to push.
Live routes added for /sitemap.xml and /robots.txt so a crawler hitting
the live service sees the same enumeration the snapshot bakes into the
bucket. renderSitemap / renderRobots in service/seo.js are the single
rendering path both call sites use.
Scope deliberately limited to the browse surface — /submit and the API
routes depend on the live backend and were never going to work on a
static mirror. The browse surface IS what the fallback is for: readers
survive an outage, publishers can't.
systemd unit + timer under deploy/ run the snapshot weekly on Sunday
04:00 UTC with 30 min jitter and Persistent=yes so a missed run after a
reboot catches up. Unit uses the sia-storage AWS profile per
[[sia-storage-credentials-location]] and the per-process endpoint URL
baked in via Environment=.
Local dry-run against an empty DB produced 14 HTML files + sitemap +
robots + the two assets.
2026-10-08 21:42:54 +02:00
|
|
|
import { renderSitemap, renderRobots } from './seo.js';
|
|
|
|
|
import { allActiveListings } from './db.js';
|
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
|
|
|
import { handleInvoiceCreate, handleInvoiceGet } from './invoice-api.js';
|
prometheus: scaffold the standalone service — pages, routing, static assets
New top-level Prometheus/ directory holds the Node service that will own
prometheus.x once the on-chain record flips from the silentmode.st
redirect to ip+tls. Nothing dynamic yet — this milestone delivers the
site skeleton and proves the family's visual conventions port cleanly to
a dynamic server.
Shape:
Prometheus/service/server.js node:http listener + map-based router
Prometheus/service/layout.js document shell, topnav, footer
Prometheus/service/pages.js home / browse / submit / publish / boundaries
Prometheus/public/style.css ported silentmode.st tokens + category grid
Prometheus/public/favicon.svg flame mark
Routes: /, /browse, /submit, /publish, /boundaries, /static/*.
Dynamic routes (/api/upload, /api/manifest, /api/publish, /api/pay,
/admin/*) land here in later milestones per the architecture doc.
Launch config .claude/launch.json gets a prometheus-service entry on
port 3700 so the preview harness can run it.
The pre-pivot WordPress-based scaffold at site-prometheus-x/ is left
in place for the next session to delete deliberately — the architecture
doc marks it as abandoned for prometheus.x but keeps the Sirius Press
infra fixes it carried in.
2026-10-05 22:22:47 +02:00
|
|
|
|
|
|
|
|
const PORT = Number(process.env.PORT) || 3700;
|
|
|
|
|
const BIND = process.env.BIND || '127.0.0.1';
|
|
|
|
|
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
// 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();
|
|
|
|
|
|
prometheus: scaffold the standalone service — pages, routing, static assets
New top-level Prometheus/ directory holds the Node service that will own
prometheus.x once the on-chain record flips from the silentmode.st
redirect to ip+tls. Nothing dynamic yet — this milestone delivers the
site skeleton and proves the family's visual conventions port cleanly to
a dynamic server.
Shape:
Prometheus/service/server.js node:http listener + map-based router
Prometheus/service/layout.js document shell, topnav, footer
Prometheus/service/pages.js home / browse / submit / publish / boundaries
Prometheus/public/style.css ported silentmode.st tokens + category grid
Prometheus/public/favicon.svg flame mark
Routes: /, /browse, /submit, /publish, /boundaries, /static/*.
Dynamic routes (/api/upload, /api/manifest, /api/publish, /api/pay,
/admin/*) land here in later milestones per the architecture doc.
Launch config .claude/launch.json gets a prometheus-service entry on
port 3700 so the preview harness can run it.
The pre-pivot WordPress-based scaffold at site-prometheus-x/ is left
in place for the next session to delete deliberately — the architecture
doc marks it as abandoned for prometheus.x but keeps the Sirius Press
infra fixes it carried in.
2026-10-05 22:22:47 +02:00
|
|
|
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);
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
} else if (url === '/api/upload') {
|
|
|
|
|
await handleUpload(req, res);
|
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
|
|
|
} 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);
|
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
|
|
|
} else if (url === '/api/invoice') {
|
|
|
|
|
await handleInvoiceCreate(req, res);
|
|
|
|
|
} else if (url.startsWith('/api/invoice/')) {
|
|
|
|
|
handleInvoiceGet(req, res, req.url);
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
} else if (url === '/healthz') {
|
|
|
|
|
await serveHealth(res);
|
|
|
|
|
} else if (url === '/.well-known/prometheus-service-key.json') {
|
|
|
|
|
serveServiceKey(res);
|
prometheus: weekly s3 snapshot of the browse surface to bns/prometheus
scripts/snapshot.mjs renders every public page (home, browse, publish,
boundaries, 8 categories, every active listing) to <out>/<path>/index.html,
plus /sitemap.xml, /robots.txt, and the two static assets. Writing to a
directory next to the service AND uploading via `aws s3 sync` are two
flags on the same command: --out to pick the directory, --upload to push.
Live routes added for /sitemap.xml and /robots.txt so a crawler hitting
the live service sees the same enumeration the snapshot bakes into the
bucket. renderSitemap / renderRobots in service/seo.js are the single
rendering path both call sites use.
Scope deliberately limited to the browse surface — /submit and the API
routes depend on the live backend and were never going to work on a
static mirror. The browse surface IS what the fallback is for: readers
survive an outage, publishers can't.
systemd unit + timer under deploy/ run the snapshot weekly on Sunday
04:00 UTC with 30 min jitter and Persistent=yes so a missed run after a
reboot catches up. Unit uses the sia-storage AWS profile per
[[sia-storage-credentials-location]] and the per-process endpoint URL
baked in via Environment=.
Local dry-run against an empty DB produced 14 HTML files + sitemap +
robots + the two assets.
2026-10-08 21:42:54 +02:00
|
|
|
} else if (url === '/sitemap.xml') {
|
|
|
|
|
serveSitemap(res);
|
|
|
|
|
} else if (url === '/robots.txt') {
|
|
|
|
|
serveRobots(res);
|
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
|
|
|
} else if (url === '/admin' || url.startsWith('/admin/')) {
|
|
|
|
|
await handleAdmin(req, res, url);
|
prometheus: browse pages — categories and per-listing detail
/c/<slug> renders one of the eight categories with every active listing
in it, newest first, as a grid of cards. Empty category shows
a "be the first" prompt rather than a dead page.
/app/<pub>/<slug> renders a listing: icon + name + publisher + version
chip, a prominent Download card with size, hash chip,
signed scan badge (green/clean | red/infected |
neutral/unknown), description, homepage link, who
signed, and an endorsements placeholder for when APS1
lands.
The home-page and /browse grids now read real counts from the DB — the
hardcoded "0 listings" placeholders are gone. A new helper
`renderCategoriesGrid()` is the one place category cards are rendered,
so the two surfaces always agree.
404s for unknown categories (`/c/nope`) and unknown listings
(`/app/foo/bar`) go through the same notFound page and now actually
return HTTP 404 instead of 200 — pages opt in by returning the
notFound object, server detects it by title and sets the status.
CATEGORIES is the single source of truth for slug/icon/title/blurb;
adding a new category now means one row here plus one entry in the
listings allow-list.
Verified end-to-end with three listings across three categories: counts
update, cards render, detail page shows the signed scan badge, 404s
return 404.
2026-10-08 01:56:52 +02:00
|
|
|
} 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);
|
|
|
|
|
}
|
prometheus: scaffold the standalone service — pages, routing, static assets
New top-level Prometheus/ directory holds the Node service that will own
prometheus.x once the on-chain record flips from the silentmode.st
redirect to ip+tls. Nothing dynamic yet — this milestone delivers the
site skeleton and proves the family's visual conventions port cleanly to
a dynamic server.
Shape:
Prometheus/service/server.js node:http listener + map-based router
Prometheus/service/layout.js document shell, topnav, footer
Prometheus/service/pages.js home / browse / submit / publish / boundaries
Prometheus/public/style.css ported silentmode.st tokens + category grid
Prometheus/public/favicon.svg flame mark
Routes: /, /browse, /submit, /publish, /boundaries, /static/*.
Dynamic routes (/api/upload, /api/manifest, /api/publish, /api/pay,
/admin/*) land here in later milestones per the architecture doc.
Launch config .claude/launch.json gets a prometheus-service entry on
port 3700 so the preview harness can run it.
The pre-pivot WordPress-based scaffold at site-prometheus-x/ is left
in place for the next session to delete deliberately — the architecture
doc marks it as abandoned for prometheus.x but keeps the Sirius Press
infra fixes it carried in.
2026-10-05 22:22:47 +02:00
|
|
|
} 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);
|
prometheus: browse pages — categories and per-listing detail
/c/<slug> renders one of the eight categories with every active listing
in it, newest first, as a grid of cards. Empty category shows
a "be the first" prompt rather than a dead page.
/app/<pub>/<slug> renders a listing: icon + name + publisher + version
chip, a prominent Download card with size, hash chip,
signed scan badge (green/clean | red/infected |
neutral/unknown), description, homepage link, who
signed, and an endorsements placeholder for when APS1
lands.
The home-page and /browse grids now read real counts from the DB — the
hardcoded "0 listings" placeholders are gone. A new helper
`renderCategoriesGrid()` is the one place category cards are rendered,
so the two surfaces always agree.
404s for unknown categories (`/c/nope`) and unknown listings
(`/app/foo/bar`) go through the same notFound page and now actually
return HTTP 404 instead of 200 — pages opt in by returning the
notFound object, server detects it by title and sets the status.
CATEGORIES is the single source of truth for slug/icon/title/blurb;
adding a new category now means one row here plus one entry in the
listings allow-list.
Verified end-to-end with three listings across three categories: counts
update, cards render, detail page shows the signed scan badge, 404s
return 404.
2026-10-08 01:56:52 +02:00
|
|
|
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) {
|
prometheus: scaffold the standalone service — pages, routing, static assets
New top-level Prometheus/ directory holds the Node service that will own
prometheus.x once the on-chain record flips from the silentmode.st
redirect to ip+tls. Nothing dynamic yet — this milestone delivers the
site skeleton and proves the family's visual conventions port cleanly to
a dynamic server.
Shape:
Prometheus/service/server.js node:http listener + map-based router
Prometheus/service/layout.js document shell, topnav, footer
Prometheus/service/pages.js home / browse / submit / publish / boundaries
Prometheus/public/style.css ported silentmode.st tokens + category grid
Prometheus/public/favicon.svg flame mark
Routes: /, /browse, /submit, /publish, /boundaries, /static/*.
Dynamic routes (/api/upload, /api/manifest, /api/publish, /api/pay,
/admin/*) land here in later milestones per the architecture doc.
Launch config .claude/launch.json gets a prometheus-service entry on
port 3700 so the preview harness can run it.
The pre-pivot WordPress-based scaffold at site-prometheus-x/ is left
in place for the next session to delete deliberately — the architecture
doc marks it as abandoned for prometheus.x but keeps the Sirius Press
infra fixes it carried in.
2026-10-05 22:22:47 +02:00
|
|
|
const html = layout(page);
|
|
|
|
|
res.writeHead(status, {
|
|
|
|
|
'content-type': 'text/html; charset=utf-8',
|
|
|
|
|
'cache-control': 'public, max-age=60',
|
|
|
|
|
});
|
|
|
|
|
res.end(html);
|
|
|
|
|
}
|
|
|
|
|
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
/**
|
|
|
|
|
* `/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.
|
|
|
|
|
*/
|
prometheus: weekly s3 snapshot of the browse surface to bns/prometheus
scripts/snapshot.mjs renders every public page (home, browse, publish,
boundaries, 8 categories, every active listing) to <out>/<path>/index.html,
plus /sitemap.xml, /robots.txt, and the two static assets. Writing to a
directory next to the service AND uploading via `aws s3 sync` are two
flags on the same command: --out to pick the directory, --upload to push.
Live routes added for /sitemap.xml and /robots.txt so a crawler hitting
the live service sees the same enumeration the snapshot bakes into the
bucket. renderSitemap / renderRobots in service/seo.js are the single
rendering path both call sites use.
Scope deliberately limited to the browse surface — /submit and the API
routes depend on the live backend and were never going to work on a
static mirror. The browse surface IS what the fallback is for: readers
survive an outage, publishers can't.
systemd unit + timer under deploy/ run the snapshot weekly on Sunday
04:00 UTC with 30 min jitter and Persistent=yes so a missed run after a
reboot catches up. Unit uses the sia-storage AWS profile per
[[sia-storage-credentials-location]] and the per-process endpoint URL
baked in via Environment=.
Local dry-run against an empty DB produced 14 HTML files + sitemap +
robots + the two assets.
2026-10-08 21:42:54 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
prometheus: upload + ClamAV scan + signed scan reports
POST /api/upload accepts a multipart file, spools it to a temp path while
hashing to SHA-256, hands the on-disk copy to clamd via INSTREAM, keeps
the file at uploads/<sha256>.bin on a clean verdict and discards it on
anything else. The response carries the (hash, size, verdict) tuple
signed by the service's ed25519 key — the artifact the manifest step
will attach to the APS1 record later.
For local dev on a box without ClamAV, MOCK_SCAN=clean|infected|error
short-circuits the TCP call while still draining the bytes end-to-end.
CLAMD_HOST / CLAMD_PORT / PROMETHEUS_MAX_UPLOAD_BYTES override the
defaults (127.0.0.1:3310, 2 GiB).
Service key lives at .keys/service.key (gitignored, 0600), generated on
first start if absent. The matching public key is served at
/.well-known/prometheus-service-key.json so clients can cache it per
origin. ed25519 chosen over BIP-137 because the scan report is a service
attestation, not a Bitcoin thing — the key never signs value, never
signs for a user, never touches the chain.
Canonicalisation walks the payload recursively (sorted keys at every
level, no whitespace). The first draft used JSON.stringify with a keys
array, which only serialises top-level keys from the array — nested
objects came out as {}, so the signature verified nothing useful.
/healthz pings clamd (or the mock) so monitors can tell when the scanner
is down.
The /submit page gets a working upload form that drives the endpoint
and renders the full signed response, so the whole chain is testable in
the browser as well as via curl.
One dependency added: busboy. Rolling our own multipart parser was the
obvious alternative; busboy is one small, maintained library with no
runtime deps of its own, and multipart RFC 7578 edge cases are not
places this project earns anything by rediscovering.
2026-10-05 22:41:55 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
prometheus: scaffold the standalone service — pages, routing, static assets
New top-level Prometheus/ directory holds the Node service that will own
prometheus.x once the on-chain record flips from the silentmode.st
redirect to ip+tls. Nothing dynamic yet — this milestone delivers the
site skeleton and proves the family's visual conventions port cleanly to
a dynamic server.
Shape:
Prometheus/service/server.js node:http listener + map-based router
Prometheus/service/layout.js document shell, topnav, footer
Prometheus/service/pages.js home / browse / submit / publish / boundaries
Prometheus/public/style.css ported silentmode.st tokens + category grid
Prometheus/public/favicon.svg flame mark
Routes: /, /browse, /submit, /publish, /boundaries, /static/*.
Dynamic routes (/api/upload, /api/manifest, /api/publish, /api/pay,
/admin/*) land here in later milestones per the architecture doc.
Launch config .claude/launch.json gets a prometheus-service entry on
port 3700 so the preview harness can run it.
The pre-pivot WordPress-based scaffold at site-prometheus-x/ is left
in place for the next session to delete deliberately — the architecture
doc marks it as abandoned for prometheus.x but keeps the Sirius Press
infra fixes it carried in.
2026-10-05 22:22:47 +02:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|