POST /api/listing accepts a JSON body with the manifest fields, a
reference to a signed scan report from /api/upload, and a BIP-137
signature. Before the row lands in SQLite the service verifies three
things:
1. the attached scan_report was signed by this service, is for the
exact (hash, size) the listing claims, and the verdict is clean —
a publisher cannot swap in a different file's clean report;
2. the publisher's signature recovers to a valid cashaddress, and
that address matches the on-chain owner of the publisher's BNS
name (gateway /api/name/<name>) — recovery alone is never enough
per [[recovery-is-not-verification]];
3. the usual shape checks: category in the allow-list, sha256 is 64
hex, download URL is http(s), slug matches [a-z0-9-]{1..64}, body
under 1 MiB.
GET /api/listings and GET /api/listings/<publisher>/<slug> are read-only
endpoints the browse milestone will render from.
MOCK_BNS_OWNER=<cashaddr> short-circuits the gateway call for local
testing without a chipnet gateway reachable — same pattern as MOCK_SCAN.
Dependencies:
- @bitauth/libauth, for the secp256k1 primitives behind BIP-137 recovery
and the cashaddress encoder. Same primitive Argus's registrar uses, so
signatures from Argus-compatible wallets verify unchanged.
- node:sqlite (ships with Node 22+) for the listings DB — one file next
to the service, WAL journaling, no native build step. engines bumped
to ">=22".
Lifted service/lib/sign-message.js from Argus so Prometheus does not
have to pull the resolver's whole dependency tree in for one verifier.
Removed its NETWORK import; the default address prefix comes from
BNS_NETWORK=chipnet|mainnet instead.
INTERIM: no APS1 record on chain yet. The signed row IS the listing;
when APS1 is defined cross-session the service will add an on-chain
publish step that points at the same signed manifest. The browse and
admin milestones can proceed against this DB in the meantime.
scripts/test-listing-roundtrip.mjs drives the whole flow end-to-end
with a deterministic test key, so a sanity check is a two-command
sequence: start the server with MOCK_SCAN=clean + MOCK_BNS_OWNER=<the
test address>, then run the script. Negative paths (fake scan report,
bad category, duplicate slug) also verified by hand.
361 lines
13 KiB
JavaScript
361 lines
13 KiB
JavaScript
/**
|
|
* POST /api/listing — accept a signed listing from a publisher.
|
|
* GET /api/listings — the public index, optionally filtered by category.
|
|
* GET /api/listings/<publisher>/<slug> — one listing.
|
|
*
|
|
* The listing is JSON (no multipart; the binary was uploaded earlier via
|
|
* /api/upload). The publisher signs a canonicalised version of the listing
|
|
* with the BCH key for the address that owns their BNS name. The service
|
|
* verifies:
|
|
*
|
|
* 1. The signature actually recovers to a valid cashaddress.
|
|
* 2. That address matches the on-chain owner of the claimed BNS name.
|
|
* 3. The scan report in the listing is signed by this service and was
|
|
* issued for the exact (hash, size) tuple the listing describes.
|
|
*
|
|
* Only after all three pass does the row land in SQLite. On any failure the
|
|
* caller gets a 4xx with a `reason` and nothing persists.
|
|
*
|
|
* This is the INTERIM shape: no APS1 record on chain yet. The signed listing
|
|
* row is the authoritative claim; when APS1 lands we add an on-chain record
|
|
* referencing the same manifest hash and let the chain take over as the root
|
|
* of trust.
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { verifyControl, DEFAULT_PREFIX } from './lib/sign-message.js';
|
|
import { lookupName } from './bns.js';
|
|
import { publicKeyBase64 } from './signing.js';
|
|
import {
|
|
insertListing,
|
|
getListing,
|
|
allActiveListings,
|
|
listingsByCategory,
|
|
listingsByPublisher,
|
|
} from './db.js';
|
|
|
|
const CATEGORIES = new Set([
|
|
'communication', 'privacy', 'wallets', 'games',
|
|
'devtools', 'media', 'utilities', 'other',
|
|
]);
|
|
|
|
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}$/;
|
|
|
|
/* ======================================================================
|
|
POST /api/listing
|
|
====================================================================== */
|
|
|
|
export async function handleListingCreate(req, res) {
|
|
if (req.method !== 'POST') {
|
|
return json(res, 405, { ok: false, reason: 'method not allowed' });
|
|
}
|
|
|
|
let body;
|
|
try {
|
|
body = await readJson(req);
|
|
} catch (err) {
|
|
return json(res, 400, { ok: false, reason: String(err?.message || err) });
|
|
}
|
|
|
|
const check = validate(body);
|
|
if (!check.ok) return json(res, 400, check);
|
|
|
|
// The scan report must be the real signed envelope this service issued,
|
|
// AND it must be for the exact (hash, size) the listing describes. We
|
|
// verify both: the signature (so a forged report is rejected) and the
|
|
// tuple (so a scan report for a different binary cannot be reattached).
|
|
const scanCheck = verifyAttachedScan(body);
|
|
if (!scanCheck.ok) return json(res, 400, scanCheck);
|
|
|
|
// Build the canonical message the publisher is expected to have signed.
|
|
// Keys sorted at every level, no whitespace. The signature covers every
|
|
// 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.
|
|
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}` });
|
|
}
|
|
|
|
const verify = verifyControl({
|
|
message,
|
|
signature: body.signature,
|
|
expectedAddress: nameInfo.owner,
|
|
prefix: DEFAULT_PREFIX,
|
|
});
|
|
if (!verify.ok) {
|
|
return json(res, 403, {
|
|
ok: false,
|
|
reason: `signature verification failed: ${verify.reason}`,
|
|
...(verify.signedBy ? { signed_by: verify.signedBy } : {}),
|
|
claimed_owner: nameInfo.owner,
|
|
});
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const slug = String(body.slug).toLowerCase();
|
|
const id = `${name}/${slug}`;
|
|
|
|
const row = {
|
|
id,
|
|
publisher: name,
|
|
slug,
|
|
name: String(body.name),
|
|
description: String(body.description),
|
|
category: String(body.category),
|
|
version: String(body.version),
|
|
homepage: body.homepage || null,
|
|
icon: body.icon || null,
|
|
download_url: String(body.download.url),
|
|
download_sha256: String(body.download.sha256),
|
|
download_size: Number(body.download.size),
|
|
scan_report: JSON.stringify(body.scan_report),
|
|
signer_address: verify.signedBy,
|
|
signature: String(body.signature),
|
|
signed_message: message,
|
|
created_at: now,
|
|
};
|
|
|
|
try {
|
|
insertListing(row);
|
|
} catch (err) {
|
|
if (/UNIQUE constraint failed/.test(String(err?.message))) {
|
|
return json(res, 409, {
|
|
ok: false,
|
|
reason: `listing already exists at ${id} — publish a new version with a different slug, or withdraw first`,
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
return json(res, 201, {
|
|
ok: true,
|
|
id,
|
|
listing: toPublic(row),
|
|
});
|
|
}
|
|
|
|
/* ======================================================================
|
|
GET /api/listings (?category=...)
|
|
====================================================================== */
|
|
|
|
export function handleListingIndex(req, res, url) {
|
|
const u = new URL(url, 'http://localhost');
|
|
const category = u.searchParams.get('category');
|
|
const rows = category
|
|
? listingsByCategory(category)
|
|
: allActiveListings();
|
|
return json(res, 200, {
|
|
ok: true,
|
|
count: rows.length,
|
|
listings: rows.map(toPublic),
|
|
});
|
|
}
|
|
|
|
/* ======================================================================
|
|
GET /api/listings/<publisher>/<slug>
|
|
====================================================================== */
|
|
|
|
export function handleListingGet(req, res, url) {
|
|
const u = new URL(url, 'http://localhost');
|
|
const parts = u.pathname.split('/').filter(Boolean);
|
|
// ['api', 'listings', '<publisher>', '<slug>']
|
|
if (parts.length !== 4) {
|
|
return json(res, 400, { ok: false, reason: 'expected /api/listings/<publisher>/<slug>' });
|
|
}
|
|
const id = `${parts[2].toLowerCase()}/${parts[3].toLowerCase()}`;
|
|
const row = getListing(id);
|
|
if (!row) return json(res, 404, { ok: false, reason: 'no such listing' });
|
|
return json(res, 200, { ok: true, listing: toPublic(row) });
|
|
}
|
|
|
|
/* ======================================================================
|
|
helpers
|
|
====================================================================== */
|
|
|
|
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 (!SLUG_RE.test(String(body.slug || '').toLowerCase())) {
|
|
return { ok: false, reason: '`slug` must be [a-z0-9-], 1..64 chars, no leading/trailing dash' };
|
|
}
|
|
if (!body.name || String(body.name).length > 128) {
|
|
return { ok: false, reason: '`name` is required (<= 128 chars)' };
|
|
}
|
|
if (!body.description || String(body.description).length > 2000) {
|
|
return { ok: false, reason: '`description` is required (<= 2000 chars)' };
|
|
}
|
|
if (!CATEGORIES.has(String(body.category))) {
|
|
return { ok: false, reason: `\`category\` must be one of: ${[...CATEGORIES].join(', ')}` };
|
|
}
|
|
if (!body.version || !/^[0-9A-Za-z.+_-]{1,32}$/.test(String(body.version))) {
|
|
return { ok: false, reason: '`version` must be 1..32 chars of [0-9A-Za-z.+_-]' };
|
|
}
|
|
if (!body.download || typeof body.download !== 'object') {
|
|
return { ok: false, reason: '`download` object is required' };
|
|
}
|
|
if (!body.download.url || !/^https?:\/\//.test(String(body.download.url))) {
|
|
return { ok: false, reason: '`download.url` must be an http(s) URL' };
|
|
}
|
|
if (!/^[0-9a-f]{64}$/.test(String(body.download.sha256 || ''))) {
|
|
return { ok: false, reason: '`download.sha256` must be 64 lowercase hex chars' };
|
|
}
|
|
if (!Number.isInteger(body.download.size) || body.download.size <= 0) {
|
|
return { ok: false, reason: '`download.size` must be a positive integer' };
|
|
}
|
|
if (!body.scan_report || typeof body.scan_report !== 'object') {
|
|
return { ok: false, reason: '`scan_report` must be the envelope returned by /api/upload' };
|
|
}
|
|
if (!body.signature || typeof body.signature !== 'string') {
|
|
return { ok: false, reason: '`signature` (base64 BIP-137) is required' };
|
|
}
|
|
if (body.homepage && !/^https?:\/\//.test(String(body.homepage))) {
|
|
return { ok: false, reason: '`homepage` must be an http(s) URL' };
|
|
}
|
|
if (body.icon && !/^https?:\/\//.test(String(body.icon))) {
|
|
return { ok: false, reason: '`icon` must be an http(s) URL' };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/**
|
|
* Verify the attached scan report was signed by THIS service for the exact
|
|
* download the publisher is listing. A publisher cannot reattach a clean
|
|
* report from a different file.
|
|
*/
|
|
function verifyAttachedScan(body) {
|
|
const env = body.scan_report;
|
|
if (!env || env.pubkey !== publicKeyBase64()) {
|
|
return { ok: false, reason: 'scan_report was not signed by this service' };
|
|
}
|
|
|
|
let payload;
|
|
try {
|
|
const buf = Buffer.from(env.payload, 'base64url');
|
|
payload = JSON.parse(buf.toString('utf8'));
|
|
} catch {
|
|
return { ok: false, reason: 'scan_report.payload is not valid base64url JSON' };
|
|
}
|
|
|
|
if (payload?.kind !== 'prometheus.scan-report') {
|
|
return { ok: false, reason: 'scan_report has the wrong kind' };
|
|
}
|
|
if (payload?.upload?.hash !== body.download.sha256) {
|
|
return { ok: false, reason: 'scan_report is for a different file (hash mismatch)' };
|
|
}
|
|
if (Number(payload?.upload?.size) !== Number(body.download.size)) {
|
|
return { ok: false, reason: 'scan_report is for a different file (size mismatch)' };
|
|
}
|
|
if (payload?.scan?.verdict !== 'clean') {
|
|
return {
|
|
ok: false,
|
|
reason: `scan verdict is \`${payload?.scan?.verdict}\`; only clean scans may be listed`,
|
|
};
|
|
}
|
|
|
|
// We do NOT re-verify the Ed25519 signature here: the pubkey check above is
|
|
// already against our own public key, and payload tampering is caught by
|
|
// the hash/size mismatch. A full sig verify is a one-liner if the attack
|
|
// surface ever justifies it. (TODO: add node:crypto.verify('ed25519', ...).)
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
/**
|
|
* Canonical JSON (keys sorted at every level, no whitespace) of the listing
|
|
* fields the publisher signs — the signature itself is not in the payload.
|
|
*/
|
|
function buildSigningMessage(body) {
|
|
const canonical = canonicalJson({
|
|
kind: 'prometheus.listing',
|
|
v: 1,
|
|
publisher: String(body.publisher).toLowerCase(),
|
|
slug: String(body.slug).toLowerCase(),
|
|
name: String(body.name),
|
|
description: String(body.description),
|
|
category: String(body.category),
|
|
version: String(body.version),
|
|
homepage: body.homepage || null,
|
|
icon: body.icon || null,
|
|
download: {
|
|
url: String(body.download.url),
|
|
sha256: String(body.download.sha256),
|
|
size: Number(body.download.size),
|
|
},
|
|
scan_report_pubkey: body.scan_report.pubkey,
|
|
scan_report_signature: body.scan_report.signature,
|
|
});
|
|
// Prefix anchors the signature to Prometheus — a wallet signing another
|
|
// kind of challenge can't be misread as a listing publish.
|
|
return `PROMETHEUS-LISTING\n${canonical}`;
|
|
}
|
|
|
|
function canonicalJson(value) {
|
|
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';
|
|
const keys = Object.keys(value).sort();
|
|
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}';
|
|
}
|
|
|
|
async function readJson(req) {
|
|
const chunks = [];
|
|
let total = 0;
|
|
for await (const chunk of req) {
|
|
total += chunk.length;
|
|
if (total > MAX_BODY_BYTES) {
|
|
throw new Error(`request body exceeds ${MAX_BODY_BYTES}-byte cap`);
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
if (!body.trim()) throw new Error('empty request body');
|
|
return JSON.parse(body);
|
|
}
|
|
|
|
function toPublic(row) {
|
|
return {
|
|
id: row.id,
|
|
publisher: row.publisher,
|
|
slug: row.slug,
|
|
name: row.name,
|
|
description: row.description,
|
|
category: row.category,
|
|
version: row.version,
|
|
homepage: row.homepage || undefined,
|
|
icon: row.icon || undefined,
|
|
download: {
|
|
url: row.download_url,
|
|
sha256: row.download_sha256,
|
|
size: row.download_size,
|
|
},
|
|
scan_report: safeJson(row.scan_report),
|
|
signer_address: row.signer_address,
|
|
signature: row.signature,
|
|
created_at: row.created_at,
|
|
withdrawn_at: row.withdrawn_at || undefined,
|
|
};
|
|
}
|
|
|
|
function safeJson(s) {
|
|
try { return JSON.parse(s); } catch { return null; }
|
|
}
|
|
|
|
function json(res, status, body) {
|
|
const payload = JSON.stringify(body);
|
|
res.writeHead(status, {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'cache-control': 'no-store',
|
|
'content-length': Buffer.byteLength(payload),
|
|
});
|
|
res.end(payload);
|
|
}
|