Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'))"
Expand All @@ -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'))"
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 74 additions & 0 deletions scripts/check-env-docs.mjs
Original file line number Diff line number Diff line change
@@ -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.`);