mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge fix/env-whitelist
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
fe8ab36e12
6 files changed
+547
-21
No files matched your search
+132
-4
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user