From 8b76f16c1e4bc0577b69d45373727eb5915a82fd Mon Sep 17 00:00:00 2001 From: Local Dev Date: Thu, 22 Oct 2026 23:31:43 +0300 Subject: [PATCH] prometheus: size-tiered pricing, 2 GiB cap, new hero copy, SEO LD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- deploy/apps-silentmode-st.conf | 6 +-- deploy/prometheus-x.conf | 8 +-- service/listings.js | 10 ++-- service/pages.js | 89 +++++++++++++++++++++++++++++++--- service/payments.js | 50 +++++++++++++++++-- service/uploads.js | 12 ++--- 6 files changed, 146 insertions(+), 29 deletions(-) diff --git a/deploy/apps-silentmode-st.conf b/deploy/apps-silentmode-st.conf index b7f85b6..6601beb 100644 --- a/deploy/apps-silentmode-st.conf +++ b/deploy/apps-silentmode-st.conf @@ -36,9 +36,9 @@ server { proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Host apps.silentmode.st; - # Matches the service's 200 MiB upload cap. Reads are small; this - # governs POST /api/upload. - client_max_body_size 200m; + # Matches the service's 2 GiB upload cap. The paywall (free below + # 100 MiB, $1/USD per 100 MiB above) is the real size gate. + client_max_body_size 2g; proxy_request_buffering off; # The service's own long-cache headers decide for static assets; this diff --git a/deploy/prometheus-x.conf b/deploy/prometheus-x.conf index 8e5197b..4cead7d 100644 --- a/deploy/prometheus-x.conf +++ b/deploy/prometheus-x.conf @@ -36,10 +36,10 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; - # 200 MiB fits Android APKs and most desktop installers; matches the - # service's own cap in uploads.js. Publishers of larger binaries - # self-host and submit a manifest with the external URL + SHA. - client_max_body_size 200m; + # 2 GiB upper cap; the paywall (free below 100 MiB, $1/USD per + # 100 MiB above) is the real size gate. Matches the service's own + # cap in uploads.js. + client_max_body_size 2g; proxy_request_buffering off; proxy_buffering off; diff --git a/service/listings.js b/service/listings.js index cfc948f..9251180 100644 --- a/service/listings.js +++ b/service/listings.js @@ -34,7 +34,7 @@ import { listingsByPublisher, insertListingWithPaymentStatus, } from './db.js'; -import { paymentsRequired } from './payments.js'; +import { listingNeedsPayment } from './payments.js'; // Nine category slugs: seven sub-categories under Apps, plus the two // single-bucket top-level categories (games, fintech). @@ -110,9 +110,11 @@ export async function handleListingCreate(req, res) { const slug = String(body.slug).toLowerCase(); const id = `${name}/${slug}`; - // In paid mode the listing lands as 'pending' and is hidden from browse - // until an invoice settles against it; in free mode it is immediately live. - const paid = paymentsRequired(); + // 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); const row = { id, publisher: name, diff --git a/service/pages.js b/service/pages.js index 262268d..0e59930 100644 --- a/service/pages.js +++ b/service/pages.js @@ -42,11 +42,32 @@ function renderHomeBrowseSection() { /* ------------------------------- home ------------------------------- */ export function home() { + // Schema.org on home — gives Google a WebSite + SearchAction result card for + // "prometheus.x" queries, plus Organization for the knowledge panel. + const websiteLd = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'WebSite', + name: 'Prometheus.X', + alternateName: ['Prometheus', 'Decentralized App Store'], + url: 'https://prometheus.x/', + description: 'The decentralized app store. Apps, programs, dApps and libraries signed on the Bitcoin Cash chain and hosted on the decentralized web.', + }); + const orgLd = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Organization', + name: 'Prometheus', + url: 'https://prometheus.x/', + sameAs: ['https://silentmode.st/prometheus', 'https://code.silentmode.st/silentmode/prometheus'], + description: 'A permissionless app store: listings on the Bitcoin Cash chain, binaries on the decentralized web, endorsements by signature. Anyone can publish; nobody can gatekeep.', + }); return { - title: 'Apps and programs on the decentralized web', + title: 'The decentralized app store', description: - 'Prometheus is a home for apps, programs, and dApps published on the Bitcoin Cash chain. Listings live on chain, binaries live on Sia, endorsements are signed. Anyone can publish; nobody can gatekeep.', + 'Prometheus.X — the decentralized app store. Web3 browsers, BCH wallets, mobile and desktop apps, dApps and libraries. Listings on the Bitcoin Cash chain, binaries on the decentralized web, endorsements by signature. Anyone can publish; nobody can gatekeep.', here: '/', + headExtra: + `\n` + + ``, body: `
@@ -57,10 +78,13 @@ export function home() {

Prometheus.X

-

A home for apps, programs, dApps, and libraries — listed on the - Bitcoin Cash chain, hosted on Sia, 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.

+

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. +

Browse apps Submit an app @@ -321,7 +345,7 @@ export function submit() {
    -
  1. Upload the binary (up to 200 MiB)
  2. +
  3. Upload the binary (up to 2 GiB — free below 100 MiB)
  4. Describe name, publisher name, category, description
  5. Sign with the BCH key for your publisher BNS name
  6. Publish POST to the directory
  7. @@ -350,6 +374,7 @@ export function submit() {

    +

@@ -559,6 +584,16 @@ export function submit() { $('u-size').textContent = fmtBytes(json.upload.size); $('u-hash').textContent = json.upload.hash.slice(0,8) + '…' + json.upload.hash.slice(-6); $('u-scan').innerHTML = 'scan ✓ clean (' + json.scan.engine + ', ' + elapsed + ' ms)'; + // Size-tier price: free below 100 MiB, then $1 USD per 100 MiB above. + const freeTier = 100 * 1024 * 1024; + const perTier = 100 * 1024 * 1024; + const priceUsd = json.upload.size <= freeTier + ? 0 + : Math.ceil((json.upload.size - freeTier) / perTier); + W.priceUsd = priceUsd; + $('u-price').innerHTML = priceUsd === 0 + ? 'listing fee: free (≤100 MiB)' + : 'listing fee: $' + priceUsd + ' USD (settled in BCH at the step-4 oracle rate)'; $('upload-result').style.display = 'block'; status.textContent = ''; }); @@ -691,8 +726,46 @@ export function submit() { $('publish-btn').disabled = false; return; } - status.innerHTML = '✅ Published.'; const href = '/app/' + encodeURIComponent(json.listing.publisher) + '/' + encodeURIComponent(json.listing.slug); + + if (json.payment_required) { + // Fetch the invoice right away so the publisher gets a payable URI + // without a second click. Admins reconcile from /admin/payments + // until the mempool watcher lands. + status.innerHTML = '💰 Listing recorded; payment required to go live.'; + let invJson; + try { + const invR = await fetch('/api/invoice', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ listing_id: json.id }), + }); + invJson = await invR.json(); + if (!invR.ok || !invJson.ok) throw new Error(invJson.reason || invJson.error || 'invoice failed'); + } catch (err) { + result.innerHTML = + '
Could not issue invoice — ' + String(err?.message || err) + + '. Open /admin/payments and have an operator issue one by hand.
'; + return; + } + const inv = invJson.invoice; + result.innerHTML = + '
' + + '

' + json.listing.name + '

' + + '

Signed by ' + json.listing.publisher + + ' · ' + json.listing.category + ' · v' + json.listing.version + '

' + + '
' + + '

Pay to publish — $' + inv.amount_usd + ' USD

' + + '

Exact amount ' + inv.amount_sat.toLocaleString() + ' sats ' + + '(= ' + inv.amount_bch + ' BCH). To: ' + inv.address + '

' + + '

Open in BCH wallet ' + + 'View your listing (hidden until paid)

' + + '

Invoice expires at ' + inv.expires_at + '. Status: /api/invoice/' + inv.id + '

' + + '
'; + return; + } + + status.innerHTML = '✅ Published.'; result.innerHTML = '
' + '

' + json.listing.name + '

' + diff --git a/service/payments.js b/service/payments.js index 10b15d9..dbac4e2 100644 --- a/service/payments.js +++ b/service/payments.js @@ -29,12 +29,37 @@ import { usdToSats } from './price.js'; const INVOICE_TTL_MS = 20 * 60 * 1000; // 20 min, matches the quote lock const JITTER_MAX = 256; // random 1..256 sats added to the base -const DEFAULT_FEE_USD = 1; +const FREE_TIER_BYTES = 100 * 1024 * 1024; // 100 MiB free +const PER_TIER_BYTES = 100 * 1024 * 1024; // $1 USD per 100 MiB above the free tier export const RECEIVE_ADDRESS = process.env.PROMETHEUS_PAYMENT_ADDRESS || ''; -export const LISTING_FEE_USD = Number(process.env.PROMETHEUS_LISTING_FEE_USD) || DEFAULT_FEE_USD; export const PAYMENT_REQUIRED = process.env.PAYMENT_REQUIRED === '1'; +/** + * Size-tiered listing fee in USD. 100 MiB free, then $1 per started 100-MiB + * tier above that. + * + * 0 - 100 MiB → $0 (free) + * 100.01 - 200 MiB → $1 + * 200.01 - 300 MiB → $2 + * ... + * 1900.01 - 2000 MiB → $19 + * 2000.01 - 2048 MiB → $20 (ceiling at 2 GiB) + * + * Returns an integer; the oracle converts to sats at invoice time. The size + * threshold is EXACTLY at tier boundaries — a 100.000 MiB file is free, a + * 100.001 MiB file is the first paid tier at $1. + */ +export function priceUsdForSize(sizeBytes) { + const n = Number(sizeBytes) || 0; + if (n <= FREE_TIER_BYTES) return 0; + return Math.ceil((n - FREE_TIER_BYTES) / PER_TIER_BYTES); +} + +// Back-compat export; still read in admin-pages.js as a display hint. +// Represents the first paid tier. +export const LISTING_FEE_USD = 1; + export function paymentsConfigured() { return RECEIVE_ADDRESS.length > 0; } @@ -43,8 +68,20 @@ export function paymentsRequired() { return PAYMENT_REQUIRED && paymentsConfigured(); } +/** + * True if this listing needs an invoice before publishing. + * Combines the operator policy (PAYMENT_REQUIRED) with the size tier: + * when PAYMENT_REQUIRED is on, ONLY listings with a non-zero size-tier + * price are gated — the free tier (≤100 MiB) always publishes immediately. + */ +export function listingNeedsPayment(sizeBytes) { + if (!paymentsRequired()) return false; + return priceUsdForSize(sizeBytes) > 0; +} + /** * Create a new invoice for a listing. Returns the invoice row. + * Throws if the listing's size falls in the free tier (caller shouldn't ask). */ export async function createInvoice(listingId) { if (!paymentsConfigured()) { @@ -53,7 +90,12 @@ export async function createInvoice(listingId) { const listing = getListing(listingId); if (!listing) throw new Error(`no such listing: ${listingId}`); - const { sats: baseSats, rate_sat_per_usd } = await usdToSats(LISTING_FEE_USD); + const usd = priceUsdForSize(listing.download_size); + if (usd === 0) { + throw new Error(`listing is in the free tier (${listing.download_size} bytes); no invoice needed`); + } + + const { sats: baseSats, rate_sat_per_usd } = await usdToSats(usd); const jitter = (randomBytes(1)[0] % JITTER_MAX) + 1; const amount_sat = baseSats + jitter; @@ -63,7 +105,7 @@ export async function createInvoice(listingId) { listing_id: listingId, address: RECEIVE_ADDRESS, amount_sat, - amount_usd: String(LISTING_FEE_USD), + amount_usd: String(usd), rate_sat_per_usd, issued_at: new Date(now).toISOString(), expires_at: new Date(now + INVOICE_TTL_MS).toISOString(), diff --git a/service/uploads.js b/service/uploads.js index e9e18f9..e19332e 100644 --- a/service/uploads.js +++ b/service/uploads.js @@ -36,14 +36,14 @@ import { signReport } from './signing.js'; const UPLOAD_DIR = process.env.PROMETHEUS_UPLOAD_DIR || resolve(dirname(fileURLToPath(import.meta.url)), '..', 'uploads'); -// 200 MiB — fits every Android APK and most desktop installers; publishers -// of larger binaries self-host and submit a manifest with the external URL + -// SHA. The service's cap and Caddy's `request_body { max_size 200MB }` on -// the prometheus.x vhost must stay in sync — if Caddy rejects first, the -// browser gets an HTML 413 page that the JSON parser cannot read. +// 2 GiB — upper ceiling. The REAL gate on size is the paywall (free below +// 100 MiB, $1/USD per 100 MiB above), not this constant. The service's cap +// and Caddy's `request_body { max_size 2GB }` on the prometheus.x vhost +// must stay in sync — if Caddy rejects first, the browser gets an HTML 413 +// page that the JSON parser cannot read. // Change via env rather than code so an operator can tighten per-instance. const MAX_FILE_BYTES = Number(process.env.PROMETHEUS_MAX_UPLOAD_BYTES) - || 200 * 1024 * 1024; + || 2 * 1024 * 1024 * 1024; /** * Express-style handler attached to `/api/upload`.