prometheus/service/admin-pages.js
Local Dev 34efb319cc prometheus: BCH payments — invoices, price oracle, admin reconcile
Payment flow for the paid wave (the free wave is still the default):

  1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the
     listing lands with payment_status='pending' and is hidden from
     browse. If not set, the listing goes straight to 'free' and is
     visible immediately (today's behaviour, unchanged).
  2. Anyone (publisher, their script, their wallet) POSTs
     /api/invoice {listing_id} and gets back a one-shot invoice:
     operator receive address, exact sat amount (USD-pegged + random
     1..256 sat jitter so no two in-flight invoices collide),
     bitcoincash: URI for a wallet, 20-min expiry.
  3. Reader pays. The amount matches exactly one pending invoice, and
     when the mempool watcher reports (address, amount, txid) to
     tryMatchPayment, both the invoice and the listing flip to paid.
     Browse starts showing the listing.

The mempool watcher that would do (3) automatically is deliberately NOT
in this commit — it needs an electrum-cash subscription or a block
explorer webhook, and that's a next milestone. In the meantime, admins
reconcile by hand from /admin/payments, which shows every invoice with
a Mark paid form (optional txid field). Same effect on the DB, same
audit log entry, different `paid_by` string.

Design decisions worth recording:
- One receive address + amount matching, NOT an HD wallet per invoice.
  BitPay used this for years; it is simple, auditable, and doesn't
  require a seed on the server to spend from later.
- Random few-sat jitter per invoice guarantees uniqueness of the
  (address, amount) pair across all in-flight invoices.
- Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale
  fallback). If both the fresh fetch and the stale fallback fail,
  /api/invoice refuses — issuing invoices on a stale price is worse
  than refusing to quote until the oracle recovers.
- Quote is locked at issue time; the 20-min deadline is the lock.
- Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice
  to 'expired' as a side effect, so there's no sweeper to run.

Admin UI /admin/payments shows every invoice (pending/paid/expired),
plus a dashboard-ish header with the configured mode + receive address.
The gating config appears in the UI even when payments are disabled, so
an operator spinning up a new instance can see at a glance what's set.

Config:
  PAYMENT_REQUIRED=1              — turn paid mode on
  PROMETHEUS_PAYMENT_ADDRESS=…    — the operator's receive address
  PROMETHEUS_LISTING_FEE_USD=1    — the fee (default 1 USD)
  PROMETHEUS_PRICE_SOURCE=…       — override the CoinGecko URL

End-to-end verified:
  PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice
  returns correct sats at live BCH price → admin login + mark-paid →
  listing becomes visible → /api/invoice/<id> returns status 'paid'
  with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00

433 lines
17 KiB
JavaScript

