You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

185 lines
6.6 KiB

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<T extends string>(
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;
}