The submit page was a one-form upload tester that said "not wired yet"
on the three steps that would turn an upload into a published listing.
It now walks a publisher through the whole thing in-page.
Four client-side steps, state in one object (W = {file, upload, manifest,
message, signature}), scroll-to-top on each transition, back buttons
preserved so revisions are a correction not a restart:
Step 1 — Upload
File picker → POST /api/upload. Response rendered as a card showing
name + size + short hash + the scan verdict chip ("scan ✓ clean
(clamav, 2ms)"). Advance button only enabled when scan is clean;
infected or error paths show the reason inline and stay on step 1.
Step 2 — Describe
Form fields for publisher BNS name, slug, display name, category
(optgroup'd by top-level: Apps subs, then Games, then FinTech),
version, description, download URL, homepage + icon (optional).
Pattern + maxlength validation matches the server's rules in
service/listings.js. Submit builds W.manifest and canonicalises
the signing message.
Step 3 — Sign
Shows the canonical message to sign. If window.bitcoincash is
injected (Theseus with Aegis), a one-click "Sign with Theseus
wallet" button calls bitcoincash.signMessage(). Fallback paste
textarea accepts a 86–88 char BIP-137 signature from any wallet
(Electron Cash, Deviant, Cashonize). Both paths populate
W.signature and enable the continue button.
Step 4 — Publish
POST /api/listing with the full body. On 201 shows the listing
card + a "View your listing →" button pointing at
/app/<pub>/<slug>. On error shows the server's reason inline and
re-enables the Publish button for retry.
Canonical JSON in the client is a direct copy of service/listings.js's
canonicalJson — same bytes, same signature. A test that drifts them
apart would be nice; for now a comment on each copy flags the pairing.
scripts/test-listing-roundtrip.mjs's default category changed from the
old `utilities` slug (removed in the taxonomy change) to `productivity`.
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 (any size up to 2 GiB)</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("'", ''');
|
||
}
|