Publishers no longer need external hosting at all. The service runs its
own s3d (SiaFoundation gateway) as a Docker sidecar on the internal
compose network; scan-clean uploads land there via PUT; readers fetch
through /dl/<sha256> on prometheus.x which proxies back out from s3d.
- deploy/docker-compose.yml: s3d service added. Image is
ghcr.io/siafoundation/s3d:latest, data folder bind-mounted at
./s3d-data/ so the operator can seed it from a Pithos connection
export. Only prometheus-internal network — never public.
- deploy/setup-s3d.sh: one-time import of the Pithos connection
tarball, writes a fresh s3d.yml with a random adminPassword, mints
an S3 access key for the Prometheus service, appends
PROMETHEUS_S3_* lines to .env. Idempotent on re-run.
- service/storage.js: endpoint now defaults to the internal s3d
(http://s3d:8000); publicBase defaults to https://prometheus.x so
download URLs flow through the service's own /dl handler. Bucket is
auto-created on first use so a fresh data folder bootstraps.
Added getObjectStream() for the read side.
- service/downloads.js: GET/HEAD /dl/<sha256> — forwards Range,
content-type and content-length from s3d, drops s3d-specific
headers (x-amz-*), sets immutable long-cache. Hash is validated
against ^[0-9a-f]{64}$ before touching upstream.
- service/server.js: route table wires /dl/* to the handler above.
Publishers no longer need to self-host. When the service is configured
with PROMETHEUS_S3_* credentials pointing at the hosted s3d
(s3.silentmode.st), every clean upload is pushed to
s3://${bucket}/${prefix}<sha256>.bin and the public URL comes back on
/api/upload as `hosted_url`. Step 2 of the submit form pre-fills the
Download URL input with that URL — publishers can overwrite if they
prefer their own hosting; by default they ship nothing.
- service/s3.js: SigV4 S3 client vendored from Pithos/core/s3.js so bug
fixes port by file-copy. Path-style, UNSIGNED-PAYLOAD streaming PUT,
Content-Length required (s3d chunked encoding does not round-trip
cleanly for large bodies).
- service/storage.js: thin wrapper exposing isConfigured() and
uploadScannedFile(). Idempotent on sha256 — same bytes → same key.
Failure is non-fatal; the publisher can still paste their own URL.
- service/uploads.js: after rename-to-final, push to Sia if configured.
Added `hosted_url` to the response (null when storage off).
- service/pages.js: step 2 pre-fills download_url when hosted_url is
present; hint text explains the override path.
- docker-compose.yml: PROMETHEUS_S3_* env plumbed through, with sane
defaults for bucket/prefix (bns + prometheus-storage/).
ClamAV ships with StreamMaxLength=25 MB, MaxFileSize=100 MB, MaxScanSize=
400 MB — mobile APKs over 25 MB came back as "INSTREAM size limit
exceeded" from the scanner, which the service wrapped in a 502 JSON
body, which Caddy sometimes replaced with its own HTML error page under
flaky mobile transport. The browser then saw 'Unexpected token <' and
told the user 'Network error'.
Overlay a minimal clamd.conf (mirrors the image default plus the three
raised limits, all pinned at 2 GiB = 2147483648 bytes) via a read-only
bind mount. Rebuild not needed — only clamav restarts.
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.
Two bugs under one roof.
The visible one: a publisher uploading anything over 10 MB on the live
site got "Network error — Unexpected token '<'" in the browser console.
Cause: the Caddy block I wrote for prometheus.x had
`request_body { max_size 2GB }` nested inside the handle block, where it
is a syntax Caddy parses but does not apply, so Caddy's own 10 MB
default was still the gate. Any oversize POST got Caddy's HTML 413 page,
which the browser's JSON parser choked on at the leading `<`.
Fix: moved `request_body { max_size … }` to the top of the prometheus.x
site block (outside `handle`), where Caddy actually honours it. The live
Caddyfile on p2pexchange is already patched + reloaded; a 50 MB upload
verified through the end-to-end path (Caddy → service → clamd → signed
scan report).
The scope one: 2 GiB was more than Prometheus should offer to publishers
at launch. Mobile apps are usually ~100 MB and even most desktop
installers come in well under 200 MiB. Dropping to 200 MiB:
- Fits every Android APK and most desktop installers.
- Shrinks the operator's Sia storage bill (fewer accidental Steam-size
uploads).
- Keeps the "publishers of larger binaries self-host and submit a
manifest with the external URL + SHA" escape hatch — the scanner
still fetches the URL and checks the hash, so a 5 GB game isn't
locked out, Prometheus just doesn't eat its storage.
Changes:
service/uploads.js MAX_FILE_BYTES default: 2 GiB → 200 MiB
service/pages.js /submit step-1 subtitle: "any size up to
2 GiB" → "up to 200 MiB"
deploy/prometheus-x.conf (unused angie fallback) 2g → 200m
deploy/apps-silentmode-st.conf (unused angie fallback) 2g → 200m
PROMETHEUS_MAX_UPLOAD_BYTES env still overrides per-instance.
The .service unit assumed a native systemd install (ExecStart=/usr/bin/node
/opt/prometheus/scripts/snapshot.mjs --upload) that does not match how the
service actually runs on p2pexchange — it is a docker-compose sibling of
Hephaestus's Caddy stack, not a user=prometheus host process.
Two phases now:
1. docker exec into the running prometheus container, render the static
mirror into /data/build (which is in the container's data volume on
the host at /var/lib/docker/volumes/prometheus_data/_data/build).
2. On the host, aws --profile sia-storage s3 sync that directory to
bns/prometheus/, using the operator's existing sia-storage credentials
per [[sia-storage-credentials-location]].
Keeping aws CLI out of the service image is a deliberate trade — adding
it would bloat the image by ~100 MB for a once-a-week job the host is
already set up to run.
Not yet installed on the VPS. To enable:
install -m644 ~/prometheus/deploy/prometheus-snapshot.{service,timer} \
/etc/systemd/system/
systemctl daemon-reload
systemctl enable --now prometheus-snapshot.timer
Before this: service's /healthz always 503'd and the upload endpoint
rejected every submission with scan verdict=error, because clamd was not
installed anywhere reachable. Service worked for the seeded browse view
but no publisher could POST a listing.
Now: a `clamav` sibling container runs the official clamav/clamav:stable
image on an internal docker network only Prometheus can reach. The
Prometheus service connects via CLAMD_HOST=clamav. Signatures live in a
dedicated volume so restarts reuse the ~250 MB freshclam downloaded on
first start.
Two networks on the Prometheus container now:
prometheus-internal bridge, service + clamav only; clamd never exposed
hephaestus external hephaestus_default, so Hephaestus's Caddy
continues to route prometheus.x → prometheus:3700
depends_on with condition=service_healthy means Prometheus waits for
clamd's healthcheck to pass before starting. clamav's start_period is
5 min to accommodate the signature download; the healthcheck itself
is clamdcheck.sh / clamdscan --ping with a 10s timeout per probe.
Deploy on the VPS:
cd ~/prometheus && git pull && cd deploy
docker compose pull clamav # first time, 500+ MB
docker compose up -d # Prometheus will wait for clamav healthy
The VPS runs Caddy inside Docker as part of the Hephaestus stack (not
system-wide angie as the earlier deploy/README assumed), so the shape
of the deploy is: Prometheus runs as its own small docker-compose that
attaches to Hephaestus's existing network, and Hephaestus's Caddy
reverse-proxies prometheus.x → prometheus:3700.
Dockerfile: node:22-alpine base (keeps the image ~55 MB and gives us
node:sqlite stable), tini for signal handling, non-root `node` user,
one /data volume holding the DB + ed25519 service key + uploads +
snapshot build output.
docker-compose: one service, attaches hephaestus_default as external
network, env sourced from an .env file alongside the compose. The .env
is NOT committed (contains ADMIN_PUBLISHER, optional VirusTotal key,
optional payment config). Healthcheck polls /healthz.
The deploy/prometheus-service.service systemd unit from the earlier
commit is now unused on this host (kept for anyone deploying natively
on a non-dockered box).
Everything the VPS needs to run Prometheus is in Prometheus/deploy/:
prometheus-service.service systemd unit, Node on 127.0.0.1:3700,
ReadWritePaths narrowed to the dirs the
service actually writes (db, uploads, keys,
build). Hardened with NoNewPrivileges,
PrivateTmp, ProtectSystem=strict,
ProtectHome. Reads /etc/default/prometheus
for instance env (admin name, VT key,
payment config) so the repo carries no
per-install secrets.
prometheus-x.conf angie vhost for the BNS name's apex.
TLS-terminated with a self-signed cert that
the on-chain `tls` record pins (no CA
involved). Reverse-proxies / to the Node
service, 2 GiB client_max_body_size for
POST /api/upload, proxy_request_buffering
off so large uploads do not spool to disk.
make-tls-cert.sh ECDSA P-256, 10-year expiry (the on-chain
pin is what actually expires, not the cert
metadata). Prints the DER sha256 that goes
in the `tls` record. Idempotent — rerun to
rotate.
README.md First-time install recipe (useradd →
rsync → npm install --omit=dev → make-tls
→ angie reload → systemctl enable --now),
the matching Argus register.js command for
the record change, update + rollback.
Execution — the deploy itself, the record change, enabling payments —
is the operator's call, not something a script here runs on its own.
layout gained three optional fields: ogType, ogImage, headExtra. The
listing detail page uses all three — ogType=article, ogImage=the icon
URL (fallback to the Prometheus favicon), and headExtra for schema.org
SoftwareApplication JSON-LD. Google renders this as a rich card for
app results; the SEO uplift is real and the data is straightforward.
JSON-LD payload composed in service/seo.js:
@type SoftwareApplication, name, description, softwareVersion,
applicationCategory (mapped from Prometheus's slug to schema's name),
url, downloadUrl, fileSize, offers (free), author (BNS name as org),
image (icon when present), contentRating ("ClamAV clean" on a clean
scan).
OG defaults for every page: og:type (website unless overridden),
og:image (favicon unless overridden). The canonical URL stays
prometheus.x on both the live service and the apps.silentmode.st
mirror — crawlers that land on the mirror attribute the content to
the real address, matching the pattern [[silentmode-site-url-scheme]]
describes.
deploy/apps-silentmode-st.conf is the angie vhost for the mirror:
reverse-proxies to 127.0.0.1:3700, declines /admin (operator always
goes direct), 2 GiB client_max_body_size for POST /api/upload,
proxy_request_buffering off so a slow upload does not spool to disk.
Reuses the main silentmode.st certificate (SAN *.silentmode.st; if
that's not set up yet, request one and reload before enabling).
scripts/snapshot.mjs renders every public page (home, browse, publish,
boundaries, 8 categories, every active listing) to <out>/<path>/index.html,
plus /sitemap.xml, /robots.txt, and the two static assets. Writing to a
directory next to the service AND uploading via `aws s3 sync` are two
flags on the same command: --out to pick the directory, --upload to push.
Live routes added for /sitemap.xml and /robots.txt so a crawler hitting
the live service sees the same enumeration the snapshot bakes into the
bucket. renderSitemap / renderRobots in service/seo.js are the single
rendering path both call sites use.
Scope deliberately limited to the browse surface — /submit and the API
routes depend on the live backend and were never going to work on a
static mirror. The browse surface IS what the fallback is for: readers
survive an outage, publishers can't.
systemd unit + timer under deploy/ run the snapshot weekly on Sunday
04:00 UTC with 30 min jitter and Persistent=yes so a missed run after a
reboot catches up. Unit uses the sia-storage AWS profile per
[[sia-storage-credentials-location]] and the per-process endpoint URL
baked in via Environment=.
Local dry-run against an empty DB produced 14 HTML files + sitemap +
robots + the two assets.