// Typed, validated environment configuration. All backend config comes from env // (12-factor); missing or malformed required values fail fast at startup rather // than surfacing as confusing runtime errors. No `any`, no assertions. export interface MinioConfig { endpoint: string; port: number; useSSL: boolean; accessKey: string; secretKey: string; /** S3 region; set explicitly so presigning never triggers a network region lookup. */ region: string; /** Browser-facing base URL; presigned URLs are signed for this host. */ publicUrl: string; buckets: { media: string; avatars: string; }; } export interface CentrifugoConfig { apiUrl: string; apiKey: string; tokenHmacSecret: string; tokenTtlSeconds: number; } export interface AuthConfig { accessSecret: string; accessTtlSeconds: number; refreshTtlSeconds: number; } export interface WebPushConfig { publicKey: string; privateKey: string; /** VAPID contact — a mailto: or https: URL. */ subject: string; } export interface NotificationsConfig { /** null when VAPID keys are unset → Web Push disabled. */ webPush: WebPushConfig | null; /** Path to the Firebase service-account JSON; null → native (FCM) push disabled. */ fcmServiceAccountFile: string | null; /** APNs is prepared but never enabled until Apple credentials exist (routes via FCM). */ apnsEnabled: boolean; queueName: string; } export interface AppConfig { nodeEnv: string; port: number; databaseUrl: string; redisUrl: string; corsOrigins: string[]; minio: MinioConfig; centrifugo: CentrifugoConfig; auth: AuthConfig; notifications: NotificationsConfig; } const required = (name: string): string => { const value = process.env[name]; if (value === undefined || value === '') { throw new Error(`Missing required environment variable: ${name}`); } return value; }; const optional = (name: string, fallback: string): string => { const value = process.env[name]; if (value === undefined || value === '') { return fallback; } return value; }; const parsePort = (name: string, raw: string): number => { const port = Number.parseInt(raw, 10); if (Number.isNaN(port) || port <= 0 || port > 65535) { throw new Error(`Environment variable ${name} is not a valid port: ${raw}`); } return port; }; const parseBoolean = (raw: string): boolean => raw.toLowerCase() === 'true'; const buildNotificationsConfig = (): NotificationsConfig => { const publicKey = optional('VAPID_PUBLIC_KEY', ''); const privateKey = optional('VAPID_PRIVATE_KEY', ''); const webPush = publicKey !== '' && privateKey !== '' ? { publicKey, privateKey, subject: optional('VAPID_SUBJECT', 'mailto:admin@altricade.com') } : null; const fcmFile = optional('FCM_SERVICE_ACCOUNT_FILE', ''); return { webPush, fcmServiceAccountFile: fcmFile === '' ? null : fcmFile, apnsEnabled: parseBoolean(optional('APNS_ENABLED', 'false')), queueName: optional('NOTIFICATIONS_QUEUE', 'notifications'), }; }; // Parse a duration like "900", "15s", "15m", "1h", "30d" into seconds. const DURATION_UNITS: Record = { s: 1, m: 60, h: 3600, d: 86400 }; const parseDurationSeconds = (name: string, raw: string): number => { const match = /^(\d+)([smhd])?$/.exec(raw); if (match === null) { throw new Error(`Environment variable ${name} is not a valid duration: ${raw}`); } const amount = Number.parseInt(match[1] ?? '', 10); const unit = match[2]; const multiplier = unit === undefined ? 1 : (DURATION_UNITS[unit] ?? 1); return amount * multiplier; }; export const loadConfig = (): AppConfig => ({ nodeEnv: optional('NODE_ENV', 'development'), port: parsePort('BACKEND_PORT', optional('BACKEND_PORT', '4000')), databaseUrl: required('DATABASE_URL'), redisUrl: required('REDIS_URL'), corsOrigins: optional('CORS_ORIGINS', 'http://localhost:5173,http://localhost:8080') .split(',') .map((origin) => origin.trim()) .filter((origin) => origin.length > 0), minio: { endpoint: required('MINIO_ENDPOINT'), port: parsePort('MINIO_PORT', optional('MINIO_PORT', '9000')), useSSL: parseBoolean(optional('MINIO_USE_SSL', 'false')), accessKey: required('MINIO_ROOT_USER'), secretKey: required('MINIO_ROOT_PASSWORD'), region: optional('MINIO_REGION', 'us-east-1'), publicUrl: optional('MINIO_PUBLIC_URL', 'http://localhost:9000'), buckets: { media: optional('MINIO_BUCKET_MEDIA', 'media'), avatars: optional('MINIO_BUCKET_AVATARS', 'avatars'), }, }, centrifugo: { apiUrl: required('CENTRIFUGO_API_URL'), apiKey: required('CENTRIFUGO_API_KEY'), tokenHmacSecret: required('CENTRIFUGO_TOKEN_HMAC_SECRET'), tokenTtlSeconds: parseDurationSeconds('CENTRIFUGO_TOKEN_TTL', optional('CENTRIFUGO_TOKEN_TTL', '1h')), }, auth: { accessSecret: required('JWT_ACCESS_SECRET'), accessTtlSeconds: parseDurationSeconds('ACCESS_TOKEN_TTL', optional('ACCESS_TOKEN_TTL', '15m')), refreshTtlSeconds: parseDurationSeconds('REFRESH_TOKEN_TTL', optional('REFRESH_TOKEN_TTL', '30d')), }, notifications: buildNotificationsConfig(), });