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.
150 lines
5.8 KiB
JavaScript
150 lines
5.8 KiB
JavaScript
#!/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));
|