mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(server): add app-config core with fail-fast env validation
This commit is contained in:
@@ -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).
|
||||
|
||||
<details>
|
||||
<summary><b>Full reference</b></summary>
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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/**`.
|
||||
@@ -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();
|
||||
@@ -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<string, string | undefined>;
|
||||
|
||||
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<typeof deriveAll>;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<string, number> = {
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user