import { plainToInstance } from 'class-transformer' import { IsIn, IsInt, IsOptional, IsString, Min, validateSync } from 'class-validator' const DEV_JWT_FALLBACK = 'gold-land-dev-secret-change-in-production' const FORBIDDEN_PRODUCTION_JWT_SECRETS = new Set([ DEV_JWT_FALLBACK, 'e2e-local-jwt-not-for-production', 'e2e-ci-jwt-not-for-production', 'ci-test-jwt-not-for-production', ]) export class EnvironmentVariables { @IsIn(['development', 'production', 'test']) NODE_ENV!: 'development' | 'production' | 'test' @IsInt() @Min(1) PORT!: number @IsString() HOST!: string @IsString() CORS_ORIGIN!: string @IsOptional() @IsString() DB_PATH?: string /** Alias for DB_PATH (e.g. Docker Compose). DB_PATH takes precedence when both are set. */ @IsOptional() @IsString() DATABASE_PATH?: string @IsOptional() @IsString() JWT_SECRET?: string /** When `true`, Fastify trusts X-Forwarded-* (only enable behind a known reverse proxy). */ @IsOptional() @IsString() TRUST_PROXY?: string } function assertProductionJwtSecret(secret: string): void { const trimmed = secret.trim() if (trimmed.length < 32) { throw new Error('JWT_SECRET must be at least 32 characters when NODE_ENV=production') } if (FORBIDDEN_PRODUCTION_JWT_SECRETS.has(trimmed)) { throw new Error('JWT_SECRET is a known development placeholder — set a unique production secret') } } export function validateEnv(config: Record): EnvironmentVariables { const nodeEnv = (config.NODE_ENV as string | undefined) ?? 'development' const jwtSecret = config.JWT_SECRET as string | undefined const trustProxyEnabled = config.TRUST_PROXY === 'true' || config.TRUST_PROXY === '1' || config.TRUST_PROXY === true if (nodeEnv === 'production') { if (!jwtSecret || jwtSecret.trim().length === 0) { throw new Error('JWT_SECRET is required when NODE_ENV=production') } assertProductionJwtSecret(jwtSecret) if (trustProxyEnabled && !(config.TRUST_PROXY_CIDRS as string | undefined)?.trim()) { throw new Error( 'TRUST_PROXY_CIDRS is required when TRUST_PROXY is enabled in production (set the reverse-proxy network CIDR, not broad defaults)', ) } } if (nodeEnv !== 'production' && !jwtSecret) { console.warn( '[config] JWT_SECRET is not set — using development fallback. Do not use in production.', ) } const normalized = { NODE_ENV: nodeEnv, PORT: Number(config.PORT ?? 3000), HOST: (config.HOST as string | undefined) ?? '0.0.0.0', CORS_ORIGIN: (config.CORS_ORIGIN as string | undefined) ?? 'http://localhost:5174', DB_PATH: (config.DB_PATH ?? config.DATABASE_PATH) as string | undefined, JWT_SECRET: jwtSecret ?? DEV_JWT_FALLBACK, TRUST_PROXY: (config.TRUST_PROXY as string | undefined) ?? 'false', } const validated = plainToInstance(EnvironmentVariables, normalized, { enableImplicitConversion: true, }) const errors = validateSync(validated, { skipMissingProperties: false }) if (errors.length > 0) { throw new Error(errors.toString()) } return validated }