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.
This commit is contained in:
Local Dev 2026-10-22 23:31:43 +03:00
parent bbef07c825
commit 8b76f16c1e
6 changed files with 146 additions and 29 deletions

View file

@ -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

View file

@ -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;

View file

@ -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,

View file

@ -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:
`<script type="application/ld+json">${websiteLd}</script>\n` +
`<script type="application/ld+json">${orgLd}</script>`,
body: `
<header class="hero">
<div class="mark" style="width:88px;height:88px;margin:0 auto 1rem">
@ -57,10 +78,13 @@ export function home() {
</svg>
</div>
<h1>Prometheus<span class="g">.X</span></h1>
<p class="tag">A home for apps, programs, dApps, and libraries — <b>listed on the
Bitcoin Cash chain, hosted on Sia, endorsed by signature</b>. Anyone can publish.
Nobody can gatekeep. Named for the one who gave fire to humans and did not ask
the gods first.</p>
<p class="tag"><b>The decentralized app store.</b> 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.</p>
<p class="tag" style="margin-top:1rem;color:var(--dim);font-size:14px">
Named for the one who gave fire to humans and did not ask the gods first.
</p>
<div class="cta">
<a class="btn acid" href="/browse">Browse apps</a>
<a class="btn ghost" href="/submit">Submit an app</a>
@ -321,7 +345,7 @@ export function submit() {
<!-- Progress indicator -->
<section class="plain">
<ol class="steps" id="step-indicator" style="margin:0 0 2rem">
<li data-step="1"><b>Upload</b> <span id="s1-sub">the binary (up to 200 MiB)</span></li>
<li data-step="1"><b>Upload</b> <span id="s1-sub">the binary (up to 2 GiB — free below 100 MiB)</span></li>
<li data-step="2"><b>Describe</b> <span id="s2-sub">name, publisher name, category, description</span></li>
<li data-step="3"><b>Sign</b> <span id="s3-sub">with the BCH key for your publisher BNS name</span></li>
<li data-step="4"><b>Publish</b> <span id="s4-sub">POST to the directory</span></li>
@ -350,6 +374,7 @@ export function submit() {
<code class="inline" id="u-hash"></code>
</p>
<p style="margin:.5rem 0 0" id="u-scan"></p>
<p style="margin:.5rem 0 0" id="u-price"></p>
<button class="btn acid" type="button" id="to-step2" style="margin-top:1rem">Continue → describe your listing</button>
</div>
</section>
@ -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 = '<span class="pill p-live">scan ✓ clean (' + json.scan.engine + ', ' + elapsed + ' ms)</span>';
// 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
? '<span class="pill p-live">listing fee: free (≤100 MiB)</span>'
: '<span class="pill p-upcoming">listing fee: $' + priceUsd + ' USD (settled in BCH at the step-4 oracle rate)</span>';
$('upload-result').style.display = 'block';
status.textContent = '';
});
@ -691,8 +726,46 @@ export function submit() {
$('publish-btn').disabled = false;
return;
}
status.innerHTML = '✅ <b>Published.</b>';
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 = '💰 <b>Listing recorded; payment required to go live.</b>';
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 =
'<div class="note danger">Could not issue invoice — ' + String(err?.message || err) +
'. Open <a href="/admin/payments">/admin/payments</a> and have an operator issue one by hand.</div>';
return;
}
const inv = invJson.invoice;
result.innerHTML =
'<div class="card" style="padding:22px 26px">' +
'<h3 style="margin:0 0 .3rem">' + json.listing.name + '</h3>' +
'<p style="margin:0;color:var(--mut);font-size:13.5px">Signed by ' + json.listing.publisher +
' · ' + json.listing.category + ' · v' + json.listing.version + '</p>' +
'<hr style="border:none;border-top:1px solid var(--line);margin:1rem 0">' +
'<h4 style="margin:0 0 .3rem">Pay to publish — $' + inv.amount_usd + ' USD</h4>' +
'<p style="margin:0;color:var(--mut);font-size:13px">Exact amount <b style="color:var(--ink);font-family:ui-monospace,monospace">' + inv.amount_sat.toLocaleString() + ' sats</b> ' +
'(= ' + inv.amount_bch + ' BCH). To: <code class="inline" style="font-size:11px">' + inv.address + '</code></p>' +
'<p style="margin:1rem 0 0"><a class="btn acid" href="' + inv.bitcoincash_uri + '">Open in BCH wallet</a> ' +
'<a class="btn ghost" href="' + href + '">View your listing (hidden until paid)</a></p>' +
'<p style="margin:1rem 0 0;color:var(--dim);font-size:12.5px">Invoice expires at ' + inv.expires_at + '. Status: /api/invoice/' + inv.id + '</p>' +
'</div>';
return;
}
status.innerHTML = '✅ <b>Published.</b>';
result.innerHTML =
'<div class="card" style="padding:22px 26px">' +
'<h3 style="margin:0 0 .3rem">' + json.listing.name + '</h3>' +

View file

@ -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(),

View file

@ -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`.