// Server-side configuration: API key resolution + live reload of the // StartOS config file. Module owns its own state — `serverApiKey` is // exported as a `let` binding so importers see the current value // (ESM live bindings). // // Resolution priority: // 1. process.env.GEMINI_API_KEY (pins; never re-read after boot) // 2. /data/config/startos-config.json's gemini_api_key // 3. /data/.env's GEMINI_API_KEY // // Whenever the StartOS config file changes (via the "Set Gemini API // Key" action), the new value is picked up within CONFIG_POLL_MS — no // service restart required. import fs from "fs/promises"; import path from "path"; // ── Module state ──────────────────────────────────────────────────────────── // Initialized by initConfig(). serverApiKey is exported as a live binding; // importers see the current value, not a snapshot. let envApiKey = ""; let envPath = null; let configDir = null; let startosConfigPath = null; export let serverApiKey = ""; // Core-decoupling shared "operator key" — read live from the StartOS // config sidecar the same way serverApiKey is, so the operator can set it // via the "Set Relay Operator Key" action without a service restart. // `RECAP_RELAY_OPERATOR_KEY` env pins the value (local dev). Consumed by // relay-default.js's getRelayOperatorKey(); see that for the semantics. let envRelayOperatorKey = ""; export let relayOperatorKey = ""; // ── Init ──────────────────────────────────────────────────────────────────── // Call once at boot. Sets up paths, reads the initial value, kicks off the // poll loop. Idempotent if you really want to call it twice (the interval // would just stack — don't). export async function initConfig({ dataDir }) { envPath = path.join(dataDir, ".env"); configDir = path.join(dataDir, "config"); startosConfigPath = path.join(configDir, "startos-config.json"); envApiKey = process.env.GEMINI_API_KEY || ""; serverApiKey = envApiKey; envRelayOperatorKey = (process.env.RECAP_RELAY_OPERATOR_KEY || "").trim(); relayOperatorKey = envRelayOperatorKey; await fs.mkdir(configDir, { recursive: true }).catch(() => {}); await refreshServerApiKey("startup"); await refreshRelayOperatorKey("startup"); const pollMs = parseInt(process.env.RECAP_CONFIG_POLL_MS || "3000", 10); setInterval(() => { refreshServerApiKey("config poll").catch(() => {}); refreshRelayOperatorKey("config poll").catch(() => {}); }, pollMs); } // ── Internals ─────────────────────────────────────────────────────────────── async function readApiKeyFromConfig() { try { const content = await fs.readFile(startosConfigPath, "utf-8"); const config = JSON.parse(content); return config.gemini_api_key || ""; } catch { return ""; } } async function readApiKeyFromEnvFile() { try { const envContent = await fs.readFile(envPath, "utf-8"); const match = envContent.match(/^GEMINI_API_KEY=(.+)$/m); if (match) return match[1].trim().replace(/^["']|["']$/g, ""); } catch {} return ""; } async function refreshServerApiKey(reason) { if (envApiKey) return; // env var pins the value const fromConfig = await readApiKeyFromConfig(); const next = fromConfig || (await readApiKeyFromEnvFile()) || ""; if (next !== serverApiKey) { serverApiKey = next; console.log(`[config] server API key ${next ? "loaded" : "cleared"} (${reason})`); } } async function readRelayOperatorKeyFromConfig() { try { const content = await fs.readFile(startosConfigPath, "utf-8"); const config = JSON.parse(content); return (config.recap_relay_operator_key || "").trim(); } catch { return ""; } } async function refreshRelayOperatorKey(reason) { if (envRelayOperatorKey) return; // env var pins the value const next = await readRelayOperatorKeyFromConfig(); if (next !== relayOperatorKey) { relayOperatorKey = next; console.log( `[config] relay operator key ${next ? "loaded" : "cleared"} (${reason})`, ); } } // ── Public helpers ────────────────────────────────────────────────────────── // Resolves the per-request key — either the client's own (BYO) or the // server's stored key (when the client signals USE_SERVER_KEY or sends // nothing). export function resolveApiKey(clientKey) { if (!clientKey || clientKey === "USE_SERVER_KEY") return serverApiKey; return clientKey; } // Where the .env file lives (some other modules read it for legacy // settings like YT_COOKIES_FROM). export function getEnvPath() { return envPath; } // Snapshot of the full StartOS config blob — keys for every provider // (gemini, anthropic, openai, openai-compatible, ollama) plus the // admin-auth fields. Each request reads it once and passes it into // resolveProviderOpts() per provider. Returns {} if the file doesn't // exist or is unreadable. export async function getConfigSnapshot() { try { const content = await fs.readFile(startosConfigPath, "utf-8"); return JSON.parse(content) || {}; } catch { return {}; } } // Patch the StartOS config file in place. Reads current, merges in the // given fields, writes atomically (tmp + rename). Used by the picker // UI's Delete button to clear server-side credentials for a provider. // The next config poll picks up the changes within CONFIG_POLL_MS; // resolveProviderOpts already reads getConfigSnapshot per-request, so // effectively the change is immediate. // // `patch` is a plain object of { config_field: value } pairs. // Pass empty strings to clear a field rather than deleting the key — // the StartOS schema declares every field with a default of '', so // empty string is the canonical "unset" representation. export async function mergeConfig(patch) { if (!patch || typeof patch !== "object") return; let current = {}; try { const content = await fs.readFile(startosConfigPath, "utf-8"); current = JSON.parse(content) || {}; } catch {} const merged = { ...current, ...patch }; const tmp = startosConfigPath + ".tmp"; await fs.mkdir(path.dirname(startosConfigPath), { recursive: true }); await fs.writeFile(tmp, JSON.stringify(merged, null, 2), { mode: 0o600 }); await fs.rename(tmp, startosConfigPath); // Re-run the gemini-key refresher so serverApiKey reflects the // patch immediately (otherwise it'd lag until the poll tick). if (Object.prototype.hasOwnProperty.call(patch, "gemini_api_key")) { await refreshServerApiKey("merge config"); } if (Object.prototype.hasOwnProperty.call(patch, "recap_relay_operator_key")) { await refreshRelayOperatorKey("merge config"); } }