/**
* HTML templates for the admin area.
*
* Deliberately plain. Every action is a form submission that redirects back,
* so a JavaScript-off session works — the only script we ship is a tiny
* convenience to copy the sign-this string on the login page.
*
* Pages follow the same {title, description, body, here?} shape as the public
* pages, so the shared layout wraps them unchanged. The admin topnav sits
* inside each body (so the public topnav still appears above it for continuity).
*/
import { escapeHtml } from './layout.js';
import { CATEGORIES } from './pages-browse.js';
import { MODES } from './modes.js';
import { adminPublisher } from './admin-session.js';
/* ------------------------------- login ------------------------------- */
export function loginPage({ challenge, error = null, name }) {
const message = `PROMETHEUS-ADMIN-LOGIN\nname=${name}\nchallenge=${challenge}`;
return {
title: 'Admin — sign in',
description: 'Sign in to the Prometheus admin.',
here: '',
body: `
<header class="hero compact">
<h1>Admin</h1>
<p class="tag">Sign in with the key that owns <code class="inline">${escapeHtml(name)}</code>.</p>
</header>
<div class="wrap narrow">
${error ? `<div class="note danger"><b>Sign-in failed.</b> ${escapeHtml(error)}</div>` : ''}
<section class="plain">
<ol class="steps">
<li><b>1. Copy the challenge below into your wallet's "sign message" box.</b>
<span>The entire block, exactly as shown.</span></li>
<li><b>2. Sign with the key for the address that owns the admin name.</b>
<span>Theseus's Aegis panel, Electron Cash, Deviant — any BIP-137
wallet works.</span></li>
<li><b>3. Paste the signature below and submit.</b>
<span>The signature authenticates you as the admin for the next 24 hours.</span></li>
</ol>
<h3 style="margin-top:1.6rem">Message to sign</h3>
<pre id="msg">${escapeHtml(message)}</pre>
<button class="btn ghost" type="button" onclick="copy()">Copy message</button>
<form method="post" action="/admin/login" style="margin-top:1.6rem;display:grid;gap:12px">
<input type="hidden" name="challenge" value="${escapeHtml(challenge)}">
<label style="color:var(--mut);font-size:14px">
Signature (base64)
<textarea name="signature" required rows="3"
style="width:100%;margin-top:.3rem;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:10px;color:var(--ink);font-family:ui-monospace,monospace;font-size:13px"></textarea>
</label>
<button class="btn acid" type="submit">Sign in</button>
</form>
</section>
<script>
function copy() {
const t = document.getElementById('msg').textContent;
navigator.clipboard.writeText(t).then(
() => { const b = event.target; const old = b.textContent; b.textContent = 'copied'; setTimeout(() => b.textContent = old, 1200); },
() => alert('copy failed — select the message and ctrl+c')
);
}
</script>
</div>`,
};
}
/* ------------------------------- shared topnav ------------------------------- */
function adminNav(session, here = '') {
const link = (href, label) =>
`<a href="${href}" style="${here === href ? 'color:var(--acid);background:rgba(214,255,61,.08);' : ''}padding:6px 12px;border-radius:8px;text-decoration:none;color:var(--mut);font-size:13.5px">${label}</a>`;
return `<nav style="padding:12px 1.2rem;background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:4px;align-items:center;flex-wrap:wrap">
<b style="color:var(--ink);margin-right:14px;font-size:14px">Admin</b>
${link('/admin', 'Dashboard')}
${link('/admin/listings', 'Listings')}
${link('/admin/payments', 'Payments')}
${link('/admin/modes', 'Modes')}
${link('/admin/audit', 'Audit')}
<span style="flex:1"></span>
<span style="color:var(--dim);font-size:12.5px">${escapeHtml(session.name)}</span>
<form method="post" action="/admin/logout" style="margin:0">
<button class="btn ghost" type="submit" style="padding:5px 10px;font-size:12.5px">Sign out</button>
</form>
</nav>`;
}
/* ------------------------------- payments ------------------------------- */
export function paymentsPage({ session, invoices, config, notice = null }) {
return {
title: 'Admin — payments',
description: 'Invoices and payment status.',
here: '/admin/payments',
body: `
${adminNav(session, '/admin/payments')}
<div class="wrap">
${notice ? `<div class="note" style="margin-top:1rem"><b>${escapeHtml(notice)}</b></div>` : ''}
<section class="plain">
<h2>Payments</h2>
<div class="grid" style="margin-top:1rem">
<div class="card">
<h3>${escapeHtml(config.required ? 'required' : 'off')}</h3>
<p>payment gate — ${escapeHtml(config.required ? 'new listings land as pending' : 'new listings publish immediately')}</p>
</div>
<div class="card">
<h3>$${escapeHtml(String(config.fee_usd))}</h3>
<p>listing fee per publication</p>
</div>
<div class="card">
<h3 style="word-break:break-all;font-size:14px;font-family:ui-monospace,monospace">${escapeHtml(config.address || '(not set)')}</h3>
<p>operator receive address</p>
</div>
</div>
${!config.address ? `
<div class="note warn" style="margin-top:1.4rem">
<b>No receive address configured.</b> Set <code class="inline">PROMETHEUS_PAYMENT_ADDRESS</code>
in the service environment and restart. Until then, /api/invoice refuses to issue.
</div>` : ''}
${config.required && !config.address ? `
<div class="note danger" style="margin-top:1rem">
<b>Payments are required but no address is set.</b> New listings are being created as
<code class="inline">pending</code> but cannot be settled. Set the address and restart,
or unset <code class="inline">PAYMENT_REQUIRED</code>.
</div>` : ''}
<h3 style="margin-top:1.8rem">Invoices (${invoices.length})</h3>
${invoices.length === 0 ? `<p class="lede">No invoices issued yet.</p>` : `
<table>
<thead><tr>
<th>id</th><th>listing</th><th>sats</th><th>status</th>
<th>issued</th><th>expires / paid</th><th></th>
</tr></thead>
<tbody>
${invoices.map((i) => renderInvoiceRow(i)).join('\n')}
</tbody>
</table>`}
</section>
<section>
<h2>Mempool watcher</h2>
<p class="lede">The mempool watcher that would auto-reconcile incoming payments to pending
invoices is a follow-up milestone. For now, confirm a payment by hand once it lands —
paste the txid into the Mark paid form on an invoice row. The listing becomes visible
on browse the moment the invoice flips to paid.</p>
</section>
</div>`,
};
}
function renderInvoiceRow(i) {
const paidChip =
i.status === 'paid' ? `<span class="pill p-live">paid</span>` :
i.status === 'expired' ? `<span class="pill" style="background:rgba(246,118,138,.14);color:var(--taken)">expired</span>` :
`<span class="pill p-upcoming">pending</span>`;
const rightCell = i.status === 'paid'
? `<div style="color:var(--dim);font-size:12.5px">${escapeHtml(i.paid_at)}<br><code class="inline" style="font-size:11px">${escapeHtml((i.paid_txid || '').slice(0, 20))}…</code></div>`
: `<div style="color:var(--dim);font-size:12.5px">${escapeHtml(i.expires_at)}</div>`;
const action = i.status === 'pending'
? `<form method="post" action="/admin/payments/${encodeURIComponent(i.id)}/mark-paid" style="margin:0;display:flex;gap:4px">
<input name="txid" placeholder="txid (optional)" style="padding:4px 6px;background:var(--panel);border:1px solid var(--line);border-radius:6px;color:var(--ink);font-size:12px;width:140px">
<button class="btn ghost" type="submit" style="padding:4px 10px;font-size:12.5px">Mark paid</button>
</form>`
: `<span style="color:var(--dim);font-size:12px">${i.status === 'paid' ? 'by ' + escapeHtml(i.paid_by || '') : ''}</span>`;
return `<tr>
<td><code class="inline" style="font-size:11px">${escapeHtml(i.id)}</code></td>
<td><code class="inline" style="font-size:11px">${escapeHtml(i.listing_id)}</code></td>
<td style="font-family:ui-monospace,monospace;font-size:13px">${i.amount_sat.toLocaleString()}</td>
<td>${paidChip}</td>
<td style="color:var(--dim);font-size:12.5px">${escapeHtml(i.issued_at)}</td>
<td>${rightCell}</td>
<td>${action}</td>
</tr>`;
}
/* ------------------------------- dashboard ------------------------------- */
export function dashboardPage({ session, counts, total, modes, recentListings, recentAudit }) {
const modeChip = (m) => `<span class="pill p-${m === 'append' ? 'live' : m === 'scan-first' ? 'alpha' : 'designed'}">${escapeHtml(m)}</span>`;
return {
title: 'Admin — dashboard',
description: 'Prometheus admin dashboard.',
here: '/admin',
body: `
${adminNav(session, '/admin')}
<div class="wrap">
<section class="plain">
<h2>Dashboard</h2>
<div class="grid" style="margin-top:1rem">
<div class="card">
<h3>${total}</h3>
<p>active listings across ${Object.values(counts).filter((n) => n > 0).length} categories</p>
</div>
<div class="card">
<h3>${modeChip(modes.global)}</h3>
<p>global publish mode</p>
</div>
<div class="card">
<h3>${Object.values(modes.categories).filter(Boolean).length}</h3>
<p>per-category overrides in effect</p>
</div>
</div>
</section>
<section>
<h2>Recent listings</h2>
${recentListings.length === 0
? `<p class="lede">No listings yet.</p>`
: `<table>
<thead><tr><th>id</th><th>category</th><th>size</th><th>at</th><th></th></tr></thead>
<tbody>
${recentListings.map((r) => `<tr>
<td><code class="inline">${escapeHtml(r.id)}</code></td>
<td>${escapeHtml(r.category)}</td>
<td>${formatBytes(r.download_size)}</td>
<td style="color:var(--dim);font-size:12.5px">${escapeHtml(r.created_at)}</td>
<td><a href="/admin/listings#${escapeHtml(r.id)}" style="font-size:12.5px">manage →</a></td>
</tr>`).join('')}
</tbody>
</table>`}
</section>
<section>
<h2>Recent audit</h2>
${recentAudit.length === 0
? `<p class="lede">No admin actions yet.</p>`
: `<table>
<thead><tr><th>at</th><th>actor</th><th>action</th><th>target</th></tr></thead>
<tbody>
${recentAudit.slice(0, 10).map((r) => `<tr>
<td style="color:var(--dim);font-size:12.5px">${escapeHtml(r.at)}</td>
<td>${escapeHtml(r.actor)}</td>
<td>${escapeHtml(r.action)}</td>
<td style="color:var(--mut)">${escapeHtml(r.target || '')}</td>
</tr>`).join('')}
</tbody>
</table>
<p style="margin-top:.8rem"><a href="/admin/audit">Full audit log →</a></p>`}
</section>
</div>`,
};
}
/* ------------------------------- listings ------------------------------- */
export function listingsPage({ session, rows, notice = null }) {
return {
title: 'Admin — listings',
description: 'All listings on Prometheus.',
here: '/admin/listings',
body: `
${adminNav(session, '/admin/listings')}
<div class="wrap">
${notice ? `<div class="note" style="margin-top:1rem"><b>${escapeHtml(notice)}</b></div>` : ''}
<section class="plain">
<h2>Listings (${rows.length})</h2>
${rows.length === 0
? `<p class="lede">No listings yet.</p>`
: `<table>
<thead><tr>
<th>id</th><th>name</th><th>category</th><th>size</th>
<th>status</th><th>created</th><th></th>
</tr></thead>
<tbody>
${rows.map((r) => renderListingRow(r)).join('\n')}
</tbody>
</table>`}
</section>
</div>`,
};
}
function renderListingRow(r) {
const withdrawn = !!r.withdrawn_at;
const statusCell = withdrawn
? `<span class="pill" style="background:rgba(246,118,138,.14);color:var(--taken)">withdrawn</span>`
: `<span class="pill p-live">active</span>`;
const actions = withdrawn
? `<span style="color:var(--dim);font-size:12.5px">${escapeHtml(r.withdrawn_at)}</span>`
: `<form method="post" action="/admin/listings/${encodeURIComponent(r.id)}/withdraw" style="margin:0"
onsubmit="return confirm('Withdraw ' + ${JSON.stringify(r.id)} + '? Clients will stop showing it in browse.');">
<button class="btn ghost" type="submit" style="padding:5px 10px;font-size:12.5px">Withdraw</button>
</form>`;
return `<tr id="${escapeHtml(r.id)}">
<td><a href="/app/${escapeHtml(r.publisher)}/${escapeHtml(r.slug)}" target="_blank" rel="noopener"><code class="inline">${escapeHtml(r.id)}</code></a></td>
<td>${escapeHtml(r.name)}</td>
<td>${escapeHtml(r.category)}</td>
<td>${formatBytes(r.download_size)}</td>
<td>${statusCell}</td>
<td style="color:var(--dim);font-size:12.5px">${escapeHtml(r.created_at)}</td>
<td>${actions}</td>
</tr>`;
}
/* ------------------------------- modes ------------------------------- */
export function modesPage({ session, modes, notice = null }) {
const modeOptions = (selected) =>
MODES.map((m) => `<option value="${m}"${m === selected ? ' selected' : ''}>${m}</option>`).join('');
return {
title: 'Admin — modes',
description: 'Publish-mode policy per category.',
here: '/admin/modes',
body: `
${adminNav(session, '/admin/modes')}
<div class="wrap">
${notice ? `<div class="note" style="margin-top:1rem"><b>${escapeHtml(notice)}</b></div>` : ''}
<section class="plain">
<h2>Publish modes</h2>
<p class="lede">The three modes describe how new listings get accepted. The
global default applies everywhere unless a category has its own override.
Clear an override by picking "inherits global" from the per-category dropdown.</p>
<div class="note">
<b>append</b> — any valid listing goes in; readers see the signed scan report and decide.<br>
<b>scan-first</b> — infected uploads are rejected before they land. Clean ones publish.<br>
<b>review</b> — clean listings sit in a human-review queue before browse shows them. <em>Toggle persists; enforcement lands in a later milestone.</em>
</div>
<form method="post" action="/admin/modes" style="margin-top:1.6rem">
<table>
<thead><tr><th>Scope</th><th>Current mode</th></tr></thead>
<tbody>
<tr>
<td><b>Global</b></td>
<td>
<select name="global" style="padding:6px 10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink)">
${modeOptions(modes.global)}
</select>
</td>
</tr>
${CATEGORIES.map((c) => {
const override = modes.categories[c.slug];
return `<tr>
<td>${c.icon} ${escapeHtml(c.title)} <code class="inline" style="font-size:11px;color:var(--dim)">${c.slug}</code></td>
<td>
<select name="cat_${c.slug}" style="padding:6px 10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink)">
<option value=""${override ? '' : ' selected'}>— inherits global —</option>
${MODES.map((m) => `<option value="${m}"${override === m ? ' selected' : ''}>${m}</option>`).join('')}
</select>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
<button class="btn acid" type="submit" style="margin-top:1.2rem">Save modes</button>
</form>
</section>
</div>`,
};
}
/* ------------------------------- audit ------------------------------- */
export function auditPage({ session, entries }) {
return {
title: 'Admin — audit log',
description: 'Hash-chained log of admin actions.',
here: '/admin/audit',
body: `
${adminNav(session, '/admin/audit')}
<div class="wrap">
<section class="plain">
<h2>Audit log</h2>
<p class="lede">Append-only. Each row's <code class="inline">this_hash</code>
is computed over its own fields plus the previous row's hash — a retroactive
edit anywhere in the chain shows up as a mismatch in the next row.</p>
${entries.length === 0
? `<p class="lede" style="margin-top:1rem">No admin actions yet.</p>`
: `<table>
<thead><tr>
<th>seq</th><th>at</th><th>actor</th><th>action</th>
<th>target</th><th>details</th><th>this_hash</th>
</tr></thead>
<tbody>
${entries.map((e) => `<tr>
<td>${e.seq}</td>
<td style="color:var(--dim);font-size:12px">${escapeHtml(e.at)}</td>
<td>${escapeHtml(e.actor)}</td>
<td>${escapeHtml(e.action)}</td>
<td><code class="inline">${escapeHtml(e.target || '')}</code></td>
<td style="font-size:12px;color:var(--mut);max-width:260px;word-break:break-all">${escapeHtml(e.details || '')}</td>
<td style="font-size:11px;color:var(--dim);font-family:ui-monospace,monospace">${escapeHtml(e.this_hash.slice(0, 10))}…</td>
</tr>`).join('')}
</tbody>
</table>`}
</section>
</div>`,
};
}
/* ------------------------------- helpers ------------------------------- */
function formatBytes(n) {
const num = Number(n) || 0;
if (num < 1024) return `${num} B`;
if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KiB`;
if (num < 1024 * 1024 * 1024) return `${(num / 1024 / 1024).toFixed(1)} MiB`;
return `${(num / 1024 / 1024 / 1024).toFixed(2)} GiB`;
}