import { join } from 'node:path'; import type { AnalyticsCategoryConfig } from './analytics.js'; /** * Central env-var parsing/validation for this app. Lives in `server/core` * (not `core/`) because it reads `process.env` directly rather than * SvelteKit's `$env/static/private` — same reasoning as `tokens.ts`: this * module tree is compiled standalone (`tsc -p tsconfig.server.json`) for * `server.js` to import, outside Vite's module graph. */ export interface AnalyticsConfig extends AnalyticsCategoryConfig { enabled: boolean; dbPath: string; } export interface SessionRuntimeConfig { /** Overrides `SessionPersona.model` (see $lib/project/persona.ts) when set. */ modelOverride?: string; defaultVoice?: string; clientToolTimeoutMs: number; tokenTtlMs: number; } export interface DebugConfig { /** * Gates whether the `messages` debug-log category (full conversation * content, including the system prompt — see docs/LOGGING.md) mirrors to * console/stdout. Independent of `debug.subscribe`'s browser-forwarding * gate (dev/`DEBUG_ACCESS_KEY`), which is unaffected either way — this * only controls the *unconditional* server-log copy. */ messages: boolean; } export interface AppConfig { port: number; /** Which `RealtimeProvider` to construct — see `providers/index.ts`'s `selectProvider`. */ realtimeProvider: 'openai' | 'mock'; /** Only required (and only meaningful) when `realtimeProvider` is `'openai'`. */ openaiApiKey: string; analytics: AnalyticsConfig; session: SessionRuntimeConfig; debug: DebugConfig; } let cached: AppConfig | null = null; /** * Parses and validates every env var this app reads, once, into a single * typed object — memoized, since env vars don't change mid-process and this * is called from both composition roots (server.js, dev-plugin.ts) plus the * session-token route. Throws one Error listing every problem found, not * just the first, so a misconfigured deployment fails loudly and completely * at startup rather than one env var at a time. * * `REALTIME_TOKEN_SECRET` is checked for presence here (so a missing secret * fails at the same startup point as everything else) but its value is * deliberately NOT stored on `AppConfig` — `tokens.ts` remains the sole * reader of the actual secret, reading `process.env` directly and lazily. * Routing it through this memoized singleton would break `tokens.spec.ts`'s * pattern of mutating `process.env.REALTIME_TOKEN_SECRET` per test to * exercise `mintSessionToken`/`verifySessionToken` against different * secrets — don't "simplify" this away. * * `DEBUG_ACCESS_KEY` (see `./logger.ts`'s `verifyDebugAccessKey` and * docs/LOGGING.md) follows the same "never store the raw secret on * `AppConfig`" pattern, but is a *stronger* exception: it's not even * presence-checked here. Unlike `REALTIME_TOKEN_SECRET`, an unset * `DEBUG_ACCESS_KEY` is a valid, intentional deployment state — the debug * panel's server-log forwarding is simply disabled for everyone in prod — * not a misconfiguration worth failing loudly about. * * `DEBUG_LOG_MESSAGES` (`debug.messages` below) is not a secret, so it *does* * go through the normal fail-fast-validated path here, the same as * `ANALYTICS_LOG_MESSAGES` — it just gates a different sink (the debug * logger's console mirror) than that one does (the analytics DB). */ export function loadConfig(): AppConfig { if (cached) return cached; const errors: string[] = []; const port = parseIntEnv('PORT', 3000, { min: 1, max: 65535 }, errors); const realtimeProvider = parseEnumEnv( 'REALTIME_PROVIDER', 'openai', ['openai', 'mock'] as const, errors ); // Only required when actually talking to OpenAI — the mock provider (see // providers/mock.ts) exists specifically so animation/UI work doesn't need // a real key at all. const openaiApiKey = realtimeProvider === 'openai' ? requireEnv('OPENAI_API_KEY', errors) : (optionalEnv('OPENAI_API_KEY') ?? ''); requireEnv('REALTIME_TOKEN_SECRET', errors); // presence only — see doc comment above const analytics: AnalyticsConfig = { enabled: parseBoolEnv('ANALYTICS_ENABLED', true, errors), dbPath: process.env.ANALYTICS_DB_PATH || join(process.cwd(), 'data', 'analytics.db'), messages: parseBoolEnv('ANALYTICS_LOG_MESSAGES', true, errors), toolCalls: parseBoolEnv('ANALYTICS_LOG_TOOL_CALLS', true, errors), events: parseBoolEnv('ANALYTICS_LOG_EVENTS', true, errors) }; const session: SessionRuntimeConfig = { modelOverride: optionalEnv('SESSION_MODEL'), defaultVoice: optionalEnv('SESSION_DEFAULT_VOICE'), clientToolTimeoutMs: parseIntEnv('CLIENT_TOOL_TIMEOUT_MS', 10_000, { min: 1 }, errors), tokenTtlMs: parseIntEnv('REALTIME_TOKEN_TTL_MS', 60_000, { min: 1 }, errors) }; const debug: DebugConfig = { messages: parseBoolEnv('DEBUG_LOG_MESSAGES', true, errors) }; if (errors.length > 0) { throw new Error(`Invalid configuration:\n${errors.map((e) => ` - ${e}`).join('\n')}`); } cached = { port, realtimeProvider, openaiApiKey, analytics, session, debug }; return cached; } function parseBoolEnv(name: string, def: boolean, errors: string[]): boolean { const raw = process.env[name]; if (raw === undefined || raw === '') return def; if (raw === 'true') return true; if (raw === 'false') return false; errors.push(`${name} must be "true" or "false" if set (got "${raw}").`); return def; } function parseEnumEnv( name: string, def: T, allowed: readonly T[], errors: string[] ): T { const raw = process.env[name]; if (raw === undefined || raw === '') return def; if ((allowed as readonly string[]).includes(raw)) return raw as T; errors.push(`${name} must be one of ${allowed.join(', ')} if set (got "${raw}").`); return def; } function parseIntEnv( name: string, def: number, opts: { min?: number; max?: number }, errors: string[] ): number { const raw = process.env[name]; if (raw === undefined || raw === '') return def; const n = Number(raw); if (!Number.isInteger(n)) { errors.push(`${name} must be an integer if set (got "${raw}").`); return def; } if (opts.min !== undefined && n < opts.min) { errors.push(`${name} must be >= ${opts.min} (got ${n}).`); return def; } if (opts.max !== undefined && n > opts.max) { errors.push(`${name} must be <= ${opts.max} (got ${n}).`); return def; } return n; } function requireEnv(name: string, errors: string[]): string { const raw = process.env[name]; if (!raw) { errors.push(`${name} is not set. See .env.example.`); return ''; } return raw; } function optionalEnv(name: string): string | undefined { const raw = process.env[name]; return raw === undefined || raw === '' ? undefined : raw; }