From 98597232f29dd95a146c11eac3c6c2c1de0fd5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:12:44 +0200 Subject: [PATCH 1/4] fix(notifications): drop the unreachable WEB_PUSH_* VAPID aliases Six WEB_PUSH_* names and NEXT_PUBLIC_VAPID_PUBLIC_KEY sat behind the three real VAPID_* variables in the env fallback. None was ever on the compose environment: whitelist and none appeared in .env.example, .env.production.example or the env manifest, so under the bundled stack a value set under any of them could not reach the process. Remove them and the self-hosting note that advertised them. --- docs/self-hosting/notifications.md | 6 ++---- src/lib/notifications/vapid-config.ts | 27 ++++++++++++++------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index b3bd02df0..6f7e01ab1 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -91,10 +91,8 @@ VAPID_PRIVATE_KEY="...(private key)" VAPID_SUBJECT="mailto:you@example.com" ``` -The database values win when both are present. The loader also accepts a -few aliases (`WEB_PUSH_VAPID_PUBLIC_KEY`, `WEB_PUSH_PUBLIC_KEY`, and the -private/subject equivalents) so a config copied from another deployment -keeps working. +The database values win when both are present. These three names are the +only ones the loader reads. ### 3. Subscribe a device diff --git a/src/lib/notifications/vapid-config.ts b/src/lib/notifications/vapid-config.ts index 228653776..cd2a87659 100644 --- a/src/lib/notifications/vapid-config.ts +++ b/src/lib/notifications/vapid-config.ts @@ -8,20 +8,21 @@ export interface VapidConfig { subject: string; } +/** + * The env fallback, read when the admin Settings panel has no key pair stored. + * + * `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` are the only + * names. Six `WEB_PUSH_*` aliases and a `NEXT_PUBLIC_VAPID_PUBLIC_KEY` one + * used to sit behind them; none was ever on the compose `environment:` + * whitelist, so under the bundled stack a value set under any of those names + * could not reach this process, and none appeared in `.env.example`, + * `.env.production.example` or the env manifest. They read as an escape hatch + * that had never been open. + */ function fromEnv(): VapidConfig | null { - const publicKey = - process.env.VAPID_PUBLIC_KEY ?? - process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? - process.env.WEB_PUSH_VAPID_PUBLIC_KEY ?? - process.env.WEB_PUSH_PUBLIC_KEY; - const privateKey = - process.env.VAPID_PRIVATE_KEY ?? - process.env.WEB_PUSH_VAPID_PRIVATE_KEY ?? - process.env.WEB_PUSH_PRIVATE_KEY; - const subject = - process.env.VAPID_SUBJECT ?? - process.env.WEB_PUSH_VAPID_SUBJECT ?? - process.env.WEB_PUSH_SUBJECT; + const publicKey = process.env.VAPID_PUBLIC_KEY; + const privateKey = process.env.VAPID_PRIVATE_KEY; + const subject = process.env.VAPID_SUBJECT; if (!publicKey || !privateKey || !subject) return null; return { publicKey, privateKey, subject }; From e9206bab0bbac15ed47d90fa492f85112cb6777b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:12:55 +0200 Subject: [PATCH 2/4] fix(compose): forward the env vars the app reads but never received The environment: block is a whitelist. Compose substitutes ${VAR} from .env at compose-up, so a value set there looks configured, but a variable the block does not name never reaches the Node process and the default stands silently. Twenty-eight variables were in that state: DEMO_MODE, the four LOG_* knobs and the three LOKI_* shipping vars, DATABASE_STATEMENT_TIMEOUT_MS, four retention and alerting windows, TELEGRAM_WEBHOOK_SECRET, NIGHTSCOUT_PRIVATE_ORIGINS, three OAuth callback overrides, both Open-Meteo endpoints, four AI knobs, three APNs key-source vars, and DASHBOARD_SSR_PREFETCH. APNS_KEY and APNS_KEY_FILE are the sharpest of them: .env.production.example and the env manifest both document a precedence chain of APNS_KEY_B64 before APNS_KEY before APNS_KEY_FILE, and only the first arrived. Each is documented in .env.production.example in its neighbours' style. The VAPID section's note claiming those three names are absent from the whitelist is corrected; they have been on it since v1.17.1. --- .env.production.example | 136 ++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 99 +++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 4 deletions(-) diff --git a/.env.production.example b/.env.production.example index c41bdeb91..ee3d19cf4 100644 --- a/.env.production.example +++ b/.env.production.example @@ -35,6 +35,40 @@ API_TOKEN_HMAC_KEY="" # own Withings developer app and pastes the pair into Settings (stored # encrypted in the DB). There is therefore no WITHINGS_CLIENT_ID/SECRET. # WITHINGS_WEBHOOK_SECRET="" +# Overrides the derived "/api/withings/callback". Set it +# when the callback registered with Withings differs from the app's own +# public URL -- e.g. a second hostname in front of the same instance. +# WITHINGS_REDIRECT_URI="" + + +# ----------------------------------------------------------------------------- +# Google Health Connect -- optional integration (per-user BYO-keys) +# ----------------------------------------------------------------------------- +# Like Fitbit, the client id/secret are per-user and live encrypted in the DB. +# This only overrides the derived +# "/api/google-health/callback"; it must stay same-origin +# with NEXT_PUBLIC_APP_URL or the handshake is refused. +# GOOGLE_HEALTH_REDIRECT_URI="" + + +# ----------------------------------------------------------------------------- +# Telegram -- optional notification channel +# ----------------------------------------------------------------------------- +# Shared secret for the Telegram bot webhook. It is both the path segment the +# bot posts to and the value compared against the update header, so set it +# before installing the webhook from Settings -> Notifications. Without it the +# settings page cannot register the webhook and inbound updates are refused. +# TELEGRAM_WEBHOOK_SECRET="" + + +# ----------------------------------------------------------------------------- +# Nightscout -- optional integration +# ----------------------------------------------------------------------------- +# Nightscout instances on your own network that the per-user connection form +# should accept, comma-separated (e.g. "http://nightscout.lan:1337"). The +# validator refuses private and internal hosts by default; this is the opt-in +# for a self-hosted Nightscout that is not reachable from the public internet. +# NIGHTSCOUT_PRIVATE_ORIGINS="" # ----------------------------------------------------------------------------- @@ -140,10 +174,9 @@ API_TOKEN_HMAC_KEY="" # The EASIEST path is the admin panel: /admin -> Web Push VAPID -> "Generate # keys" mints and stores the pair for you (private key encrypted at rest). Use # these env vars only if you prefer env-config over the DB. The loader reads -# DB first, then env (src/lib/notifications/vapid-config.ts). NOTE: these are -# NOT in docker-compose.yml's `environment:` whitelist -- if you go the env -# route under compose, add them there too or the values never reach the -# container (see docs/self-hosting/getting-started.md Troubleshooting). +# DB first, then env (src/lib/notifications/vapid-config.ts). These three +# names are the only ones it reads, and all three are on docker-compose.yml's +# `environment:` whitelist, so a value set here reaches the container. # VAPID_PUBLIC_KEY="" # VAPID_PRIVATE_KEY="" # VAPID_SUBJECT="mailto:you@example.com" @@ -164,6 +197,7 @@ API_TOKEN_HMAC_KEY="" # APNS_KEY="" # OR APNS_KEY (raw PEM) OR APNS_KEY_FILE -- one satisfies the manifest # APNS_KEY_FILE="" # APNS_CRITICAL_ENTITLEMENT="" # "true" only if your build has Apple's Critical Alerts entitlement; else urgent = time-sensitive +# APNS_PRODUCTION="" # "true" forces the production APNs gateway; normally derived from NODE_ENV # ----------------------------------------------------------------------------- @@ -242,6 +276,26 @@ API_TOKEN_HMAC_KEY="" # docs/integrations/ai-providers.md. # DOCUMENT_AI_LIMIT_PER_HOUR="" +# Extra hosts the ADMIN-configured AI base URL may point at, on top of the +# built-in public providers (comma-separated bare hostnames). Set it when you +# run a gateway this instance should trust. It widens the admin setting only; +# per-user BYO keys keep the pinned public-host posture either way. +# ADMIN_AI_BASE_URL_ALLOWLIST="" + +# Codex (ChatGPT sign-in) model slugs. CODEX_MODEL pins one slug ahead of the +# built-in chain; CODEX_MODEL_FALLBACK_CHAIN replaces the chain outright +# (comma-separated, first match wins). Reach for these when the provider +# rotates a slug and requests start failing with 503 -- without them a +# rotation needs a new release to fix. +# CODEX_MODEL="" +# CODEX_MODEL_FALLBACK_CHAIN="" + +# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot. Off by +# default: the read-back is the most overclaim-prone Coach surface, so it is a +# deliberate operator opt-in. Set to "1" to enable; anything else keeps it +# dormant while the review worker still records outcomes. +# COACH_EXPERIMENT_VERDICT="1" + # ----------------------------------------------------------------------------- # Off-host backups -- all-or-none @@ -267,6 +321,37 @@ API_TOKEN_HMAC_KEY="" # says 90 on both surfaces. # AUDIT_LOG_RETENTION_DAYS="365" +# How long Coach conversations survive, in days. Default 365, floor 30. Like +# the audit window above this number is shown to the user -- the Data & +# Privacy page states it -- so an instance running 90 has to be able to say +# 90 rather than repeat the default. +# COACH_MESSAGE_RETENTION_DAYS="365" + +# How long the host CPU/memory samples behind the admin monitoring charts are +# kept, in days. Default 7. Raise it for a longer trend; lower it on a host +# where disk is the scarce resource. +# HOST_METRIC_RETENTION_DAYS="7" + +# Thinning of dense intraday samples (heart rate, glucose) once they age out +# of the detail window. On by default. Set "false" to keep every raw sample +# forever -- accurate to the second, at the cost of unbounded table growth. +# DENSE_INTRADAY_RETENTION_ENABLED="false" + +# Consecutive failures before an integration is reported as persistently +# failing and notified about. Default 3, minimum 1. Raise it when a flaky +# upstream makes the notification noisy. +# INTEGRATION_FAILURE_ALERT_THRESHOLD="3" + + +# ----------------------------------------------------------------------------- +# Database session timeout -- optional +# ----------------------------------------------------------------------------- +# Statement timeout applied to every database session, in milliseconds. +# Default 60000, which bounds a genuinely runaway query while leaving headroom +# for the heaviest legitimate read (the live-aggregate analytics fallback on a +# large account). Set "0" to disable it and restore unbounded behaviour. +# DATABASE_STATEMENT_TIMEOUT_MS="60000" + # ----------------------------------------------------------------------------- # FHIR health-record export -- optional @@ -389,3 +474,46 @@ API_TOKEN_HMAC_KEY="" # off — force-disable the renderer # on — force-enable (crashes the container if the CPU truly lacks AVX2) # NATIVE_CANVAS="off" + + +# ----------------------------------------------------------------------------- +# Logging -- optional +# ----------------------------------------------------------------------------- +# LOG_LEVEL is one of debug / info / warn / error. Default info: drop to debug +# while chasing a problem, raise to warn on a busy instance. LOG_SAMPLE_RATE +# keeps that fraction of SUCCESSFUL events (default 1.0 = all; 0.1 thins a +# chatty log without losing errors). LOG_SLOW_THRESHOLD_MS is the duration +# above which a request is flagged slow (default 3000). LOG_INCLUDE_STACK set +# to "false" strips stack traces from the emitted events. +# LOG_LEVEL="info" +# LOG_SAMPLE_RATE="1.0" +# LOG_SLOW_THRESHOLD_MS="3000" +# LOG_INCLUDE_STACK="false" + +# Optional log shipping to a Loki instance. LOKI_ENDPOINT is the push URL and +# is what turns shipping on; the username/password pair is only needed when +# your Loki sits behind basic auth. Leave all three unset and logs stay on +# stdout for the Docker log driver to rotate. +# LOKI_ENDPOINT="" +# LOKI_USERNAME="" +# LOKI_PASSWORD="" + + +# ----------------------------------------------------------------------------- +# Demo mode -- read-only showcase instance +# ----------------------------------------------------------------------------- +# "true" makes the proxy refuse every mutation except login and paints a demo +# banner. This is the switch behind a public try-it-out deployment, not a +# safety belt for a real one -- leave it unset on an instance holding real +# data. +# DEMO_MODE="true" + + +# ----------------------------------------------------------------------------- +# Server-side prefetch -- diagnostics escape hatch +# ----------------------------------------------------------------------------- +# The dashboard, insights, coach and checkups pages prefetch their data on the +# server so the first paint arrives filled in. Set "false" to fall back to +# pure client fetching. Reach for it only when a prefetch is misbehaving on +# your host and you want to see whether the client path is healthy. +# DASHBOARD_SSR_PREFETCH="false" diff --git a/docker-compose.yml b/docker-compose.yml index 36bd212bd..f09232067 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,7 +64,23 @@ x-healthlog-env: &healthlog_env # docker-compose reads .env for `${VAR}` substitution at compose-up time). SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}" NATIVE_CANVAS: "${NATIVE_CANVAS:-}" + # Read-only showcase instance. DEMO_MODE=true makes the proxy refuse every + # mutation except login and paints the demo banner. Leave it unset on a + # real self-host — it is a public-demo switch, not a safety belt. + DEMO_MODE: "${DEMO_MODE:-}" + # Statement timeout applied to every database session, in milliseconds + # (default 60000; 0 disables it and restores the unbounded behaviour). + # Raise it if a very large account's analytics fallback is being cut off. + DATABASE_STATEMENT_TIMEOUT_MS: "${DATABASE_STATEMENT_TIMEOUT_MS:-}" WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}" + # Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`; + # set it when the URL registered with Withings differs from the app's own + # public URL. The WHOOP / Polar / Oura / Strava equivalents are below. + WITHINGS_REDIRECT_URI: "${WITHINGS_REDIRECT_URI:-}" + # Path-segment + HMAC secret for the Telegram bot webhook. Set it to the + # same value you register with BotFather; without it the settings page + # cannot install the webhook and inbound updates are refused. + TELEGRAM_WEBHOOK_SECRET: "${TELEGRAM_WEBHOOK_SECRET:-}" # v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare # the real visitor IP is in `cf-connecting-ip`; set # TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by @@ -125,6 +141,22 @@ x-healthlog-env: &healthlog_env # the block exists: `environment:` is a whitelist, and a value in .env that # is not named here never arrives. AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}" + # How long Coach conversations are kept, in days (default 365, floor 30). + # Like the audit window this number is PUBLISHED to the user on the Data & + # Privacy page, so an instance running 90 has to be able to say 90. + COACH_MESSAGE_RETENTION_DAYS: "${COACH_MESSAGE_RETENTION_DAYS:-}" + # How long the host CPU/memory samples behind the admin charts are kept, + # in days (default 7). Raise it for a longer trend, lower it on a small + # disk. + HOST_METRIC_RETENTION_DAYS: "${HOST_METRIC_RETENTION_DAYS:-}" + # Thinning of dense intraday samples (heart rate, glucose) once they age + # out of the detail window. On by default; set "false" to keep every raw + # sample forever, at the cost of table growth. + DENSE_INTRADAY_RETENTION_ENABLED: "${DENSE_INTRADAY_RETENTION_ENABLED:-}" + # Consecutive failures before an integration is reported as persistently + # failing and notified about (default 3, minimum 1). Raise it if a flaky + # upstream is noisy. + INTEGRATION_FAILURE_ALERT_THRESHOLD: "${INTEGRATION_FAILURE_ALERT_THRESHOLD:-}" # Per-export ceiling on MedicationAdministration rows in the FHIR # health-record Bundle. Bounded 1..50000, default 5000. Listed here so # an operator override in .env actually reaches the container (the @@ -161,6 +193,23 @@ x-healthlog-env: &healthlog_env STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}" STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}" STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}" + # Fitbit + Google Health are per-user BYO-key integrations, so there is no + # client id/secret env for either. These override the derived + # `${NEXT_PUBLIC_APP_URL}/api/{fitbit,google-health}/callback` when the URL + # registered with the provider differs from the app's public URL. Both are + # asserted same-origin with NEXT_PUBLIC_APP_URL, so they cannot redirect + # the authorization code somewhere else. + FITBIT_REDIRECT_URI: "${FITBIT_REDIRECT_URI:-}" + GOOGLE_HEALTH_REDIRECT_URI: "${GOOGLE_HEALTH_REDIRECT_URI:-}" + # Nightscout origins on your own network that the per-user connection + # validator should accept, comma-separated (e.g. "http://nightscout.lan"). + # Without it a LAN/private Nightscout URL is refused as an SSRF target. + NIGHTSCOUT_PRIVATE_ORIGINS: "${NIGHTSCOUT_PRIVATE_ORIGINS:-}" + # Open-Meteo endpoints for the environment/weather correlation. Default to + # the hosted service; point them at your own Open-Meteo deployment to keep + # the coordinate lookups off a third party. + OPENMETEO_BASE_URL: "${OPENMETEO_BASE_URL:-}" + OPENMETEO_GEOCODING_URL: "${OPENMETEO_GEOCODING_URL:-}" # OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc). # All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the # "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY @@ -201,6 +250,22 @@ x-healthlog-env: &healthlog_env # in .env reaches the container (the `environment:` block is a whitelist). # See docs/integrations/ai-providers.md. ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}" + # Extra hosts the admin-configured AI base URL may point at, on top of the + # built-in public providers. Comma-separated bare hostnames. Set it when + # you run a gateway this instance should trust; it widens only the admin + # setting, never the per-user BYO-key posture. + ADMIN_AI_BASE_URL_ALLOWLIST: "${ADMIN_AI_BASE_URL_ALLOWLIST:-}" + # Codex (ChatGPT sign-in) model slugs. Set CODEX_MODEL to pin one slug + # ahead of the built-in chain, or CODEX_MODEL_FALLBACK_CHAIN (comma- + # separated) to replace the chain outright. Reach for these when the + # provider rotates a slug and every request starts coming back 503 — + # without them a rotation needs a new release to fix. + CODEX_MODEL: "${CODEX_MODEL:-}" + CODEX_MODEL_FALLBACK_CHAIN: "${CODEX_MODEL_FALLBACK_CHAIN:-}" + # Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot. + # Off by default — the read-back is the most overclaim-prone Coach + # surface, so it is an explicit operator opt-in. Set to "1" to enable. + COACH_EXPERIMENT_VERDICT: "${COACH_EXPERIMENT_VERDICT:-}" # Daily-briefing output-token ceiling (optional; default 2500, clamped # 500-8000). Raise when a verbose model's briefing JSON is cut off. # Listed here so an operator value in .env reaches the container (the @@ -244,12 +309,46 @@ x-healthlog-env: &healthlog_env # PWA users need none of this (Web Push covers them). See # docs/self-hosting/notifications.md. APNS_KEY_B64: "${APNS_KEY_B64:-}" + # The other two key sources, in precedence order after APNS_KEY_B64: + # APNS_KEY is the raw PEM body, APNS_KEY_FILE a path to the .p8 inside the + # container (mount it yourself). Both are documented in + # .env.production.example and in the manifest check-env reads, and both + # were absent from this whitelist — so an operator who followed the + # documented precedence chain got a silently disabled APNs. + APNS_KEY: "${APNS_KEY:-}" + APNS_KEY_FILE: "${APNS_KEY_FILE:-}" APNS_KEY_ID: "${APNS_KEY_ID:-}" APNS_TEAM_ID: "${APNS_TEAM_ID:-}" APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}" + # Force the production APNs gateway. Normally derived from NODE_ENV; set + # "true" when a production-signed build has to reach the production + # gateway from a non-production deployment. + APNS_PRODUCTION: "${APNS_PRODUCTION:-}" # Set "true" only if your own iOS build carries Apple's Critical Alerts # entitlement; otherwise urgent alerts use the time-sensitive level. APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}" + # Structured logging. LOG_LEVEL is one of debug/info/warn/error (default + # info) — drop to debug while chasing a problem, raise to warn on a busy + # instance. LOG_SAMPLE_RATE keeps that fraction of successful events + # (default 1.0 = all; 0.1 thins a chatty log without losing errors). + # LOG_SLOW_THRESHOLD_MS is the duration above which a request is flagged + # slow (default 3000). LOG_INCLUDE_STACK="false" strips stack traces from + # the emitted events. + LOG_LEVEL: "${LOG_LEVEL:-}" + LOG_SAMPLE_RATE: "${LOG_SAMPLE_RATE:-}" + LOG_SLOW_THRESHOLD_MS: "${LOG_SLOW_THRESHOLD_MS:-}" + LOG_INCLUDE_STACK: "${LOG_INCLUDE_STACK:-}" + # Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the push + # URL to turn it on; the username/password pair is only needed when your + # Loki is behind basic auth. Leave all three unset and logs stay on stdout + # for the Docker log driver to rotate. + LOKI_ENDPOINT: "${LOKI_ENDPOINT:-}" + LOKI_USERNAME: "${LOKI_USERNAME:-}" + LOKI_PASSWORD: "${LOKI_PASSWORD:-}" + # Server-side prefetch for the dashboard, insights, coach and checkups + # pages. On by default; set "false" to fall back to pure client fetching, + # which is the escape hatch when a prefetch is misbehaving on your host. + DASHBOARD_SSR_PREFETCH: "${DASHBOARD_SSR_PREFETCH:-}" services: # Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing From ee14b9d56440921c231b07fc201057fffe852477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:13:02 +0200 Subject: [PATCH 3/4] test: gate every env read against the compose whitelist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derives both sets mechanically — the whitelist from docker-compose.yml, the read set from the source — and fails when a variable is read without being forwarded. Written red against the tree before the fix, where it named 35. A search for process.env. alone is not the read set: four modules resolve the name through a helper and index process.env dynamically, which hides every OIDC_* variable and ALLOW_LOCAL_AI_PRIVATE_HOSTS. Each is registered with its own call-shape matcher and its own non-zero assertion, so a rename fails the file loudly instead of quietly shrinking the set. The walk and the matchers both assert a floor, because an empty match set agrees with any allowlist. Thirteen deliberate exceptions each carry a written reason, and the list is kept honest from both ends: an entry that stops being read, or that later appears in the whitelist, fails too. --- .../compose-env-whitelist-guard.test.ts | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 src/__tests__/compose-env-whitelist-guard.test.ts diff --git a/src/__tests__/compose-env-whitelist-guard.test.ts b/src/__tests__/compose-env-whitelist-guard.test.ts new file mode 100644 index 000000000..20c3ad68e --- /dev/null +++ b/src/__tests__/compose-env-whitelist-guard.test.ts @@ -0,0 +1,252 @@ +/** + * Structural guard: every runtime environment variable the server reads is on + * the compose `environment:` whitelist. + * + * ## The failure this freezes + * + * `docker-compose.yml`'s `environment:` block is a WHITELIST. Compose reads + * `.env` for `${VAR}` substitution at compose-up time, so a value an operator + * sets there appears to be configured — but a variable the block does not name + * never reaches the Node process. The symptom is the worst kind: no error, no + * warning, the documented knob simply does nothing and the default silently + * stands. v1.5.2 closed one instance of this (`SESSION_COOKIE_SECURE`); it + * came back at scale, which is why the question is a gate now rather than a + * comment on each entry. + * + * ## What it proves and what it does not + * + * It proves one thing exactly: no variable is read by shipped server code + * without either reaching the container or carrying a written reason why it + * must not. It does NOT prove the whitelist's defaults are right, that the + * `.env.production.example` prose is accurate, or that a variable behaves as + * documented. Those stay review questions. + * + * ## Why the matchers are what they are + * + * A grep for `process.env.X` alone is not the read set. Four modules resolve a + * name through a helper and index `process.env` dynamically, so their reads + * are invisible to the direct matcher — `ALLOW_LOCAL_AI_PRIVATE_HOSTS` and + * every `OIDC_*` among them. Each is registered below with its own call-shape + * matcher AND its own non-zero assertion, so a refactor that renames the + * helper fails this file loudly instead of quietly shrinking the read set. + * + * An empty match set is the other way a sweep goes quiet: it agrees with any + * allowlist. Both the walk and every matcher therefore assert a floor. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const ROOT = process.cwd(); +const SRC = join(ROOT, "src"); + +/** `process.env.NAME` and `process.env["NAME"]`. */ +const DIRECT_READ = + /process\.env(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*["'`]([A-Za-z_][A-Za-z0-9_]*)["'`]\s*\])/g; + +/** + * Modules that index `process.env` through a variable rather than a literal. + * The direct matcher cannot see their reads at all, so each names the exact + * call shape that carries the variable name, and each is asserted non-empty + * in T2 below. `minimum` is the count that must still match — pinned under + * the real number so an added read does not need a test edit, but a removed + * matcher does. + */ +const INDIRECT_READERS: ReadonlyArray<{ + file: string; + pattern: RegExp; + minimum: number; + why: string; +}> = [ + { + file: "lib/auth/oidc.ts", + pattern: /envOrEmpty\(\s*["']([A-Z][A-Z0-9_]*)["']/g, + minimum: 6, + why: "every OIDC_* name goes through the envOrEmpty() helper", + }, + { + file: "app/api/settings/privacy-summary/route.ts", + pattern: /intEnv\(\s*["']([A-Z][A-Z0-9_]*)["']/g, + minimum: 1, + why: "the retention windows are parsed through a local intEnv() helper", + }, + { + file: "lib/ai/local-host-allowlist.ts", + pattern: /ENV_VAR\s*=\s*["']([A-Z][A-Z0-9_]*)["']/g, + minimum: 1, + why: "the name is hoisted into an ENV_VAR constant, then indexed", + }, + { + file: "lib/boot/readiness-summary.ts", + pattern: /\benv\.([A-Z][A-Z0-9_]*)/g, + minimum: 5, + why: "the readiness report reads a `process.env`-defaulted parameter", + }, +]; + +/** + * Read but deliberately NOT on the compose whitelist. Every entry carries the + * reason adding it would be wrong — not merely why nobody got round to it. + * Kept honest from both ends by T4: an entry that stops being read, or that + * later appears in compose, fails this file. + */ +const NOT_OPERATOR_CONFIGURABLE: Readonly> = { + // --- Provided by the runtime or the platform, never by an operator. --- + NEXT_RUNTIME: "set by Next.js to distinguish the node and edge runtimes", + npm_package_version: "set by the package manager when it spawns the process", + + // --- Baked at build time by the Dockerfile's ARG/ENV pairs. --- + NEXT_PUBLIC_APP_VERSION: + "Dockerfile build arg; a runtime value cannot change what is compiled in", + NEXT_PUBLIC_APP_BUILD_SHA: + "Dockerfile build arg, stamped from the release commit", + NEXT_PUBLIC_APP_BUILT_AT: "Dockerfile build arg, stamped at image build", + BUILD_TIMESTAMP: + "build-provenance stamp; an operator value would be a lie about the image", + GIT_COMMIT: + "build-provenance stamp, read only as a fallback for the build SHA", + COMMIT_SHA: + "build-provenance stamp, read only as a fallback for the build SHA", + SOURCE_COMMIT: + "build-provenance stamp, read only as a fallback for the build SHA", + + // --- Reachable, but through a different name that IS on the whitelist. --- + DB_CONNECTION_LIMIT: + "compose splices it into DATABASE_URL's connection_limit parameter; forwarding it as a second env var would be two paths to one knob", + DB_POOL_TIMEOUT: + "compose splices it into DATABASE_URL's pool_timeout parameter, same reasoning as DB_CONNECTION_LIMIT", + DATABASE_POOL_MAX: + "a third spelling of DB_CONNECTION_LIMIT, read only as its fallback; one operator-facing name is enough", + + // --- Not an operator decision. --- + CODEX_OAUTH_CLIENT_ID: + "the ChatGPT OAuth application id is a fixed protocol constant, overridable only for development against a different app", + + // --- Never executes inside the container. --- + COACH_EVAL_API_KEY: + "repository secret for the nightly model-graded Coach eval; that workflow runs in CI, never in the app image", + COACH_EVAL_GENERATOR_MODEL: "CI-only knob for the same nightly eval workflow", + COACH_EVAL_JUDGE_MODEL: "CI-only knob for the same nightly eval workflow", + HEALTHLOG_MCP_TOKEN: + "read by the local stdio MCP bridge the operator runs on their own machine; the production image strips tsx so it cannot run there", + + // --- A whitelist entry would be obeyed by half the readers. --- + NEXT_PUBLIC_DASHBOARD_SNAPSHOT: + "NEXT_PUBLIC_* is inlined into the client bundle at build time and three of its four readers are client components, so a runtime value would steer the server half only — a split-brain worse than no knob. .env.production.example already documents it as a build-time toggle and says it is deliberately absent from the whitelist; adding it would make that note false.", +}; + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((rel) => !rel.startsWith("generated/")) + .filter((rel) => !rel.includes("__tests__")) + .filter((rel) => !rel.endsWith(".test.ts") && !rel.endsWith(".test.tsx")); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +/** Every environment variable shipped server code reads, however it reads it. */ +function runtimeReads(): Set { + const names = new Set(); + for (const rel of sourceFiles()) { + const src = read(rel); + for (const m of src.matchAll(DIRECT_READ)) names.add(m[1] ?? m[2]); + } + for (const reader of INDIRECT_READERS) { + for (const m of read(reader.file).matchAll(reader.pattern)) names.add(m[1]); + } + return names; +} + +/** + * The keys the compose `environment:` block (via the shared `x-healthlog-env` + * anchor) forwards into the container. Two-space-indented `KEY: value` lines + * are the anchor's own entries; the services merge the anchor rather than + * repeating it. + */ +function composeWhitelist(): Set { + const compose = readFileSync(join(ROOT, "docker-compose.yml"), "utf8"); + const keys = new Set(); + for (const line of compose.split("\n")) { + const m = line.match(/^\s{2,}([A-Z][A-Z0-9_]*):\s/); + if (m) keys.add(m[1]); + } + // The `db` service's own three; the app never reads them. + for (const k of ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB"]) { + keys.delete(k); + } + return keys; +} + +describe("compose env whitelist", () => { + it("T1 — the sweep actually reads something", () => { + const reads = runtimeReads(); + // Pinned well under the real count. A sweep that collapses to a handful + // agrees with any allowlist, which is how an empty scan passes as green. + expect(reads.size).toBeGreaterThan(80); + expect(composeWhitelist().size).toBeGreaterThan(50); + }); + + it("T2 — every indirect reader still matches its call shape", () => { + for (const reader of INDIRECT_READERS) { + const found = [...read(reader.file).matchAll(reader.pattern)].map( + (m) => m[1], + ); + expect( + found.length, + `${reader.file}: matched ${found.length} name(s), expected at least ` + + `${reader.minimum} — ${reader.why}. A zero here means the matcher ` + + `went stale, not that the reads went away.`, + ).toBeGreaterThanOrEqual(reader.minimum); + } + }); + + it("T3 — no runtime variable is read without reaching the container", () => { + const whitelist = composeWhitelist(); + const missing = [...runtimeReads()] + .filter((name) => !whitelist.has(name)) + .filter((name) => !(name in NOT_OPERATOR_CONFIGURABLE)) + .sort(); + + expect( + missing, + `These variables are read by shipped server code but are not on the ` + + `docker-compose.yml \`environment:\` whitelist, so a value set in a ` + + `self-hoster's .env never reaches the process and the default stands ` + + `silently. Either add each to the whitelist (with a default and a ` + + `line in .env.production.example) or record in ` + + `NOT_OPERATOR_CONFIGURABLE why forwarding it would be wrong.`, + ).toEqual([]); + }); + + it("T4 — the exception list carries no stale entries", () => { + const reads = runtimeReads(); + const whitelist = composeWhitelist(); + + const unread = Object.keys(NOT_OPERATOR_CONFIGURABLE) + .filter((name) => !reads.has(name)) + .sort(); + expect( + unread, + "Listed as a deliberate exception but no longer read anywhere — drop the entry.", + ).toEqual([]); + + const alsoForwarded = Object.keys(NOT_OPERATOR_CONFIGURABLE) + .filter((name) => whitelist.has(name)) + .sort(); + expect( + alsoForwarded, + "Listed as deliberately not forwarded, yet present in the compose whitelist — one of the two is wrong.", + ).toEqual([]); + + for (const [name, reason] of Object.entries(NOT_OPERATOR_CONFIGURABLE)) { + expect( + reason.length, + `${name} needs a real reason, not a placeholder`, + ).toBeGreaterThan(30); + } + }); +}); From 97e610a17348fc0e7e14c1fead78c540f071efdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:13:05 +0200 Subject: [PATCH 4/4] docs: record the env-whitelist fix under Unreleased --- CHANGELOG.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48b6ec02c..304dde702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [Unreleased] + +### Fixed + +- **Twenty-eight settings a self-hoster could write down but not actually + change.** The `environment:` block in `docker-compose.yml` is a whitelist: + compose reads `.env` for `${VAR}` substitution, so a value set there looks + configured, but a variable the block does not name never reaches the app. + Everything below was read by the server and missing from that list, so the + default silently stood no matter what was in `.env`. Now forwarded, each + with a line in `.env.production.example` saying what it does and when to + reach for it: `DEMO_MODE`; the four log knobs (`LOG_LEVEL`, + `LOG_SAMPLE_RATE`, `LOG_SLOW_THRESHOLD_MS`, `LOG_INCLUDE_STACK`) and log + shipping (`LOKI_ENDPOINT`, `LOKI_USERNAME`, `LOKI_PASSWORD`); + `DATABASE_STATEMENT_TIMEOUT_MS`; four retention and alerting windows + (`COACH_MESSAGE_RETENTION_DAYS`, `HOST_METRIC_RETENTION_DAYS`, + `DENSE_INTRADAY_RETENTION_ENABLED`, `INTEGRATION_FAILURE_ALERT_THRESHOLD`); + `TELEGRAM_WEBHOOK_SECRET`; `NIGHTSCOUT_PRIVATE_ORIGINS`; the three callback + overrides `WITHINGS_REDIRECT_URI`, `FITBIT_REDIRECT_URI` and + `GOOGLE_HEALTH_REDIRECT_URI`; both Open-Meteo endpoints; + `ADMIN_AI_BASE_URL_ALLOWLIST`, `CODEX_MODEL`, `CODEX_MODEL_FALLBACK_CHAIN` + and `COACH_EXPERIMENT_VERDICT`; `APNS_KEY`, `APNS_KEY_FILE` and + `APNS_PRODUCTION`; and `DASHBOARD_SSR_PREFETCH`. The Coach retention window + matters twice over — the Data & Privacy page quotes it back to the user, so + an instance keeping conversations for 90 days now says 90 instead of + repeating the default. A Codex slug rotation is likewise something an + operator can now ride out by pinning a model, rather than waiting for a + release. +- **`APNS_KEY` and `APNS_KEY_FILE` work where the documentation always said + they did.** The example file and the manifest both describe a precedence + chain of `APNS_KEY_B64` before `APNS_KEY` before `APNS_KEY_FILE`, but only + the first ever reached the container, so an operator who chose either of the + other two got a silently disabled APNs and no explanation. +- **The Web Push key loader no longer advertises names it cannot read.** Six + `WEB_PUSH_*` aliases and a `NEXT_PUBLIC_VAPID_PUBLIC_KEY` one sat behind the + three real `VAPID_*` variables. None was ever on the compose whitelist and + none appeared in any example file, so under the bundled stack a value set + under those names could not arrive — an escape hatch that had never been + open. They are gone, and the self-hosting guide no longer mentions them. The + VAPID section of the example file also carried a note saying the three real + names were absent from the whitelist; they have been on it since v1.17.1. +- **A test now answers the question instead of a comment on each entry.** The + whitelist is derived from the compose file and the read set from the source + — including the four modules that resolve a variable name through a helper, + which a plain search for `process.env.` does not see at all. A variable read + without being forwarded fails the build unless it carries a written reason + why forwarding it would be wrong. Thirteen do: build stamps, values the + runtime supplies, two spellings of a knob that already reaches the container + through `DATABASE_URL`, three that only ever run in CI, and the dashboard + snapshot flag, which is compiled into the client bundle and so cannot be + steered from a running container at all. This is the second time the class + has shipped; v1.5.2 closed one instance of it by hand. + ## [1.38.4] — 2026-09-02 The shared AI key reaches the provider its address names, and the image