From 65c6730463524b1e7eca9b174284b0ffda73e509 Mon Sep 17 00:00:00 2001 From: Nathan Schneider Date: Sun, 18 Oct 2026 23:28:23 -0600 Subject: [PATCH] Per-member key auto-load: seed keys via sync-keys.js (portal feed); restore customUserVars for image billing --- librechat.yaml | 5 +++ sync-keys.js | 100 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 sync-keys.js diff --git a/librechat.yaml b/librechat.yaml index 1339836..3f0eddc 100644 --- a/librechat.yaml +++ b/librechat.yaml @@ -178,3 +178,8 @@ mcpServers: # Per-user credentials aren't available at boot; each member initializes # their own connection after saving their key in the MCP panel. startup: false + customUserVars: + IC_API_KEY: + title: "Inference Co-op API key" + description: "Auto-loaded from your membership — no action needed." + sensitive: true diff --git a/sync-keys.js b/sync-keys.js new file mode 100644 index 0000000..5a4940c --- /dev/null +++ b/sync-keys.js @@ -0,0 +1,100 @@ +#!/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 (e.g. nightly steward cron or after + * onboarding). + */ +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"; +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); } + +// data-schemas resolves CREDS_KEY/CREDS_IV at require time — env is ready above +const { encrypt } = 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"); + + 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 })); + 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); });