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
148 changes: 93 additions & 55 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
63 changes: 42 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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

Expand All @@ -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

Expand Down
69 changes: 69 additions & 0 deletions src/__tests__/envExample.test.ts
Original file line number Diff line number Diff line change
@@ -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}\``);
}
});
});
Loading