From 7ab2a3249a0b89e24286b660caf1c9db74092205 Mon Sep 17 00:00:00 2001 From: Keysat Date: Fri, 8 May 2026 17:01:45 -0500 Subject: [PATCH] Module split: extract API key + live reload to server/config.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • initConfig({ dataDir }) — boot-time setup; mkdir's configDir, reads initial value, kicks off the 3 s poll loop • serverApiKey — exported as a 'let' binding (ESM live binding) so importers see the current value • resolveApiKey(clientKey) — picks per-request key (BYO vs server) • getEnvPath() — exposes /data/.env path so the cookies module can read its own legacy YT_COOKIES_FROM setting Bug fix uncovered during this extraction: /api/health was directly referencing ytCookiesFileExists / ytCookiesFilePath (module-scoped vars I'd already moved to cookies.js). The route silently 500'd on the first request after the cookies extraction. Now uses ytCookieMethod() and getCookieFilePath() instead. server/index.js: 2510 → 2461 lines. Smoke tested: server boots; /api/health responds; updating /data/config/startos-config.json flips hasServerKey from false → true within 3 s ([config] server API key loaded log line confirms). --- server/config.js | 91 ++++++++++++++++++++++++++++++++++++++++++++++++ server/index.js | 73 +++++++------------------------------- 2 files changed, 103 insertions(+), 61 deletions(-) create mode 100644 server/config.js diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..62cf61a --- /dev/null +++ b/server/config.js @@ -0,0 +1,91 @@ +// 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 = ""; + +// ── 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; + + await fs.mkdir(configDir, { recursive: true }).catch(() => {}); + await refreshServerApiKey("startup"); + + const pollMs = parseInt(process.env.RECAP_CONFIG_POLL_MS || "3000", 10); + setInterval(() => { + refreshServerApiKey("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})`); + } +} + +// ── 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; +} diff --git a/server/index.js b/server/index.js index 02272af..dd8e598 100644 --- a/server/index.js +++ b/server/index.js @@ -32,7 +32,10 @@ import { ytExtraArgs, ytCookieMethod, setupCookieRoutes, + getCookieFilePath, } from "./cookies.js"; +import * as config from "./config.js"; +import { resolveApiKey } from "./config.js"; const execFileAsync = promisify(execFile); const app = express(); @@ -48,67 +51,14 @@ const configDir = path.join(DATA_DIR, "config"); await fs.mkdir(historyDir, { recursive: true }).catch(() => {}); await fs.mkdir(configDir, { recursive: true }).catch(() => {}); -// ── Server-side API key (shared across all clients) ─────────────────────── -// Priority: GEMINI_API_KEY env var → StartOS config → .env file -// -// The StartOS config path is watched for changes — when a user updates the -// key via the "Set Gemini API Key" action, the new value is picked up -// without a service restart. Env var takes priority and pins the value. -const envPath = path.join(DATA_DIR, ".env"); -const startosConfigPath = path.join(configDir, "startos-config.json"); -const envApiKey = process.env.GEMINI_API_KEY || ""; -let serverApiKey = envApiKey; - -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})`); - } -} - -await refreshServerApiKey("startup"); - -// Poll the StartOS config file every few seconds and refresh the in-memory -// API key when it changes — so a key updated via the "Set Gemini API Key" -// action is picked up without a service restart. Polling is more reliable -// than fs.watch (FSEvents on macOS, inotify edge cases on Linux, atomic- -// write rename behavior in the SDK file model). The cost is a single stat -// every 3 s, which is negligible. -const CONFIG_POLL_MS = parseInt(process.env.RECAP_CONFIG_POLL_MS || "3000", 10); -setInterval(() => { - refreshServerApiKey("config poll").catch(() => {}); -}, CONFIG_POLL_MS); +// API key + live reload moved to ./config.js +await config.initConfig({ dataDir: DATA_DIR }); +const envPath = config.getEnvPath(); // Cookies state + helpers + routes moved to ./cookies.js await initCookies({ dataDir: DATA_DIR, envPath }); -function resolveApiKey(clientKey) { - // Client can send "USE_SERVER_KEY" or empty to use the server's stored key - if (!clientKey || clientKey === "USE_SERVER_KEY") return serverApiKey; - return clientKey; -} +// resolveApiKey moved to ./config.js app.use(cors()); app.use(express.json({ limit: "100mb" })); @@ -330,17 +280,18 @@ app.use("/assets", express.static(path.join(__dirname, "..", "assets"))); app.get("/api/health", async (req, res) => { const info = await checkYtdlp(); // Check cookies.txt freshness - let cookieInfo = { method: ytCookieMethod() }; - if (ytCookiesFileExists) { + const cookieMethod = ytCookieMethod(); + let cookieInfo = { method: cookieMethod }; + if (cookieMethod === "cookies.txt") { try { - const stat = await fs.stat(ytCookiesFilePath); + const stat = await fs.stat(getCookieFilePath()); const ageMs = Date.now() - stat.mtimeMs; const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24)); cookieInfo.fileAgeDays = ageDays; cookieInfo.fileExpiring = ageDays > 12; // cookies typically expire after ~14 days } catch {} } - res.json({ ok: true, ytdlp: info.installed, hasServerKey: !!serverApiKey, cookies: cookieInfo, ...info }); + res.json({ ok: true, ytdlp: info.installed, hasServerKey: !!config.serverApiKey, cookies: cookieInfo, ...info }); }); // ── Status endpoints ───────────────────────────────────────────────────────