The ownership rule says a boot-stable value that only Nest providers read belongs to a registerAs token, yet TransitService froze TRANSIT_API_URL in a module-top readEnv() const and KitineraryExtractorService read KITINERARY_EXTRACTOR_PATH and PATH through readEnv() in its probe. Both now come from tokens: transitConfig and kitineraryConfig, derived in boot-derive.ts and dropped from deriveIntegrations, so readEnv() no longer reads those variables at all. TransitModule and KitineraryExtractorModule import AppConfigModule, which keeps them buildable on their own in tests. Behaviour is unchanged: the same defaults, the same trailing-slash strip and PATH split, now frozen per built app instead of at import. The app-config README names each boot-stable variable that stays on readEnv() together with the reader the container cannot inject into.
12 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
data-paths.ts resolveDataPaths(): data/, uploads/, backups, tmp, logs, key files, DB file
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. The report prints each malformed value, because that is what makes a typo findable, except for the credentials inSECRET_ENV_KEYS(env.ts), which print as***. A new password, token, key or secret variable goes into that set;validate.test.tsfails on a credential-sounding name missing from it. - 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.
data-paths.ts: the one data layout
resolveDataPaths() is the only place the server anchors data/ and
uploads/ (on SERVER_ROOT, the package directory, never on process.cwd())
and names what lives in them: backups, the scratch dir, the logs, the JWT
secret and encryption key files, and the database file (TREK_DB_FILE when set,
else data/travel.db). Never derive one of these paths from __dirname in a
new file: a moved file would quietly move the data.
Two ways in, by where the code runs:
- Nest providers inject
DataPathsService(src/nest/app-config/), which resolves the same layout once per built app:AdminService(the JWT secret file),StorageRegistryService(the built-in uploads and backups roots and the scratch dir) and, throughStorageRegistryService.tempDir(),StorageJobsService. A new provider that needs a data path takes it the same way. - Plain modules call
resolveDataPaths()because they run without a container or are shared with code that does:config.tskey resolution,db/db-path.ts,index.ts, the file logger,demo/demo-reset.ts, the backup restore and archive functions (nest/backup/backup.impl.ts,auto-backup.settings.ts, file I/O with no container state), the plugin trees (nest/plugins/paths.ts, also read by the out-of-process supervisor), andnest/storage/storage-paths.ts, which keeps the seed-config test seam thatStorageAdminServiceand the registry share.files.constants.tsstill exportsfilesDirfor the tests that pin the layout; no production code reads it.
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.
Ownership: one door per variable
Every variable has exactly one owner, and the owner decides how it is read:
- A registerAs token (
src/nest/app-config/tokens.ts, derived byboot-derive.ts): boot-stable values that only Nest classes need. The token is snapshotted per built app and injected (@Inject(storageConfig.KEY), orapp.get(httpConfig.KEY)for the pre-init Express layer). Today:httpConfig(TRUST_PROXY, HSTS_INCLUDE_SUBDOMAINS, HTTP_KEEP_ALIVE_TIMEOUT_MS),storageConfig(TREK_PLACE_PHOTO_DIR),transitConfig(TRANSIT_API_URL, for TransitService) andkitineraryConfig(KITINERARY_EXTRACTOR_PATH and PATH, for KitineraryExtractorService's binary probe). A module whose provider injects one importsAppConfigModule, so it also builds on its own in a test. readEnv()/RuntimeEnvService(derive.ts): everything else, both the runtime-toggled values and the boot-stable ones that code outside a Nest provider reads, which freezes them in module-top consts. Each of those has a reader the container cannot inject into:- PORT, HOST:
index.ts, before the app exists. - SESSION_DURATION(_REMEMBER), DEFAULT_LANGUAGE, ENCRYPTION_KEY:
src/config.ts, a plain module evaluated at process start. - MCP_*:
src/mcp/, the process-wide session state. - TREK_PLUGIN_RPC_/LOG_/MAX_RSS_MB: the plugin host and supervisor, which are deliberately not Nest (the sandbox boundary).
- TREK_PLUGIN_REGISTRY_URL:
PluginRegistryServiceis a provider, but several plugin suites build it by hand with a trailing@Optional()UnitOfWork, so a required token parameter cannot go in without reordering that signature. - TREK_WIKI_DIR:
nest/help/wiki.ts, a plain module the help MCP tools call. - BACKUP_*:
nest/backup/backup-archive.ts, which the first-start restore (boot-restore.ts, called fromindex.ts) runs before the database opens. - LOG_LEVEL: the logger, imported everywhere.
- ALLOW_INTERNAL_NETWORK, ALLOW_LINK_LOCAL_IPS: the SSRF guard in
utils/. - TREK_DB_FILE, TREK_DB_JOURNAL_MODE, TREK_DB_SYNCHRONOUS: the database, opened before the container.
- PORT, HOST:
deriveAll() never reads a variable a token owns, and a token is dropped once
nothing injects it. tests/unit/app-config/config-ownership.test.ts records
the keys each side reads through a Proxy and fails on an overlap, on a token
without a consumer and on a variable the schema does not validate. Moving a
variable to a token means moving its field from derive.ts to
boot-derive.ts and converting every reader in the same change.
server/.env.example names every variable the schema validates except the
few in env-reference.test.ts's NOT_OPERATOR_SETTINGS; that test fails on
drift in either direction. The managed-hosting switches (TREK_MANAGED and the
keys only a managed install sets: MAPBOX_ACCESS_TOKEN, CARTO_API_KEY,
PLACES_API_BASE) are on that list on purpose and stay out of every public
reference.
Classification: boot-stable vs runtime-toggled
Boot-stable (frozen at app/module creation; a registerAs token where the
variable is token-owned, module-top readEnv() consts elsewhere):
PORT, HOST, TRUST_PROXY, HSTS_INCLUDE_SUBDOMAINS, HTTP_KEEP_ALIVE_TIMEOUT_MS, 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, BACKUP_,
TRANSIT_API_URL, KITINERARY_EXTRACTOR_PATH, LOG_LEVEL, ALLOW_INTERNAL_NETWORK*, ALLOW_LINK_LOCAL_IPS*, DEFAULT_LANGUAGE,
TREK_DB_FILE, TREK_DB_JOURNAL_MODE, TREK_DB_SYNCHRONOUS, ENCRYPTION_KEY**.
(* frozen today because the consuming module captures it at import; tests that
override these set them at file top, before the SUT import.)
(** ENCRYPTION_KEY is boot-stable for the cipher key material itself —
src/config.ts resolves and freezes it at process start, per the exemption
below.)
Runtime-toggled (read live on every access via readEnv() /
RuntimeEnvService; tests mutate these mid-lifetime):
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, AMAP_API_BASE, AMAP_API_KEY, AMAP_API_SECRET, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
TREK_PLUGINS_ENABLED / _DEV_LINK / IGNORE_TREK_RANGE / DIR / DATA_DIR / TREK_PLUGIN_PERMISSIONS,
OIDC*, SMTP*, FORCE_HTTPS, COOKIE_SECURE,
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN, TZ, ADMIN_EMAIL,
TREK_DB_PRE_MIGRATE_SNAPSHOT(KEEP) (read when a migration run starts; the
legacy-upgrade suites set it per file),
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check),
LLM_TIMEOUT_MS, NOMINATIM_URL, VAPID (resolved per use by the Web Push key
service, which the notification suites build without the container).
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.- 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.