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.
85 lines
2.5 KiB
JavaScript
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;
|
|
}
|