prometheus: /submit becomes a 4-step publisher wizard

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`.
This commit is contained in:
Local Dev 2026-10-22 22:58:35 +03:00
parent d6f8504bfe
commit 916440fb31
2 changed files with 358 additions and 54 deletions

View file

@ -28,7 +28,7 @@ import { signMessageBip137, addressFromPrivateKey } from '../service/lib/sign-me
const BASE = process.env.BASE || 'http://127.0.0.1:3700';
const PUBLISHER = process.env.PUBLISHER || 'testpub.x';
const SLUG = process.env.SLUG || `hello-world-${Date.now()}`;
const CATEGORY = process.env.CATEGORY || 'utilities';
const CATEGORY = process.env.CATEGORY || 'productivity';
// A hard-coded deterministic test key — this is NEVER a real wallet key, just
// a predictable 32-byte scalar so the roundtrip script can print the matching

View file

@ -307,97 +307,401 @@ 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.',
'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, scan, sign, publish. Four screens; your key stays with you.</p>
<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">
<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 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>
<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>
<!-- =============== 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="up" style="display:grid;gap:12px;max-width:520px;margin-top:1rem">
<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 &amp; scan</button>
<div id="status" style="color:var(--mut);font-size:14px;min-height:1.4em"></div>
<div id="upload-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>
<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/&lt;publisher&gt;/&lt;slug&gt;</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 &amp; web tools</option>
<option value="communication">Communication</option>
<option value="privacy">Privacy &amp; security</option>
<option value="devtools">Developer tools</option>
<option value="media">Media &amp; 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>
(() => {
const form = document.getElementById('up');
const status = document.getElementById('status');
const result = document.getElementById('result');
// ===== 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
};
form.addEventListener('submit', async (ev) => {
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 = form.file.files[0];
const file = ev.target.file.files[0];
if (!file) return;
status.textContent = \`Uploading \${file.name} (\${fmtBytes(file.size)})…\`;
result.style.display = 'none';
result.textContent = '';
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();
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);
status.innerHTML = '⚠️ <b>Network error</b> — ' + String(err?.message || err);
return;
}
result.style.display = 'block';
result.textContent = JSON.stringify(json, null, 2);
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 = '';
});
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';
// ===== 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>