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,
|
||||
};
|
||||
}
|
||||
94
project/src/infrastructure/config/tests/config.test.ts
Normal file
94
project/src/infrastructure/config/tests/config.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConfigError, loadConfig } from '../config.js';
|
||||
|
||||
const BASE = { DATABASE_URL: 'postgres://u:p@localhost:5432/db' };
|
||||
|
||||
function problemsOf(fn: () => unknown): ReadonlyArray<string> {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
return error.problems;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error('expected ConfigError was not thrown');
|
||||
}
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('returns typed config for a valid full env', () => {
|
||||
const config = loadConfig({
|
||||
...BASE,
|
||||
PORT: '8080',
|
||||
HOST: '127.0.0.1',
|
||||
LOG_LEVEL: 'debug',
|
||||
NODE_ENV: 'production',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
FLAG_NEW_CHECKOUT: 'true',
|
||||
FLAG_BETA_SEARCH: 'false',
|
||||
});
|
||||
|
||||
expect(config.port).toBe(8080);
|
||||
expect(config.host).toBe('127.0.0.1');
|
||||
expect(config.logLevel).toBe('debug');
|
||||
expect(config.nodeEnv).toBe('production');
|
||||
expect(config.databaseUrl).toBe(BASE.DATABASE_URL);
|
||||
expect(config.redisUrl).toBe('redis://localhost:6379');
|
||||
expect(config.flags).toEqual({ new_checkout: true, beta_search: false });
|
||||
});
|
||||
|
||||
it('applies defaults when optional vars are absent', () => {
|
||||
const config = loadConfig(BASE);
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.host).toBe('0.0.0.0');
|
||||
expect(config.logLevel).toBe('info');
|
||||
expect(config.nodeEnv).toBe('development');
|
||||
expect(config.redisUrl).toBeUndefined();
|
||||
expect(config.flags).toEqual({});
|
||||
});
|
||||
|
||||
it('fails fast naming DATABASE_URL when missing', () => {
|
||||
expect(() => loadConfig({})).toThrowError(ConfigError);
|
||||
expect(problemsOf(() => loadConfig({}))).toContain('DATABASE_URL is required');
|
||||
});
|
||||
|
||||
it('rejects invalid PORT values naming the var', () => {
|
||||
for (const bad of ['not-a-number', '0', '70000', '3.5']) {
|
||||
expect(() => loadConfig({ ...BASE, PORT: bad })).toThrowError(ConfigError);
|
||||
expect(problemsOf(() => loadConfig({ ...BASE, PORT: bad })).join(' ')).toContain('PORT');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid NODE_ENV', () => {
|
||||
expect(problemsOf(() => loadConfig({ ...BASE, NODE_ENV: 'staging' })).join(' ')).toContain(
|
||||
'NODE_ENV',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects FLAG_* values that are not true/false', () => {
|
||||
expect(problemsOf(() => loadConfig({ ...BASE, FLAG_BROKEN: 'yes' })).join(' ')).toContain(
|
||||
'FLAG_BROKEN',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports every problem in a single ConfigError', () => {
|
||||
const problems = problemsOf(() =>
|
||||
loadConfig({ PORT: 'nope', NODE_ENV: 'staging', FLAG_BAD: 'maybe' }),
|
||||
);
|
||||
expect(problems.some((p) => p.includes('DATABASE_URL'))).toBe(true);
|
||||
expect(problems.some((p) => p.includes('PORT'))).toBe(true);
|
||||
expect(problems.some((p) => p.includes('NODE_ENV'))).toBe(true);
|
||||
expect(problems.some((p) => p.includes('FLAG_BAD'))).toBe(true);
|
||||
});
|
||||
|
||||
it('never echoes secret values in error messages', () => {
|
||||
let message = '';
|
||||
try {
|
||||
loadConfig({ DATABASE_URL: 'postgres://u:supersecret@h/db', PORT: 'nope' });
|
||||
} catch (error) {
|
||||
message = (error as Error).message;
|
||||
}
|
||||
expect(message).not.toContain('supersecret');
|
||||
expect(message).toContain('PORT');
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,27 @@
|
||||
import { buildApp } from '../../app/build-app.js';
|
||||
import { ConfigError, loadConfig } from '../config/config.js';
|
||||
import { createFlagStore } from '../../modules/flags/index.js';
|
||||
import { createLogger } from '../logging/logger.js';
|
||||
|
||||
const logger = createLogger();
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
let config;
|
||||
try {
|
||||
config = loadConfig(process.env);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
// Config is invalid: fail fast and loud before anything else starts.
|
||||
console.error(error.message);
|
||||
} else {
|
||||
console.error('Failed to load configuration', error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const logger = createLogger({ level: config.logLevel });
|
||||
|
||||
try {
|
||||
const app = await buildApp({ logger });
|
||||
await app.listen({ port, host });
|
||||
logger.info({ port, host }, 'HTTP server listening');
|
||||
const app = await buildApp({ logger, flags: createFlagStore(config.flags) });
|
||||
await app.listen({ port: config.port, host: config.host });
|
||||
logger.info({ port: config.port, host: config.host }, 'HTTP server listening');
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Failed to start HTTP server');
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user