/** * Pure per-namespace derive functions: (raw env) → typed config namespace. * * Every field pins the EXACT coercion of the call site(s) it replaces — parity * is law — with ONE deliberate exception: boolean switches are unified through * parseBool (true/1/on/yes vs false/0/off/no, any casing), because the legacy * per-site literals ('true' here, '1' there, 'on' elsewhere) were an accident, * not a contract. Where sites apply different DEFAULTS for the same variable * (DEMO_ADMIN_EMAIL), only the raw value is exposed and the default stays at * the call site. * * These functions are plain string coercions with NO caching and NO side * effects: `readEnv()` (env.ts) re-derives from the live process.env on every * call, which is what keeps the runtime env-mutation semantics the test suite * depends on. Zod validation runs once at boot (env.schema.ts), never here. */ import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared'; import { csvList, csvListFiltered, numberOr, parseBool, parseDurationMs, parseLinkLocalAllowList, positiveIntOr, positiveNumberOr, resolveDurability, resolveKeepaliveMs, resolveSessionTtlMs, stripTrailingSlashes, } from './parsers'; import { imageVersion } from './image-version'; export type RawEnv = Record; export function deriveApp(raw: RawEnv) { const nodeEnv = raw.NODE_ENV; return { /** Raw NODE_ENV — for the case-SENSITIVE sites (`=== 'production'` in globalMiddleware HSTS, platform statics, spa-fallback; `=== 'development'` in authService.dev_mode). */ nodeEnv, isProduction: nodeEnv?.toLowerCase() === 'production', isDevelopment: nodeEnv?.toLowerCase() === 'development', /** Case-sensitive on purpose — db/database.ts picks :memory: only on exactly 'test'. */ isTest: nodeEnv === 'test', port: numberOr(raw.PORT, 3001), host: raw.HOST, /** * The image's own VERSION file, else raw APP_VERSION. Fallbacks differ per * site ('0.0.0' vs package.json vs semver-validated); each keeps its own. * The file wins because a container keeps its env across image updates * (image-version.ts). */ appVersion: imageVersion() ?? raw.APP_VERSION, /** Raw APP_URL — trailing-slash stripping differs per site (feeds strips one, notifications strips all). */ appUrl: raw.APP_URL, tz: raw.TZ, logLevel: raw.LOG_LEVEL, /** Resolved: lowercased, validated against the supported set, falls back to 'en' (src/config.ts semantics). */ defaultLanguage: resolveDefaultLanguage(raw.DEFAULT_LANGUAGE), }; } function resolveDefaultLanguage(raw: string | undefined): string { const lang = raw?.toLowerCase() || 'en'; // Case-insensitive match resolved to the CANONICAL code (zh-TW, not zh-tw). // The legacy lowercase-only `includes` could never match zh-TW and silently // fell back to English for a supported language — deliberate fix. const canonical = SUPPORTED_LANGUAGE_CODES.find((c) => c.toLowerCase() === lang); return canonical ?? 'en'; } /** * The live half of the HTTP settings. TRUST_PROXY and HSTS_INCLUDE_SUBDOMAINS * are not here: they are boot-stable and owned by the `httpConfig` token * (boot-derive.ts), so they cannot be read through two doors. */ export function deriveHttp(raw: RawEnv) { return { allowedOriginsRaw: raw.ALLOWED_ORIGINS, /** globalMiddleware CORS variant: trim + drop empty entries. */ corsOrigins: csvListFiltered(raw.ALLOWED_ORIGINS), /** websocket variant: trim only — an empty entry stays (and can never match an Origin header). */ wsOrigins: csvList(raw.ALLOWED_ORIGINS), forceHttps: parseBool(raw.FORCE_HTTPS) === true, /** COOKIE_SECURE is tri-state: an explicit falsy value disables secure cookies; anything else means auto-detect. */ cookieSecureDisabled: parseBool(raw.COOKIE_SECURE) === false, apiDocsEnabled: parseBool(raw.TREK_API_DOCS_ENABLED) === true, }; } const DEFAULT_SESSION_DURATION = '24h'; const DEFAULT_SESSION_DURATION_REMEMBER = '30d'; const DEFAULT_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days (scheduler.ts) export function deriveSession(raw: RawEnv) { const durationRaw = raw.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION; const parsedMs = parseDurationMs(durationRaw); const rememberRaw = raw.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER; const parsedRememberMs = parseDurationMs(rememberRaw); const durationMs = parsedMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!; const rememberMs = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!; return { /** Human-readable session length actually in effect (invalid input falls back, matching src/config.ts). */ duration: parsedMs == null ? DEFAULT_SESSION_DURATION : durationRaw, durationMs, durationSeconds: Math.floor(durationMs / 1000), durationRemember: parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rememberRaw, durationRememberMs: rememberMs, durationRememberSeconds: Math.floor(rememberMs / 1000), idempotencyTtlSeconds: positiveNumberOr(raw.IDEMPOTENCY_TTL_SECONDS, DEFAULT_IDEMPOTENCY_TTL_SECONDS), }; } /** * Whether this instance is centrally administered: somebody other than its * admin user owns the configuration, the credentials and the upgrade schedule. * * Only a boolean, on purpose. It answers "who configures this install", and the * surfaces that care read it themselves rather than being listed here, so adding * one never touches this file. * * `=== true` so an unset, empty or unparseable value means off. A managed * install sets it deliberately; nothing should be able to switch it on by * accident, and a typo must not silently take settings away from an admin. */ export function deriveManaged(raw: RawEnv) { return { enabled: parseBool(raw.TREK_MANAGED) === true, }; } /** * Where the Places calls go and which credential they carry. * * Both unset on a self-hosted install, which is the path the code has always * taken: the endpoints stay Google's and the key comes from the users table. * Set them and the calls leave through something the operator runs, with a * credential no instance admin can read back. */ export function deriveMaps(raw: RawEnv) { return { placesApiBase: raw.PLACES_API_BASE || undefined, /** * Base URL of the TREK Places API. Unset means the public instance; an * operator who runs their own copy of the index points at it here, and one * who wants nothing to leave their network points at their own machine. */ trekPlacesUrl: raw.TREK_PLACES_URL || undefined, /** * Whether the index answers at all. On unless an operator says otherwise, * because it is the path we want people on and an upgrade must not quietly * drop back to Nominatim, whose usage policy forbids what TREK was doing * with it. * * Deliberately an environment variable and not an admin switch: it decides * whether searches leave the instance, and a setting that reaches for the * network is one an operator wants pinned in their compose file, not one a * second admin can flip in a browser. * * Off only on a value of the false family the schema admits (false, 0, * off, no), so the switch reads like every other boolean here. Unset, * blank and anything else stay on: the schema refuses a value outside the * family at boot, and a path that skips validation must not quietly drop * the install back to Nominatim. */ trekPlacesEnabled: parseBool(raw.TREK_PLACES_ENABLED) !== false, placesApiKey: raw.PLACES_API_KEY || undefined, /** The same two knobs for Amap (高德), which an install in China uses instead. */ amapApiBase: raw.AMAP_API_BASE || undefined, amapApiKey: raw.AMAP_API_KEY || undefined, /** * Amap's optional 数字签名 secret. A key created with one rejects every * unsigned request, so this is not a hardening option — it is required * whenever the operator's key was issued that way. */ amapApiSecret: raw.AMAP_API_SECRET || undefined, /** Public pk.* token shipped with a managed instance; reaches the browser by design. */ mapboxToken: raw.MAPBOX_ACCESS_TOKEN || undefined, /** CARTO basemap key; without one the tiles come back watermarked (#2054). Public too. */ cartoKey: raw.CARTO_API_KEY || undefined, }; } export function deriveDemo(raw: RawEnv) { return { enabled: parseBool(raw.DEMO_MODE) === true, adminUser: raw.DEMO_ADMIN_USER || 'admin', /** Raw on purpose: demo-seed defaults to admin@trek.app, demo-reset to admin@nomad.app. */ adminEmailRaw: raw.DEMO_ADMIN_EMAIL, adminPass: raw.DEMO_ADMIN_PASS || 'admin12345', /** False means the admin account is being created with the published default. */ adminPassSet: !!raw.DEMO_ADMIN_PASS, }; } export function deriveAdminBootstrap(raw: RawEnv) { return { email: raw.ADMIN_EMAIL, password: raw.ADMIN_PASSWORD, }; } export function deriveOidc(raw: RawEnv) { return { issuer: raw.OIDC_ISSUER, clientId: raw.OIDC_CLIENT_ID, clientSecret: raw.OIDC_CLIENT_SECRET, displayName: raw.OIDC_DISPLAY_NAME, discoveryUrl: raw.OIDC_DISCOVERY_URL, scope: raw.OIDC_SCOPE || 'openid email profile', only: parseBool(raw.OIDC_ONLY) === true, adminClaim: raw.OIDC_ADMIN_CLAIM || 'groups', adminValue: raw.OIDC_ADMIN_VALUE, // Unset keeps the old order (name, then preferred_username), so an instance // that never sets it names new accounts exactly as before (#1677). usernameClaim: raw.OIDC_USERNAME_CLAIM?.trim() || undefined, }; } export function deriveSmtp(raw: RawEnv) { return { host: raw.SMTP_HOST, port: raw.SMTP_PORT, user: raw.SMTP_USER, pass: raw.SMTP_PASS, from: raw.SMTP_FROM, skipTlsVerify: parseBool(raw.SMTP_SKIP_TLS_VERIFY) === true, }; } export function deriveMcp(raw: RawEnv) { return { sessionTtlMs: resolveSessionTtlMs(raw.MCP_SESSION_TTL), maxSessionsPerUser: positiveIntOr(raw.MCP_MAX_SESSION_PER_USER, 20), sseKeepaliveMs: resolveKeepaliveMs(raw.MCP_SSE_KEEPALIVE), rateLimitMax: positiveIntOr(raw.MCP_RATE_LIMIT, 300), }; } export function derivePlugins(raw: RawEnv) { return { /** Kill-switch is default-on: only an explicit falsy value disables (plugins/kill-switch.ts). */ enabled: parseBool(raw.TREK_PLUGINS_ENABLED) !== false, devLink: parseBool(raw.TREK_PLUGINS_DEV_LINK) === true, /** * Range bypass is default-OFF: only an explicit truthy value turns the TREK-version * gates into warnings (plugins/install/host-compat.ts). Never inferred from any * other switch — an admin has to ask for "install it anyway" by name. */ ignoreTrekRange: parseBool(raw.TREK_PLUGINS_IGNORE_TREK_RANGE) === true, dir: raw.TREK_PLUGINS_DIR, dataDir: raw.TREK_PLUGINS_DATA_DIR, /** Permission jail is default-on: only an explicit falsy value turns it off. */ permissionsOff: parseBool(raw.TREK_PLUGIN_PERMISSIONS) === false, registryUrl: raw.TREK_PLUGIN_REGISTRY_URL || 'https://raw.githubusercontent.com/liketrek/TREK-Plugins/main/dist/index.json', /** * The child process and plugin-sdk keep their literal `=== 'on'` check (they * are exempt from this layer); the supervisor normalizes this boolean to the * literal 'on' when building the child's scrubbed env. */ allowPrivateEgress: parseBool(raw.TREK_PLUGIN_ALLOW_PRIVATE_EGRESS) === true, rpcBurst: numberOr(raw.TREK_PLUGIN_RPC_BURST, 60), rpcPerSec: numberOr(raw.TREK_PLUGIN_RPC_PER_SEC, 20), rpcInflight: numberOr(raw.TREK_PLUGIN_RPC_INFLIGHT, 16), logBurst: numberOr(raw.TREK_PLUGIN_LOG_BURST, 50), logPerSec: numberOr(raw.TREK_PLUGIN_LOG_PER_SEC, 10), maxRssMb: numberOr(raw.TREK_PLUGIN_MAX_RSS_MB, 300), }; } export function deriveWebauthn(raw: RawEnv) { return { rpId: raw.WEBAUTHN_RP_ID, origins: raw.WEBAUTHN_ORIGINS, }; } export function deriveIntegrations(raw: RawEnv) { return { unsplashAccessKey: raw.UNSPLASH_ACCESS_KEY?.trim(), transitApiBase: stripTrailingSlashes(raw.TRANSIT_API_URL || 'https://api.transitous.org'), // Trimmed before the default fires: the schema validates the trimmed value and // treats a blank one as unset, so a padded or whitespace-only value would // otherwise pass startup and then be the string that cannot be fetched. nominatimUrl: stripTrailingSlashes(raw.NOMINATIM_URL?.trim() || 'https://nominatim.openstreetmap.org'), overpassUrl: raw.OVERPASS_URL, // Longer than the `[timeout:20]` the query itself carries, or we abort an answer the // mirror was still allowed to be working on. See OVERPASS_QUERY_TIMEOUT_S. overpassTimeoutMs: positiveNumberOr(raw.OVERPASS_TIMEOUT_MS, 25000), kitineraryExtractorPath: raw.KITINERARY_EXTRACTOR_PATH, /** * One ceiling for a model call, replacing the three per-client constants * that used to disagree. The default is deliberately generous: heavier * parsing work should fit without a code change. * Floored to a whole number — it reaches undici's headersTimeout, which * rejects a fractional value. */ llmTimeoutMs: Math.floor(positiveNumberOr(raw.LLM_TIMEOUT_MS, 900_000)), // Windows spells it Path; every other platform PATH. Split here so callers // get a list and never re-implement the delimiter. searchPath: (raw.PATH || raw.Path || '') .split(process.platform === 'win32' ? ';' : ':') .map(p => p.trim()) .filter(Boolean), }; } export function deriveFiles(raw: RawEnv) { return { /** Largest document a user may upload to a trip, a booking or a note (#1364). Videos keep their own cap. */ uploadLimitMb: positiveNumberOr(raw.FILE_UPLOAD_LIMIT_MB, 50), }; } export function deriveBackup(raw: RawEnv) { return { uploadLimitMb: positiveNumberOr(raw.BACKUP_UPLOAD_LIMIT_MB, 500), maxDecompressedMb: positiveNumberOr(raw.BACKUP_MAX_DECOMPRESSED_MB, 5 * 1024), /** backupService only bundles data/.encryption_key into archives when the key does NOT come from env. */ encryptionKeyFromEnv: !!raw.ENCRYPTION_KEY, /** Archive restored on the first start, while no database exists yet (#1089). Null when unset or blank. */ restoreFromBackup: raw.RESTORE_FROM_BACKUP?.trim() || null, }; } export function deriveDb(raw: RawEnv) { const durability = resolveDurability(raw.TREK_DB_JOURNAL_MODE, raw.TREK_DB_SYNCHRONOUS); return { trekDbFile: raw.TREK_DB_FILE, /** Resolved journal_mode — WAL unless the operator asked for something else (network storage needs DELETE/TRUNCATE). */ journalMode: durability.journalMode, /** Resolved synchronous level — NORMAL under WAL (what SQLite already does), FULL under a rollback journal. */ synchronous: durability.synchronous, /** Complaints about unusable values; derivation stays side-effect free, so db/durability.ts logs them when it opens the file. */ durabilityWarnings: durability.warnings, /** * Snapshot the database before a boot migrates it. Undefined when unset: the * caller then decides (on, except under NODE_ENV=test). false is the escape * hatch for a data dir that cannot hold a second copy. */ preMigrateSnapshot: parseBool(raw.TREK_DB_PRE_MIGRATE_SNAPSHOT), /** How many pre-migration snapshots stay in the data dir, newest first. */ preMigrateSnapshotKeep: Math.min(positiveIntOr(raw.TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP, 3), 100), }; } /** TREK_PLACE_PHOTO_DIR is boot-stable and owned by the `storageConfig` token (boot-derive.ts). */ export function derivePaths(raw: RawEnv) { return { wikiDir: raw.TREK_WIKI_DIR, }; } export function deriveNet(raw: RawEnv) { return { allowInternalNetwork: parseBool(raw.ALLOW_INTERNAL_NETWORK) === true, allowLinkLocalIps: parseLinkLocalAllowList(raw.ALLOW_LINK_LOCAL_IPS).ips, // The variables Node's own env proxy reads, both spellings, so the guarded // requests below follow the same proxy as everything else (#1754). proxy: { http: (raw.HTTP_PROXY ?? raw.http_proxy)?.trim() || undefined, https: (raw.HTTPS_PROXY ?? raw.https_proxy)?.trim() || undefined, noProxy: ((raw.NO_PROXY ?? raw.no_proxy) || '') .split(',') .map(entry => entry.trim().toLowerCase()) .filter(Boolean), }, }; } export function derivePush(raw: RawEnv) { return { // Trimmed, and blank counts as unset: the schema validated the trimmed value, // so a padded key must not reach the crypto as a different string. vapidPublicKey: raw.VAPID_PUBLIC_KEY?.trim() || undefined, vapidPrivateKey: raw.VAPID_PRIVATE_KEY?.trim() || undefined, vapidSubject: raw.VAPID_SUBJECT?.trim() || undefined, }; } export function deriveAll(raw: RawEnv) { return { app: deriveApp(raw), http: deriveHttp(raw), session: deriveSession(raw), managed: deriveManaged(raw), maps: deriveMaps(raw), demo: deriveDemo(raw), adminBootstrap: deriveAdminBootstrap(raw), oidc: deriveOidc(raw), smtp: deriveSmtp(raw), mcp: deriveMcp(raw), plugins: derivePlugins(raw), webauthn: deriveWebauthn(raw), integrations: deriveIntegrations(raw), backup: deriveBackup(raw), files: deriveFiles(raw), db: deriveDb(raw), paths: derivePaths(raw), net: deriveNet(raw), push: derivePush(raw), }; } export type AppEnv = ReturnType;