#!/usr/bin/env node /* * sync-keys.js — seed every active member's chat key into LibreChat's * per-user key store so members NEVER see an API-key prompt. * * keys[name="Inference Co-op"] <- {apiKey: } (chat endpoint) * pluginauths[mcp:inference-image] <- IC_API_KEY (image MCP) * * Values are encrypted with the SAME crypto LibreChat uses (data-schemas * encrypt(): AES-256-CBC, CREDS_KEY/CREDS_IV from the app process env), so * LibreChat decrypts them natively. * * Run: cd /app/code/librechat/api && node /app/data/sync-keys.js * (requires CLOUDRON_MONGODB_URL in the environment — present in `cloudron exec`) * Idempotent: safe to run repeatedly. * * SCHEDULED INSIDE THIS APP (2026-10-25): the manifest's `scheduler` addon runs * this every 10 minutes, so seeding no longer depends on any machine outside * the co-op's Cloudron box. Two guards keep a bad run from damaging members: * it refuses to write unless CREDS_KEY can first decrypt an existing key row * (proof it is the app's real key), and unless each fresh ciphertext round-trips * back to the plaintext. */ const fs = require("fs"); const os = require("os"); const path = require("path"); // --- env assembly: exec sessions lack the app's process env --------------- function loadEnvFile(p) { try { for (const line of fs.readFileSync(p, "utf8").split("\n")) { const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ""); } } catch (e) { /* optional */ } } loadEnvFile("/app/data/env"); try { // CREDS_KEY / CREDS_IV / MONGODB live in the app process env for (const line of fs.readFileSync("/proc/1/environ", "utf8").split("\0")) { const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]; } } catch (e) { /* optional */ } const MONGO_URL = process.env.CLOUDRON_MONGODB_URL || process.env.MONGODB_URL; const PORTAL_URL = process.env.PORTAL_URL || "https://portal.inference.coop"; const SYNC_TOKEN = process.env.LIBRECHAT_SYNC_TOKEN || ""; const ENDPOINT_NAME = "Inference Co-op"; const MCP_PLUGIN_KEY = "mcp_inference-image" // Constants.mcp_prefix is "mcp_" (underscore), not "mcp:"; const MCP_AUTH_FIELD = "IC_API_KEY"; if (!MONGO_URL) { console.error("FATAL: no MongoDB URL in environment"); process.exit(1); } if (!SYNC_TOKEN) { console.error("FATAL: LIBRECHAT_SYNC_TOKEN not set"); process.exit(1); } if (!process.env.CREDS_KEY || !process.env.CREDS_IV) { console.error("FATAL: CREDS_KEY/CREDS_IV not available — refusing to write (keys would be undecryptable)"); process.exit(1); } // data-schemas resolves CREDS_KEY/CREDS_IV at require time — env is ready above const { encrypt, decrypt } = require("/app/code/librechat/node_modules/@librechat/data-schemas"); const mongoose = require("/app/code/librechat/node_modules/mongoose"); (async () => { // 1. Fetch active members' chat keys from the portal (dedicated sync token) const res = await fetch(`${PORTAL_URL}/admin/librechat/keys`, { headers: { "X-Sync-Token": SYNC_TOKEN }, }); if (!res.ok) { console.error(`FATAL: portal key feed returned ${res.status}`); process.exit(1); } const { members } = await res.json(); console.log(`portal feed: ${members.length} active member key(s)`); await mongoose.connect(MONGO_URL); const db = mongoose.connection.db; const users = db.collection("users"); const keys = db.collection("keys"); const auths = db.collection("pluginauths"); // --- safety: prove CREDS_KEY is the app's own key before writing anything -- // A row that decrypts to a real key proves we hold the key the app encrypted // with. If rows exist and none decrypt, our CREDS_KEY is wrong, and writing // would replace working member keys with undecryptable ones — so refuse. const sample = await keys.find({ name: ENDPOINT_NAME }).limit(25).toArray(); let credsCheck = sample.length === 0 ? "no-existing-rows" : null; for (const row of sample) { try { const obj = JSON.parse(await decrypt(row.value)); if (obj && typeof obj.apiKey === "string" && obj.apiKey.length > 8) { credsCheck = "decrypt-ok"; break; } } catch (e) { /* try the next row */ } } if (!credsCheck) { console.error(`FATAL: CREDS_KEY decrypts none of ${sample.length} existing key row(s) — refusing to write`); process.exit(1); } console.log(`creds check: ${credsCheck} (${sample.length} existing row(s))`); let seeded = 0, skipped = 0; for (const m of members) { const email = String(m.email || "").toLowerCase(); const sk = String(m.key || ""); if (!email || !sk) continue; const user = await users.findOne({ email: { $regex: new RegExp(`^${email.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") }, }); if (!user) { skipped++; continue; } // not logged in to the sandbox yet const uid = user._id; // chat endpoint key: value is a JSON object string, same shape the UI saves const encChat = await encrypt(JSON.stringify({ apiKey: sk })); if (JSON.parse(await decrypt(encChat)).apiKey !== sk) { console.error("FATAL: encrypt/decrypt round-trip mismatch — refusing to write"); process.exit(1); } await keys.updateOne( { userId: uid, name: ENDPOINT_NAME }, { $set: { userId: uid, name: ENDPOINT_NAME, value: encChat } }, { upsert: true } ); // image MCP credential const encImage = await encrypt(sk); await auths.updateOne( { userId: uid, pluginKey: MCP_PLUGIN_KEY, authField: MCP_AUTH_FIELD }, { $set: { userId: uid, pluginKey: MCP_PLUGIN_KEY, authField: MCP_AUTH_FIELD, value: encImage } }, { upsert: true } ); seeded++; console.log(`seeded ${email} (user ${String(uid).slice(-6)})`); } console.log(`done: ${seeded} seeded, ${skipped} skipped (no sandbox account yet)`); await mongoose.disconnect(); })().catch((e) => { console.error("FATAL:", e); process.exit(1); });