diff --git a/service/listings.js b/service/listings.js index 9251180..d1f9bad 100644 --- a/service/listings.js +++ b/service/listings.js @@ -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' }; diff --git a/service/pages-browse.js b/service/pages-browse.js index e0b28bb..d7023b3 100644 --- a/service/pages-browse.js +++ b/service/pages-browse.js @@ -321,7 +321,7 @@ export function appPage(req, publisher, slug) { : `
📦
`; 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) {

${escapeHtml(row.name)}

- by ${escapeHtml(row.publisher)} · + by ${escapeHtml(shortenPublisher(row.publisher))} · v${escapeHtml(row.version)} · ${catChip}
@@ -375,10 +375,12 @@ export function appPage(req, publisher, slug) {

Who published this

-

Signed by ${escapeHtml(row.publisher)} — a BNS name owned by - ${escapeHtml(row.signer_address)}. 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.

+

Signed by ${escapeHtml(shortenPublisher(row.publisher))} — + ${/^(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 ${escapeHtml(row.signer_address)}.`} + 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.

@@ -414,7 +416,7 @@ function renderListingCard(row) { ${escapeHtml(truncate(row.description, 160))}

- 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)}
`; } @@ -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; diff --git a/service/pages.js b/service/pages.js index 5e2bab4..f91ffc9 100644 --- a/service/pages.js +++ b/service/pages.js @@ -446,11 +446,11 @@ export function submit() { the signed payload is a new signature, not a quick fix.

-