/** * Typed configuration loader. Pure over an env object: no reads of process.env * here, so tests are deterministic. Fail fast AND clear: every problem is * collected, then a single ConfigError is thrown. Error messages name variable * NAMES only, never values, so secrets cannot leak. */ export type StringRecord = Readonly>; export type NodeEnv = 'development' | 'test' | 'production'; export interface AppConfig { nodeEnv: NodeEnv; port: number; host: string; logLevel: string; databaseUrl: string; redisUrl?: string; /** Session cookie Secure flag. Default true; set COOKIE_SECURE=false for local http dev. */ cookieSecure: boolean; /** Initial flag state parsed from FLAG_* vars. */ flags: Readonly>; } export class ConfigError extends Error { constructor(public readonly problems: ReadonlyArray) { super(`Invalid configuration:\n- ${problems.join('\n- ')}`); this.name = 'ConfigError'; } } const NODE_ENVS: ReadonlyArray = ['development', 'test', 'production']; function parseBoolFlag(varName: string, raw: string, problems: string[]): boolean | undefined { const normalized = raw.trim().toLowerCase(); if (normalized === 'true') return true; if (normalized === 'false') return false; problems.push(`${varName} must be "true" or "false"`); return undefined; } export function loadConfig(env: StringRecord): AppConfig { const problems: string[] = []; const databaseUrl = env.DATABASE_URL; if (!databaseUrl) { problems.push('DATABASE_URL is required'); } let port = 3000; const rawPort = env.PORT; if (rawPort !== undefined && rawPort !== '') { const parsed = Number(rawPort); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { problems.push('PORT must be an integer between 1 and 65535'); } else { port = parsed; } } const host = env.HOST && env.HOST !== '' ? env.HOST : '0.0.0.0'; const logLevel = env.LOG_LEVEL && env.LOG_LEVEL !== '' ? env.LOG_LEVEL : 'info'; let nodeEnv: NodeEnv = 'development'; const rawNodeEnv = env.NODE_ENV; if (rawNodeEnv !== undefined && rawNodeEnv !== '') { if ((NODE_ENVS as ReadonlyArray).includes(rawNodeEnv)) { nodeEnv = rawNodeEnv as NodeEnv; } else { problems.push('NODE_ENV must be one of: development, test, production'); } } const redisUrl = env.REDIS_URL && env.REDIS_URL !== '' ? env.REDIS_URL : undefined; let cookieSecure = true; const rawCookieSecure = env.COOKIE_SECURE; if (rawCookieSecure !== undefined && rawCookieSecure !== '') { const normalized = rawCookieSecure.trim().toLowerCase(); if (normalized === 'true') cookieSecure = true; else if (normalized === 'false') cookieSecure = false; else problems.push('COOKIE_SECURE must be "true" or "false"'); } const flags: Record = {}; for (const [key, raw] of Object.entries(env)) { if (!key.startsWith('FLAG_') || raw === undefined) continue; const name = key.slice('FLAG_'.length).toLowerCase(); const value = parseBoolFlag(key, raw, problems); if (value !== undefined) { flags[name] = value; } } if (problems.length > 0) { throw new ConfigError(problems); } return { nodeEnv, port, host, logLevel, databaseUrl: databaseUrl as string, redisUrl, cookieSecure, flags, }; }