prometheus/service/bns.js
Local Dev 72f7d41902 prometheus: signed listings — BIP-137 publisher sign, SQLite index
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.
2026-10-08 01:48:12 +02:00

85 lines
2.5 KiB
JavaScript

/**
* Looking up a BNS name's current on-chain owner via the Silent Mode gateway.
*
* The listing endpoint uses this to answer "did this publisher really sign
* this?" — recover the signer address from the BIP-137 signature (that's just
* cryptography) and compare it against what the chain says owns the name
* (that's the trust claim).
*
* Memory [[recovery-is-not-verification]] is what makes this a two-step check
* rather than one — recovery always succeeds against *some* key, so we must
* compare the result against a claimed destination.
*
* MOCK_BNS_OWNER=<cashaddr> short-circuits the network call for local tests
* where we don't have a chipnet gateway reachable or don't want to depend on
* one for a unit test.
*/
const DEFAULT_GATEWAY =
(process.env.BNS_NETWORK || 'chipnet').toLowerCase() === 'mainnet'
? 'https://silentmode.st'
: 'https://navigate.st';
const BNS_GATEWAY = process.env.BNS_GATEWAY || DEFAULT_GATEWAY;
/**
* Fetch a name's record from the gateway. Returns:
* {ok: true, owner, records, registered: true}
* {ok: false, reason}
*
* Caches successful lookups for 60 seconds — ownership rarely changes faster
* than that, and a listing POST should not thrash the gateway on retries.
*/
const cache = new Map(); // name -> {at, result}
const CACHE_TTL_MS = 60_000;
export async function lookupName(name) {
const mock = process.env.MOCK_BNS_OWNER;
if (mock) {
return { ok: true, owner: mock, records: {}, registered: true, source: 'mock' };
}
const key = String(name).toLowerCase();
const now = Date.now();
const cached = cache.get(key);
if (cached && now - cached.at < CACHE_TTL_MS) {
return cached.result;
}
let resp;
try {
resp = await fetch(`${BNS_GATEWAY}/api/name/${encodeURIComponent(key)}`, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
} catch (err) {
return { ok: false, reason: `gateway unreachable: ${err?.message || err}` };
}
if (!resp.ok) {
return { ok: false, reason: `gateway returned HTTP ${resp.status}` };
}
let body;
try {
body = await resp.json();
} catch (err) {
return { ok: false, reason: 'gateway returned non-JSON' };
}
if (!body.registered) {
const result = { ok: false, reason: 'name is not registered', body };
cache.set(key, { at: now, result });
return result;
}
const result = {
ok: true,
owner: body.owner,
records: body.records || {},
registered: true,
source: 'gateway',
};
cache.set(key, { at: now, result });
return result;
}