prometheus/service/admin.js

237 lines
7.2 KiB
JavaScript
Raw Normal View History

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
/**
* Admin route handlers.
*
* Everything here is form-based so a JS-off browser still works. The one GET
* that mutates state (the login POST → session cookie) is a form submission;
* every other mutation POSTs and redirects to the GET it changed.
*
* Pages come from admin-pages.js; this file is the glue between HTTP and the
* DB / session.
*/
import { layout } from './layout.js';
import {
newChallenge, verifyLogin, readSession, clearCookie, adminPublisher, loginMessage,
} from './admin-session.js';
import {
allListingsIncludingWithdrawn, allActiveListings, withdrawListing, getListing,
countActive, recentAudit as recentAuditRows,
} from './db.js';
import { allModes, setMode, MODES } from './modes.js';
import { CATEGORIES, countsByCategory } from './pages-browse.js';
import { logAudit } from './audit.js';
import {
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
loginPage, dashboardPage, listingsPage, modesPage, auditPage, paymentsPage,
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
} from './admin-pages.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 { allInvoices } from './db.js';
import {
manuallyMarkPaid, LISTING_FEE_USD, RECEIVE_ADDRESS, paymentsRequired,
} from './payments.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
/* ----------------- dispatch (called from server.js) ----------------- */
/**
* Admin router. Returns true if it handled the request. The caller falls
* through to the regular router if we return false.
*/
export async function handleAdmin(req, res, url) {
// /admin/login (GET + POST) is the only path accessible without a session.
if (url === '/admin/login') {
if (req.method === 'POST') return doLogin(req, res);
return renderPage(res, loginPage({
challenge: newChallenge(adminPublisher()),
name: adminPublisher() || '(ADMIN_PUBLISHER env not set)',
}));
}
if (url === '/admin/logout' && req.method === 'POST') {
return doLogout(res);
}
// Everything else requires a session.
const session = readSession(req);
if (!session) {
res.writeHead(302, { location: '/admin/login' });
return res.end();
}
if (url === '/admin' || url === '/admin/') return showDashboard(res, session);
if (url === '/admin/listings') return showListings(res, session);
if (url === '/admin/modes') {
if (req.method === 'POST') return saveModes(req, res, session);
return showModes(res, session);
}
if (url === '/admin/audit') return showAudit(res, session);
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
if (url === '/admin/payments') return showPayments(res, session);
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
const withdrawMatch = url.match(/^\/admin\/listings\/([^/]+)\/([^/]+)\/withdraw$/);
if (withdrawMatch && req.method === 'POST') {
return doWithdraw(req, res, session, `${decodeURIComponent(withdrawMatch[1])}/${decodeURIComponent(withdrawMatch[2])}`);
}
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
const markPaidMatch = url.match(/^\/admin\/payments\/([^/]+)\/mark-paid$/);
if (markPaidMatch && req.method === 'POST') {
return doMarkPaid(req, res, session, decodeURIComponent(markPaidMatch[1]));
}
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
return notFoundHtml(res);
}
/* ----------------- login + logout ----------------- */
async function doLogin(req, res) {
const form = await readForm(req);
const result = await verifyLogin({
challenge: String(form.challenge || ''),
signature: String(form.signature || ''),
});
if (!result.ok) {
return renderPage(res, loginPage({
challenge: newChallenge(adminPublisher()),
name: adminPublisher(),
error: result.reason,
}), 400);
}
logAudit({ actor: result.admin.name, action: 'login' });
res.writeHead(302, {
location: '/admin',
'set-cookie': result.cookie,
});
res.end();
}
function doLogout(res) {
res.writeHead(302, {
location: '/admin/login',
'set-cookie': clearCookie(),
});
res.end();
}
/* ----------------- GET pages ----------------- */
function showDashboard(res, session) {
const counts = countsByCategory();
const total = countActive();
const modes = allModes(CATEGORIES.map((c) => c.slug));
const recentListings = allActiveListings().slice(0, 10);
const recentAudit = recentAuditRows(20);
renderPage(res, dashboardPage({ session, counts, total, modes, recentListings, recentAudit }));
}
function showListings(res, session) {
const rows = allListingsIncludingWithdrawn();
renderPage(res, listingsPage({ session, rows }));
}
function showModes(res, session) {
const modes = allModes(CATEGORIES.map((c) => c.slug));
renderPage(res, modesPage({ session, modes }));
}
function showAudit(res, session) {
const entries = recentAuditRows(200);
renderPage(res, auditPage({ session, entries }));
}
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
function showPayments(res, session) {
const invoices = allInvoices();
const config = {
required: paymentsRequired(),
fee_usd: LISTING_FEE_USD,
address: RECEIVE_ADDRESS,
};
renderPage(res, paymentsPage({ session, invoices, config }));
}
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
/* ----------------- POST actions ----------------- */
async function doWithdraw(req, res, session, id) {
const row = getListing(id);
if (!row) return notFoundHtml(res);
const changed = withdrawListing(id, new Date().toISOString());
if (changed) {
logAudit({
actor: session.name,
action: 'withdraw_listing',
target: id,
details: { category: row.category, publisher: row.publisher },
});
}
res.writeHead(302, { location: '/admin/listings' });
res.end();
}
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
async function doMarkPaid(req, res, session, invoiceId) {
const form = await readForm(req);
try {
const invoice = manuallyMarkPaid(invoiceId, { txid: form.txid || '', actor: session.name });
logAudit({
actor: session.name,
action: 'mark_invoice_paid',
target: invoiceId,
details: { listing_id: invoice.listing_id, txid: form.txid || null, amount_sat: invoice.amount_sat },
});
} catch (err) {
// Fall through to the redirect; the admin can retry from the UI.
}
res.writeHead(302, { location: '/admin/payments' });
res.end();
}
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
async function saveModes(req, res, session) {
const form = await readForm(req);
// Global first — the per-category reads depend on the current global.
const g = String(form.global || '');
if (MODES.includes(g)) {
setMode({ category: null, value: g, actor: session.name });
}
for (const c of CATEGORIES) {
const v = String(form[`cat_${c.slug}`] || '');
// v === '' means "inherits global" — pass empty value to clear override.
setMode({ category: c.slug, value: v, actor: session.name });
}
res.writeHead(302, { location: '/admin/modes' });
res.end();
}
/* ----------------- helpers ----------------- */
function renderPage(res, page, status = 200) {
const html = layout(page);
res.writeHead(status, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
});
res.end(html);
}
function notFoundHtml(res) {
const page = {
title: 'Not here',
description: 'The requested admin page does not exist.',
body: `<header class="hero compact"><h1>Not here</h1></header>
<div class="wrap"><section class="plain"><p>Go <a href="/admin">home</a>.</p></section></div>`,
};
renderPage(res, page, 404);
}
async function readForm(req) {
const chunks = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > 64 * 1024) throw new Error('form too large');
chunks.push(chunk);
}
const body = Buffer.concat(chunks).toString('utf8');
const params = new URLSearchParams(body);
const out = {};
for (const [k, v] of params) out[k] = v;
return out;
}