Files
mercadodevida/project/src/infrastructure/config/config.ts
rikrdo 75293f39bc feat(identity): F-005 register/login/logout with argon2 sessions and rate limiting
- Hexagonal identity module: domain ports, use cases, argon2id hasher, pg repos
- Migration 002_identity: identity_users + identity_sessions (token hash only)
- Opaque 512-bit session tokens; DB stores SHA-256 hash; 7-day TTL in SQL
- Cookie HttpOnly + Secure (COOKIE_SECURE, default true) + SameSite=Lax
- LoginRateLimiter: 10 failures -> 429 + Retry-After, 15-min cooldown
- Anti-enumeration: identical generic 401 + dummy-hash timing equalization
- buildApp gains optional pool/cookieSecure; foundation-only app preserved
- 47 unit + 14 integration tests; live smoke covers all acceptance criteria
2026-08-14 22:58:32 +02:00

110 lines
3.4 KiB
TypeScript

/**
* 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;
/** 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<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;
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<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,
cookieSecure,
flags,
};
}