prometheus/service/listings.js

377 lines
14 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
/**
* 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,
prometheus: BCH payments — invoices, price oracle, admin reconcile Payment flow for the paid wave (the free wave is still the default): 1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the listing lands with payment_status='pending' and is hidden from browse. If not set, the listing goes straight to 'free' and is visible immediately (today's behaviour, unchanged). 2. Anyone (publisher, their script, their wallet) POSTs /api/invoice {listing_id} and gets back a one-shot invoice: operator receive address, exact sat amount (USD-pegged + random 1..256 sat jitter so no two in-flight invoices collide), bitcoincash: URI for a wallet, 20-min expiry. 3. Reader pays. The amount matches exactly one pending invoice, and when the mempool watcher reports (address, amount, txid) to tryMatchPayment, both the invoice and the listing flip to paid. Browse starts showing the listing. The mempool watcher that would do (3) automatically is deliberately NOT in this commit — it needs an electrum-cash subscription or a block explorer webhook, and that's a next milestone. In the meantime, admins reconcile by hand from /admin/payments, which shows every invoice with a Mark paid form (optional txid field). Same effect on the DB, same audit log entry, different `paid_by` string. Design decisions worth recording: - One receive address + amount matching, NOT an HD wallet per invoice. BitPay used this for years; it is simple, auditable, and doesn't require a seed on the server to spend from later. - Random few-sat jitter per invoice guarantees uniqueness of the (address, amount) pair across all in-flight invoices. - Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale fallback). If both the fresh fetch and the stale fallback fail, /api/invoice refuses — issuing invoices on a stale price is worse than refusing to quote until the oracle recovers. - Quote is locked at issue time; the 20-min deadline is the lock. - Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice to 'expired' as a side effect, so there's no sweeper to run. Admin UI /admin/payments shows every invoice (pending/paid/expired), plus a dashboard-ish header with the configured mode + receive address. The gating config appears in the UI even when payments are disabled, so an operator spinning up a new instance can see at a glance what's set. Config: PAYMENT_REQUIRED=1 — turn paid mode on PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD) PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL End-to-end verified: PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice returns correct sats at live BCH price → admin login + mark-paid → listing becomes visible → /api/invoice/<id> returns status 'paid' with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
insertListingWithPaymentStatus,
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
} from './db.js';
prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD Three threads, one commit: 1. Size-tiered listing fee instead of a flat $1 priceUsdForSize(bytes) in service/payments.js: 0 - 100 MiB → free 100.001 - 200 MiB → $1 200.001 - 300 MiB → $2 ... 1900.001 - 2000 MiB → $19 2000.001 - 2048 MiB → $20 (ceiling at 2 GiB) listingNeedsPayment(bytes) replaces paymentsRequired() — a free-tier upload always publishes immediately even when PAYMENT_REQUIRED=1; the operator switch only governs the paid tiers. createInvoice derives the fee from the listing's own size at invoice time. 2. Upload cap back to 2 GiB The 200-MiB cap from the previous commit (which fixed the Caddy-413 HTML bug) is reverted. The paywall is now the real size gate; Caddy is just the ceiling. p2pexchange Caddyfile already updated + reloaded. 3. Hero + SEO Hero leads with the plain-English product category: The decentralized app store. A home for apps, programs, dApps, and libraries — listed and settled on the Bitcoin Cash chain, hosted on the decentralized web, endorsed by signature. Anyone can publish. Nobody can gatekeep. Named for the one who gave fire to humans and did not ask the gods first. "hosted on Sia" → "hosted on the decentralized web". Sia is the mechanism, not the pitch — general readers aren't tripped on the acronym on the hero. Meta description rewritten to lead with "decentralized app store" and name the long-tail terms the audience searches for ("web3 browsers", "BCH wallets", "mobile and desktop apps"). Two JSON-LD blocks (WebSite + Organization) inlined on home via layout's headExtra channel — knowledge panel + WebSite result card. 4. Submit wizard Step 1 result shows the computed listing fee under the scan verdict, so publishers see the price BEFORE filling out the manifest. Step 4 publish branches on the server's payment_required flag. On a paid listing it POSTs /api/invoice right after the listing POST and renders a payment card with the exact sat amount, the operator receive address, a `bitcoincash:` URI the wallet opens, and the invoice status URL for polling.
2026-10-22 23:31:43 +03:00
import { listingNeedsPayment } from './payments.js';
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
prometheus: taxonomy — Apps / Games / FinTech, with sub-chips inside Apps Three top-level groups replace the flat eight categories on the home page and /browse: Apps 7 sub-categories inside: browsers / communication / privacy / devtools / media / productivity / other Games single bucket for now; sub-chips later when there's volume FinTech single bucket for now (BCH wallets, bridges, POS, DeFi) Routes: /t/<slug> top-level page. For Apps shows the 7 sub-chips + an aggregated listings view across them. For Games and FinTech shows the listings directly (no sub-chips). /c/<slug> sub-category page under Apps (browsers, communication, …). The sole-sub categories /c/games and /c/fintech 301 to /t/games and /t/fintech respectively, so each view has exactly one canonical URL. Slug renames + removals in the CATEGORIES allow-list on listings.js: utilities → productivity (the display label was already pointing at that in people's heads — "productivity" fits storage + sync + file tools better) wallets → fintech (broader scope: bridges + POS + DeFi, not just wallets) games stays as a top-level sole-bucket browsers NEW sub-category under Apps for BCNR resolvers, mobile browsers, extensions Seed listings re-categorised + two slug fixes: theseus.x/theseus-navigator-win category: other → browsers ariadne.x/ariadne-resolver-win → RENAMED to ariadne-thread-win, name "Ariadne's Thread (Windows resolver)" shortened to just "Ariadne's Thread"; category: privacy → browsers ariadne.x/ariadne-android → RENAMED to ariadne-navigator, name "Ariadne's Thread (Android)" → "Ariadne Navigator" (it is a different program, not a platform build of Thread); category: privacy → browsers pithos.x/pithos-desktop category: utilities → productivity scripts/migrate-taxonomy.mjs is a one-shot that deletes every row with signature='SM_OPERATOR_SEED'; a subsequent `seed.mjs` run then inserts the fresh set with the new slugs. Publisher-POSTed listings (none in production today) are left alone. Idempotent — a second run finds nothing to remove. New icons: top-apps.svg 2×2 rounded tile grid (OS launcher metaphor) cat-browsers.svg browser window + 3 chrome dots + content block Three icons reused: cat-games.svg doubles as the Games top icon, cat-wallets.svg as the FinTech top icon (same visual, different label); cat-utilities.svg stays as the Productivity sub icon.
2026-10-22 22:39:08 +03:00
// Nine category slugs: seven sub-categories under Apps, plus the two
// single-bucket top-level categories (games, fintech).
// `wallets` and `utilities` are the previous names of `fintech` and
// `productivity`; the migration script that landed with the taxonomy change
// rewrote any seeded rows carrying the old slugs.
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
const CATEGORIES = new Set([
prometheus: taxonomy — Apps / Games / FinTech, with sub-chips inside Apps Three top-level groups replace the flat eight categories on the home page and /browse: Apps 7 sub-categories inside: browsers / communication / privacy / devtools / media / productivity / other Games single bucket for now; sub-chips later when there's volume FinTech single bucket for now (BCH wallets, bridges, POS, DeFi) Routes: /t/<slug> top-level page. For Apps shows the 7 sub-chips + an aggregated listings view across them. For Games and FinTech shows the listings directly (no sub-chips). /c/<slug> sub-category page under Apps (browsers, communication, …). The sole-sub categories /c/games and /c/fintech 301 to /t/games and /t/fintech respectively, so each view has exactly one canonical URL. Slug renames + removals in the CATEGORIES allow-list on listings.js: utilities → productivity (the display label was already pointing at that in people's heads — "productivity" fits storage + sync + file tools better) wallets → fintech (broader scope: bridges + POS + DeFi, not just wallets) games stays as a top-level sole-bucket browsers NEW sub-category under Apps for BCNR resolvers, mobile browsers, extensions Seed listings re-categorised + two slug fixes: theseus.x/theseus-navigator-win category: other → browsers ariadne.x/ariadne-resolver-win → RENAMED to ariadne-thread-win, name "Ariadne's Thread (Windows resolver)" shortened to just "Ariadne's Thread"; category: privacy → browsers ariadne.x/ariadne-android → RENAMED to ariadne-navigator, name "Ariadne's Thread (Android)" → "Ariadne Navigator" (it is a different program, not a platform build of Thread); category: privacy → browsers pithos.x/pithos-desktop category: utilities → productivity scripts/migrate-taxonomy.mjs is a one-shot that deletes every row with signature='SM_OPERATOR_SEED'; a subsequent `seed.mjs` run then inserts the fresh set with the new slugs. Publisher-POSTed listings (none in production today) are left alone. Idempotent — a second run finds nothing to remove. New icons: top-apps.svg 2×2 rounded tile grid (OS launcher metaphor) cat-browsers.svg browser window + 3 chrome dots + content block Three icons reused: cat-games.svg doubles as the Games top icon, cat-wallets.svg as the FinTech top icon (same visual, different label); cat-utilities.svg stays as the Productivity sub icon.
2026-10-22 22:39:08 +03:00
'browsers', 'communication', 'privacy', 'devtools', 'media', 'productivity', 'other',
'games',
'fintech',
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
]);
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}`;
prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD Three threads, one commit: 1. Size-tiered listing fee instead of a flat $1 priceUsdForSize(bytes) in service/payments.js: 0 - 100 MiB → free 100.001 - 200 MiB → $1 200.001 - 300 MiB → $2 ... 1900.001 - 2000 MiB → $19 2000.001 - 2048 MiB → $20 (ceiling at 2 GiB) listingNeedsPayment(bytes) replaces paymentsRequired() — a free-tier upload always publishes immediately even when PAYMENT_REQUIRED=1; the operator switch only governs the paid tiers. createInvoice derives the fee from the listing's own size at invoice time. 2. Upload cap back to 2 GiB The 200-MiB cap from the previous commit (which fixed the Caddy-413 HTML bug) is reverted. The paywall is now the real size gate; Caddy is just the ceiling. p2pexchange Caddyfile already updated + reloaded. 3. Hero + SEO Hero leads with the plain-English product category: The decentralized app store. A home for apps, programs, dApps, and libraries — listed and settled on the Bitcoin Cash chain, hosted on the decentralized web, endorsed by signature. Anyone can publish. Nobody can gatekeep. Named for the one who gave fire to humans and did not ask the gods first. "hosted on Sia" → "hosted on the decentralized web". Sia is the mechanism, not the pitch — general readers aren't tripped on the acronym on the hero. Meta description rewritten to lead with "decentralized app store" and name the long-tail terms the audience searches for ("web3 browsers", "BCH wallets", "mobile and desktop apps"). Two JSON-LD blocks (WebSite + Organization) inlined on home via layout's headExtra channel — knowledge panel + WebSite result card. 4. Submit wizard Step 1 result shows the computed listing fee under the scan verdict, so publishers see the price BEFORE filling out the manifest. Step 4 publish branches on the server's payment_required flag. On a paid listing it POSTs /api/invoice right after the listing POST and renders a payment card with the exact sat amount, the operator receive address, a `bitcoincash:` URI the wallet opens, and the invoice status URL for polling.
2026-10-22 23:31:43 +03:00
// Size-tier pricing: listings over 100 MiB require payment when the
// operator has enabled PAYMENT_REQUIRED. Smaller listings always publish
// immediately; a paid-tier listing lands as 'pending' and is hidden from
// browse until an invoice settles against it.
const paid = listingNeedsPayment(body.download.size);
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
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,
prometheus: BCH payments — invoices, price oracle, admin reconcile Payment flow for the paid wave (the free wave is still the default): 1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the listing lands with payment_status='pending' and is hidden from browse. If not set, the listing goes straight to 'free' and is visible immediately (today's behaviour, unchanged). 2. Anyone (publisher, their script, their wallet) POSTs /api/invoice {listing_id} and gets back a one-shot invoice: operator receive address, exact sat amount (USD-pegged + random 1..256 sat jitter so no two in-flight invoices collide), bitcoincash: URI for a wallet, 20-min expiry. 3. Reader pays. The amount matches exactly one pending invoice, and when the mempool watcher reports (address, amount, txid) to tryMatchPayment, both the invoice and the listing flip to paid. Browse starts showing the listing. The mempool watcher that would do (3) automatically is deliberately NOT in this commit — it needs an electrum-cash subscription or a block explorer webhook, and that's a next milestone. In the meantime, admins reconcile by hand from /admin/payments, which shows every invoice with a Mark paid form (optional txid field). Same effect on the DB, same audit log entry, different `paid_by` string. Design decisions worth recording: - One receive address + amount matching, NOT an HD wallet per invoice. BitPay used this for years; it is simple, auditable, and doesn't require a seed on the server to spend from later. - Random few-sat jitter per invoice guarantees uniqueness of the (address, amount) pair across all in-flight invoices. - Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale fallback). If both the fresh fetch and the stale fallback fail, /api/invoice refuses — issuing invoices on a stale price is worse than refusing to quote until the oracle recovers. - Quote is locked at issue time; the 20-min deadline is the lock. - Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice to 'expired' as a side effect, so there's no sweeper to run. Admin UI /admin/payments shows every invoice (pending/paid/expired), plus a dashboard-ish header with the configured mode + receive address. The gating config appears in the UI even when payments are disabled, so an operator spinning up a new instance can see at a glance what's set. Config: PAYMENT_REQUIRED=1 — turn paid mode on PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD) PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL End-to-end verified: PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice returns correct sats at live BCH price → admin login + mark-paid → listing becomes visible → /api/invoice/<id> returns status 'paid' with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
payment_status: paid ? 'pending' : 'free',
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
};
try {
prometheus: BCH payments — invoices, price oracle, admin reconcile Payment flow for the paid wave (the free wave is still the default): 1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the listing lands with payment_status='pending' and is hidden from browse. If not set, the listing goes straight to 'free' and is visible immediately (today's behaviour, unchanged). 2. Anyone (publisher, their script, their wallet) POSTs /api/invoice {listing_id} and gets back a one-shot invoice: operator receive address, exact sat amount (USD-pegged + random 1..256 sat jitter so no two in-flight invoices collide), bitcoincash: URI for a wallet, 20-min expiry. 3. Reader pays. The amount matches exactly one pending invoice, and when the mempool watcher reports (address, amount, txid) to tryMatchPayment, both the invoice and the listing flip to paid. Browse starts showing the listing. The mempool watcher that would do (3) automatically is deliberately NOT in this commit — it needs an electrum-cash subscription or a block explorer webhook, and that's a next milestone. In the meantime, admins reconcile by hand from /admin/payments, which shows every invoice with a Mark paid form (optional txid field). Same effect on the DB, same audit log entry, different `paid_by` string. Design decisions worth recording: - One receive address + amount matching, NOT an HD wallet per invoice. BitPay used this for years; it is simple, auditable, and doesn't require a seed on the server to spend from later. - Random few-sat jitter per invoice guarantees uniqueness of the (address, amount) pair across all in-flight invoices. - Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale fallback). If both the fresh fetch and the stale fallback fail, /api/invoice refuses — issuing invoices on a stale price is worse than refusing to quote until the oracle recovers. - Quote is locked at issue time; the 20-min deadline is the lock. - Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice to 'expired' as a side effect, so there's no sweeper to run. Admin UI /admin/payments shows every invoice (pending/paid/expired), plus a dashboard-ish header with the configured mode + receive address. The gating config appears in the UI even when payments are disabled, so an operator spinning up a new instance can see at a glance what's set. Config: PAYMENT_REQUIRED=1 — turn paid mode on PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD) PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL End-to-end verified: PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice returns correct sats at live BCH price → admin login + mark-paid → listing becomes visible → /api/invoice/<id> returns status 'paid' with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
insertListingWithPaymentStatus(row);
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
} 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),
prometheus: BCH payments — invoices, price oracle, admin reconcile Payment flow for the paid wave (the free wave is still the default): 1. Publisher POSTs /api/listing. If PAYMENT_REQUIRED=1 is set, the listing lands with payment_status='pending' and is hidden from browse. If not set, the listing goes straight to 'free' and is visible immediately (today's behaviour, unchanged). 2. Anyone (publisher, their script, their wallet) POSTs /api/invoice {listing_id} and gets back a one-shot invoice: operator receive address, exact sat amount (USD-pegged + random 1..256 sat jitter so no two in-flight invoices collide), bitcoincash: URI for a wallet, 20-min expiry. 3. Reader pays. The amount matches exactly one pending invoice, and when the mempool watcher reports (address, amount, txid) to tryMatchPayment, both the invoice and the listing flip to paid. Browse starts showing the listing. The mempool watcher that would do (3) automatically is deliberately NOT in this commit — it needs an electrum-cash subscription or a block explorer webhook, and that's a next milestone. In the meantime, admins reconcile by hand from /admin/payments, which shows every invoice with a Mark paid form (optional txid field). Same effect on the DB, same audit log entry, different `paid_by` string. Design decisions worth recording: - One receive address + amount matching, NOT an HD wallet per invoice. BitPay used this for years; it is simple, auditable, and doesn't require a seed on the server to spend from later. - Random few-sat jitter per invoice guarantees uniqueness of the (address, amount) pair across all in-flight invoices. - Price oracle is CoinGecko (public, no key, 60s cache, 15-min stale fallback). If both the fresh fetch and the stale fallback fail, /api/invoice refuses — issuing invoices on a stale price is worse than refusing to quote until the oracle recovers. - Quote is locked at issue time; the 20-min deadline is the lock. - Lazy expiry: GET /api/invoice/<id> flips an expired pending invoice to 'expired' as a side effect, so there's no sweeper to run. Admin UI /admin/payments shows every invoice (pending/paid/expired), plus a dashboard-ish header with the configured mode + receive address. The gating config appears in the UI even when payments are disabled, so an operator spinning up a new instance can see at a glance what's set. Config: PAYMENT_REQUIRED=1 — turn paid mode on PROMETHEUS_PAYMENT_ADDRESS=… — the operator's receive address PROMETHEUS_LISTING_FEE_USD=1 — the fee (default 1 USD) PROMETHEUS_PRICE_SOURCE=… — override the CoinGecko URL End-to-end verified: PAYMENT_REQUIRED=1 → new listing hidden from browse → /api/invoice returns correct sats at live BCH price → admin login + mark-paid → listing becomes visible → /api/invoice/<id> returns status 'paid' with paid_by='manual:admin.x'.
2026-10-08 21:52:01 +02:00
...(paid ? { payment_required: true, note: 'POST /api/invoice with this listing_id to receive a payment request' } : {}),
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
});
}
/* ======================================================================
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);
}