prometheus: allow BCH-address-only publishers (no BNS name needed)

A BNS name costs cents but is still a gate — someone discovering Prometheus
for the first time shouldn't have to go register sirius.x before they can
publish. Accept a raw cashaddress in the `publisher` field: the sign path
skips the BNS owner-of lookup and treats the field itself as the signing
address, which is still an ECDSA pubkey the chain verifies against.

- listings.js: PUBLISHER_RE = BNS name OR cashaddress; isCashaddr() helper;
  verify branch skips lookupName() and uses the address directly as
  expectedAddress (recovery-is-not-verification still holds — we compare
  the recovered address to the claimed one, both sides)
- pages.js submit form: pattern + placeholder + hint explain both options,
  with the trade-off (address publishers display as truncated hex instead
  of a readable name)
- pages-browse.js: shortenPublisher() collapses raw addresses to
  bchtest:qpumq…sh478 on cards + detail page + <title>; full address kept
  in title attribute for hover, and in the URL path. BNS names are
  untouched.
This commit is contained in:
Local Dev 2026-10-23 02:02:07 +03:00
parent f5a8ed40e7
commit 1abaefe948
3 changed files with 68 additions and 25 deletions

View file

@ -49,7 +49,14 @@ const CATEGORIES = new Set([
const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB — listings are small JSON
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
const BNS_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}\.[a-z0-9]{1,20}$/;
// A publisher is EITHER a BNS name (foo.x) OR a raw cashaddress (bitcoincash:q…).
// Address-only publishers skip the on-chain name lookup — the signer_address
// IS the identity, with no name claim to verify. Readers' trust lists can
// still subscribe to the address; they just lose the mnemonic benefit of a name.
const BNS_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}\.[a-z0-9]{1,20}$/;
const CASHADDR_RE = /^(bitcoincash|bchtest):q[a-z0-9]{41}$/;
const PUBLISHER_RE = new RegExp(`(?:${BNS_NAME_RE.source.slice(1,-1)})|(?:${CASHADDR_RE.source.slice(1,-1)})`);
function isCashaddr(s) { return CASHADDR_RE.test(String(s)); }
/* ======================================================================
POST /api/listing
@ -82,19 +89,34 @@ export async function handleListingCreate(req, res) {
// field except itself, so changing any value invalidates the signature.
const message = buildSigningMessage(body);
// Resolve the publisher's claimed BNS name to its current on-chain owner.
// This is the trust claim — recovery alone is never enough; we must know
// *who* was supposed to sign before we accept a signature as proof.
// Two paths for the "who signed this" check:
//
// - BNS name: look up the current on-chain owner, verify the signature
// recovers to that address. The address can change (name transfer); the
// signature's validity is anchored to who owns the name RIGHT NOW.
//
// - Cashaddress: skip the lookup entirely. The address IS the identity,
// and the signature just has to recover to it. The publisher trades the
// mnemonic benefit of a name for not needing one.
//
// Either way, recovery alone is never enough — we always compare the
// recovered address against a claimed one, per [[recovery-is-not-verification]].
const name = String(body.publisher).toLowerCase();
const nameInfo = await lookupName(name);
if (!nameInfo.ok) {
return json(res, 400, { ok: false, reason: `publisher '${name}': ${nameInfo.reason}` });
let expectedAddress;
if (isCashaddr(name)) {
expectedAddress = name;
} else {
const nameInfo = await lookupName(name);
if (!nameInfo.ok) {
return json(res, 400, { ok: false, reason: `publisher '${name}': ${nameInfo.reason}` });
}
expectedAddress = nameInfo.owner;
}
const verify = verifyControl({
message,
signature: body.signature,
expectedAddress: nameInfo.owner,
expectedAddress,
prefix: DEFAULT_PREFIX,
});
if (!verify.ok) {
@ -102,7 +124,7 @@ export async function handleListingCreate(req, res) {
ok: false,
reason: `signature verification failed: ${verify.reason}`,
...(verify.signedBy ? { signed_by: verify.signedBy } : {}),
claimed_owner: nameInfo.owner,
claimed_owner: expectedAddress,
});
}
@ -198,8 +220,11 @@ function validate(body) {
if (!body || typeof body !== 'object') {
return { ok: false, reason: 'body must be a JSON object' };
}
if (!BNS_NAME_RE.test(String(body.publisher || '').toLowerCase())) {
return { ok: false, reason: '`publisher` must be a BNS name like `label.tld`' };
if (!PUBLISHER_RE.test(String(body.publisher || '').toLowerCase())) {
return {
ok: false,
reason: '`publisher` must be a BNS name like `foo.x` or a BCH cashaddress like `bitcoincash:q…`',
};
}
if (!SLUG_RE.test(String(body.slug || '').toLowerCase())) {
return { ok: false, reason: '`slug` must be [a-z0-9-], 1..64 chars, no leading/trailing dash' };

View file

@ -321,7 +321,7 @@ export function appPage(req, publisher, slug) {
: `<div style="width:80px;height:80px;border-radius:16px;border:1px solid var(--line);background:var(--panel);display:grid;place-items:center;font-size:36px">📦</div>`;
return {
title: `${row.name} by ${row.publisher}`,
title: `${row.name} by ${shortenPublisher(row.publisher)}`,
description: row.description.slice(0, 200),
here: `/app/${row.publisher}/${row.slug}`,
ogType: 'article',
@ -334,7 +334,7 @@ export function appPage(req, publisher, slug) {
<div style="text-align:left">
<h1 style="margin:0;font-size:1.8rem">${escapeHtml(row.name)}</h1>
<div style="color:var(--mut);font-size:14px;margin-top:.3rem">
by <b style="color:var(--ink)">${escapeHtml(row.publisher)}</b> ·
by <b style="color:var(--ink)" title="${escapeAttr(row.publisher)}">${escapeHtml(shortenPublisher(row.publisher))}</b> ·
v${escapeHtml(row.version)} ·
${catChip}
</div>
@ -375,10 +375,12 @@ export function appPage(req, publisher, slug) {
<section>
<h2>Who published this</h2>
<p>Signed by <b>${escapeHtml(row.publisher)}</b> — a BNS name owned by
<code class="inline">${escapeHtml(row.signer_address)}</code>. Any installer
the chain says belongs to that key is trusted by your client; any client
that follows a different trust list will not see it as trusted.</p>
<p>Signed by <b title="${escapeAttr(row.publisher)}">${escapeHtml(shortenPublisher(row.publisher))}</b> —
${/^(bitcoincash|bchtest):q[a-z0-9]{41}$/.test(String(row.publisher))
? `a raw BCH address publisher. The signing key is this address itself; no BNS name is attached.`
: `a BNS name owned by <code class="inline">${escapeHtml(row.signer_address)}</code>.`}
Any installer the chain says belongs to that key is trusted by your client;
any client that follows a different trust list will not see it as trusted.</p>
</section>
<section>
@ -414,7 +416,7 @@ function renderListingCard(row) {
${escapeHtml(truncate(row.description, 160))}
</p>
<div style="color:var(--dim);font-size:12px;margin-top:auto;padding-top:.5rem">
by ${escapeHtml(row.publisher)} · v${escapeHtml(row.version)} · ${formatBytes(row.download_size)}
by ${escapeHtml(shortenPublisher(row.publisher))} · v${escapeHtml(row.version)} · ${formatBytes(row.download_size)}
</div>
</a>`;
}
@ -455,6 +457,20 @@ function shortSha(sha) {
return `${s.slice(0, 8)}…${s.slice(-6)}`;
}
/**
* Shorten a publisher for display. BNS names (foo.x) are returned as-is;
* raw cashaddresses are collapsed to `bchtest:qpumq…sh478` so they fit a
* card without breaking the layout. The URL/path side still uses the full
* publisher — this is a display-only transform.
*/
function shortenPublisher(pub) {
const s = String(pub || '');
const m = s.match(/^(bitcoincash|bchtest):(q[a-z0-9]{41})$/);
if (!m) return s;
const addr = m[2];
return `${m[1]}:${addr.slice(0, 5)}…${addr.slice(-5)}`;
}
function truncate(s, max) {
const str = String(s || '');
if (str.length <= max) return str;

View file

@ -446,11 +446,11 @@ export function submit() {
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}$"
<label>Publisher
<input name="publisher" required placeholder="foo.x — or bchtest:q..."
pattern="(?:^[a-z0-9][a-z0-9-]{0,62}\\.[a-z0-9]{1,20}$)|(?:^(?:bitcoincash|bchtest):q[a-z0-9]{41}$)"
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>
<small style="color:var(--dim)">A BNS name like <code class="inline">foo.x</code> (register at <a href="//sirius.x/">sirius.x</a>, costs cents) — <b>or</b> a BCH cashaddress if you don't have a name yet. Addresses work; they just show on your listing as <code class="inline">bchtest:qpumq…sh478</code> instead of a readable name, which is harder for readers to follow.</small>
</label>
<label>Short slug (URL)
@ -530,9 +530,11 @@ export function submit() {
</button>
<div class="wiz-body">
<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>
form. Sign it with the BCH key for
<b style="color:var(--ink)" id="sign-publisher">your publisher</b>
— the on-chain owner of your BNS name, or the key for your cashaddress
if you went that route. 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>