Files
michilisandClaude Opus 5 9ffa53094d Make a starved discovery cycle say so, in the log and on /api/health.
For about a year the production RELAYS list did not include the relay carrying
the kind 38000/38172 archive. Every backfill read about thirty events, wrote them
faithfully, reported ok=true, and the nightly build republished an index of eight
mints. Nothing measured the difference between "the cycle completed" and "the
cycle read anything", so nothing went red.

Three signals now do:

  - Per-relay attribution. queryRelays() replaces pool.querySync(), which merges
    every relay into one deduplicated array and throws away who sent what. It
    keeps one subscription per relay over the pool's existing sockets and shares
    a single alreadyHaveEvent across them, so an event five relays carry is still
    verified once; receivedEvent fires before that check, which is what makes the
    per-relay count mean "what this relay contributed". The deadline moved out of
    each Subscription's own EOSE timer so `eose` means a frame arrived rather than
    something timed out.

  - A WARN naming any relay that will not connect, on every cycle, and any relay
    that connected and sent nothing, on backfills only. An incremental cycle is
    supposed to come back empty.

  - BACKFILL_MIN_EVENTS, default 200. Under it, ERROR discovery starvation
    suspected and a flag health reports as discovery_starved, forcing 503. Sticky
    across incremental cycles so an hourly cycle finding four events cannot clear
    what a backfill diagnosed; stored in the database so a restart cannot either.

A fresh database is starved until its first backfill lands. That is intended: it
holds the build's health gate rather than publishing a site made from nothing.

Verified against the live relay set — 1528 events, five relays connected, EOSE on
all five, health 200 — and against an unreachable list, which produces the two
WARN lines, the ERROR, and 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:07:01 +02:00

182 lines
10 KiB
Bash

