diff --git a/.env.example b/.env.example index 995a557..d2859d2 100644 --- a/.env.example +++ b/.env.example @@ -1,55 +1,93 @@ -# StableRoute backend — environment variable template. -# Copy this file to `.env` and adjust as needed. `.env` is git-ignored. - -# Port the HTTP server listens on. Defaults to 3001 when unset. -PORT=3001 - -# Grace period (in milliseconds) before the shutdown handler forces process.exit(1) -# when server.close() is still draining connections. Defaults to 10000 (10 s). -# Must be a positive integer; any other value is silently ignored and the default -# of 10 000 ms is used instead. -SHUTDOWN_GRACE_MS=10000 - -# HTTP keep-alive timeout in milliseconds. Controls how long an idle socket is -# kept open before the server closes it. Must be shorter than the load balancer's -# idle timeout. Node.js default: 5000 ms. -KEEP_ALIVE_TIMEOUT_MS=5000 - -# HTTP headers timeout in milliseconds. Sets the maximum time the server waits -# for complete request headers. Must exceed KEEP_ALIVE_TIMEOUT_MS to avoid -# spurious connection resets behind a proxy (Node.js will close the socket on -# headersTimeout even while keep-alive is waiting for the next request). Node.js -# default: 60000 ms, but we default to 61000 ms to be safely above our keep-alive -# default of 5000 ms. -HEADERS_TIMEOUT_MS=61000 - -# HTTP request timeout in milliseconds. Sets the maximum time the server waits -# for the complete request body after the headers have been received. Set to 0 -# to disable. Node.js default: 300000 ms (5 min). -REQUEST_TIMEOUT_MS=300000 - -# Runtime mode. Use "development" locally and "production" in deployment. -# Setting it to "test" disables the rate limiter and per-request logging -# (used by the Jest suite); do not use "test" outside of tests. -NODE_ENV=development - -# Commit SHA injected by the CI/CD pipeline and surfaced by GET /api/v1/version. -# Leave unset (or blank) for local development; the endpoint falls back to "unknown". -# GIT_COMMIT= - -# Build timestamp injected by the CI/CD pipeline and surfaced by GET /api/v1/version. -# ISO-8601 format recommended (e.g. 2026-01-01T00:00:00Z). -# Leave unset (or blank) for local development; the endpoint falls back to "unknown". -# BUILD_TIME= - -# Admin bearer token protecting all /api/v1/admin/* routes. -# When unset the admin endpoints are unprotected (dev mode only). -# Always set this in production to a cryptographically random value. -# Generate one: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -# ADMIN_TOKEN= - -# Persistent storage backend (GrantFox #70). -# memory — default; state is lost on restart. -# json-file — persist to STORAGE_FILE (survives restarts). -STORAGE_BACKEND=memory -# STORAGE_FILE=./stableroute-data.json +# ============================================================================= +# StableRoute Backend Environment Configuration Template +# Copy this file to .env for local development: cp .env.example .env +# NOTE: Never commit .env to version control (.env is git-ignored). +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Server & Runtime Configuration +# ----------------------------------------------------------------------------- + +# TCP port the HTTP server binds to (default: 3001) +PORT=3001 + +# Runtime environment mode: development, production, or test (default: development) +NODE_ENV=development + +# Pino logger verbosity level: trace, debug, info, warn, error, fatal, silent (default: info) +LOG_LEVEL=info + +# ----------------------------------------------------------------------------- +# Security & Access Control +# ----------------------------------------------------------------------------- + +# Secret Bearer token required to access administrative API endpoints /api/v1/admin/* (default: unset) +ADMIN_TOKEN=dev-admin-secret-token + +# Allowed origin(s) for CORS requests; single origin or comma-separated list (default: *) +CORS_ALLOWED_ORIGINS=* + +# Express trust proxy setting: loopback, linklocal, unroutable, boolean, IP list, or hop count (default: unset) +TRUST_PROXY=loopback + +# ----------------------------------------------------------------------------- +# Features & Routing +# ----------------------------------------------------------------------------- + +# Allow quote requests for asset pairs not explicitly registered in the pair registry (default: false) +ALLOW_UNREGISTERED_QUOTES=false + +# ----------------------------------------------------------------------------- +# Storage & Persistence +# ----------------------------------------------------------------------------- + +# Data persistence backend strategy: memory or json-file (default: memory) +STORAGE_BACKEND=memory + +# File path for store persistence when STORAGE_BACKEND=json-file (default: ./stableroute-data.json) +STORAGE_FILE=./stableroute-data.json + +# File path for JSON store persistence adapter override (default: unset) +PERSIST_PATH=./stableroute-store.json + +# Custom file path for persisting service pause state (default: ./pause-state.json) +PAUSE_STATE_FILE=./pause-state.json + +# ----------------------------------------------------------------------------- +# Timeouts & Request Limits +# ----------------------------------------------------------------------------- + +# Per-request timeout in milliseconds before responding with 503 request_timeout (default: 10000) +REQUEST_TIMEOUT_MS=10000 + +# HTTP server keep-alive socket timeout in milliseconds (default: 5000) +KEEP_ALIVE_TIMEOUT_MS=5000 + +# HTTP server headers timeout in milliseconds; should exceed KEEP_ALIVE_TIMEOUT_MS (default: 61000) +HEADERS_TIMEOUT_MS=61000 + +# Time-to-live in milliseconds for cached idempotent request responses (default: 86400000) +IDEMPOTENCY_TTL_MS=86400000 + +# Maximum number of entries stored in the idempotency LRU cache (default: 10000) +IDEMPOTENCY_CACHE_MAX=10000 + +# ----------------------------------------------------------------------------- +# Shutdown & Draining +# ----------------------------------------------------------------------------- + +# Grace period in milliseconds given for active requests to finish before forced exit (default: 10000) +SHUTDOWN_GRACE_MS=10000 + +# Timeout in milliseconds for flushing pending persistence operations during shutdown (default: 5000) +FLUSH_TIMEOUT_MS=5000 + +# ----------------------------------------------------------------------------- +# Build & Metadata (Injected by CI/CD deployment pipeline) +# ----------------------------------------------------------------------------- + +# Git commit SHA surfaced by GET /api/v1/version (default: unknown) +GIT_COMMIT=a1b2c3d + +# ISO 8601 build timestamp surfaced by GET /api/v1/version (default: unknown) +BUILD_TIME=2026-01-01T00:00:00Z diff --git a/README.md b/README.md index b35b71d..ec2821b 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,17 @@ rationale), a Mermaid request-flow diagram, and the canonical error envelope. ```bash npm install ``` -3. Build and test: +3. Copy the environment configuration template: + ```bash + cp .env.example .env + ``` + See [Configuration](#configuration) for details on available variables. +4. Build and test: ```bash npm run build npm test ``` -4. Run locally: +5. Run locally: ```bash npm run dev ``` @@ -48,15 +53,42 @@ rationale), a Mermaid request-flow diagram, and the canonical error envelope. ## Configuration -The backend is configured entirely through environment variables. The -table below lists every variable the code reads — there are no others. +The backend is configured entirely through environment variables. The table below lists every environment variable the code currently reads — there are no others. + +| Variable | Purpose | Default | Example | +|----------|---------|---------|---------| +| `PORT` | TCP port the HTTP server binds to. | `3001` | `8080` | +| `NODE_ENV` | Runtime mode (`development`, `production`, `test`). Setting `test` disables logging and rate limiting for Jest. | _(unset)_ | `production` | +| `LOG_LEVEL` | Pino logger verbosity level (`trace`, `debug`, `info`, `warn`, `error`, `fatal`, `silent`). | `info` | `debug` | +| `ADMIN_TOKEN` | Secret Bearer token required for administrative endpoints (`/api/v1/admin/*`). Requests are rejected if unset. | _(unset)_ | `dev-admin-secret-token` | +| `CORS_ALLOWED_ORIGINS` | Allowed origin(s) for Cross-Origin Resource Sharing (CORS). Supports single origin or comma-separated list. | `*` | `http://localhost:3000` | +| `TRUST_PROXY` | Express trust proxy setting (`loopback`, `linklocal`, `unroutable`, boolean, IP list, or hop count). | _(unset)_ | `loopback` | +| `ALLOW_UNREGISTERED_QUOTES` | Permit quote requests for asset pairs not explicitly registered in the pair registry (`true`/`false`). | `false` | `true` | +| `STORAGE_BACKEND` | Data persistence backend strategy (`memory` or `json-file`). | `memory` | `json-file` | +| `STORAGE_FILE` | File path for store persistence when `STORAGE_BACKEND=json-file`. | `./stableroute-data.json` | `./data/store.json` | +| `PERSIST_PATH` | File path for JSON store persistence adapter override. | _(unset)_ | `./stableroute-store.json` | +| `PAUSE_STATE_FILE` | Custom file path for persisting service pause state across restarts. | `./pause-state.json` | `./data/pause-state.json` | +| `REQUEST_TIMEOUT_MS` | Per-request timeout in milliseconds before responding with `503 request_timeout`. | `10000` | `15000` | +| `KEEP_ALIVE_TIMEOUT_MS` | HTTP server keep-alive socket timeout in milliseconds. | `5000` | `10000` | +| `HEADERS_TIMEOUT_MS` | HTTP server headers timeout in milliseconds. Should exceed `KEEP_ALIVE_TIMEOUT_MS`. | `61000` | `65000` | +| `IDEMPOTENCY_TTL_MS` | Time-to-live in milliseconds for cached idempotent request responses. | `86400000` | `43200000` | +| `IDEMPOTENCY_CACHE_MAX` | Maximum number of response entries stored in the idempotency LRU cache. | `10000` | `50000` | +| `SHUTDOWN_GRACE_MS` | Grace period in milliseconds given for active requests to finish before forced process exit. | `10000` | `15000` | +| `FLUSH_TIMEOUT_MS` | Timeout in milliseconds for flushing pending persistence operations during shutdown. | `5000` | `8000` | +| `GIT_COMMIT` | Commit SHA surfaced by `GET /api/v1/version`. Injected by deploy pipeline. | `unknown` | `a1b2c3d` | +| `BUILD_TIME` | Build ISO 8601 timestamp surfaced by `GET /api/v1/version`. Injected by deploy pipeline. | `unknown` | `2026-01-01T00:00:00Z` | + +### Environment Template (`.env.example`) + +[.env.example](.env.example) is the template for these variables. Copy it to `.env` and edit the values for local development: + +```bash +cp .env.example .env +``` + +> **Note on Security & Git:** `.env` is git-ignored (see [.gitignore](.gitignore)), so your local `.env` file is never committed to version control. **Never commit `.env` or real production secrets.** `.env.example` contains safe placeholder defaults and inline comments for contributors. -| Variable | Purpose | Default | Example | -|------------|------------------------------------------------------------------------------------------------------|---------------|---------------| -| `PORT` | TCP port the HTTP server binds to. | `3001` | `8080` | -| `NODE_ENV` | Runtime mode. Setting it to `test` disables the rate limiter and per-request logging (used by Jest). | _(unset)_ | `production` | -| `GIT_COMMIT` | Commit SHA surfaced by `GET /api/v1/version`. Injected by the deploy pipeline; falls back to `"unknown"`. | _(unset)_ | `a1b2c3d` | -| `BUILD_TIME` | Build timestamp surfaced by `GET /api/v1/version`. Injected by the deploy pipeline; falls back to `"unknown"`. | _(unset)_ | `2026-01-01T00:00:00Z` | +Note that Node.js / Express does not automatically auto-load `.env` at runtime unless variables are exported into your shell, supplied via your process manager, or loaded using Node's `--env-file` flag. ### Build/version endpoint @@ -71,17 +103,6 @@ operators can confirm which build is live during an incident: `GIT_COMMIT`/`BUILD_TIME` env vars (each falling back to `"unknown"`); `node` is `process.version`. No health checks run and no secrets are exposed. -`.env.example` is the template for these variables. Copy it to `.env` -and edit the values for local development: - -```bash -cp .env.example .env -``` - -`.env` is git-ignored (see `.gitignore`), so your local values are never -committed. The application does not auto-load `.env`; export the -variables into your shell (or use your process manager / `--env-file`) -before starting the server. ## Scripts diff --git a/src/__tests__/envExample.test.ts b/src/__tests__/envExample.test.ts new file mode 100644 index 0000000..75f98c2 --- /dev/null +++ b/src/__tests__/envExample.test.ts @@ -0,0 +1,69 @@ +import fs from "node:fs"; +import path from "node:path"; + +describe(".env.example & README.md Configuration Reference", () => { + const rootDir = path.resolve(__dirname, "../.."); + const envExamplePath = path.join(rootDir, ".env.example"); + const readmePath = path.join(rootDir, "README.md"); + + const EXPECTED_ENV_VARS = [ + "PORT", + "NODE_ENV", + "LOG_LEVEL", + "ADMIN_TOKEN", + "CORS_ALLOWED_ORIGINS", + "TRUST_PROXY", + "ALLOW_UNREGISTERED_QUOTES", + "STORAGE_BACKEND", + "STORAGE_FILE", + "PERSIST_PATH", + "PAUSE_STATE_FILE", + "REQUEST_TIMEOUT_MS", + "KEEP_ALIVE_TIMEOUT_MS", + "HEADERS_TIMEOUT_MS", + "IDEMPOTENCY_TTL_MS", + "IDEMPOTENCY_CACHE_MAX", + "SHUTDOWN_GRACE_MS", + "FLUSH_TIMEOUT_MS", + "GIT_COMMIT", + "BUILD_TIME", + ] as const; + + it("verifies .env.example exists in the repository root", () => { + expect(fs.existsSync(envExamplePath)).toBe(true); + }); + + it("parses .env.example into key-value pairs without syntax errors", () => { + const content = fs.readFileSync(envExamplePath, "utf-8"); + const lines = content.split("\n"); + const parsedKeys: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const match = trimmed.match(/^([A-Z0-9_]+)=(.*)$/); + expect(match).not.toBeNull(); + if (match) { + parsedKeys.push(match[1]); + } + } + + expect(parsedKeys.sort()).toEqual([...EXPECTED_ENV_VARS].sort()); + }); + + it("contains no sensitive real secrets or production tokens", () => { + const content = fs.readFileSync(envExamplePath, "utf-8"); + expect(content).not.toMatch(/ghp_[A-Za-z0-9]+/); + expect(content).not.toMatch(/sk_live_[A-Za-z0-9]+/); + expect(content).not.toMatch(/AWS_SECRET_ACCESS_KEY/i); + expect(content).not.toContain("super-secret-production-key"); + }); + + it("verifies every environment variable is documented in README.md", () => { + const readmeContent = fs.readFileSync(readmePath, "utf-8"); + for (const envVar of EXPECTED_ENV_VARS) { + expect(readmeContent).toContain(`\`${envVar}\``); + } + }); +});