Two bugs under one roof.
The visible one: a publisher uploading anything over 10 MB on the live
site got "Network error — Unexpected token '<'" in the browser console.
Cause: the Caddy block I wrote for prometheus.x had
`request_body { max_size 2GB }` nested inside the handle block, where it
is a syntax Caddy parses but does not apply, so Caddy's own 10 MB
default was still the gate. Any oversize POST got Caddy's HTML 413 page,
which the browser's JSON parser choked on at the leading `<`.
Fix: moved `request_body { max_size … }` to the top of the prometheus.x
site block (outside `handle`), where Caddy actually honours it. The live
Caddyfile on p2pexchange is already patched + reloaded; a 50 MB upload
verified through the end-to-end path (Caddy → service → clamd → signed
scan report).
The scope one: 2 GiB was more than Prometheus should offer to publishers
at launch. Mobile apps are usually ~100 MB and even most desktop
installers come in well under 200 MiB. Dropping to 200 MiB:
- Fits every Android APK and most desktop installers.
- Shrinks the operator's Sia storage bill (fewer accidental Steam-size
uploads).
- Keeps the "publishers of larger binaries self-host and submit a
manifest with the external URL + SHA" escape hatch — the scanner
still fetches the URL and checks the hash, so a 5 GB game isn't
locked out, Prometheus just doesn't eat its storage.
Changes:
service/uploads.js MAX_FILE_BYTES default: 2 GiB → 200 MiB
service/pages.js /submit step-1 subtitle: "any size up to
2 GiB" → "up to 200 MiB"
deploy/prometheus-x.conf (unused angie fallback) 2g → 200m
deploy/apps-silentmode-st.conf (unused angie fallback) 2g → 200m
PROMETHEUS_MAX_UPLOAD_BYTES env still overrides per-instance.
842 lines
36 KiB
JavaScript
842 lines
36 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.
|
||
*/
|
||
|
||
import { renderCategoriesGrid, countsByCategory } from './pages-browse.js';
|
||
import { countActive } from './db.js';
|
||
|
||
/**
|
||
* The "Browse by category" section used on the home page. Lives here so the
|
||
* home page's inline template reads cleanly, but delegates the actual grid to
|
||
* pages-browse.js so a single row of counts drives both surfaces.
|
||
*/
|
||
function renderHomeBrowseSection() {
|
||
const total = countActive();
|
||
return `<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>
|
||
|
||
${renderCategoriesGrid({ verbose: true })}
|
||
|
||
${total === 0 ? `
|
||
<div class="note warn" style="margin-top:1.6rem"><b>No live listings yet.</b>
|
||
The on-chain publication protocol (<code class="inline">APS1</code>) is in
|
||
progress; in the meantime the site, the scanner, the publisher-signed
|
||
listing endpoint and the publishers' walkthrough are up. First real
|
||
listings appear in this grid as they publish.</div>
|
||
` : ''}
|
||
</section>`;
|
||
}
|
||
|
||
/* ------------------------------- 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="width:88px;height:88px;margin:0 auto 1rem">
|
||
<svg viewBox="0 0 64 64" width="88" height="88" aria-hidden="true" style="display:block">
|
||
<path d="M32 6 C 38 18 46 24 46 36 C 46 48 40 56 32 56 C 24 56 18 48 18 36 C 18 28 24 22 20 14 C 26 18 30 20 32 6 Z" fill="#d6ff3d"/>
|
||
<path d="M32 20 C 36 28 40 32 40 40 C 40 48 36 52 32 52 C 28 52 24 48 24 40 C 24 34 28 30 32 20 Z" fill="#0b0e14"/>
|
||
<circle cx="32" cy="44" r="3" fill="#d6ff3d"/>
|
||
</svg>
|
||
</div>
|
||
<h1>Prometheus<span class="g">.X</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">
|
||
|
||
${renderHomeBrowseSection()}
|
||
|
||
<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() {
|
||
const total = countActive();
|
||
const totalLabel = total === 0
|
||
? `No listings yet — the eight categories are set and ready to receive publications.`
|
||
: `${total} ${total === 1 ? 'listing' : 'listings'} across ${Object.values(countsByCategory()).filter((n) => n > 0).length} ${Object.values(countsByCategory()).filter((n) => n > 0).length === 1 ? 'category' : 'categories'}.`;
|
||
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">
|
||
<p class="lede">${totalLabel}</p>
|
||
|
||
${renderCategoriesGrid({ verbose: false })}
|
||
</section>
|
||
|
||
</div>`,
|
||
};
|
||
}
|
||
|
||
/* ------------------------------- submit ----------------------------- */
|
||
|
||
export function submit() {
|
||
return {
|
||
title: 'Submit an app',
|
||
description:
|
||
'Upload an app to Prometheus. We scan it, generate the manifest, you sign with your BNS key and the listing goes live.',
|
||
here: '/submit',
|
||
body: `
|
||
<header class="hero compact">
|
||
<h1>Submit an app</h1>
|
||
<p class="tag">Upload → fill manifest → sign with your wallet → publish.
|
||
Your key stays with you.</p>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
|
||
<!-- Progress indicator -->
|
||
<section class="plain">
|
||
<ol class="steps" id="step-indicator" style="margin:0 0 2rem">
|
||
<li data-step="1"><b>Upload</b> <span id="s1-sub">the binary (up to 200 MiB)</span></li>
|
||
<li data-step="2"><b>Describe</b> <span id="s2-sub">name, publisher name, category, description</span></li>
|
||
<li data-step="3"><b>Sign</b> <span id="s3-sub">with the BCH key for your publisher BNS name</span></li>
|
||
<li data-step="4"><b>Publish</b> <span id="s4-sub">POST to the directory</span></li>
|
||
</ol>
|
||
</section>
|
||
|
||
<!-- =============== STEP 1: upload =============== -->
|
||
<section id="step1">
|
||
<h2>1 · Upload the binary</h2>
|
||
<p class="lede">The server scans with ClamAV + VirusTotal (where configured),
|
||
computes the SHA-256, and attaches a signed scan report to your listing.
|
||
Only clean uploads can proceed.</p>
|
||
|
||
<form id="upload-form" style="display:grid;gap:12px;max-width:560px;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 & scan</button>
|
||
<div id="upload-status" style="color:var(--mut);font-size:14px;min-height:1.4em"></div>
|
||
</form>
|
||
|
||
<div id="upload-result" class="card" style="display:none;margin-top:1.2rem">
|
||
<h3 style="margin:0 0 .4rem">Upload accepted</h3>
|
||
<p style="margin:0;color:var(--mut);font-size:13.5px">
|
||
<b style="color:var(--ink)" id="u-name"></b> ·
|
||
<span id="u-size"></span> ·
|
||
<code class="inline" id="u-hash"></code>
|
||
</p>
|
||
<p style="margin:.5rem 0 0" id="u-scan"></p>
|
||
<button class="btn acid" type="button" id="to-step2" style="margin-top:1rem">Continue → describe your listing</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- =============== STEP 2: describe =============== -->
|
||
<section id="step2" style="display:none">
|
||
<h2>2 · Describe your listing</h2>
|
||
<p class="lede">Every field is signed in step 3, so take a minute — editing
|
||
the signed payload is a new signature, not a quick fix.</p>
|
||
|
||
<form id="manifest-form" style="display:grid;gap:14px;max-width:620px;margin-top:1rem">
|
||
<label>Publisher BNS name
|
||
<input name="publisher" required placeholder="example.x"
|
||
pattern="^[a-z0-9][a-z0-9-]{0,62}\\.[a-z0-9]{1,20}$"
|
||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:ui-monospace,monospace;margin-top:.3rem">
|
||
<small style="color:var(--dim)">The BCH key for this name's on-chain owner is who signs. Register one at <a href="//sirius.x/">sirius.x</a>.</small>
|
||
</label>
|
||
|
||
<label>Short slug (URL)
|
||
<input name="slug" required placeholder="my-app"
|
||
pattern="^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$"
|
||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:ui-monospace,monospace;margin-top:.3rem">
|
||
<small style="color:var(--dim)">Lives at <code class="inline">/app/<publisher>/<slug></code>. Lowercase, hyphens; 1–64 chars.</small>
|
||
</label>
|
||
|
||
<label>Display name
|
||
<input name="name" required maxlength="128" placeholder="My App"
|
||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);margin-top:.3rem">
|
||
</label>
|
||
|
||
<label>Category
|
||
<select name="category" required
|
||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);margin-top:.3rem">
|
||
<optgroup label="Apps">
|
||
<option value="browsers">Browsers & web tools</option>
|
||
<option value="communication">Communication</option>
|
||
<option value="privacy">Privacy & security</option>
|
||
<option value="devtools">Developer tools</option>
|
||
<option value="media">Media & publishing</option>
|
||
<option value="productivity">Productivity</option>
|
||
<option value="other">Other</option>
|
||
</optgroup>
|
||
<option value="games">Games</option>
|
||
<option value="fintech">FinTech</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label>Version
|
||
<input name="version" required maxlength="32" placeholder="1.0.0"
|
||
pattern="^[0-9A-Za-z.+_-]{1,32}$"
|
||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:ui-monospace,monospace;margin-top:.3rem">
|
||
</label>
|
||
|
||
<label>Description
|
||
<textarea name="description" required maxlength="2000" rows="4"
|
||
placeholder="What does this app do? Who's it for? What makes it worth the install?"
|
||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);margin-top:.3rem;resize:vertical"></textarea>
|
||
<small style="color:var(--dim)">Up to 2000 characters.</small>
|
||
</label>
|
||
|
||
<label>Download URL
|
||
<input name="download_url" required type="url" placeholder="https://example.com/myapp-1.0.0.exe"
|
||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:ui-monospace,monospace;margin-top:.3rem">
|
||
<small style="color:var(--dim)">Where readers' clients fetch the binary. The SHA-256 (from step 1) must match what the URL serves.</small>
|
||
</label>
|
||
|
||
<label>Homepage (optional)
|
||
<input name="homepage" type="url" placeholder="https://example.com"
|
||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:ui-monospace,monospace;margin-top:.3rem">
|
||
</label>
|
||
|
||
<label>Icon URL (optional)
|
||
<input name="icon" type="url" placeholder="https://example.com/icon.svg"
|
||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:ui-monospace,monospace;margin-top:.3rem">
|
||
<small style="color:var(--dim)">Shown at 80 px on the listing detail. Fallback: your category's icon.</small>
|
||
</label>
|
||
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:.6rem">
|
||
<button class="btn ghost" type="button" id="back-to-1">← Back to upload</button>
|
||
<button class="btn acid" type="submit">Continue → sign</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<!-- =============== STEP 3: sign =============== -->
|
||
<section id="step3" style="display:none">
|
||
<h2>3 · Sign with your BNS key</h2>
|
||
<p class="lede">The message below carries every field from step 2 in canonical
|
||
form. Sign it with the BCH key for the address that owns
|
||
<b style="color:var(--ink)" id="sign-publisher">your name</b> on chipnet.
|
||
Any BIP-137 wallet works (Electron Cash, Deviant, Cashonize, Theseus's Aegis).</p>
|
||
|
||
<h3 style="margin-top:1.4rem">Message to sign</h3>
|
||
<pre id="sign-message" style="max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-all;font-size:12px"></pre>
|
||
|
||
<div id="theseus-sign" style="display:none;margin-top:1.2rem;padding:14px 18px;background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--acid);border-radius:10px">
|
||
<b style="color:var(--ink)">Theseus wallet detected.</b>
|
||
<p style="margin:.3rem 0;color:var(--mut);font-size:14px">Aegis is injected on this page. Sign without pasting.</p>
|
||
<button class="btn acid" type="button" id="theseus-sign-btn">Sign with Theseus wallet</button>
|
||
</div>
|
||
|
||
<div style="margin-top:1.2rem">
|
||
<h3 style="margin:0 0 .3rem">Or paste a BIP-137 signature</h3>
|
||
<p style="color:var(--mut);font-size:13.5px;margin:0 0 .5rem">Base64, 88 characters. From any wallet's "sign message" dialog.</p>
|
||
<textarea id="sig-paste" rows="3"
|
||
style="width:100%;padding:10px;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--ink);font-family:ui-monospace,monospace;font-size:12px;resize:vertical"
|
||
placeholder="H1234abcd…"></textarea>
|
||
</div>
|
||
|
||
<div id="sign-status" style="color:var(--mut);font-size:14px;min-height:1.4em;margin-top:.8rem"></div>
|
||
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:1rem">
|
||
<button class="btn ghost" type="button" id="back-to-2">← Back to details</button>
|
||
<button class="btn acid" type="button" id="to-step4" disabled>Continue → publish</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- =============== STEP 4: publish =============== -->
|
||
<section id="step4" style="display:none">
|
||
<h2>4 · Publish</h2>
|
||
<p class="lede">POSTs the signed listing to <code class="inline">/api/listing</code>.
|
||
The server re-verifies the signature against the on-chain owner of your BNS
|
||
name and stores the row. The listing is live on browse the moment the POST
|
||
returns 201.</p>
|
||
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:1rem">
|
||
<button class="btn ghost" type="button" id="back-to-3">← Back to sign</button>
|
||
<button class="btn acid" type="button" id="publish-btn">Publish now</button>
|
||
</div>
|
||
|
||
<div id="publish-status" style="color:var(--mut);font-size:14px;min-height:1.4em;margin-top:1rem"></div>
|
||
<div id="publish-result" style="margin-top:1rem"></div>
|
||
</section>
|
||
|
||
<script>
|
||
(() => {
|
||
// ===== state =====
|
||
const W = {
|
||
file: null,
|
||
upload: null, // from /api/upload: {upload:{hash,size,name,...}, scan:{...}, report:{...}}
|
||
manifest: null, // from the form in step 2
|
||
message: null, // canonical message string in step 3
|
||
signature: null, // base64 BIP-137
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
function show(step) {
|
||
for (const n of [1,2,3,4]) $('step'+n).style.display = n === step ? '' : 'none';
|
||
// Progress indicator highlights the current step.
|
||
document.querySelectorAll('#step-indicator li').forEach((li) => {
|
||
const s = Number(li.dataset.step);
|
||
li.style.opacity = s === step ? '1' : s < step ? '.5' : '.3';
|
||
});
|
||
window.scrollTo({top:0, behavior:'smooth'});
|
||
}
|
||
show(1);
|
||
|
||
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';
|
||
}
|
||
|
||
// Canonical JSON: keys sorted at every level, no whitespace. MUST match the
|
||
// service's canonicalJson() in service/listings.js — the signature covers
|
||
// exactly these bytes, and a mismatch here is an unverifiable signature.
|
||
function canonicalJson(v) {
|
||
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
||
if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']';
|
||
const k = Object.keys(v).sort();
|
||
return '{' + k.map((key) => JSON.stringify(key) + ':' + canonicalJson(v[key])).join(',') + '}';
|
||
}
|
||
|
||
// ===== STEP 1: upload =====
|
||
$('upload-form').addEventListener('submit', async (ev) => {
|
||
ev.preventDefault();
|
||
const file = ev.target.file.files[0];
|
||
if (!file) return;
|
||
W.file = file;
|
||
const status = $('upload-status');
|
||
status.textContent = \`Uploading \${file.name} (\${fmtBytes(file.size)}) and scanning…\`;
|
||
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();
|
||
} catch (err) {
|
||
status.innerHTML = '⚠️ <b>Network error</b> — ' + String(err?.message || err);
|
||
return;
|
||
}
|
||
const elapsed = Math.round(performance.now() - started);
|
||
if (!json.ok) {
|
||
status.innerHTML = '⚠️ <b>Rejected</b> — ' + (json.reason || json.error || 'unknown error');
|
||
return;
|
||
}
|
||
if (json.scan.verdict !== 'clean') {
|
||
status.innerHTML = \`🚨 <b>\${json.scan.verdict}</b> — the scanner refused this file. \` +
|
||
(json.scan.signatures?.length ? \`Signatures: \${json.scan.signatures.join(', ')}.\` : '') +
|
||
' Try a different file.';
|
||
return;
|
||
}
|
||
W.upload = json;
|
||
$('u-name').textContent = json.upload.name;
|
||
$('u-size').textContent = fmtBytes(json.upload.size);
|
||
$('u-hash').textContent = json.upload.hash.slice(0,8) + '…' + json.upload.hash.slice(-6);
|
||
$('u-scan').innerHTML = '<span class="pill p-live">scan ✓ clean (' + json.scan.engine + ', ' + elapsed + ' ms)</span>';
|
||
$('upload-result').style.display = 'block';
|
||
status.textContent = '';
|
||
});
|
||
|
||
// ===== STEP 1 → 2 =====
|
||
$('to-step2').addEventListener('click', () => {
|
||
// Prefill name + download_url + hash preview.
|
||
const f = $('manifest-form');
|
||
if (W.upload) {
|
||
// leave the fields the user needs to fill blank; show the hash we'll use
|
||
}
|
||
show(2);
|
||
});
|
||
|
||
// ===== STEP 2: describe =====
|
||
$('back-to-1').addEventListener('click', () => show(1));
|
||
$('manifest-form').addEventListener('submit', (ev) => {
|
||
ev.preventDefault();
|
||
if (!W.upload) { alert('Upload a file first.'); show(1); return; }
|
||
const f = ev.target;
|
||
W.manifest = {
|
||
publisher: f.publisher.value.trim().toLowerCase(),
|
||
slug: f.slug.value.trim().toLowerCase(),
|
||
name: f.name.value.trim(),
|
||
description: f.description.value.trim(),
|
||
category: f.category.value,
|
||
version: f.version.value.trim(),
|
||
homepage: f.homepage.value.trim() || null,
|
||
icon: f.icon.value.trim() || null,
|
||
download: {
|
||
url: f.download_url.value.trim(),
|
||
sha256: W.upload.upload.hash,
|
||
size: W.upload.upload.size,
|
||
},
|
||
scan_report: W.upload.report,
|
||
};
|
||
// Canonical signing message, same shape service/listings.js builds.
|
||
const canonical = canonicalJson({
|
||
kind: 'prometheus.listing',
|
||
v: 1,
|
||
publisher: W.manifest.publisher,
|
||
slug: W.manifest.slug,
|
||
name: W.manifest.name,
|
||
description: W.manifest.description,
|
||
category: W.manifest.category,
|
||
version: W.manifest.version,
|
||
homepage: W.manifest.homepage,
|
||
icon: W.manifest.icon,
|
||
download: W.manifest.download,
|
||
scan_report_pubkey: W.upload.report.pubkey,
|
||
scan_report_signature: W.upload.report.signature,
|
||
});
|
||
W.message = 'PROMETHEUS-LISTING\\n' + canonical;
|
||
$('sign-publisher').textContent = W.manifest.publisher;
|
||
$('sign-message').textContent = W.message;
|
||
// Reset step 3 state so a revise can proceed cleanly.
|
||
W.signature = null;
|
||
$('sig-paste').value = '';
|
||
$('sign-status').textContent = '';
|
||
$('to-step4').disabled = true;
|
||
show(3);
|
||
});
|
||
|
||
// ===== STEP 3: sign =====
|
||
$('back-to-2').addEventListener('click', () => show(2));
|
||
|
||
// Theseus-wallet injected? Show the one-click sign path.
|
||
if (window.bitcoincash && typeof window.bitcoincash.signMessage === 'function') {
|
||
$('theseus-sign').style.display = 'block';
|
||
}
|
||
|
||
$('theseus-sign-btn').addEventListener('click', async () => {
|
||
const status = $('sign-status');
|
||
status.textContent = 'Asking Theseus wallet for a signature…';
|
||
try {
|
||
const sig = await window.bitcoincash.signMessage(W.message);
|
||
// Different wallets return different shapes; normalise to base64 string.
|
||
const base64 = typeof sig === 'string' ? sig : sig?.signature || sig?.sig || '';
|
||
if (!base64) throw new Error('wallet returned no signature');
|
||
W.signature = base64;
|
||
$('sig-paste').value = base64;
|
||
status.innerHTML = '✓ Signed. <b>' + base64.length + ' chars.</b> Continue to publish.';
|
||
$('to-step4').disabled = false;
|
||
} catch (err) {
|
||
status.innerHTML = '⚠️ Theseus-wallet sign failed — ' + String(err?.message || err) +
|
||
'. Paste a signature from another wallet below.';
|
||
}
|
||
});
|
||
|
||
$('sig-paste').addEventListener('input', (ev) => {
|
||
const v = ev.target.value.trim();
|
||
if (v.length === 88 || v.length === 86 || v.length === 87) {
|
||
W.signature = v;
|
||
$('sign-status').innerHTML = '✓ ' + v.length + '-char signature captured. Continue to publish.';
|
||
$('to-step4').disabled = false;
|
||
} else {
|
||
W.signature = null;
|
||
$('to-step4').disabled = true;
|
||
$('sign-status').textContent = v ? \`Need 86–88 chars; got \${v.length}.\` : '';
|
||
}
|
||
});
|
||
|
||
$('to-step4').addEventListener('click', () => show(4));
|
||
|
||
// ===== STEP 4: publish =====
|
||
$('back-to-3').addEventListener('click', () => show(3));
|
||
$('publish-btn').addEventListener('click', async () => {
|
||
const status = $('publish-status');
|
||
const result = $('publish-result');
|
||
status.textContent = 'Publishing…';
|
||
result.innerHTML = '';
|
||
$('publish-btn').disabled = true;
|
||
|
||
const body = { ...W.manifest, signature: W.signature };
|
||
let json;
|
||
try {
|
||
const r = await fetch('/api/listing', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
json = await r.json();
|
||
if (!r.ok || !json.ok) {
|
||
status.innerHTML = '⚠️ <b>' + (r.status) + '</b> — ' + (json.reason || json.error || 'unknown');
|
||
$('publish-btn').disabled = false;
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
status.innerHTML = '⚠️ Network error — ' + String(err?.message || err);
|
||
$('publish-btn').disabled = false;
|
||
return;
|
||
}
|
||
status.innerHTML = '✅ <b>Published.</b>';
|
||
const href = '/app/' + encodeURIComponent(json.listing.publisher) + '/' + encodeURIComponent(json.listing.slug);
|
||
result.innerHTML =
|
||
'<div class="card" style="padding:22px 26px">' +
|
||
'<h3 style="margin:0 0 .3rem">' + json.listing.name + '</h3>' +
|
||
'<p style="margin:0;color:var(--mut);font-size:13.5px">Signed by ' + json.listing.publisher +
|
||
' · ' + json.listing.category + ' · v' + json.listing.version + '</p>' +
|
||
'<p style="margin:1rem 0 0"><a class="btn acid" href="' + href + '">View your listing →</a> ' +
|
||
'<a class="btn ghost" href="/browse">Back to browse</a></p>' +
|
||
'</div>';
|
||
});
|
||
})();
|
||
</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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|