Merge fix/env-whitelist

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 03:23:39 +02:00
commit fe8ab36e12
6 files changed
+547 -21

No files matched your search

+132 -4
View File
@@ -35,6 +35,40 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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 "<NEXT_PUBLIC_APP_URL>/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
# "<NEXT_PUBLIC_APP_URL>/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="<openssl rand -hex 32>"
# -----------------------------------------------------------------------------
# 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="<openssl rand -hex 32>"
# 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="<openssl rand -hex 32>"
# 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="<openssl rand -hex 32>"
# 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="<openssl rand -hex 32>"
# 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="<openssl rand -hex 32>"
# 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"
+48
View File
@@ -14,6 +14,54 @@
### 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.
- **A dismissed correlation finding comes back when its evidence has really
moved.** Dismissing a finding stores a fingerprint of the evidence behind it,
and the check that decides whether to raise it again never read that
+99
View File
@@ -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
+2 -4
View File
@@ -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
@@ -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<Record<string, string>> = {
// --- 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<string> {
const names = new Set<string>();
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<string> {
const compose = readFileSync(join(ROOT, "docker-compose.yml"), "utf8");
const keys = new Set<string>();
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);
}
});
});
+14 -13
View File
@@ -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 };