docs: .env.example lists every variable the app reads

It documented 18 of 67, so anyone setting up a deployment had to find the
rest by reading source — including CORS_ORIGINS, REDIS_URL, NODE_ENV and the
whole Clinical Assistant retrieval group. Each entry now shows the default
the code falls back to, so an unset line reads as "this default" rather than
"broken".

Also documents the OpenBao switch: the entrypoint only fetches secrets when
OPENBAO_ADDR is set, a compose or env_file value always beats the OpenBao
copy, and turning it off means unsetting that one variable and putting the
keys here — with the command to list the key names without printing values.

A test now derives the set of variables from source, so this cannot drift
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-10 22:09:56 +02:00
parent 6dcdf36c81
commit cd27293a52
2 changed files with 92 additions and 0 deletions

View file

@ -197,3 +197,71 @@ DB_PASSWORD=pedscribe_secret_change_me
# AUDIO_BACKUPS_S3_SECRET_KEY_FILE=/run/secrets/audio-backups-secret-key
# AUDIO_BACKUPS_S3_ACCESS_KEY=
# AUDIO_BACKUPS_S3_SECRET_KEY=
# ============================================================
# EVERY REMAINING VARIABLE THE APP READS
# Completed 2026-09-11: this file listed 18 of the 67 variables src/ actually
# reads. Each entry below shows the default the code falls back to, so an
# unset line means "this default", not "broken".
# ============================================================
# ── Mail ────────────────────────────────────────────────────────────────────
# SMTP_SECURE=false # true for implicit TLS (port 465)
# ── Identity and limits ─────────────────────────────────────────────────────
# SITE_NAME=Pediatric AI Scribe
# API_RATE_LIMIT_MAX=200 # requests per window across /api
# LOGIN_RATE_LIMIT_MAX=10 # login attempts per 15 minutes
# NODE_ENV=production # with APP_URL, puts the app in production mode:
# refuses to start without JWT_SECRET or a CORS origin
# CORS_ORIGINS= # extra allowed origins, comma-separated, beyond APP_URL
# ── Services ────────────────────────────────────────────────────────────────
# REDIS_URL=redis://ped-ai-redis:6379
# LOKI_URL=http://monitoring-loki:3100 # log shipping; unset disables it
# MCP_SERVER_URL= # legacy single MCP URL
# ── LiteLLM gateway ─────────────────────────────────────────────────────────
# LITELLM_MASTER_KEY= # falls back to OPENAI_API_KEY, then 'sk-litellm'
# LITELLM_DEFAULT_MODEL= # falls back to OPENAI_MODEL
# LITELLM_FALLBACK_MODEL= # tried when the default model fails
# OPENAI_MODEL= # legacy name for the default chat model
# LITELLM_TTS_VOICES= # comma-separated voices offered in the pickers
# ── Clinical Assistant: retrieval ───────────────────────────────────────────
# CLINICAL_ASSISTANT_MCP_URL=http://mcp:8000/mcp
# CLINICAL_ASSISTANT_MCP_URLS= # comma-separated, tried in order
# CLINICAL_ASSISTANT_SEARCH_TOOL=clinical_semantic_search # the only accepted value
# CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS=30000
# CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS=90000
# CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS=600000
# CLINICAL_ASSISTANT_MCP_WARMUP= # open a session at boot
# CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS=
# ── Clinical Assistant: models and prompts ──────────────────────────────────
# CLINICAL_ASSISTANT_IMAGE_MODEL=openai-gpt-image-1
# CLINICAL_ASSISTANT_PROMPT_MODEL= # model that writes the starter prompts
# CLINICAL_ASSISTANT_PROMPT_POOL_TARGET=1000
# CLINICAL_ASSISTANT_PROMPT_POOL_KEY=clinical-assistant:prompt-pool:v2
# CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS=
# CLINICAL_ASSISTANT_PROMPT_POOL_WARMUP_DELAY_MS=
# CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS=600000
# PATIENT_TAKEHOME_BEHAVIOR= # overrides the admin-editable take-home guidance
# ============================================================
# OPENBAO (optional)
# The entrypoint fetches secrets from OpenBao only when OPENBAO_ADDR is set;
# unset, it uses this file and nothing else. A value set here or in the compose
# environment always wins over the OpenBao copy, so an override needs no
# OpenBao change.
#
# To use OpenBao: set OPENBAO_ADDR, OPENBAO_ROLE_ID, OPENBAO_SECRET_ID
# (and OPENBAO_KV_PATH if not kv/ped-ai/prod)
# To stop using it: unset OPENBAO_ADDR, and put the keys it held in this
# file. List them without printing values:
# bao kv get -format=json kv/ped-ai/prod | jq 'keys'
# ============================================================
# OPENBAO_ADDR=
# OPENBAO_ROLE_ID=
# OPENBAO_SECRET_ID=
# OPENBAO_KV_PATH=kv/ped-ai/prod

View file

@ -126,3 +126,27 @@ test('object storage resolves the same way for every purpose', () => {
{ AUDIO_BACKUPS_S3_BUCKET: 'a', AUDIO_BACKUPS_S3_ACCESS_KEY: 'inline', AUDIO_BACKUPS_S3_ACCESS_KEY_FILE: keyFile, AUDIO_BACKUPS_S3_SECRET_KEY: 'S' });
assert.equal(fileWins.credentials.accessKeyId, 'from-file');
});
// .env.example listed 18 of the 67 variables the app reads, so anyone setting
// up a deployment had to find the rest by reading source.
test('.env.example documents every variable the app reads', () => {
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const read = f => fs.readFileSync(path.join(root, f), 'utf8');
const sources = ['server.js'].concat(
fs.readdirSync(path.join(root, 'src'), { recursive: true })
.filter(f => String(f).endsWith('.js')).map(f => path.join('src', String(f))));
const used = new Set();
for (const file of sources) {
for (const m of read(file).matchAll(/process\.env\.([A-Z_0-9]+)/g)) used.add(m[1]);
}
// Set by node:test in its own child processes, never by a deployment.
used.delete('NODE_TEST_CONTEXT');
const documented = new Set(
[...read('.env.example').matchAll(/^#?\s*([A-Z_0-9]+)=/gm)].map(m => m[1]));
const missing = [...used].filter(name => !documented.has(name)).sort();
assert.deepEqual(missing, [], 'undocumented variables: ' + missing.join(', '));
});