Module split: extract API key + live reload to server/config.js

• 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).
This commit is contained in:
Keysat
2026-05-08 17:01:45 -05:00
parent 2c655dc9ee
commit 7ab2a3249a
2 changed files with 103 additions and 61 deletions
+91
View File
@@ -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;
}
+12 -61
View File
@@ -32,7 +32,10 @@ import {
ytExtraArgs, ytExtraArgs,
ytCookieMethod, ytCookieMethod,
setupCookieRoutes, setupCookieRoutes,
getCookieFilePath,
} from "./cookies.js"; } from "./cookies.js";
import * as config from "./config.js";
import { resolveApiKey } from "./config.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const app = express(); const app = express();
@@ -48,67 +51,14 @@ const configDir = path.join(DATA_DIR, "config");
await fs.mkdir(historyDir, { recursive: true }).catch(() => {}); await fs.mkdir(historyDir, { recursive: true }).catch(() => {});
await fs.mkdir(configDir, { recursive: true }).catch(() => {}); await fs.mkdir(configDir, { recursive: true }).catch(() => {});
// ── Server-side API key (shared across all clients) ─────────────────────── // API key + live reload moved to ./config.js
// Priority: GEMINI_API_KEY env var → StartOS config → .env file await config.initConfig({ dataDir: DATA_DIR });
// const envPath = config.getEnvPath();
// 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);
// Cookies state + helpers + routes moved to ./cookies.js // Cookies state + helpers + routes moved to ./cookies.js
await initCookies({ dataDir: DATA_DIR, envPath }); await initCookies({ dataDir: DATA_DIR, envPath });
function resolveApiKey(clientKey) { // resolveApiKey moved to ./config.js
// 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;
}
app.use(cors()); app.use(cors());
app.use(express.json({ limit: "100mb" })); 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) => { app.get("/api/health", async (req, res) => {
const info = await checkYtdlp(); const info = await checkYtdlp();
// Check cookies.txt freshness // Check cookies.txt freshness
let cookieInfo = { method: ytCookieMethod() }; const cookieMethod = ytCookieMethod();
if (ytCookiesFileExists) { let cookieInfo = { method: cookieMethod };
if (cookieMethod === "cookies.txt") {
try { try {
const stat = await fs.stat(ytCookiesFilePath); const stat = await fs.stat(getCookieFilePath());
const ageMs = Date.now() - stat.mtimeMs; const ageMs = Date.now() - stat.mtimeMs;
const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24)); const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
cookieInfo.fileAgeDays = ageDays; cookieInfo.fileAgeDays = ageDays;
cookieInfo.fileExpiring = ageDays > 12; // cookies typically expire after ~14 days cookieInfo.fileExpiring = ageDays > 12; // cookies typically expire after ~14 days
} catch {} } 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 ─────────────────────────────────────────────────────── // ── Status endpoints ───────────────────────────────────────────────────────