/** * Runtime configuration for the realtime gateway. * * All values come from environment variables (see ../.env.example). * This module is pure: it parses and validates only — no I/O, no side * effects — so it can be unit-tested by passing an explicit env object. */ export interface RealtimeConfig { /** WebSocket gateway port (ws). */ port: number /** HTTP health/metrics port. */ healthPort: number /** Shared JWT secret (HS256) used to verify client tokens. Required. */ jwtSecret: string /** Redis connection URL (cross-node fan-out, presence). */ redisUrl: string /** API base URL (token/session verification fallback). */ apiUrl: string /** Pino log level: fatal | error | warn | info | debug | trace. */ logLevel: string /** Rate-limit window in milliseconds per client. */ rateLimitWindowMs: number /** Max messages per client per window. */ rateLimitMax: number /** Maximum sockets per document room. */ maxRoomSize: number /** Use the Redis update log + presence (REALTIME_REDIS=0 → in-memory, dev). */ useRedis: boolean /** Use the Prisma doc store + resolvers (REALTIME_DB=0 → in-memory/fail-closed, dev). */ useDatabase: boolean /** * The shared service token for the API's internal endpoints * (`INTERNAL_SERVICE_TOKEN`, TASK-0092 slice 2). Non-empty → the doc * store talks to the API over HTTP (`HttpDocStore`); empty → the * in-process Prisma store (single-node dev) — the fail-closed contract * (an empty token never opens the internal surface). */ internalServiceToken: string /** Trust the X-Forwarded-For client IP (server behind a proxy). */ trustProxy: boolean } /** Parse an integer env var; throws a descriptive error on bad values. */ function parseIntEnv(name: string, value: string | undefined, fallback: number): number { if (value === undefined || value === '') return fallback const parsed = Number.parseInt(value, 10) if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error(`Invalid value for ${name}: "${value}" (expected a positive integer)`) } return parsed } /** Parse a boolean env var; treats "0"/"false" (case-insensitive) as false. */ function parseBoolEnv(name: string, value: string | undefined, fallback: boolean): boolean { if (value === undefined || value === '') return fallback return !/^(0|false|no|off)$/i.test(value.trim()) } /** * Load and validate the realtime configuration. * * Reads `env` (defaults to `process.env`). Throws when `JWT_SECRET` is * missing or empty — the gateway cannot securely accept connections * without a signing secret. */ export function loadConfig(env: NodeJS.ProcessEnv = process.env): RealtimeConfig { const jwtSecret = env.JWT_SECRET?.trim() ?? '' if (jwtSecret.length === 0) { throw new Error('Missing required environment variable: JWT_SECRET') } return { // `REALTIME_PORT` is the documented gateway port (docs/development/ // development-guide.md); it must NOT fall back to the shared `PORT` env, // which in the dev stack is the API's 4000 (that collision was a latent // bug: the gateway tried to bind :4000 and died with EADDRINUSE). // `PORT` is kept as an explicit fallback for standalone runs. port: parseIntEnv('REALTIME_PORT', env.REALTIME_PORT ?? env.PORT, 4001), healthPort: parseIntEnv('REALTIME_HEALTH_PORT', env.REALTIME_HEALTH_PORT ?? env.HEALTH_PORT, 4002), jwtSecret, redisUrl: env.REDIS_URL ?? 'redis://localhost:6379', apiUrl: env.API_URL ?? 'http://localhost:4000', logLevel: env.LOG_LEVEL ?? 'info', rateLimitWindowMs: parseIntEnv('RATE_LIMIT_WINDOW_MS', env.RATE_LIMIT_WINDOW_MS, 60000), rateLimitMax: parseIntEnv('RATE_LIMIT_MAX', env.RATE_LIMIT_MAX, 300), maxRoomSize: parseIntEnv('MAX_ROOM_SIZE', env.MAX_ROOM_SIZE, 200), useRedis: parseBoolEnv('REALTIME_REDIS', env.REALTIME_REDIS, true), useDatabase: parseBoolEnv('REALTIME_DB', env.REALTIME_DB, true), internalServiceToken: env.INTERNAL_SERVICE_TOKEN?.trim() ?? '', trustProxy: parseBoolEnv('REALTIME_TRUST_PROXY', env.REALTIME_TRUST_PROXY, false), } }