- 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
110 lines
3.4 KiB
TypeScript
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,
|
|
};
|
|
}
|