diff --git a/.env.example b/.env.example index d5640124..9cfa0a2e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js index e799c44d..88a28845 100644 --- a/test/backend-hardening.test.js +++ b/test/backend-hardening.test.js @@ -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(', ')); +});