/** * 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: `

Admin

Sign in with the key that owns ${escapeHtml(name)}.

${error ? `
Sign-in failed. ${escapeHtml(error)}
` : ''}
  1. 1. Copy the challenge below into your wallet's "sign message" box. The entire block, exactly as shown.
  2. 2. Sign with the key for the address that owns the admin name. Theseus's Aegis panel, Electron Cash, Deviant — any BIP-137 wallet works.
  3. 3. Paste the signature below and submit. The signature authenticates you as the admin for the next 24 hours.

Message to sign

${escapeHtml(message)}
`, }; } /* ------------------------------- shared topnav ------------------------------- */ function adminNav(session, here = '') { const link = (href, label) => `${label}`; return ``; } /* ------------------------------- 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')}
${notice ? `
${escapeHtml(notice)}
` : ''}

Payments

${escapeHtml(config.required ? 'required' : 'off')}

payment gate — ${escapeHtml(config.required ? 'new listings land as pending' : 'new listings publish immediately')}

$${escapeHtml(String(config.fee_usd))}

listing fee per publication

${escapeHtml(config.address || '(not set)')}

operator receive address

${!config.address ? `
No receive address configured. Set PROMETHEUS_PAYMENT_ADDRESS in the service environment and restart. Until then, /api/invoice refuses to issue.
` : ''} ${config.required && !config.address ? `
Payments are required but no address is set. New listings are being created as pending but cannot be settled. Set the address and restart, or unset PAYMENT_REQUIRED.
` : ''}

Invoices (${invoices.length})

${invoices.length === 0 ? `

No invoices issued yet.

` : ` ${invoices.map((i) => renderInvoiceRow(i)).join('\n')}
idlistingsatsstatus issuedexpires / paid
`}

Mempool watcher

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.

`, }; } function renderInvoiceRow(i) { const paidChip = i.status === 'paid' ? `paid` : i.status === 'expired' ? `expired` : `pending`; const rightCell = i.status === 'paid' ? `
${escapeHtml(i.paid_at)}
${escapeHtml((i.paid_txid || '').slice(0, 20))}…
` : `
${escapeHtml(i.expires_at)}
`; const action = i.status === 'pending' ? `
` : `${i.status === 'paid' ? 'by ' + escapeHtml(i.paid_by || '') : ''}`; return ` ${escapeHtml(i.id)} ${escapeHtml(i.listing_id)} ${i.amount_sat.toLocaleString()} ${paidChip} ${escapeHtml(i.issued_at)} ${rightCell} ${action} `; } /* ------------------------------- dashboard ------------------------------- */ export function dashboardPage({ session, counts, total, modes, recentListings, recentAudit }) { const modeChip = (m) => `${escapeHtml(m)}`; return { title: 'Admin — dashboard', description: 'Prometheus admin dashboard.', here: '/admin', body: ` ${adminNav(session, '/admin')}

Dashboard

${total}

active listings across ${Object.values(counts).filter((n) => n > 0).length} categories

${modeChip(modes.global)}

global publish mode

${Object.values(modes.categories).filter(Boolean).length}

per-category overrides in effect

Recent listings

${recentListings.length === 0 ? `

No listings yet.

` : ` ${recentListings.map((r) => ``).join('')}
idcategorysizeat
${escapeHtml(r.id)} ${escapeHtml(r.category)} ${formatBytes(r.download_size)} ${escapeHtml(r.created_at)} manage →
`}

Recent audit

${recentAudit.length === 0 ? `

No admin actions yet.

` : ` ${recentAudit.slice(0, 10).map((r) => ``).join('')}
atactoractiontarget
${escapeHtml(r.at)} ${escapeHtml(r.actor)} ${escapeHtml(r.action)} ${escapeHtml(r.target || '')}

Full audit log →

`}
`, }; } /* ------------------------------- 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')}
${notice ? `
${escapeHtml(notice)}
` : ''}

Listings (${rows.length})

${rows.length === 0 ? `

No listings yet.

` : ` ${rows.map((r) => renderListingRow(r)).join('\n')}
idnamecategorysize statuscreated
`}
`, }; } function renderListingRow(r) { const withdrawn = !!r.withdrawn_at; const statusCell = withdrawn ? `withdrawn` : `active`; const actions = withdrawn ? `${escapeHtml(r.withdrawn_at)}` : `
`; return ` ${escapeHtml(r.id)} ${escapeHtml(r.name)} ${escapeHtml(r.category)} ${formatBytes(r.download_size)} ${statusCell} ${escapeHtml(r.created_at)} ${actions} `; } /* ------------------------------- modes ------------------------------- */ export function modesPage({ session, modes, notice = null }) { const modeOptions = (selected) => MODES.map((m) => ``).join(''); return { title: 'Admin — modes', description: 'Publish-mode policy per category.', here: '/admin/modes', body: ` ${adminNav(session, '/admin/modes')}
${notice ? `
${escapeHtml(notice)}
` : ''}

Publish modes

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.

append — any valid listing goes in; readers see the signed scan report and decide.
scan-first — infected uploads are rejected before they land. Clean ones publish.
review — clean listings sit in a human-review queue before browse shows them. Toggle persists; enforcement lands in a later milestone.
${CATEGORIES.map((c) => { const override = modes.categories[c.slug]; return ``; }).join('')}
ScopeCurrent mode
Global
${c.icon} ${escapeHtml(c.title)} ${c.slug}
`, }; } /* ------------------------------- 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')}

Audit log

Append-only. Each row's this_hash 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.

${entries.length === 0 ? `

No admin actions yet.

` : ` ${entries.map((e) => ``).join('')}
seqatactoraction targetdetailsthis_hash
${e.seq} ${escapeHtml(e.at)} ${escapeHtml(e.actor)} ${escapeHtml(e.action)} ${escapeHtml(e.target || '')} ${escapeHtml(e.details || '')} ${escapeHtml(e.this_hash.slice(0, 10))}…
`}
`, }; } /* ------------------------------- 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`; }