A BNS name costs cents but is still a gate — someone discovering Prometheus
for the first time shouldn't have to go register sirius.x before they can
publish. Accept a raw cashaddress in the `publisher` field: the sign path
skips the BNS owner-of lookup and treats the field itself as the signing
address, which is still an ECDSA pubkey the chain verifies against.
- listings.js: PUBLISHER_RE = BNS name OR cashaddress; isCashaddr() helper;
verify branch skips lookupName() and uses the address directly as
expectedAddress (recovery-is-not-verification still holds — we compare
the recovered address to the claimed one, both sides)
- pages.js submit form: pattern + placeholder + hint explain both options,
with the trade-off (address publishers display as truncated hex instead
of a readable name)
- pages-browse.js: shortenPublisher() collapses raw addresses to
bchtest:qpumq…sh478 on cards + detail page + <title>; full address kept
in title attribute for hover, and in the URL path. BNS names are
untouched.
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.
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.
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'.
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.