# cashumints.space configuration.
#
# Copy to .env and edit. Every value below is the built-in default, so an empty .env
# behaves exactly like no .env at all.
#
# cp .env.example .env
#
# One file at the repo root serves all three packages. It is loaded by:
# - api/ via node --env-file-if-exists=../.env (see api/package.json)
# - web/ via web/scripts/load-env.mjs, imported from astro.config.mjs
#
# Real environment variables always win over this file, so a systemd unit or a
# one-off `PORT=9000 pnpm dev:api` still overrides it. .env is gitignored.
# ─── Ports ───────────────────────────────────────────────────────────────────
# The two servers `pnpm dev` starts. Change PORT and API_URL together: the web
# build and the dev proxy reach the API at API_URL, so they must agree.
# API HTTP port.
PORT=8787
# Astro dev server / preview port.
WEB_PORT=4321
# ─── Web ─────────────────────────────────────────────────────────────────────
# Where the build and the dev proxy reach the API. A build-machine address; it is
# never written into the markup a visitor downloads.
API_URL=http://127.0.0.1:8787
# Browser-facing API origin, baked into the markup and the islands. Empty means
# same origin: icons resolve to /icons and islands fetch /api, which the dev proxy
# and nginx forward to API_URL. Deliberately does NOT fall back to API_URL — a
# visitor's browser cannot reach 127.0.0.1. Set it only when the API answers on its
# own origin, e.g. https://api.cashumints.space
PUBLIC_API_URL=
# Canonical origin for canonical links, OpenGraph tags and the sitemap.
SITE_URL=https://cashumints.space
# Plausible-compatible analytics script and the domain reported with each page view.
# Leave either value empty to disable analytics.
PLAUSIBLE_URL=https://analytics.azzamo.net/js/script.js
PLAUSIBLE_DOMAIN=cashumints.space
# Whether a rated mint's JSON-LD also carries the review-snippet-eligible Product
# type next to Service (web/src/lib/schema.ts explains the trade). On by default;
# set to 0 to ship the plain Service node on the next build, no code change needed.
SEO_PRODUCT_JSONLD=1
# ─── API storage ─────────────────────────────────────────────────────────────
# The API serves everything from its own database and nothing from a mint or a relay at
# request time, so a restart loses nothing: mint metadata, cached /v1/info payloads,
# reviews, probe history and the discovery cursor all live here. Icons live next to it
# as files. In production point both at a directory the service user owns.
# Which database. Unset means SQLite at DB_PATH below, which is what every existing
# deployment has and needs no setup at all. Set it to a postgres:// URL to use Postgres
# instead — put the API on one host and the database on another, run more than one API
# process, or fold the data into an existing backup and replication setup.
#
# Both backends create their tables on the first connection, so an empty database is
# the whole installation. To carry existing data across, in either direction:
#
# pnpm --filter ./api migrate --to postgres://user:pw@localhost:5432/cashumints
#
# then set DATABASE_URL to the same value and restart. Stop the API first: migrating
# from a database that is still being written to copies a moving target.
#
# DATABASE_URL=postgres://cashumints:secret@localhost:5432/cashumints
# DATABASE_URL=sqlite:/var/lib/cashumints/cashumints.db
# SQLite file. Ignored when DATABASE_URL is set. Unset means api/data/, resolved from
# the source tree regardless of where you run from. Set it and it is taken as given: a
# relative path resolves against the working directory (api/ under every pnpm script),
# so prefer an absolute path.
# DB_PATH=/var/lib/cashumints/cashumints.db
# Postgres connections held open. Ignored by SQLite, which has exactly one. The default
# of 10 is well above what one indexer plus the read endpoints need.
# DB_POOL_MAX=10
# Cached mint icons, served at /icons/*. Files, not rows — they are kept here whichever
# database is in use, and `migrate` does not touch them.
# ICON_DIR=/var/lib/cashumints/icons
# ─── Nostr relays ────────────────────────────────────────────────────────────
# Three comma separated lists, all optional. Each one, unset or empty, falls back
# to the list of the same name in shared/src/nostr.ts; the values below ARE those
# defaults, spelled out so the relays this site talks to are visible in one place.
# Setting one replaces the default pool entirely — it does not add to it.
# Review and mint-announcement relays: kind 38000 and 38172. Read by the API
# indexer (probe/discovery cycles) and by the build-time "latest reviews" fetch.
# The union of the old site's read pool and its publish pool — the two differed,
# so reviews the old site published to snort/primal were invisible to it.
RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net
# How many events a backfill has to read before it counts as having read anything.
#
# A backfill asks every relay above for the whole history of four kinds; on a working
# relay list that is thousands of events. Under this floor, discovery logs
# `ERROR discovery starvation suspected` and /api/health answers 503 with
# `discovery_starved: true` until the next backfill clears it.
#
# This exists because a RELAYS list missing the relay that carries the announcement
# archive returned about thirty events per backfill for a year, reported ok=true every
# time, and left the index at eight mints with every health signal green. Lower it only
# for a private or test relay that genuinely holds less; 1 disables the check.
#BACKFILL_MIN_EVENTS=200
# Profile relays for the BUILD (kind 0, prerendered reviewer names on the home
# page). A wider pool than RELAYS on purpose: relay.cashumints.space holds no kind
# 0 at all and snort/primal hold almost none, so the two aggregators below are what
# turn npubs into names. Read the PROFILE_RELAYS comment in shared/src/nostr.ts
# before trimming this — relay.nostr.band was measured and deliberately excluded.
PROFILE_RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net,wss://purplepag.es,wss://relay.nostr.net
# Profile relays for the BROWSER: the same kind 0 lookup, done client side for
# pubkeys the build did not resolve. PUBLIC_ means it is baked into the shipped
# markup and a visitor's browser connects to these directly, so list only relays
# that accept public websocket connections. Normally kept equal to PROFILE_RELAYS.
PUBLIC_PROFILE_RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net,wss://purplepag.es,wss://relay.nostr.net
# Review relays for the BROWSER: where the reviews panel reads from and where a
# newly signed review is published. Defaults to RELAYS above when unset, which is
# what production wants. Point it at a local relay to exercise the publish path in
# development without putting test reviews on the public network.
# PUBLIC_REVIEW_RELAYS=
# Relays used to reach a NIP-46 remote signer (the "Remote signer" and "Primal"
# login routes). Not review relays: these carry small encrypted RPC between this
# browser and the reader's signer app, so they have to be relays both ends can
# reach. Defaults to relay.nsec.app and relay.primal.net when unset.
# PUBLIC_CONNECT_RELAYS=
# ─── Indexer timing ──────────────────────────────────────────────────────────
# All are positive integers; anything unparseable falls back to the default.
# Minutes between probe cycles (every mint's /v1/info).
PROBE_INTERVAL_MIN=10
# Minutes between discovery cycles (relay scan for new mints and reviews).
DISCOVERY_INTERVAL_MIN=60
# Mints probed in parallel.
PROBE_CONCURRENCY=8
# Per-mint request timeout, milliseconds. Cashu mints only: a federation is not
# fetched directly, see below.
PROBE_TIMEOUT_MS=5000
# ─── Fedimint ────────────────────────────────────────────────────────────────
# A federation has no HTTP status endpoint of its own — confirming one is up means
# being a Fedimint client — so its status is read from an outside checker instead,
# once per probe cycle for every federation at once. Every row whose status came
# from here records that fact and its page prints it, so nothing implies this site
# opened a socket itself.
#
# Empty disables the lookup entirely: every federation then carries the `announced`
# status, which is the honest one for "nothing checks this". It is never inferred
# to be online or offline. See the "Ecosystems" section of README.md.
FEDIMINT_OBSERVER_URL=https://observer.fedimint.org/api/federations
# ─── Ranking ─────────────────────────────────────────────────────────────────
# Bayesian prior mean C. The neutral 3 is intentional — read the "Ranking" section
# of README.md before changing it. `global` uses the observed global mean instead,
# which is the literal BACKEND.md behaviour and ranks noticeably worse.
SCORE_PRIOR_MEAN=3
# How many mints one address may submit to `POST /api/index` in an hour. Ten is several
# times what any honest use of the review dialog needs; raise it while exercising the
# whole flow end to end, which trips ten in about a minute. Behind a proxy, the limiter
# can only tell visitors apart if `X-Forwarded-For` is forwarded (see README, "API").
# INDEX_RATE_LIMIT=10
# ─── Tooling ─────────────────────────────────────────────────────────────────
# Dev server Boneyard captures against (`pnpm bones`). Defaults to WEB_PORT.
# BONES_URL=http://localhost:4321