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:
rikrdo
2026-08-14 22:29:18 +02:00
parent 41f144d7bd
commit 4851692031
25 changed files with 756 additions and 18 deletions

View File

@@ -8,15 +8,24 @@ import Fastify, { type FastifyInstance } from 'fastify';
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import type { IncomingMessage } from 'node:http';
import { registerHealthRoutes } from '../modules/health/index.js';
import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js';
import { AppError, errorEnvelope } from '../shared/errors.js';
import { createLogger, type Logger } from '../infrastructure/logging/logger.js';
declare module 'fastify' {
interface FastifyInstance {
flags: FeatureFlagProvider;
}
}
const REQUEST_ID_HEADER = 'x-request-id';
const SAFE_REQUEST_ID = /^[A-Za-z0-9._-]{1,128}$/;
export interface BuildAppDeps {
/** Injectable logger so tests can capture structured output. */
logger?: Logger;
/** Feature flags. Default: empty store, every flag OFF (fail-safe). */
flags?: FeatureFlagProvider;
}
function generateRequestId(raw: IncomingMessage): string {
@@ -33,9 +42,11 @@ function generateRequestId(raw: IncomingMessage): string {
*/
export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance> {
const logger = deps.logger ?? createLogger();
const flags = deps.flags ?? createFlagStore();
const startTimes = new WeakMap<FastifyRequest, number>();
const app = Fastify({ logger: false, genReqId: generateRequestId });
app.decorate('flags', flags);
app.addHook('onRequest', async (request, reply) => {
startTimes.set(request, performance.now());

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { createFlagStore } from '../../modules/flags/index.js';
import { createLogger } from '../../infrastructure/logging/logger.js';
import type { DestinationStream } from 'pino';
function silentLogger() {
const destination: DestinationStream = { write: () => undefined };
return createLogger({ level: 'info', destination });
}
describe('feature flags wiring (composition)', () => {
it('default flags are all OFF when no store is injected', async () => {
const app = await buildApp({ logger: silentLogger() });
app.get('/__test/guarded', async (request) => {
return { enabled: request.server.flags.isEnabled('risky_path') };
});
const response = await app.inject({ method: 'GET', url: '/__test/guarded' });
expect(response.json()).toEqual({ enabled: false });
await app.close();
});
it('guarded path is skipped when the flag is off and runs once enabled at runtime', async () => {
const store = createFlagStore({ risky_path: false });
const app = await buildApp({ logger: silentLogger(), flags: store });
app.get('/__test/guarded', async (request) => {
if (!request.server.flags.isEnabled('risky_path')) {
return { path: 'skipped' };
}
return { path: 'executed' };
});
const off = await app.inject({ method: 'GET', url: '/__test/guarded' });
expect(off.json()).toEqual({ path: 'skipped' });
// Activation without redeploy: same running app instance.
store.setEnabled('risky_path', true);
const on = await app.inject({ method: 'GET', url: '/__test/guarded' });
expect(on.json()).toEqual({ path: 'executed' });
store.setEnabled('risky_path', false);
const offAgain = await app.inject({ method: 'GET', url: '/__test/guarded' });
expect(offAgain.json()).toEqual({ path: 'skipped' });
await app.close();
});
});

View 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,
};
}

View 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');
});
});

View File

@@ -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);

View File

@@ -0,0 +1,34 @@
/**
* Feature flags domain. Deployment and activation are separate operations:
* flags can change at runtime without redeploy. Unknown flags are OFF
* (fail-safe default for risky paths).
*/
export interface FeatureFlagProvider {
isEnabled(name: string): boolean;
}
export class InMemoryFeatureFlagStore implements FeatureFlagProvider {
private readonly state: Map<string, boolean>;
constructor(initial: Readonly<Record<string, boolean>> = {}) {
this.state = new Map(
Object.entries(initial).map(([name, enabled]) => [name.toLowerCase(), enabled]),
);
}
isEnabled(name: string): boolean {
return this.state.get(name.toLowerCase()) === true;
}
/** Runtime mutation: activation does not require a redeploy. */
setEnabled(name: string, enabled: boolean): void {
this.state.set(name.toLowerCase(), enabled);
}
}
export function createFlagStore(
initial: Readonly<Record<string, boolean>> = {},
): InMemoryFeatureFlagStore {
return new InMemoryFeatureFlagStore(initial);
}

View File

@@ -0,0 +1,9 @@
/**
* Public API of the flags module. Everything the module exposes to the
* outside world goes through this file.
*/
export {
type FeatureFlagProvider,
InMemoryFeatureFlagStore,
createFlagStore,
} from './domain/feature-flag-store.js';

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { createFlagStore } from '../index.js';
describe('InMemoryFeatureFlagStore', () => {
it('reflects seeded state', () => {
const store = createFlagStore({ new_checkout: true, beta_search: false });
expect(store.isEnabled('new_checkout')).toBe(true);
expect(store.isEnabled('beta_search')).toBe(false);
});
it('defaults unknown flags to OFF (fail-safe)', () => {
const store = createFlagStore();
expect(store.isEnabled('never_heard_of_it')).toBe(false);
});
it('matches flag names case-insensitively', () => {
const store = createFlagStore({ New_Checkout: true });
expect(store.isEnabled('new_checkout')).toBe(true);
expect(store.isEnabled('NEW_CHECKOUT')).toBe(true);
});
it('flips state at runtime without a new store (no redeploy)', () => {
const store = createFlagStore({ risky_path: false });
expect(store.isEnabled('risky_path')).toBe(false);
store.setEnabled('risky_path', true);
expect(store.isEnabled('risky_path')).toBe(true);
store.setEnabled('risky_path', false);
expect(store.isEnabled('risky_path')).toBe(false);
});
it('skips a guarded path when the flag is off', () => {
const store = createFlagStore({ risky_path: false });
let ran = false;
if (store.isEnabled('risky_path')) {
ran = true;
}
expect(ran).toBe(false);
store.setEnabled('risky_path', true);
if (store.isEnabled('risky_path')) {
ran = true;
}
expect(ran).toBe(true);
});
});