prometheus/scripts/test-listing-roundtrip.mjs

151 lines
5.8 KiB
JavaScript
Raw Normal View History

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
#!/usr/bin/env node
/**
* End-to-end roundtrip for the listing endpoint.
*
* Not a CI test — a manual sanity check you can run against a locally running
* service to confirm every link in the chain is wired. It:
*
* 1. generates a test key, derives the cashaddress,
* 2. uploads a small file to /api/upload (requires the server with
* MOCK_SCAN=clean),
* 3. builds a listing body around the scan report, signs the canonical
* message with the test key (BIP-137),
* 4. POSTs it to /api/listing (requires MOCK_BNS_OWNER set to the test
* address so the on-chain-owner check passes without a real chipnet
* lookup), and
* 5. reads /api/listings and prints the row.
*
* Run the service like this:
* MOCK_SCAN=clean MOCK_BNS_OWNER=<the address this script prints> \
* node service/server.js
*
* Then from another terminal:
* node scripts/test-listing-roundtrip.mjs
*/
import { signMessageBip137, addressFromPrivateKey } from '../service/lib/sign-message.js';
const BASE = process.env.BASE || 'http://127.0.0.1:3700';
const PUBLISHER = process.env.PUBLISHER || 'testpub.x';
const SLUG = process.env.SLUG || `hello-world-${Date.now()}`;
const CATEGORY = process.env.CATEGORY || 'utilities';
// A hard-coded deterministic test key — this is NEVER a real wallet key, just
// a predictable 32-byte scalar so the roundtrip script can print the matching
// cashaddress BEFORE anyone sets MOCK_BNS_OWNER, instead of asking you to
// chase your own tail. Override with TEST_PRIVKEY=<64 hex> if a different key
// is useful (e.g. testing two publishers at once).
const DEFAULT_TEST_PRIVKEY_HEX =
'0101010101010101010101010101010101010101010101010101010101010101';
const PRIVKEY_HEX = process.env.TEST_PRIVKEY || DEFAULT_TEST_PRIVKEY_HEX;
if (!/^[0-9a-f]{64}$/i.test(PRIVKEY_HEX)) {
console.error('TEST_PRIVKEY must be 64 hex chars');
process.exit(2);
}
const privateKey = Buffer.from(PRIVKEY_HEX, 'hex');
const log = (...a) => console.log('[test]', ...a);
const fail = (msg, extra) => {
console.error('[test] FAIL:', msg);
if (extra !== undefined) console.error(extra);
process.exit(1);
};
async function main() {
// 1. derive the test address from the deterministic key
const address = addressFromPrivateKey(privateKey);
log(`test address: ${address}`);
log(`→ server must have: MOCK_SCAN=clean MOCK_BNS_OWNER=${address}`);
// 2. upload
log('uploading a 20-byte sample file …');
const sample = Buffer.from(`prometheus listing test ${Date.now()}`);
const form = new FormData();
form.append('file', new Blob([sample], { type: 'text/plain' }), 'sample.txt');
const upResp = await fetch(`${BASE}/api/upload`, { method: 'POST', body: form });
const upJson = await upResp.json();
if (!upResp.ok || !upJson.ok) fail('upload failed', upJson);
log(`upload hash: ${upJson.upload.hash} size: ${upJson.upload.size} verdict: ${upJson.scan.verdict}`);
// 3. build the listing
const listing = {
publisher: PUBLISHER,
slug: SLUG,
name: 'Hello, world',
description: 'An unremarkable sample listing to prove the publisher-sign path works end-to-end.',
category: CATEGORY,
version: '0.1.0',
homepage: 'https://example.com',
icon: 'https://example.com/icon.png',
download: {
url: 'https://example.com/sample.txt',
sha256: upJson.upload.hash,
size: upJson.upload.size,
},
scan_report: upJson.report,
};
// 4. canonical signing message, same shape the service builds
const message = buildSigningMessage(listing);
const signature = signMessageBip137(message, privateKey);
log(`signed ${message.length} bytes of canonical message`);
const body = { ...listing, signature };
// 5. POST /api/listing
log(`POST ${BASE}/api/listing …`);
const resp = await fetch(`${BASE}/api/listing`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const json = await resp.json();
if (!resp.ok || !json.ok) fail(`POST returned ${resp.status}`, json);
log(`✓ listing created: ${json.id}`);
// 6. read it back via /api/listings/<id> and /api/listings?category=…
const detail = await (await fetch(`${BASE}/api/listings/${json.id}`)).json();
if (!detail.ok) fail('GET detail failed', detail);
log(`✓ GET /api/listings/${json.id} returned ${detail.listing.name} v${detail.listing.version}`);
const index = await (await fetch(`${BASE}/api/listings?category=${CATEGORY}`)).json();
if (!index.ok) fail('GET index failed', index);
const found = index.listings.find((l) => l.id === json.id);
if (!found) fail(`listing ${json.id} missing from category index`, index);
log(`✓ listing appears in /api/listings?category=${CATEGORY} (${index.count} total in category)`);
log('all four checks passed.');
}
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,
});
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(',') + '}';
}
main().catch((err) => fail(err?.message || String(err), err?.stack));