/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.
252 lines
8.1 KiB
JavaScript
252 lines
8.1 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 } from './pages-browse.js';
|
|
import { handleAdmin } from './admin.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 (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 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);
|
|
}
|