import { config as loadDotenv } from 'dotenv'; import { z } from 'zod'; import type { AppConfig } from './config.types'; export const envFilePaths = ['.env', '../../.env']; const booleanFromString = z .string() .transform((value) => value.toLowerCase()) .pipe(z.enum(['true', 'false'])) .transform((value) => value === 'true'); const envSchema = z.object({ NODE_ENV: z .enum(['development', 'test', 'production']) .default('development'), PORT: z.coerce.number().int().min(1).max(65535).default(3000), APP_BASE_URL: z.url(), FRONTEND_BASE_URL: z.url().optional(), TRUST_PROXY: booleanFromString.default(false), DATABASE_HOST: z.string().min(1), DATABASE_PORT: z.coerce.number().int().min(1).max(65535).default(3306), DATABASE_NAME: z.string().min(1), DATABASE_USER: z.string().min(1), DATABASE_PASSWORD: z.string().min(1), DATABASE_SSL: booleanFromString.default(false), OIDC_ISSUER: z.url(), OIDC_CLIENT_ID: z.string().min(1), OIDC_CLIENT_SECRET: z.string().min(1), OIDC_SCOPES: z.string().min(1).default('openid profile email'), OIDC_LOGOUT_URL: z.preprocess( (value) => (value === '' ? undefined : value), z.url().optional(), ), OIDC_ALLOWED_ALGORITHMS: z .string() .min(1) .transform((value) => value .split(/[\s,]+/) .map((entry) => entry.trim()) .filter(Boolean), ) .refine( (algorithms) => algorithms.length > 0 && algorithms.every((algorithm) => algorithm.toLowerCase() !== 'none'), 'OIDC_ALLOWED_ALGORITHMS darf none nicht erlauben.', ), OIDC_HTTP_TIMEOUT_MS: z.coerce .number() .int() .min(1000) .max(30000) .default(5000), SESSION_COOKIE_NAME: z.string().min(1).default('app_session'), SESSION_IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(300).default(28800), SESSION_ABSOLUTE_TIMEOUT_SECONDS: z.coerce .number() .int() .min(3600) .default(604800), SESSION_SECRET: z.string().min(32), SESSION_ENCRYPTION_KEY: z.string().min(32), CORS_ORIGINS: z.string().transform((value) => value .split(',') .map((entry) => entry.trim()) .filter(Boolean), ), CSRF_HEADER_NAME: z.string().min(1).default('X-CSRF-Token'), LOG_LEVEL: z.string().min(1).default('info'), SWAGGER_ENABLED: booleanFromString.default(false), RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60), RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().min(1).default(300), RATE_LIMIT_SENSITIVE_WINDOW_SECONDS: z.coerce .number() .int() .min(1) .default(60), RATE_LIMIT_SENSITIVE_MAX_REQUESTS: z.coerce.number().int().min(1).default(10), }); export function loadConfigFromEnv(env: Record): AppConfig { const parsed = envSchema.safeParse(env); if (!parsed.success) { const issues = parsed.error.issues.map( (issue) => `${issue.path.join('.')}: ${issue.message}`, ); throw new Error(`Ungueltige Konfiguration:\n${issues.join('\n')}`); } const value = parsed.data; if (value.NODE_ENV === 'production') { const insecure = [ value.SESSION_SECRET.includes('change'), value.SESSION_ENCRYPTION_KEY.includes('change'), value.OIDC_CLIENT_SECRET.includes('change'), ]; if (insecure.some(Boolean)) { throw new Error( 'Production-Secrets muessen explizit und sicher gesetzt werden.', ); } } return { nodeEnv: value.NODE_ENV, port: value.PORT, appBaseUrl: value.APP_BASE_URL, frontendBaseUrl: value.FRONTEND_BASE_URL ?? value.APP_BASE_URL, trustProxy: value.TRUST_PROXY, database: { host: value.DATABASE_HOST, port: value.DATABASE_PORT, name: value.DATABASE_NAME, user: value.DATABASE_USER, password: value.DATABASE_PASSWORD, ssl: value.DATABASE_SSL, }, oidc: { issuer: value.OIDC_ISSUER, clientId: value.OIDC_CLIENT_ID, clientSecret: value.OIDC_CLIENT_SECRET, scopes: value.OIDC_SCOPES, allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS, httpTimeoutMs: value.OIDC_HTTP_TIMEOUT_MS, ...(value.OIDC_LOGOUT_URL ? { logoutUrl: value.OIDC_LOGOUT_URL } : {}), }, session: { cookieName: value.SESSION_COOKIE_NAME, idleTimeoutSeconds: value.SESSION_IDLE_TIMEOUT_SECONDS, absoluteTimeoutSeconds: value.SESSION_ABSOLUTE_TIMEOUT_SECONDS, secret: value.SESSION_SECRET, encryptionKey: value.SESSION_ENCRYPTION_KEY, }, corsOrigins: value.CORS_ORIGINS, csrfHeaderName: value.CSRF_HEADER_NAME, logLevel: value.LOG_LEVEL, swaggerEnabled: value.SWAGGER_ENABLED, rateLimit: { global: { windowSeconds: value.RATE_LIMIT_WINDOW_SECONDS, maxRequests: value.RATE_LIMIT_MAX_REQUESTS, }, sensitive: { windowSeconds: value.RATE_LIMIT_SENSITIVE_WINDOW_SECONDS, maxRequests: value.RATE_LIMIT_SENSITIVE_MAX_REQUESTS, }, }, }; } export function loadConfigForCli(): AppConfig { loadDotenv({ path: envFilePaths, override: false, quiet: true }); return loadConfigFromEnv(process.env); }