diff --git a/README.md b/README.md index e93912052..107b17348 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,13 @@ Caddy handles TLS and WebSockets automatically. ## Environment variables +> [!NOTE] +> Variables are validated at startup (fail-fast). An unset or blank variable +> always falls back to its default, but a variable set to a malformed value +> (e.g. `PORT=abc`, `SESSION_DURATION=bogus`, `DEMO_MODE=maybe`) aborts boot +> with a report listing every offending variable. Boolean switches accept +> `true`/`false`, `1`/`0`, `on`/`off` and `yes`/`no` (any casing). +
Full reference diff --git a/build-from-sources b/build-from-sources deleted file mode 100755 index 44baf80c5..000000000 --- a/build-from-sources +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" -CLIENT_DIR="$REPO_ROOT/client" -SERVER_DIR="$REPO_ROOT/server" -PUBLIC_DIR="$REPO_ROOT/server/public" - -echo "==> Installing client dependencies" -cd "$CLIENT_DIR" -npm ci - -echo "==> Building client" -npm run build - -echo "==> Installing server dependencies" -cd "$SERVER_DIR" -npm ci - -echo "==> Populating server/public" -find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete -cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/" -cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts" - -echo "==> Done — server/public is ready" diff --git a/server/.env.example b/server/.env.example index 7be9f4cb3..eba493ac4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,3 +1,8 @@ +# NOTE: variables are validated at startup (fail-fast). An UNSET or blank +# variable always falls back to its default, but a variable that is set to a +# malformed value (e.g. PORT=abc, SESSION_DURATION=bogus, DEMO_MODE=maybe) +# aborts boot with a report of every offending variable. +# Boolean switches accept true/false, 1/0, on/off and yes/no (any casing). PORT=3001 # Port to run the server on # HOST=0.0.0.0 # Bind address for the HTTP server. Only set this when running TREK from sources or via the Proxmox community script — never in Docker (the container handles binding). NODE_ENV=development # development = development mode; production = production mode diff --git a/server/src/app-config/README.md b/server/src/app-config/README.md new file mode 100644 index 000000000..eaa4006e9 --- /dev/null +++ b/server/src/app-config/README.md @@ -0,0 +1,76 @@ +# 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() +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 + +1. **`readEnv()` is live.** It re-derives from the current `process.env` on + every call, with no caching or memoization, ever. ~60 test files mutate + `process.env` at runtime and depend on the next read observing the change. +2. **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. +3. **Parity is law — with one deliberate exception.** Every derived field pins + the exact coercion of the call site(s) it replaced (`Number(x) || d` + treating `"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** through `parseBool` — every boolean-like variable + accepts `true/1/on/yes` and `false/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. +4. **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. +5. **Never convert a frozen read to a live read or vice versa.** Modules that + captured env in module-top `const`s (mcp, backupService, ssrfGuard, plugin + rate limits, …) keep freeze-at-import timing, merely sourcing the value from + `readEnv()` at module top. Request-time reads stay request-time. + +## 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, BACKUP_*, +TRANSIT_API_URL, LOG_LEVEL*, ALLOW_INTERNAL_NETWORK*, DEFAULT_LANGUAGE, +TREK_DB_FILE, 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.) + +**Runtime-toggled** (read live on every access via `readEnv()` / +`RuntimeEnvService`; tests mutate these mid-lifetime): +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.ts` child-env whitelist block — + the env there is an IPC channel to the sandbox, not app configuration. +- Dynamic-key reads (`process.env[name]`) in `plugins/host/daily-budget.ts` and + `plugins/host/plugin-audit.ts`. +- `src/config.ts` ENCRYPTION_KEY/JWT_SECRET resolution — key material with file + persistence and runtime rotation, not env config. +- Standalone scripts (`reset-admin.js`, `scripts/*`) and `tests/**`. diff --git a/server/src/app-config/boot-validate.ts b/server/src/app-config/boot-validate.ts new file mode 100644 index 000000000..0d51cc0c5 --- /dev/null +++ b/server/src/app-config/boot-validate.ts @@ -0,0 +1,12 @@ +/** + * Side-effect module: validates the environment the moment it is imported. + * + * index.ts imports this immediately after `import 'dotenv/config'` — as a + * side-effect import (not a function call) so validation runs BEFORE the rest + * of the import graph executes its module-load side effects (config.ts key + * resolution, db/database.ts initDb, ...). A plain call in index.ts's body + * would run after all of those, because imports hoist. + */ +import { validateEnvAtBoot } from './env'; + +validateEnvAtBoot(); diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts new file mode 100644 index 000000000..ae7733cc6 --- /dev/null +++ b/server/src/app-config/derive.ts @@ -0,0 +1,235 @@ +/** + * 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 { + csvList, + csvListFiltered, + numberOr, + parseBool, + parseDurationMs, + positiveIntOr, + positiveNumberOr, + resolveKeepaliveMs, + resolveSessionTtlMs, + stripTrailingSlashes, +} from './parsers'; + +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, + /** Raw APP_VERSION — fallbacks differ per site ('0.0.0' vs package.json vs semver-validated); each keeps its own. */ + appVersion: 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, + defaultLanguage: raw.DEFAULT_LANGUAGE, + }; +} + +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), + trustProxyRaw: raw.TRUST_PROXY, + trustProxy: Number.parseInt(raw.TRUST_PROXY ?? '') || 1, + forceHttps: parseBool(raw.FORCE_HTTPS) === true, + hstsIncludeSubdomains: parseBool(raw.HSTS_INCLUDE_SUBDOMAINS) === 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), + }; +} + +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', + }; +} + +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, + }; +} + +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, + 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'), + overpassUrl: raw.OVERPASS_URL, + overpassTimeoutMs: positiveNumberOr(raw.OVERPASS_TIMEOUT_MS, 12000), + kitineraryExtractorPath: raw.KITINERARY_EXTRACTOR_PATH, + }; +} + +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, + }; +} + +export function deriveDb(raw: RawEnv) { + return { + trekDbFile: raw.TREK_DB_FILE, + }; +} + +export function derivePaths(raw: RawEnv) { + return { + wikiDir: raw.TREK_WIKI_DIR, + placePhotoDir: raw.TREK_PLACE_PHOTO_DIR, + }; +} + +export function deriveNet(raw: RawEnv) { + return { + allowInternalNetwork: parseBool(raw.ALLOW_INTERNAL_NETWORK) === true, + }; +} + +export function deriveAll(raw: RawEnv) { + return { + app: deriveApp(raw), + http: deriveHttp(raw), + session: deriveSession(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), + db: deriveDb(raw), + paths: derivePaths(raw), + net: deriveNet(raw), + }; +} + +export type AppEnv = ReturnType; diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts new file mode 100644 index 000000000..fd458dd86 --- /dev/null +++ b/server/src/app-config/env.schema.ts @@ -0,0 +1,146 @@ +/** + * The single Zod catalog of TREK's environment surface, validated ONCE at boot + * (see env.ts / boot-validate.ts) — never per-request and never inside + * buildApp(), so the test harness (which boots dozens of apps per run with a + * minimal env) is unaffected. + * + * Fail-fast contract: a variable that is UNSET (or blank) always falls back to + * its default — only a PRESENT-but-malformed value aborts startup. Keys not + * listed here pass through untouched (Zod strips unknown keys, it does not + * reject them). + */ +import { z } from 'zod'; +import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared'; +import { parseDurationMs } from './parsers'; + +/** Present-but-malformed fails; unset/blank always passes (defaults apply). */ +function optionalWith(test: (v: string) => boolean, message: string) { + return z + .string() + .optional() + .refine((v) => v === undefined || v.trim() === '' || test(v.trim()), message); +} + +const anyString = z.string().optional(); +const boolStr = optionalWith( + (v) => /^(true|false|on|off|yes|no|1|0)$/i.test(v), + 'must be a boolean-like value (true/false, 1/0, on/off, yes/no)', +); +const integer = (min: number, max = Number.MAX_SAFE_INTEGER, msg = `must be an integer between ${min} and ${max}`) => + optionalWith((v) => { + const n = Number(v); + return Number.isInteger(n) && n >= min && n <= max; + }, msg); +const positiveNumber = optionalWith((v) => { + const n = Number(v); + return Number.isFinite(n) && n > 0; +}, 'must be a positive number'); +const url = optionalWith((v) => { + try { + new URL(v); + return true; + } catch { + return false; + } +}, 'must be a valid URL (with protocol)'); +const duration = optionalWith( + (v) => parseDurationMs(v) != null, + 'must be a duration like "1h", "7d" or "30d"', +); +const oneOf = (values: string[]) => + optionalWith((v) => values.includes(v.toLowerCase()), `must be one of: ${values.join(', ')}`); + +export const envSchema = z.object({ + // Core runtime + PORT: integer(1, 65535, 'must be a port number (1-65535)'), + HOST: anyString, + // NODE_ENV and TZ are deliberately unvalidated: non-standard values (e.g. + // NODE_ENV=staging) behave as "not production" today and must keep booting. + NODE_ENV: anyString, + TZ: anyString, + LOG_LEVEL: oneOf(['error', 'warn', 'info', 'debug']), + APP_VERSION: anyString, + APP_URL: url, + ALLOWED_ORIGINS: anyString, + DEFAULT_LANGUAGE: oneOf([...SUPPORTED_LANGUAGE_CODES]), + + // Security / session + ENCRYPTION_KEY: anyString, + SESSION_DURATION: duration, + SESSION_DURATION_REMEMBER: duration, + COOKIE_SECURE: boolStr, + FORCE_HTTPS: boolStr, + HSTS_INCLUDE_SUBDOMAINS: boolStr, + TRUST_PROXY: integer(0, 2 ** 31, 'must be an integer (number of trusted proxy hops)'), + ALLOW_INTERNAL_NETWORK: boolStr, + IDEMPOTENCY_TTL_SECONDS: positiveNumber, + + // OIDC + OIDC_ISSUER: url, + OIDC_CLIENT_ID: anyString, + OIDC_CLIENT_SECRET: anyString, + OIDC_DISPLAY_NAME: anyString, + OIDC_DISCOVERY_URL: url, + OIDC_SCOPE: anyString, + OIDC_ONLY: boolStr, + OIDC_ADMIN_CLAIM: anyString, + OIDC_ADMIN_VALUE: anyString, + + // SMTP + SMTP_HOST: anyString, + SMTP_PORT: integer(1, 65535, 'must be a port number (1-65535)'), + SMTP_USER: anyString, + SMTP_PASS: anyString, + SMTP_FROM: anyString, + SMTP_SKIP_TLS_VERIFY: boolStr, + + // WebAuthn + WEBAUTHN_RP_ID: anyString, + WEBAUTHN_ORIGINS: anyString, + + // MCP + MCP_SESSION_TTL: positiveNumber, + MCP_MAX_SESSION_PER_USER: positiveNumber, + MCP_SSE_KEEPALIVE: integer(0, Number.MAX_SAFE_INTEGER, 'must be a non-negative integer (seconds, 0 disables)'), + MCP_RATE_LIMIT: positiveNumber, + + // Integrations + UNSPLASH_ACCESS_KEY: anyString, + TRANSIT_API_URL: url, + // OVERPASS_URL accepts a comma-separated endpoint list and silently drops + // non-http(s) entries today — left unvalidated to keep that behavior. + OVERPASS_URL: anyString, + OVERPASS_TIMEOUT_MS: positiveNumber, + KITINERARY_EXTRACTOR_PATH: anyString, + + // Data / paths + TREK_DB_FILE: anyString, + TREK_WIKI_DIR: anyString, + TREK_PLACE_PHOTO_DIR: anyString, + BACKUP_UPLOAD_LIMIT_MB: positiveNumber, + BACKUP_MAX_DECOMPRESSED_MB: positiveNumber, + + // Admin / demo + ADMIN_EMAIL: anyString, + ADMIN_PASSWORD: anyString, + DEMO_MODE: boolStr, + DEMO_ADMIN_USER: anyString, + DEMO_ADMIN_EMAIL: anyString, + DEMO_ADMIN_PASS: anyString, + + // API docs / plugins + TREK_API_DOCS_ENABLED: boolStr, + TREK_PLUGINS_ENABLED: boolStr, + TREK_PLUGINS_DEV_LINK: boolStr, + TREK_PLUGINS_DIR: anyString, + TREK_PLUGINS_DATA_DIR: anyString, + TREK_PLUGIN_PERMISSIONS: boolStr, + TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: boolStr, + TREK_PLUGIN_REGISTRY_URL: url, + TREK_PLUGIN_RPC_BURST: positiveNumber, + TREK_PLUGIN_RPC_PER_SEC: positiveNumber, + TREK_PLUGIN_RPC_INFLIGHT: positiveNumber, + TREK_PLUGIN_LOG_BURST: positiveNumber, + TREK_PLUGIN_LOG_PER_SEC: positiveNumber, + TREK_PLUGIN_MAX_RSS_MB: positiveNumber, +}); diff --git a/server/src/app-config/env.ts b/server/src/app-config/env.ts new file mode 100644 index 000000000..b3a62540f --- /dev/null +++ b/server/src/app-config/env.ts @@ -0,0 +1,37 @@ +import { deriveAll, type AppEnv, type RawEnv } from './derive'; +import { envSchema } from './env.schema'; + +export type { AppEnv, RawEnv }; + +/** + * Live typed view of the environment. Re-derives from the CURRENT process.env + * on every call — never cache the result across requests: the test suite (and + * the admin demo tooling) mutates process.env at runtime and depends on the + * next read observing the change. Derivation is a handful of string coercions, + * not a Zod parse; validation happens once at boot via validateEnvAtBoot(). + */ +export function readEnv(): AppEnv { + return deriveAll(process.env as RawEnv); +} + +/** + * Fail-fast startup validation: a variable that is PRESENT but malformed aborts + * boot with an aggregated report; unset/blank variables always pass (their + * documented defaults apply). Called once from the production entrypoint (see + * boot-validate.ts, imported by index.ts right after dotenv) — deliberately NOT + * wired into buildApp() or ConfigModule, so tests booting apps with a minimal + * env are unaffected. + */ +export function validateEnvAtBoot(raw: RawEnv = process.env as RawEnv): void { + const result = envSchema.safeParse(raw); + if (result.success) return; + const lines = result.error.issues.map((issue) => { + const key = String(issue.path[0]); + return ` - ${key}=${JSON.stringify(raw[key])}: ${issue.message}`; + }); + console.error(`Invalid environment configuration:\n${lines.join('\n')}`); + throw new Error( + `Invalid environment configuration (${lines.length} problem${lines.length === 1 ? '' : 's'}). ` + + 'Fix the variables listed above, or unset them to use their defaults.', + ); +} diff --git a/server/src/app-config/index.ts b/server/src/app-config/index.ts new file mode 100644 index 000000000..d7ecf0dfb --- /dev/null +++ b/server/src/app-config/index.ts @@ -0,0 +1,22 @@ +export { readEnv, validateEnvAtBoot } from './env'; +export type { AppEnv, RawEnv } from './env'; +export { + deriveAll, + deriveApp, + deriveHttp, + deriveSession, + deriveDemo, + deriveAdminBootstrap, + deriveOidc, + deriveSmtp, + deriveMcp, + derivePlugins, + deriveWebauthn, + deriveIntegrations, + deriveBackup, + deriveDb, + derivePaths, + deriveNet, +} from './derive'; +export * from './parsers'; +export { envSchema } from './env.schema'; diff --git a/server/src/app-config/parsers.ts b/server/src/app-config/parsers.ts new file mode 100644 index 000000000..5c7201363 --- /dev/null +++ b/server/src/app-config/parsers.ts @@ -0,0 +1,108 @@ +/** + * Pure env-string coercion helpers shared by the whole config layer (derive.ts, + * env.schema.ts) and — until their call sites migrate — by legacy readers like + * src/mcp/config.ts. Kept free of imports so units can test them in isolation. + * + * Each helper reproduces a coercion family that already exists in the codebase. + * Parity is law: do NOT "fix" a family's quirks here (e.g. `numberOr` treating + * "0" as unset) — call sites were written against the current semantics. + */ + +const TRUE_VALUES = new Set(['true', '1', 'on', 'yes']); +const FALSE_VALUES = new Set(['false', '0', 'off', 'no']); + +/** + * Unified boolean coercion for env switches — the ONE deliberate departure from + * legacy parity: historically each site accepted a different literal ('true' + * vs '1' vs 'on'), so operators guessed. Now every boolean-like variable + * accepts true/1/on/yes and false/0/off/no (any casing, padded ok) and derives + * to a real boolean. Anything else — including unset and blank — returns + * undefined so the field's default applies; out-of-family values are rejected + * at boot by env.schema.ts anyway (undefined here is the safe fallback for + * paths that skip validation, e.g. tests). + */ +export function parseBool(raw: string | undefined): boolean | undefined { + if (raw === undefined) return undefined; + const v = raw.trim().toLowerCase(); + if (TRUE_VALUES.has(v)) return true; + if (FALSE_VALUES.has(v)) return false; + return undefined; +} + +/** `Number(raw) || fallback` — NaN, 0 and '' all fall back (PORT, plugin limits). */ +export function numberOr(raw: string | undefined, fallback: number): number { + return Number(raw) || fallback; +} + +/** Finite and > 0, else fallback (IDEMPOTENCY_TTL_SECONDS, OVERPASS_TIMEOUT_MS, BACKUP_*). */ +export function positiveNumberOr(raw: string | undefined, fallback: number): number { + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +/** `Number.parseInt` finite and > 0, else fallback (MCP_MAX_SESSION_PER_USER, MCP_RATE_LIMIT). */ +export function positiveIntOr(raw: string | undefined, fallback: number): number { + const n = Number.parseInt(raw ?? ''); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +/** Comma-split + trim, null when unset — the websocket ALLOWED_ORIGINS variant (no empty-entry filter). */ +export function csvList(raw: string | undefined): string[] | null { + return raw ? raw.split(',').map((o) => o.trim()) : null; +} + +/** Comma-split + trim + drop empties — the CORS ALLOWED_ORIGINS variant (globalMiddleware). */ +export function csvListFiltered(raw: string | undefined): string[] | null { + return raw + ? raw + .split(',') + .map((o) => o.trim()) + .filter(Boolean) + : null; +} + +/** Strip ALL trailing slashes (`/\/+$/`) — notifications.getAppUrl / TRANSIT_API_URL variant. */ +export function stripTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ''); +} + +const DURATION_UNITS_MS: Record = { + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + w: 604_800_000, + y: 31_557_600_000, +}; + +/** + * ms-style duration strings ('1h', '7d', '30d', …) → milliseconds, null when + * invalid. Same grammar as the SESSION_DURATION parsing in src/config.ts. + */ +export function parseDurationMs(value: string): number | null { + const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim()); + if (!m) return null; + const n = parseFloat(m[1]); + if (!Number.isFinite(n) || n <= 0) return null; + return n * DURATION_UNITS_MS[(m[2] || 'ms').toLowerCase()]; +} + +/** + * Session idle TTL in SECONDS via MCP_SESSION_TTL, default 1 hour, clamped to + * 24h so a milliseconds-value typo can't produce a 1000-hour session. + * (Same contract as src/mcp/config.ts, which commit 7 retires in favor of this.) + */ +export function resolveSessionTtlMs(raw: string | undefined): number { + const parsed = Number.parseInt(raw ?? ''); + return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 24 * 60 * 60) * 1000 : 60 * 60 * 1000; +} + +/** + * SSE keep-alive interval in SECONDS via MCP_SSE_KEEPALIVE, default 25s + * (below common proxy idle timeouts like nginx-ingress's 60s). 0 disables. + */ +export function resolveKeepaliveMs(raw: string | undefined): number { + const parsed = Number.parseInt(raw ?? ''); + return Number.isFinite(parsed) && parsed >= 0 ? parsed * 1000 : 25_000; +} diff --git a/server/src/index.ts b/server/src/index.ts index f33ac29ec..1e46343c0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,9 @@ import 'reflect-metadata'; import 'dotenv/config'; +// Fail-fast env validation — must stay directly after dotenv so a malformed +// variable aborts before any other module runs its import-time side effects +// (config.ts key resolution, db/database.ts initDb, ...). +import './app-config/boot-validate'; import path from 'node:path'; import fs from 'node:fs'; import http from 'node:http'; diff --git a/server/tests/unit/app-config/derive.test.ts b/server/tests/unit/app-config/derive.test.ts new file mode 100644 index 000000000..fde8b92ce --- /dev/null +++ b/server/tests/unit/app-config/derive.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from 'vitest'; + +import { + deriveApp, + deriveHttp, + deriveSession, + deriveDemo, + deriveOidc, + deriveSmtp, + deriveMcp, + derivePlugins, + deriveIntegrations, + deriveBackup, + deriveNet, + deriveAll, +} from '../../../src/app-config/derive'; + +// These tests PIN the exact legacy coercions each derived field replaced. +// If one fails after an edit, the edit changed runtime behavior — fix the +// edit, not the test (parity is law). + +describe('deriveApp', () => { + it('NODE_ENV: loose helpers lowercase, isTest stays case-sensitive (db/database.ts)', () => { + expect(deriveApp({ NODE_ENV: 'PRODUCTION' }).isProduction).toBe(true); + expect(deriveApp({ NODE_ENV: 'Development' }).isDevelopment).toBe(true); + expect(deriveApp({ NODE_ENV: 'test' }).isTest).toBe(true); + expect(deriveApp({ NODE_ENV: 'TEST' }).isTest).toBe(false); // strict on purpose + expect(deriveApp({}).isProduction).toBe(false); + }); + + it('PORT: Number(x) || 3001 (index.ts)', () => { + expect(deriveApp({ PORT: '8080' }).port).toBe(8080); + expect(deriveApp({ PORT: 'abc' }).port).toBe(3001); + expect(deriveApp({}).port).toBe(3001); + }); + + it('APP_VERSION / APP_URL stay raw — per-site fallbacks live at the call sites', () => { + expect(deriveApp({}).appVersion).toBeUndefined(); + expect(deriveApp({ APP_URL: 'https://x.example/' }).appUrl).toBe('https://x.example/'); + }); +}); + +describe('deriveHttp', () => { + it('ALLOWED_ORIGINS: CORS variant filters empties, websocket variant keeps them', () => { + const env = { ALLOWED_ORIGINS: ' https://a.example ,, https://b.example ' }; + expect(deriveHttp(env).corsOrigins).toEqual(['https://a.example', 'https://b.example']); + expect(deriveHttp(env).wsOrigins).toEqual(['https://a.example', '', 'https://b.example']); + expect(deriveHttp({}).corsOrigins).toBeNull(); + expect(deriveHttp({}).wsOrigins).toBeNull(); + }); + + it('TRUST_PROXY: parseInt || 1 (globalMiddleware)', () => { + expect(deriveHttp({ TRUST_PROXY: '2' }).trustProxy).toBe(2); + expect(deriveHttp({ TRUST_PROXY: 'abc' }).trustProxy).toBe(1); + expect(deriveHttp({}).trustProxy).toBe(1); + }); + + it('boolean switches accept the whole boolean-like family (unified semantics)', () => { + expect(deriveHttp({ FORCE_HTTPS: 'TRUE' }).forceHttps).toBe(true); + expect(deriveHttp({ FORCE_HTTPS: 'on' }).forceHttps).toBe(true); + expect(deriveHttp({ HSTS_INCLUDE_SUBDOMAINS: 'TRUE' }).hstsIncludeSubdomains).toBe(true); + expect(deriveHttp({ HSTS_INCLUDE_SUBDOMAINS: '1' }).hstsIncludeSubdomains).toBe(true); + expect(deriveHttp({ HSTS_INCLUDE_SUBDOMAINS: 'off' }).hstsIncludeSubdomains).toBe(false); + }); + + it('COOKIE_SECURE is tri-state: explicit falsy disables, anything else auto-detects', () => { + expect(deriveHttp({ COOKIE_SECURE: 'false' }).cookieSecureDisabled).toBe(true); + expect(deriveHttp({ COOKIE_SECURE: 'off' }).cookieSecureDisabled).toBe(true); + expect(deriveHttp({ COOKIE_SECURE: 'true' }).cookieSecureDisabled).toBe(false); + expect(deriveHttp({}).cookieSecureDisabled).toBe(false); + }); + + it('TREK_API_DOCS_ENABLED: trimmed, boolean-like (api-docs.kill-switch)', () => { + expect(deriveHttp({ TREK_API_DOCS_ENABLED: ' TRUE ' }).apiDocsEnabled).toBe(true); + expect(deriveHttp({ TREK_API_DOCS_ENABLED: '1' }).apiDocsEnabled).toBe(true); + expect(deriveHttp({ TREK_API_DOCS_ENABLED: 'false' }).apiDocsEnabled).toBe(false); + expect(deriveHttp({}).apiDocsEnabled).toBe(false); + }); +}); + +describe('deriveSession', () => { + it('parses durations and falls back on invalid input (src/config.ts semantics)', () => { + const ok = deriveSession({ SESSION_DURATION: '12h', SESSION_DURATION_REMEMBER: '90d' }); + expect(ok.duration).toBe('12h'); + expect(ok.durationMs).toBe(12 * 3_600_000); + expect(ok.durationSeconds).toBe(12 * 3600); + expect(ok.durationRememberMs).toBe(90 * 86_400_000); + + const bad = deriveSession({ SESSION_DURATION: 'bogus' }); + expect(bad.duration).toBe('24h'); + expect(bad.durationMs).toBe(24 * 3_600_000); + expect(deriveSession({}).durationRemember).toBe('30d'); + }); + + it('IDEMPOTENCY_TTL_SECONDS: positive number or 30 days (scheduler.ts)', () => { + expect(deriveSession({ IDEMPOTENCY_TTL_SECONDS: '60' }).idempotencyTtlSeconds).toBe(60); + expect(deriveSession({ IDEMPOTENCY_TTL_SECONDS: '0' }).idempotencyTtlSeconds).toBe(30 * 24 * 60 * 60); + expect(deriveSession({}).idempotencyTtlSeconds).toBe(30 * 24 * 60 * 60); + }); +}); + +describe('deriveDemo', () => { + it('DEMO_MODE coerces the whole boolean-like family to one bool', () => { + expect(deriveDemo({ DEMO_MODE: 'TRUE' }).enabled).toBe(true); + expect(deriveDemo({ DEMO_MODE: 'yes' }).enabled).toBe(true); + expect(deriveDemo({ DEMO_MODE: 'off' }).enabled).toBe(false); + expect(deriveDemo({ DEMO_MODE: 'maybe' }).enabled).toBe(false); // out-of-family → default (validation rejects it at boot) + expect(deriveDemo({}).enabled).toBe(false); + }); + + it('DEMO_ADMIN_EMAIL stays raw — demo-seed and demo-reset apply different defaults', () => { + expect(deriveDemo({}).adminEmailRaw).toBeUndefined(); + expect(deriveDemo({}).adminUser).toBe('admin'); + expect(deriveDemo({}).adminPass).toBe('admin12345'); + }); +}); + +describe('deriveOidc', () => { + it('scope and adminClaim carry their single-site defaults', () => { + expect(deriveOidc({}).scope).toBe('openid email profile'); + expect(deriveOidc({}).adminClaim).toBe('groups'); + expect(deriveOidc({ OIDC_ADMIN_CLAIM: 'roles' }).adminClaim).toBe('roles'); + }); + + it('OIDC_ONLY coerces the boolean-like family', () => { + expect(deriveOidc({ OIDC_ONLY: 'True' }).only).toBe(true); + expect(deriveOidc({ OIDC_ONLY: '1' }).only).toBe(true); + expect(deriveOidc({ OIDC_ONLY: 'no' }).only).toBe(false); + }); +}); + +describe('deriveSmtp', () => { + it('SMTP_SKIP_TLS_VERIFY coerces the boolean-like family', () => { + expect(deriveSmtp({ SMTP_SKIP_TLS_VERIFY: 'true' }).skipTlsVerify).toBe(true); + expect(deriveSmtp({ SMTP_SKIP_TLS_VERIFY: 'TRUE' }).skipTlsVerify).toBe(true); + expect(deriveSmtp({ SMTP_SKIP_TLS_VERIFY: 'off' }).skipTlsVerify).toBe(false); + expect(deriveSmtp({}).skipTlsVerify).toBe(false); + }); +}); + +describe('deriveMcp', () => { + it('pins the mcp/index.ts module-const parsing', () => { + expect(deriveMcp({}).sessionTtlMs).toBe(3_600_000); + expect(deriveMcp({ MCP_MAX_SESSION_PER_USER: '5' }).maxSessionsPerUser).toBe(5); + expect(deriveMcp({ MCP_MAX_SESSION_PER_USER: '0' }).maxSessionsPerUser).toBe(20); + expect(deriveMcp({ MCP_SSE_KEEPALIVE: '0' }).sseKeepaliveMs).toBe(0); + expect(deriveMcp({ MCP_RATE_LIMIT: '100' }).rateLimitMax).toBe(100); + expect(deriveMcp({}).rateLimitMax).toBe(300); + }); +}); + +describe('derivePlugins', () => { + it('kill-switch is default-on; only an explicit falsy value disables', () => { + expect(derivePlugins({}).enabled).toBe(true); + expect(derivePlugins({ TREK_PLUGINS_ENABLED: 'anything' }).enabled).toBe(true); // out-of-family → default-on + for (const v of ['false', '0', 'off', 'no', ' OFF ']) { + expect(derivePlugins({ TREK_PLUGINS_ENABLED: v }).enabled).toBe(false); + } + }); + + it('devLink and permissions jail coerce the boolean-like family; jail is default-on', () => { + expect(derivePlugins({ TREK_PLUGINS_DEV_LINK: '1' }).devLink).toBe(true); + expect(derivePlugins({ TREK_PLUGINS_DEV_LINK: 'true' }).devLink).toBe(true); + expect(derivePlugins({ TREK_PLUGINS_DEV_LINK: 'off' }).devLink).toBe(false); + expect(derivePlugins({ TREK_PLUGIN_PERMISSIONS: 'OFF' }).permissionsOff).toBe(true); + expect(derivePlugins({ TREK_PLUGIN_PERMISSIONS: 'false' }).permissionsOff).toBe(true); + expect(derivePlugins({ TREK_PLUGIN_PERMISSIONS: 'on' }).permissionsOff).toBe(false); + expect(derivePlugins({}).permissionsOff).toBe(false); + }); + + it('allowPrivateEgress is a bool (supervisor normalizes it to the literal "on" for the child)', () => { + expect(derivePlugins({ TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: 'on' }).allowPrivateEgress).toBe(true); + expect(derivePlugins({ TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: 'true' }).allowPrivateEgress).toBe(true); + expect(derivePlugins({ TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: 'off' }).allowPrivateEgress).toBe(false); + expect(derivePlugins({}).allowPrivateEgress).toBe(false); + }); + + it('rate limits use Number(x) || default (rate-limit.ts / supervisor)', () => { + expect(derivePlugins({}).rpcBurst).toBe(60); + expect(derivePlugins({ TREK_PLUGIN_RPC_PER_SEC: '5' }).rpcPerSec).toBe(5); + expect(derivePlugins({ TREK_PLUGIN_MAX_RSS_MB: 'abc' }).maxRssMb).toBe(300); + }); +}); + +describe('deriveIntegrations', () => { + it('pins unsplash trim, transit base strip + default, overpass timeout', () => { + expect(deriveIntegrations({ UNSPLASH_ACCESS_KEY: ' key ' }).unsplashAccessKey).toBe('key'); + expect(deriveIntegrations({}).transitApiBase).toBe('https://api.transitous.org'); + expect(deriveIntegrations({ TRANSIT_API_URL: 'https://t.example//' }).transitApiBase).toBe('https://t.example'); + expect(deriveIntegrations({}).overpassTimeoutMs).toBe(12000); + expect(deriveIntegrations({ OVERPASS_TIMEOUT_MS: '-1' }).overpassTimeoutMs).toBe(12000); + }); +}); + +describe('deriveBackup', () => { + it('pins the backupService limit parsing (positive number or default)', () => { + expect(deriveBackup({}).uploadLimitMb).toBe(500); + expect(deriveBackup({ BACKUP_UPLOAD_LIMIT_MB: ' 750 ' }).uploadLimitMb).toBe(750); + expect(deriveBackup({ BACKUP_UPLOAD_LIMIT_MB: '-1' }).uploadLimitMb).toBe(500); + expect(deriveBackup({}).maxDecompressedMb).toBe(5 * 1024); + expect(deriveBackup({ ENCRYPTION_KEY: 'x' }).encryptionKeyFromEnv).toBe(true); + }); +}); + +describe('deriveNet', () => { + it('ALLOW_INTERNAL_NETWORK coerces the boolean-like family (ssrfGuard)', () => { + expect(deriveNet({ ALLOW_INTERNAL_NETWORK: 'True' }).allowInternalNetwork).toBe(true); + expect(deriveNet({ ALLOW_INTERNAL_NETWORK: '1' }).allowInternalNetwork).toBe(true); + expect(deriveNet({}).allowInternalNetwork).toBe(false); + }); +}); + +describe('deriveAll', () => { + it('assembles every namespace', () => { + const env = deriveAll({ PORT: '4000', DEMO_MODE: 'true' }); + expect(env.app.port).toBe(4000); + expect(env.demo.enabled).toBe(true); + for (const ns of [ + 'app', 'http', 'session', 'demo', 'adminBootstrap', 'oidc', 'smtp', 'mcp', + 'plugins', 'webauthn', 'integrations', 'backup', 'db', 'paths', 'net', + ] as const) { + expect(env[ns]).toBeDefined(); + } + }); +}); diff --git a/server/tests/unit/app-config/parsers.test.ts b/server/tests/unit/app-config/parsers.test.ts new file mode 100644 index 000000000..21b02dfb1 --- /dev/null +++ b/server/tests/unit/app-config/parsers.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; + +import { + csvList, + csvListFiltered, + numberOr, + parseBool, + parseDurationMs, + positiveIntOr, + positiveNumberOr, + resolveKeepaliveMs, + resolveSessionTtlMs, + stripTrailingSlashes, +} from '../../../src/app-config/parsers'; + +describe('parseBool', () => { + it('accepts the whole truthy family, any casing, padded', () => { + for (const v of ['true', 'TRUE', 'True', '1', 'on', 'ON', 'yes', 'Yes', ' true ']) { + expect(parseBool(v)).toBe(true); + } + }); + + it('accepts the whole falsy family', () => { + for (const v of ['false', 'FALSE', '0', 'off', 'OFF', 'no', 'No', ' off ']) { + expect(parseBool(v)).toBe(false); + } + }); + + it('returns undefined for unset, blank and out-of-family values (default applies)', () => { + expect(parseBool(undefined)).toBeUndefined(); + expect(parseBool('')).toBeUndefined(); + expect(parseBool(' ')).toBeUndefined(); + expect(parseBool('maybe')).toBeUndefined(); + expect(parseBool('enabled')).toBeUndefined(); + }); +}); + +describe('numberOr', () => { + it('parses plain numbers', () => { + expect(numberOr('8080', 3001)).toBe(8080); + }); + + // Pins the legacy `Number(x) || d` quirk: "0" is treated as unset. + it('falls back on NaN, empty and "0"', () => { + expect(numberOr('abc', 3001)).toBe(3001); + expect(numberOr('', 3001)).toBe(3001); + expect(numberOr(undefined, 3001)).toBe(3001); + expect(numberOr('0', 3001)).toBe(3001); + }); + + it('keeps negative values (legacy quirk: -5 || d === -5)', () => { + expect(numberOr('-5', 3001)).toBe(-5); + }); +}); + +describe('positiveNumberOr', () => { + it('accepts positive numbers including fractions', () => { + expect(positiveNumberOr('12.5', 500)).toBe(12.5); + }); + + it('falls back on zero, negatives, NaN and unset', () => { + expect(positiveNumberOr('0', 500)).toBe(500); + expect(positiveNumberOr('-3', 500)).toBe(500); + expect(positiveNumberOr('abc', 500)).toBe(500); + expect(positiveNumberOr(undefined, 500)).toBe(500); + }); +}); + +describe('positiveIntOr', () => { + it('parses leading integers the way parseInt does (pins "5x" → 5)', () => { + expect(positiveIntOr('5', 20)).toBe(5); + expect(positiveIntOr('5x', 20)).toBe(5); + }); + + it('falls back on zero, negatives and garbage', () => { + expect(positiveIntOr('0', 20)).toBe(20); + expect(positiveIntOr('-1', 20)).toBe(20); + expect(positiveIntOr('abc', 20)).toBe(20); + expect(positiveIntOr(undefined, 20)).toBe(20); + }); +}); + +describe('csvList / csvListFiltered', () => { + it('returns null when unset or empty', () => { + expect(csvList(undefined)).toBeNull(); + expect(csvList('')).toBeNull(); + expect(csvListFiltered(undefined)).toBeNull(); + }); + + it('trims entries', () => { + expect(csvList(' a.example , b.example ')).toEqual(['a.example', 'b.example']); + }); + + // The two ALLOWED_ORIGINS call sites differ: websocket keeps empty entries, + // the CORS middleware drops them. Both behaviors are pinned. + it('csvList keeps empty entries, csvListFiltered drops them', () => { + expect(csvList('a,,b')).toEqual(['a', '', 'b']); + expect(csvListFiltered('a,,b')).toEqual(['a', 'b']); + }); +}); + +describe('stripTrailingSlashes', () => { + it('strips all trailing slashes', () => { + expect(stripTrailingSlashes('https://x.example///')).toBe('https://x.example'); + expect(stripTrailingSlashes('https://x.example')).toBe('https://x.example'); + }); +}); + +describe('parseDurationMs', () => { + it('parses the ms-style units used by SESSION_DURATION', () => { + expect(parseDurationMs('1h')).toBe(3_600_000); + expect(parseDurationMs('7d')).toBe(7 * 86_400_000); + expect(parseDurationMs('500')).toBe(500); // unitless = ms + expect(parseDurationMs(' 12H ')).toBe(12 * 3_600_000); // trims + case-insensitive + expect(parseDurationMs('1.5h')).toBe(1.5 * 3_600_000); + }); + + it('rejects zero, negatives and garbage', () => { + expect(parseDurationMs('0h')).toBeNull(); + expect(parseDurationMs('bogus')).toBeNull(); + expect(parseDurationMs('-1d')).toBeNull(); + expect(parseDurationMs('1 fortnight')).toBeNull(); + }); +}); + +describe('resolveSessionTtlMs', () => { + it('defaults to 1h and clamps to 24h', () => { + expect(resolveSessionTtlMs(undefined)).toBe(3_600_000); + expect(resolveSessionTtlMs('120')).toBe(120_000); + expect(resolveSessionTtlMs(String(48 * 60 * 60))).toBe(24 * 60 * 60 * 1000); + expect(resolveSessionTtlMs('0')).toBe(3_600_000); + expect(resolveSessionTtlMs('abc')).toBe(3_600_000); + }); +}); + +describe('resolveKeepaliveMs', () => { + it('defaults to 25s and allows 0 to disable', () => { + expect(resolveKeepaliveMs(undefined)).toBe(25_000); + expect(resolveKeepaliveMs('10')).toBe(10_000); + expect(resolveKeepaliveMs('0')).toBe(0); + expect(resolveKeepaliveMs('abc')).toBe(25_000); + }); +}); diff --git a/server/tests/unit/app-config/validate.test.ts b/server/tests/unit/app-config/validate.test.ts new file mode 100644 index 000000000..fb169fb6d --- /dev/null +++ b/server/tests/unit/app-config/validate.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { validateEnvAtBoot, readEnv } from '../../../src/app-config/env'; + +describe('validateEnvAtBoot', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes with a completely empty environment (all defaults)', () => { + expect(() => validateEnvAtBoot({})).not.toThrow(); + }); + + it('passes with a representative valid environment', () => { + expect(() => + validateEnvAtBoot({ + PORT: '3001', + NODE_ENV: 'production', + DEMO_MODE: 'false', + SESSION_DURATION: '12h', + SESSION_DURATION_REMEMBER: '90d', + DEFAULT_LANGUAGE: 'en', + APP_URL: 'https://trek.example.com', + ALLOWED_ORIGINS: 'https://trek.example.com', + LOG_LEVEL: 'debug', + TRUST_PROXY: '2', + OIDC_ISSUER: 'https://auth.example.com/realms/trek', + SMTP_PORT: '587', + MCP_RATE_LIMIT: '100', + BACKUP_UPLOAD_LIMIT_MB: '750', + TREK_PLUGINS_ENABLED: 'off', + TREK_PLUGIN_PERMISSIONS: 'on', + }), + ).not.toThrow(); + }); + + it('treats blank values as unset (defaults apply, no error)', () => { + expect(() => validateEnvAtBoot({ DEMO_MODE: '', PORT: ' ', TZ: '' })).not.toThrow(); + }); + + it('ignores unknown environment variables', () => { + expect(() => validateEnvAtBoot({ SOME_OTHER_TOOLS_VAR: '!!not-a-trek-var!!' })).not.toThrow(); + }); + + it('does not validate NODE_ENV (non-standard values must keep booting)', () => { + expect(() => validateEnvAtBoot({ NODE_ENV: 'staging' })).not.toThrow(); + }); + + it('throws on present-but-malformed values with an aggregated report', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => + validateEnvAtBoot({ + PORT: 'not-a-port', + SESSION_DURATION: 'bogus', + DEMO_MODE: 'maybe', + APP_URL: 'not a url', + }), + ).toThrow(/Invalid environment configuration \(4 problems\)/); + const report = error.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(report).toContain('PORT="not-a-port"'); + expect(report).toContain('SESSION_DURATION="bogus"'); + expect(report).toContain('DEMO_MODE="maybe"'); + expect(report).toContain('APP_URL="not a url"'); + }); + + it.each([ + ['PORT', '70000'], + ['TRUST_PROXY', '1.5'], + ['DEFAULT_LANGUAGE', 'klingon'], + ['LOG_LEVEL', 'verbose'], + ['SESSION_DURATION_REMEMBER', '-1d'], + ['OIDC_ISSUER', 'auth.example.com'], + ['SMTP_PORT', 'smtp'], + ['MCP_SSE_KEEPALIVE', '-1'], + ['IDEMPOTENCY_TTL_SECONDS', '0'], + ['BACKUP_UPLOAD_LIMIT_MB', '0'], + ['HSTS_INCLUDE_SUBDOMAINS', 'enabled'], + ['TREK_PLUGINS_ENABLED', 'maybe'], + ['TREK_PLUGINS_DEV_LINK', 'always'], + ['TREK_PLUGIN_PERMISSIONS', 'disabled'], + ['TREK_PLUGIN_MAX_RSS_MB', '-100'], + ])('rejects malformed %s=%s', (key, value) => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => validateEnvAtBoot({ [key]: value })).toThrow(/Invalid environment configuration/); + }); + + it.each([ + ['DEMO_MODE', 'TRUE'], + ['TREK_API_DOCS_ENABLED', ' TRUE '], + ['TREK_PLUGINS_ENABLED', ' OFF '], + ['DEFAULT_LANGUAGE', 'EN'], + ['LOG_LEVEL', 'ERROR'], + ['SESSION_DURATION', ' 12H '], + ])('accepts differently-cased/padded valid %s=%s', (key, value) => { + expect(() => validateEnvAtBoot({ [key]: value })).not.toThrow(); + }); + + // Booleans accept the whole boolean-like family (true/false, 1/0, on/off, + // yes/no) and derive to real booleans via parseBool — DEMO_MODE=yes enables + // demo mode, TREK_PLUGINS_DEV_LINK=true activates dev-link (see derive.test). + it.each([ + ['DEMO_MODE', 'yes'], + ['HSTS_INCLUDE_SUBDOMAINS', '1'], + ['TREK_PLUGINS_DEV_LINK', 'true'], + ['TREK_PLUGIN_PERMISSIONS', 'off'], + ['TREK_PLUGIN_ALLOW_PRIVATE_EGRESS', 'on'], + ['FORCE_HTTPS', 'On'], + ])('accepts boolean-like %s=%s', (key, value) => { + expect(() => validateEnvAtBoot({ [key]: value })).not.toThrow(); + }); +}); + +describe('readEnv', () => { + it('reads process.env live — a runtime mutation is visible on the next call', () => { + const before = process.env.DEMO_MODE; + try { + process.env.DEMO_MODE = 'true'; + expect(readEnv().demo.enabled).toBe(true); + process.env.DEMO_MODE = 'false'; + expect(readEnv().demo.enabled).toBe(false); + delete process.env.DEMO_MODE; + expect(readEnv().demo.enabled).toBe(false); + } finally { + if (before === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = before; + } + }); +});