feat(server): add app-config core with fail-fast env validation

This commit is contained in:
jubnl
2026-07-25 15:03:27 +02:00
parent b955998905
commit 48ba9092c3
14 changed files with 1148 additions and 25 deletions
+76
View File
@@ -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/**`.
+12
View File
@@ -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();
+235
View File
@@ -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>;
+146
View File
@@ -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,
});
+37
View File
@@ -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.',
);
}
+22
View File
@@ -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';
+108
View File
@@ -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;
}
+4
View File
@@ -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';