prometheus/service/pages.js
Local Dev c4566b4ea3 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

584 lines
24 KiB
JavaScript

/**
* The pages Prometheus.X renders.
*
* Each export is `(req?) => {title, description, body}`. The server is the one
* that wraps them in the layout — these just return the inner content.
*
* The copy was lifted from site/prometheus/index.html (the ICANN teaser) and
* split by concern so each page is one topic a reader opened on purpose. The
* single-scroll version lives at silentmode.st/prometheus and will stay there
* as the SEO-friendly mirror.
*/
/* ------------------------------- home ------------------------------- */
export function home() {
return {
title: 'Apps and programs on the decentralized web',
description:
'Prometheus is a home for apps, programs, and dApps published on the Bitcoin Cash chain. Listings live on chain, binaries live on Sia, endorsements are signed. Anyone can publish; nobody can gatekeep.',
here: '/',
body: `
<header class="hero">
<div class="mark" style="font-size:52px">🔥</div>
<h1>Prometheus
<span class="pill p-designed" style="vertical-align:middle;font-size:12px;margin-left:.6rem">designed · not yet built</span>
</h1>
<p class="tag">A home for apps, programs, dApps, and libraries — <b>listed on the
Bitcoin Cash chain, hosted on Sia, endorsed by signature</b>. Anyone can publish.
Nobody can gatekeep. Named for the one who gave fire to humans and did not ask
the gods first.</p>
<div class="cta">
<a class="btn acid" href="/browse">Browse apps</a>
<a class="btn ghost" href="/submit">Submit an app</a>
</div>
</header>
<div class="wrap">
<section class="plain">
<h2>Browse by category</h2>
<p class="lede">On a conventional store, one company decides which shelf an app
belongs on. Here the publisher labels their own listing, and your trust list
decides whether to show it. The categories below are the initial shape; a new
one gets proposed by publishing a listing against it.</p>
<div class="categories">
<a class="cat empty" href="/c/communication"><div class="ico">💬</div>
<h3>Communication</h3>
<p>Messengers, voice, boards, group chats. Clients that route through peers
rather than someone else's server.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/privacy"><div class="ico">🛡️</div>
<h3>Privacy &amp; security</h3>
<p>Tor and mix-net tooling, key and vault managers, disk crypto. The things
a conventional store delists first when a government writes.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/wallets"><div class="ico">💸</div>
<h3>Wallets &amp; payments</h3>
<p>BCH wallets, cross-chain bridges, point-of-sale terminals. The chain
publishes the listing and the chain settles the payment.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/games"><div class="ico">🎮</div>
<h3>Games</h3>
<p>Standalone titles, on-chain leaderboards, in-app purchases that reach
the publisher instead of a 30% toll.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/devtools"><div class="ico">🧰</div>
<h3>Developer tools</h3>
<p>SDKs, libraries, indexers, faucets, protocol utilities. The pieces other
apps on this list are built out of.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/media"><div class="ico">🎧</div>
<h3>Media &amp; publishing</h3>
<p>Readers, players, one-file blogs, podcast hosts. Clients that find their
audience without going through an aggregator.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/utilities"><div class="ico">⚙️</div>
<h3>Utilities</h3>
<p>File tools, format converters, backup, calculators. The small sharp
things that solve one problem and do not ask for an account first.</p>
<span class="count">0 listings</span>
</a>
<a class="cat empty" href="/c/other"><div class="ico">✨</div>
<h3>Everything else</h3>
<p>Anything that does not fit above. New categories are proposed in-band —
any listing can declare one and clients will offer to add it.</p>
<span class="count">0 listings</span>
</a>
</div>
<div class="note warn" style="margin-top:1.6rem"><b>No live listings yet.</b>
The listing protocol (<code class="inline">APS1</code> record on chain) and
the submission flow are being wired now. The site, the scanner, the
publishers' walkthrough and this directory are up; a listing you can click
on and install is next.</div>
</section>
<section>
<h2>How Prometheus works, in a nutshell</h2>
<ol class="steps">
<li><b>A listing is a signed on-chain record.</b>
<span>Publishing an app writes a small <code class="inline">APS1</code>
record to Bitcoin Cash — same protocol shape as a BNS name registration,
different prefix. The payload is a hash pointing at a full manifest stored
on Sia. Nobody approves the record; the transaction is the publish.</span></li>
<li><b>The binary lives on Sia.</b>
<span>App files are uploaded to the Sia storage network — encrypted on
the publisher's machine, sharded across roughly 30 independent hosts,
held by nobody in particular. The listing carries the SHA-256 the file
must match.</span></li>
<li><b>Scan reports are signed and attached.</b>
<span>Prometheus runs an automated malware scan on every binary it hosts
and publishes the signed report alongside the listing. The chain still
goes up; the report tells your client whether to warn.</span></li>
<li><b>Install verifies against the chain.</b>
<span>Clients pull the file, check the SHA-256 against the on-chain
record, check the signing certificate against the fingerprint, and only
then install. The chain is the truth, not the mirror.</span></li>
</ol>
<p style="margin-top:1.2rem"><a href="/publish">The full publishers' walkthrough,
step by step →</a></p>
</section>
</div>`,
};
}
/* ------------------------------- publish ---------------------------- */
export function publish() {
return {
title: "Publishers' walkthrough",
description:
'The end-to-end flow of publishing an app on Prometheus: name, hosting, manifest, listing, verification, updates, withdrawal, and endorsements.',
here: '/publish',
body: `
<header class="hero compact">
<h1>Publishers' walkthrough</h1>
<p class="tag">Pick a name, host the binary, write the manifest, sign the listing,
verify it resolves, ship an update when it's time. Here's the whole shape.</p>
</header>
<div class="wrap">
<section class="plain">
<p class="lede">Publishing an app on Prometheus is a sequence of signed records
pointing at files stored where you keep them. The whole thing — identity,
hosting, the listing itself — is yours; nothing here grants anyone else
permission to pull your app down, raise your price, or take a cut of what
people pay you for it.</p>
<p>Steps are tagged <span class="pill p-live">available today</span> or
<span class="pill p-designed">designed</span> so it is clear what you can do
now and what is waiting on the APS1 record format landing.</p>
</section>
<section>
<h2>What you need before you start</h2>
<div class="grid">
<div class="card">
<span class="pill p-live">available today</span>
<h3>A BNS name</h3>
<p>Any name registered through <a href="//sirius.x/">Sirius</a> works. The
name is what signs the listing, so it is also what readers trust the
listing by. Fractions of a cent to register, no monthly bill.</p>
</div>
<div class="card">
<span class="pill p-live">available today</span>
<h3>Somewhere to put the binary</h3>
<p>Prometheus can host for you — upload here and we put it on Sia, signed
and scanned. If you'd rather self-host on your own URL, the listing just
records the URL and the hash and will fetch from wherever you say.</p>
</div>
<div class="card">
<span class="pill p-live">available today</span>
<h3>A signing key</h3>
<p>The private key for the BNS name. If the name is in a wallet you already
use for Bitcoin Cash, that key already exists; publishing re-uses it.</p>
</div>
<div class="card">
<span class="pill p-live">available today</span>
<h3>A pinch of BCH</h3>
<p>One listing is one transaction. The transaction fee is cents, denominated
in sats. During the free wave, that is the only cost. Later, a flat
listing fee covers the Sia storage.</p>
</div>
</div>
</section>
<section>
<h2>The walkthrough</h2>
<ol class="steps">
<li><b>1. Register a name — or use one you already own.</b>
<span>The listing is signed by the name's key. A reader who follows your
name will see your listing with the publisher chip set to that name; a
reader who does not will see it marked unsigned by anyone they trust.
One name can carry any number of listings, so a studio with three games
publishes under one identity.</span></li>
<li><b>2. Upload the binary (or point us at your URL).</b>
<span>Prometheus runs a malware scan on upload. If it passes, the file
goes into a Sia bucket Silent Mode controls, the signed scan report is
prepared, and the manifest fields are prefilled. If you'd rather host
the file yourself, give us the URL and the expected SHA-256; the manifest
records both, the scan still runs against whatever the URL serves, and
the client refuses to install a byte-mismatched download.</span></li>
<li><b>3. Fill in the manifest.</b>
<span>Name, description, category, version, icon, download URL,
<code class="inline">sha256</code>, and the signing-cert fingerprint when
the binary itself is signed (it should be, if the platform supports it).
Icons are referenced by URL, not embedded. A 2 KB manifest with a 500 KB
icon beside it is the right shape; a 500 KB manifest is not.</span></li>
<li><b>4. Sign the APS1 record with your BNS key.</b>
<span>One transaction. The on-chain record carries the manifest hash,
the category label, and the signature from your name's key. From the
moment the transaction confirms, every client that follows the chain can
see your listing — no approval queue, no editorial review, no publisher
dashboard waiting for a human on the other end.</span></li>
<li><b>5. Verify it resolves.</b>
<span>Open one of the Ariadne clients and look for your listing in the
category you labelled it under. The client should pull the manifest from
Sia, show the fields you wrote, surface the scan report, and offer the
download. Clicking install should fetch the binary, check the SHA-256,
check the signing certificate, and only then run.</span></li>
<li><b>6. Ship updates with another signed record.</b>
<span>A new version is another APS1 transaction referencing the same app
id, signed by the same key, pointing at a new manifest hash. Clients
already following the app see the update offered the next time they
check. Nobody can push a hostile update unless they have your key.</span></li>
<li><b>7. Withdraw a listing when it is time.</b>
<span>A withdrawal is a signed record that marks the app id as retired.
Clients hide the listing from browse and refuse to install it, though
anyone with the manifest hash and the binary can still side-load it —
the chain is a signal, not a lock.</span></li>
</ol>
</section>
<section>
<h2>A note on endorsements</h2>
<p>A listing is one signed record. An <em>endorsement</em> is another — a short
on-chain statement from any name saying "I vouch for this app." Readers whose
trust list includes the endorser's name will see your listing marked as
endorsed by them; readers who do not follow that name will not. Endorsements
are not reviews, and the client does not aggregate them into a star rating.
They are statements: who said what, when, signed.</p>
<div class="note"><b>You can endorse too.</b> Any name can publish an endorsement
of any listing, and most publishers end up endorsing a handful of apps they
use themselves. The trust graph that emerges is the directory's real curation
layer; the categories are just filing.</div>
</section>
<section>
<h2>What this costs</h2>
<div class="grid">
<div class="card">
<h3>Per listing</h3>
<p>One Bitcoin Cash transaction. Fractions of a cent at current rates. Paid
once, lives on chain.</p>
</div>
<div class="card">
<h3>Per update</h3>
<p>Another transaction. Same shape, same cost. There is no "update bundle"
discount because there is no central party to collect one.</p>
</div>
<div class="card">
<h3>Per month of hosting</h3>
<p>Free during the opening wave while Prometheus underwrites the storage.
Afterwards, a flat per-listing fee (planned around $1 at current rates)
covers Sia storage for the typical binary. Your own HTTPS host costs
what it costs you.</p>
</div>
<div class="card">
<h3>Per download</h3>
<p>Zero to the directory. The reader pulls the binary from wherever the
manifest points; Prometheus never meters the traffic.</p>
</div>
</div>
</section>
<section>
<div class="note warn"><b>Where this stands today.</b> Names, Sia hosting and
BCH signing exist and are in use. The <code class="inline">APS1</code> record
format and the client-side browse/install flow are being wired. This page
describes the end state; the first live listings will go up as soon as the
submit path lands on this site and the Ariadne clients learn to read it.</div>
</section>
</div>`,
};
}
/* ------------------------------- browse ----------------------------- */
export function browse() {
return {
title: 'Browse',
description:
'Browse apps and programs published on Prometheus by category. Signed listings on the Bitcoin Cash chain; scan reports attached; your trust list filters.',
here: '/browse',
body: `
<header class="hero compact">
<h1>Browse</h1>
<p class="tag">Signed listings on chain, scan reports attached, your trust list
decides what to render.</p>
</header>
<div class="wrap">
<section class="plain">
<div class="note warn"><b>No live listings yet.</b> The listing protocol is
being wired; the browse pages below will light up as the APS1 index starts
carrying records. In the meantime, the eight categories are set and ready
to receive publications.</div>
<div class="categories" style="margin-top:1.4rem">
<a class="cat empty" href="/c/communication"><div class="ico">💬</div>
<h3>Communication</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/privacy"><div class="ico">🛡️</div>
<h3>Privacy &amp; security</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/wallets"><div class="ico">💸</div>
<h3>Wallets &amp; payments</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/games"><div class="ico">🎮</div>
<h3>Games</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/devtools"><div class="ico">🧰</div>
<h3>Developer tools</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/media"><div class="ico">🎧</div>
<h3>Media &amp; publishing</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/utilities"><div class="ico">⚙️</div>
<h3>Utilities</h3><span class="count">0 listings</span></a>
<a class="cat empty" href="/c/other"><div class="ico">✨</div>
<h3>Everything else</h3><span class="count">0 listings</span></a>
</div>
</section>
</div>`,
};
}
/* ------------------------------- submit ----------------------------- */
export function submit() {
return {
title: 'Submit an app',
description:
'Upload an app to Prometheus. We scan it, generate the manifest and the APS1 record, you sign with your BNS key and the listing goes on chain.',
here: '/submit',
body: `
<header class="hero compact">
<h1>Submit an app</h1>
<p class="tag">Upload, scan, sign, publish. Four screens; your key stays with you.</p>
</header>
<div class="wrap">
<section class="plain">
<div class="note warn"><b>Milestone 1 of 4: upload + scan.</b> This step is
live and testable. The next milestone wires the manifest and APS1 record
(see <a href="/publish">the publishers' walkthrough</a>); payment and the
on-chain publish come after that.</div>
<ol class="steps" style="margin-top:1.4rem">
<li><b>1. Upload the binary.</b>
<span>Pick a file (any file — we don't care what the format is, only
what ClamAV says about the bytes). The server scans it, hashes it,
and hands back a signed scan report.</span></li>
<li><b>2. Fill the manifest.</b>
<span>Name, description, category, version, icon URL. Scan verdict is
attached automatically. <em>Not wired yet.</em></span></li>
<li><b>3. Sign with your BNS key.</b>
<span>The service shows the APS1 payload. Sign in Theseus, paste a
BIP-137 signature from any BCH wallet, or import your name's mnemonic
once in the page (wiped on close). <em>Not wired yet.</em></span></li>
<li><b>4. Pay the listing fee (eventually).</b>
<span>Free during the opening wave; a BCH invoice for the per-listing
fee once storage costs cross a threshold. <em>Not wired yet.</em></span></li>
</ol>
</section>
<section>
<h2>Upload a file to try it</h2>
<p class="lede">Pick any file to send through the scanner. The upload is
accepted, SHA-256'd, and either kept (clean) or discarded (infected or
error). Nothing is published; this is just the scan step.</p>
<form id="up" style="display:grid;gap:12px;max-width:520px;margin-top:1rem">
<input type="file" name="file" required
style="padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;color:var(--ink)">
<button class="btn acid" type="submit">Upload &amp; scan</button>
<div id="status" style="color:var(--mut);font-size:14px;min-height:1.4em"></div>
</form>
<pre id="result" style="display:none;max-height:480px;overflow:auto;margin-top:1.2rem"></pre>
</section>
<script>
(() => {
const form = document.getElementById('up');
const status = document.getElementById('status');
const result = document.getElementById('result');
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const file = form.file.files[0];
if (!file) return;
status.textContent = \`Uploading \${file.name} (\${fmtBytes(file.size)})…\`;
result.style.display = 'none';
result.textContent = '';
const body = new FormData();
body.append('file', file);
const started = performance.now();
let json;
try {
const r = await fetch('/api/upload', { method: 'POST', body });
json = await r.json();
const elapsed = Math.round(performance.now() - started);
const v = json?.scan?.verdict;
const emoji = v === 'clean' ? '✅' : v === 'infected' ? '🚨' : '⚠️';
status.innerHTML = \`\${emoji} <b>\${v || 'done'}</b> — \${elapsed} ms round trip.\`;
} catch (err) {
status.innerHTML = '⚠️ <b>network error</b> — ' + String(err?.message || err);
return;
}
result.style.display = 'block';
result.textContent = JSON.stringify(json, null, 2);
});
function fmtBytes(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MiB';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GiB';
}
})();
</script>
</div>`,
};
}
/* ------------------------------- boundaries ------------------------- */
export function boundaries() {
return {
title: 'Boundaries',
description:
'What Prometheus does and does not do. Not a curator. Not a moderator. Storage costs money. Readers decide; the chain carries signed records.',
here: '/boundaries',
body: `
<header class="hero compact">
<h1>Boundaries</h1>
<p class="tag">What this directory is not, and why.</p>
</header>
<div class="wrap">
<section class="plain">
<p class="lede">A permissionless app directory earns the right to call itself
permissionless by being honest about what that costs. Here is the shape of
the trade.</p>
</section>
<section>
<h2>What Prometheus is not</h2>
<div class="grid">
<div class="card">
<h3>Not a curator</h3>
<p>Prometheus does not decide what is on it. Malware can be published — as
it can on any store. The scan report tells your client to warn; the
trust lists you subscribe to filter the view. If nobody you trust
endorses it, your client says so.</p>
</div>
<div class="card">
<h3>Not a moderator</h3>
<p>Content that is illegal in your jurisdiction is your responsibility to
avoid, same as any other permissionless network. Silent Mode operates
infrastructure; it does not decide what runs on it.</p>
</div>
<div class="card">
<h3>Not a payment processor</h3>
<p>Payments in an app go publisher-to-reader. The listing may carry a BCH
receive address or a wallet-connect URL; Prometheus never routes a
transaction. There is no refund desk because there is no middleman to
issue a refund.</p>
</div>
<div class="card">
<h3>Not an identity provider</h3>
<p>The only identity on Prometheus is the BNS name a listing is signed by.
Readers decide which names count via their own trust lists. There is no
Prometheus account, no "verified publisher" badge, no sign-in flow for
readers.</p>
</div>
</div>
</section>
<section>
<h2>What Silent Mode does control</h2>
<div class="grid">
<div class="card">
<h3>The scan report</h3>
<p>Our ClamAV + VirusTotal pipeline runs on every binary we host. The
signed report is attached to the listing. We cannot stop a listing from
being on chain; we can tell readers what we found.</p>
</div>
<div class="card">
<h3>Which binaries we host</h3>
<p>Silent Mode's Sia bucket. Operators can refuse to host something and
require the publisher to self-host at their own URL. The listing still
exists; the scan report says we did not host it.</p>
</div>
<div class="card">
<h3>The default sort</h3>
<p>What the Browse page shows first is a choice. We publish the sort order
and let clients override it. The chain holds the data; the directory is
one lens.</p>
</div>
<div class="card">
<h3>The publish mode</h3>
<p>Three modes exist (append-only, scan-first-blocking, human-review).
Operators pick which one is active per category. The current mode is
always visible on this page; a change is announced before it takes
effect.</p>
</div>
</div>
</section>
<section>
<h2>Storage costs money</h2>
<p>Sia hosting is inexpensive but not free — publishers pay for the bytes
their apps take. There is no advertising model, no data harvesting, no
subscription. Storage is the only cost, and it is small. During the
opening wave Silent Mode absorbs it; afterwards it is a flat per-listing
fee paid in BCH.</p>
</section>
</div>`,
};
}
/* ------------------------------- 404 -------------------------------- */
export function notFound(req) {
return {
title: 'Not here',
description: 'The requested page does not exist on Prometheus.',
here: '',
body: `
<header class="hero compact">
<h1>Not here</h1>
<p class="tag">Nothing lives at <code class="inline">${escapeHtml(req?.url || '')}</code>.</p>
</header>
<div class="wrap">
<section class="plain">
<p>Try the <a href="/">home page</a>, the <a href="/browse">browse</a> or the
<a href="/publish">publishers' walkthrough</a>.</p>
</section>
</div>`,
};
}
/* ------------------------------- helpers ---------------------------- */
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}