feat(F-004): typed fail-fast config and feature flag module
- loadConfig: pure over env object, accumulates all problems, names var names only - DATABASE_URL now required at startup; PORT/HOST/LOG_LEVEL/NODE_ENV/REDIS_URL defaulted - flags module behind FeatureFlagProvider; unknown flags OFF; runtime setEnabled (no redeploy) - buildApp decorates app.flags; server.ts fail-fast before app boot - tests caught and fixed flag-store case-normalization bug before gates - zero new dependencies; all gates approved; verify.sh green
This commit is contained in:
97
project/src/infrastructure/config/config.ts
Normal file
97
project/src/infrastructure/config/config.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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<Record<string, string | undefined>>;
|
||||
|
||||
export type NodeEnv = 'development' | 'test' | 'production';
|
||||
|
||||
export interface AppConfig {
|
||||
nodeEnv: NodeEnv;
|
||||
port: number;
|
||||
host: string;
|
||||
logLevel: string;
|
||||
databaseUrl: string;
|
||||
redisUrl?: string;
|
||||
/** Initial flag state parsed from FLAG_* vars. */
|
||||
flags: Readonly<Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export class ConfigError extends Error {
|
||||
constructor(public readonly problems: ReadonlyArray<string>) {
|
||||
super(`Invalid configuration:\n- ${problems.join('\n- ')}`);
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
const NODE_ENVS: ReadonlyArray<NodeEnv> = ['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<string>).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;
|
||||
|
||||
const flags: Record<string, boolean> = {};
|
||||
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,
|
||||
flags,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user