6.9 KiB
app-config — TREK's environment configuration layer
One validated source of truth for every environment variable the server reads. Framework-free (no Nest imports), so it is consumable from anywhere: Nest DI classes, the legacy free-function services, mcp, websocket, scheduler, db and the pre-init Express middleware.
env.schema.ts Zod catalog of the whole env surface; fail-fast at boot
derive.ts pure (raw env) → typed namespace functions, exact per-site coercions
env.ts readEnv() live accessor + validateEnvAtBoot()
app-url.ts getAppUrl()/getMcpSafeUrl() instance base-URL resolution
boot-validate.ts side-effect import used by index.ts (validates before other modules load)
parsers.ts shared coercion helpers (boolTrueLoose, numberOr, csvList, …)
The Nest binding lives in src/nest/app-config/ (AppConfigModule, registerAs
tokens, RuntimeEnvService) and consumes the SAME derive functions.
Invariants — read before touching anything here
readEnv()is live. It re-derives from the currentprocess.envon every call, with no caching or memoization, ever. ~60 test files mutateprocess.envat runtime and depend on the next read observing the change.- Validation runs once, at boot, from the production entrypoint only.
Never wire the schema into
buildApp(),ConfigModule.forRoot({ validate })or a request path. Unset/blank variables always pass (defaults apply); only present-but-malformed values abort startup. The one addition on top of the schema ismanagedPreconditions()inenv.ts: cross-field rules that only apply withTREK_MANAGED, for combinations that are individually valid and together wrong. It is empty without the switch, so a self-hoster never meets it. Put a rule there only if booting anyway would be a security or data problem — not to enforce a preference. - Parity is law — with one deliberate exception. Every derived field pins
the exact coercion of the call site(s) it replaced (
Number(x) || dtreating"0"as unset, per-site defaults for the same variable, …). Do not "fix" a quirk here; log it as a follow-up instead. The exception: boolean switches are unified throughparseBool— every boolean-like variable acceptstrue/1/on/yesandfalse/0/off/no(any casing) and derives to a real boolean. This intentionally replaces the legacy per-site literals ('true'here,'1'there,'on'elsewhere); tests that pinned those narrow literals get updated when their call site migrates. - Env vs DB-setting layering stays at the call site. For
process.env.X || getSetting('x')patterns, this layer supplies only the env half; the runtime-mutable admin-setting fallback remains inline where it was. - Never convert a frozen read to a live read or vice versa. Modules that
captured env in module-top
consts (mcp, backupService, ssrfGuard, plugin rate limits, …) keep freeze-at-import timing, merely sourcing the value fromreadEnv()at module top. Request-time reads stay request-time.
app-url.ts — instance base-URL resolution (moved 2026-07-28)
getAppUrl() (APP_URL → first ALLOWED_ORIGINS entry → http://localhost:PORT,
each candidate URL-validated, ALL trailing slashes stripped) and
getMcpSafeUrl() (same, sanitized to HTTPS/localhost/127.0.0.1 for the MCP
SDK's issuer check) moved here verbatim from services/notifications.ts
(audit findings auth-2/notifications-1/admin-3/mcp-1). They follow
invariant 1: readEnv() per call, live, never cached. The invalid-URL silent
fallthrough and the strip-ALL-slashes quirk (see parsers.ts
stripTrailingSlashes) are parity-pinned — do not "fix" them here.
Classification: boot-stable vs runtime-toggled
Boot-stable (frozen at app/module creation; snapshot registerAs/ConfigType
DI in Nest, module-top readEnv() consts elsewhere):
PORT, HOST, TRUST_PROXY, SESSION_DURATION(REMEMBER), MCP_SESSION_TTL,
MCP_MAX_SESSION_PER_USER, MCP_SSE_KEEPALIVE, TREK_PLUGIN_RPC/LOG_/MAX_RSS_MB,
TREK_PLUGIN_REGISTRY_URL, TREK_WIKI_DIR*, TREK_PLACE_PHOTO_DIR,
TREK_UPLOADS_DIR, BACKUP_,
TRANSIT_API_URL, LOG_LEVEL, ALLOW_INTERNAL_NETWORK*, DEFAULT_LANGUAGE,
TREK_DB_FILE, TREK_DB_JOURNAL_MODE, TREK_DB_SYNCHRONOUS, ENCRYPTION_KEY,
TREK_S3_* (endpoint/bucket/credentials/region/keyPrefix/retries/timeoutMs —
read once by the storage registry at driver init, not on the request path).
(* frozen today because the consuming module captures it at import; tests that
override these set them at file top, before the SUT import.)
Runtime-toggled (read live on every access via readEnv() /
RuntimeEnvService; tests mutate these mid-lifetime):
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
TREK_PLUGINS_ENABLED / DEV_LINK / DIR / DATA_DIR / TREK_PLUGIN_PERMISSIONS,
OIDC*, SMTP*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN*, TZ, ADMIN_EMAIL,
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check).
Exemptions — raw process.env stays
src/nest/plugins/runtime/plugin-host-entry.ts— runs in a scrubbed child process; must not import this layer.src/nest/plugins/supervisor/plugin-supervisor.tschild-env whitelist block — the env there is an IPC channel to the sandbox, not app configuration.- Dynamic-key reads (
process.env[name]) inplugins/host/daily-budget.tsandplugins/host/plugin-audit.ts. src/config.tsENCRYPTION_KEY/JWT_SECRET resolution — key material with file persistence and runtime rotation, not env config.- Standalone scripts (
reset-admin.js,scripts/*) andtests/**. Note thatreset-admin.jsandscripts/migrate-encryption.tsopen the database file read-write and therefore re-implementTREK_DB_JOURNAL_MODE/TREK_DB_SYNCHRONOUSinline (the journal mode lives in the file header, so they must agree with the server). Neither can import this layer — the image shipsdist/, notsrc/— so the defaults inparsers.resolveDurability()and in those two files move together.
The exemption list is enforced by the no-restricted-syntax ban on
process.env in eslint.config.mjs — keep the two lists in sync.
Follow-up candidates (quirks pinned during the migration, deliberately NOT fixed)
numberOr(Number(x) || d) treats"0"and negative-invalid values oddly:PORT=0silently becomes 3001,TREK_PLUGIN_RPC_BURST=-5stays -5.TRUST_PROXY=0resolves to 1 hop (parseInt || 1).- APP_URL trailing-slash stripping differs per site (feeds strips one slash, notifications strips all).
DEMO_ADMIN_EMAILdefaults differ (demo-seed: admin@trek.app, demo-reset: admin@nomad.app).- NODE_ENV comparisons are case-sensitive at some sites (HSTS activation, platform statics, spa-fallback, authService dev_mode, oidcService frontendUrl) and case-insensitive at others.