From 763afeb310b8d368dc2698c87527def76a70cafd Mon Sep 17 00:00:00 2001 From: Otfrugger <296287929+Otfrugger@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:40:00 +0100 Subject: [PATCH] docs(env): document missing env vars and add a CI check for the invariant Add commented KEY=default entries to .env.example for every process.env read under apps/api/src that had no matching line in an example file: RATE_LIMIT_STRICT_WINDOW_MS/MAX, TRUST_PROXY_HOPS, REDIS_URL, DEFAULT_SELLER_WALLET, DEFAULT_SELLER_NAME, WEBHOOK_HOST_ALLOWLIST, ANCHOR_PROBE_FAILURE_THRESHOLD/COOLDOWN_MS, WATCHER_CONCURRENCY, WATCHER_MAX_ACCOUNTS_PER_TICK, WATCHER_CIRCUIT_BREAKER_THRESHOLD/COOLDOWN_MS, WATCHER_IDLE_BACKOFF_TICKS, WATCHER_AGGRESSIVE_POLL_TICKS, SHUTDOWN_TIMEOUT_MS. TRUST_PROXY_HOPS carries the blank-value footgun warning from docs/MAINNET.md. Add scripts/check-env-docs.mjs, mirroring check-domain-boundary.mjs: it scans apps/api/src for process.env.X reads and fails if X (outside an explicit NODE_ENV/RENDER_EXTERNAL_HOSTNAME exemption for platform-injected vars) isn't documented in .env.example or .env.public.example. Wired into CI via a new docs:check-env-docs script. --- .env.example | 60 +++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 3 ++ package.json | 1 + scripts/check-env-docs.mjs | 74 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 scripts/check-env-docs.mjs diff --git a/.env.example b/.env.example index f72e804b2..ebcb8bba1 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,24 @@ CORS_ORIGINS=http://localhost:3000 # Per-IP fixed-window rate limit. Set RATE_LIMIT_MAX=0 to disable. RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_MAX=120 +# Tighter, separate bucket applied to expensive routes (e.g. link creation, +# cash-out). Same shape as RATE_LIMIT_WINDOW_MS/MAX above, smaller numbers. +# RATE_LIMIT_STRICT_WINDOW_MS=60000 +# RATE_LIMIT_STRICT_MAX=20 + +# Reverse-proxy hops in front of this instance, i.e. how many entries at the +# end of X-Forwarded-For to trust before reaching the real client IP — this +# is what the per-IP rate limiter buckets on. Defaults to 0 on testnet/local +# (no proxy) and 1 on public (one platform edge proxy). WARNING: a blank +# value (e.g. `TRUST_PROXY_HOPS=`) is NOT the same as unset — it parses to 0 +# and silently collapses every client behind the proxy into a single bucket. +# See docs/MAINNET.md for the incident this guards against. +# TRUST_PROXY_HOPS=0 + +# Optional: when set, rate-limit counters are shared across instances via +# Redis instead of an in-process Map, so N instances enforce the configured +# limit rather than allowing N times it. +# REDIS_URL= # Log verbosity: trace|debug|info|warn|error|fatal. Default "info". # Logs are emitted as JSON lines on stdout for grep/jq on Render. @@ -79,6 +97,14 @@ OFFRAMP=mock # DEFAULT_SELLER_WALLET to use the auto-generated keypair. # DEFAULT_SELLER_SECRET= +# Seller wallet (G... public key) that receives funds and appears as the +# checkout destination. Leave unset on testnet to use an auto-generated +# keypair. Required on public network — see .env.public.example. +# DEFAULT_SELLER_WALLET= + +# Cosmetic display name for the demo/default seller shown on checkout pages. +# DEFAULT_SELLER_NAME=Demo Seller + # Required whenever OFFRAMP is not "mock": AES-256-GCM key (32 bytes, hex) encrypting # the seller's SEP-12 KYC field values at rest. Generate with: # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" @@ -93,6 +119,40 @@ OFFRAMP=mock # Required in production; falls back to an insecure fixed dev key if unset # elsewhere (a warning is logged). # WEBHOOK_SECRET_ENCRYPTION_KEY= + +# Comma-separated hostnames that bypass the SSRF guard's private-IP-range +# checks for webhook URLs (registration and delivery). Leave unset unless +# every webhook target is a known, trusted host on a private range. +# WEBHOOK_HOST_ALLOWLIST= + +# ---- Anchor health probe ---- +# Consecutive probe failures before /health reports the anchor unhealthy. +# ANCHOR_PROBE_FAILURE_THRESHOLD=3 +# How long (ms) an unhealthy anchor is skipped before the next probe attempt. +# ANCHOR_PROBE_COOLDOWN_MS=30000 + +# ---- Ledger watcher tuning ---- +# Max accounts processed concurrently within a single watcher tick. +# WATCHER_CONCURRENCY=10 +# Max accounts processed per tick, selected by fair round-robin so no account +# is starved when the number of open links exceeds this. +# WATCHER_MAX_ACCOUNTS_PER_TICK=50 +# Consecutive errors on one account before its circuit breaker opens (that +# account is skipped, others keep being watched). +# WATCHER_CIRCUIT_BREAKER_THRESHOLD=5 +# How long (ms) an open circuit breaker skips an account before retrying. +# WATCHER_CIRCUIT_BREAKER_COOLDOWN_MS=60000 +# Ticks of no activity before an idle account's poll interval starts backing +# off (it is never stopped, only polled less often — see watcher-loop.ts). +# WATCHER_IDLE_BACKOFF_TICKS=10 +# Reserved for an initial aggressive-polling window on newly created +# accounts; not currently read by the watcher loop (a new account instead +# skips backoff entirely until its first activity). +# WATCHER_AGGRESSIVE_POLL_TICKS=5 +# Grace period (ms) the process waits for in-flight work to finish on +# SIGTERM/SIGINT before forcing shutdown. +# SHUTDOWN_TIMEOUT_MS=5000 + # ---- Backups (pnpm db:backup / pnpm db:restore - see docs/RUNBOOK.md) ---- # Required. 64-char hex (32 bytes) AES-256-GCM key. Generate with: # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 454a9db4f..d980bbb15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: Docs — domain boundary (packages/core stays chain-agnostic) run: pnpm docs:check-domain-boundary + - name: Docs — every env var apps/api reads is documented + run: pnpm docs:check-env-docs + - name: Build run: pnpm build env: diff --git a/package.json b/package.json index b366dea57..92320a735 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "docs:status-diagram": "node scripts/gen-status-diagram.mjs", "docs:check-status-diagram": "node scripts/gen-status-diagram.mjs --check", "docs:check-domain-boundary": "node scripts/check-domain-boundary.mjs", + "docs:check-env-docs": "node scripts/check-env-docs.mjs", "sweep": "node scripts/uptime-check.mjs", "secrets:mainnet": "pnpm --filter @checkout/api exec node scripts/gen-mainnet-secrets.mjs", "demo:seed": "tsx scripts/demo-seed.ts", diff --git a/scripts/check-env-docs.mjs b/scripts/check-env-docs.mjs new file mode 100644 index 000000000..24e79730d --- /dev/null +++ b/scripts/check-env-docs.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Enforces "every env var the API reads is documented": fails if any +// process.env.X read under apps/api/src has no matching X= line in either +// .env.example or .env.public.example. Run via `pnpm docs:check-env-docs` +// (wired into CI) — see issue #164. + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const apiSrc = resolve(here, "..", "apps/api/src"); + +// Platform-injected/standard-convention vars, not Quay-specific tuning knobs +// an operator sets from the example templates. +const EXEMPT = new Set(["NODE_ENV", "RENDER_EXTERNAL_HOSTNAME"]); + +const ENV_READ_RE = /process\.env\.([A-Z][A-Z0-9_]*)/g; + +function* walk(dir) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const s = statSync(full); + if (s.isDirectory()) yield* walk(full); + else if (entry.endsWith(".ts")) yield full; + } +} + +const readVars = new Map(); // name -> [files] +for (const file of walk(apiSrc)) { + const raw = readFileSync(file, "utf8"); + // Strip line/block comments first so illustrative snippets in doc comments + // (e.g. `process.env.X ?? "6000"`) aren't mistaken for real reads. + const src = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); + let m; + while ((m = ENV_READ_RE.exec(src))) { + const name = m[1]; + if (EXEMPT.has(name)) continue; + if (!readVars.has(name)) readVars.set(name, []); + readVars.get(name).push(file); + } +} + +const exampleFiles = [ + resolve(here, "..", ".env.example"), + resolve(here, "..", ".env.public.example"), +]; +const documented = new Set(); +const KEY_LINE_RE = /^#?\s*([A-Z][A-Z0-9_]*)=/; +for (const file of exampleFiles) { + const src = readFileSync(file, "utf8"); + for (const line of src.split("\n")) { + const m = KEY_LINE_RE.exec(line); + if (m) documented.add(m[1]); + } +} + +const missing = [...readVars.keys()].filter((name) => !documented.has(name)).sort(); + +if (missing.length > 0) { + console.error("[check-env-docs] env vars read in apps/api/src but not documented in an example file:\n"); + for (const name of missing) { + const files = readVars.get(name).map((f) => f.replace(resolve(here, ".."), "").replace(/\\/g, "/")); + console.error(` ${name} (read in ${[...new Set(files)].join(", ")})`); + } + console.error( + "\nAdd a commented KEY=default line (with a one-line explanation of what changing it " + + "does) to .env.example, or to .env.public.example if it's mainnet-only. If this var is " + + "platform-injected rather than operator-set, add it to EXEMPT in scripts/check-env-docs.mjs instead.", + ); + process.exit(1); +} + +console.log(`[check-env-docs] clean — every env var read in apps/api/src is documented.`);