Spaces:
Runtime error
Runtime error
| /** | |
| * OmniRoute β Zero-Config Bootstrap | |
| * | |
| * Auto-generates required secrets (JWT_SECRET, STORAGE_ENCRYPTION_KEY) if | |
| * missing or empty, persists them to {DATA_DIR}/server.env so they survive | |
| * restarts, Docker volume remounts, and upgrades. | |
| * | |
| * Works across all deployment modes: | |
| * - npm / app runners: called from run-standalone.mjs and run-next.mjs | |
| * - Docker: same, secrets persisted in mounted volume | |
| * - Electron: called from main.js startup, persisted in DATA_DIR | |
| * | |
| * Priority (lowest β highest): | |
| * 1. Auto-generated defaults | |
| * 2. {DATA_DIR}/server.env (persisted on first boot) | |
| * 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env) | |
| * 4. process.env (shell / Docker -e flags, highest priority) | |
| */ | |
| import { randomBytes, createDecipheriv, scryptSync, createHash } from "node:crypto"; | |
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| import { createRequire } from "node:module"; | |
| import { homedir } from "node:os"; | |
| import { join, resolve } from "node:path"; | |
| const require = createRequire(import.meta.url); | |
| // ββ OAuth secrets that are optional but warn if missing βββββββββββββββββββββ | |
| const OPTIONAL_OAUTH_SECRETS = [ | |
| { keys: ["ANTIGRAVITY_OAUTH_CLIENT_SECRET"], label: "Antigravity OAuth" }, | |
| { keys: ["QODER_OAUTH_CLIENT_SECRET"], label: "Qoder OAuth" }, | |
| { | |
| keys: ["GEMINI_CLI_OAUTH_CLIENT_SECRET", "GEMINI_OAUTH_CLIENT_SECRET"], | |
| label: "Gemini OAuth", | |
| }, | |
| ]; | |
| // ββ Resolve DATA_DIR (mirrors dataPaths.ts logic) βββββββββββββββββββββββββββ | |
| function resolveDataDir(overridePath, env = process.env) { | |
| if (overridePath?.trim()) return resolve(overridePath); | |
| const configured = env.DATA_DIR?.trim(); | |
| if (configured) return resolve(configured); | |
| if (process.platform === "win32") { | |
| const appData = env.APPDATA || join(homedir(), "AppData", "Roaming"); | |
| return join(appData, "omniroute"); | |
| } | |
| const xdg = env.XDG_CONFIG_HOME?.trim(); | |
| if (xdg) return join(resolve(xdg), "omniroute"); | |
| return join(homedir(), ".omniroute"); | |
| } | |
| function getPreferredEnvFilePath(env = process.env) { | |
| const candidates = []; | |
| if (env.DATA_DIR?.trim()) { | |
| candidates.push(join(resolve(env.DATA_DIR.trim()), ".env")); | |
| } | |
| candidates.push(join(resolveDataDir(null, env), ".env")); | |
| candidates.push(join(process.cwd(), ".env")); | |
| return candidates.find((filePath) => existsSync(filePath)) ?? null; | |
| } | |
| function isNativeSqliteLoadError(error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| const code = error && typeof error === "object" && "code" in error ? error.code : undefined; | |
| return ( | |
| message.includes("Module did not self-register") || | |
| message.includes("NODE_MODULE_VERSION") || | |
| message.includes("ERR_DLOPEN_FAILED") || | |
| message.includes("Could not locate the bindings file") || | |
| message.includes("Cannot find module 'better-sqlite3'") || | |
| code === "ERR_DLOPEN_FAILED" || | |
| code === "MODULE_NOT_FOUND" | |
| ); | |
| } | |
| function hasEncryptedCredentials(dataDir) { | |
| const dbPath = join(dataDir, "storage.sqlite"); | |
| if (!existsSync(dbPath)) return false; | |
| try { | |
| const Database = require("better-sqlite3"); | |
| const db = new Database(dbPath, { readonly: true, fileMustExist: true }); | |
| try { | |
| const row = db | |
| .prepare( | |
| `SELECT 1 | |
| FROM provider_connections | |
| WHERE access_token LIKE 'enc:v1:%' | |
| OR refresh_token LIKE 'enc:v1:%' | |
| OR api_key LIKE 'enc:v1:%' | |
| OR id_token LIKE 'enc:v1:%' | |
| LIMIT 1` | |
| ) | |
| .get(); | |
| return !!row; | |
| } finally { | |
| db.close(); | |
| } | |
| } catch (error) { | |
| if (isNativeSqliteLoadError(error)) { | |
| return false; | |
| } | |
| const message = error instanceof Error ? error.message : String(error); | |
| throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`); | |
| } | |
| } | |
| // ββ Parse a simple KEY=VALUE env file βββββββββββββββββββββββββββββββββββββββ | |
| function parseEnvFile(filePath) { | |
| if (!existsSync(filePath)) return {}; | |
| const env = {}; | |
| const lines = readFileSync(filePath, "utf8").split(/\r?\n/); | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (!trimmed || trimmed.startsWith("#")) continue; | |
| const eqIdx = trimmed.indexOf("="); | |
| if (eqIdx < 1) continue; | |
| const key = trimmed.slice(0, eqIdx).trim(); | |
| const val = unquoteEnvValue(trimmed.slice(eqIdx + 1).trim()); | |
| env[key] = val; | |
| } | |
| return env; | |
| } | |
| function unquoteEnvValue(value) { | |
| if (value.length < 2) return value; | |
| const quote = value[0]; | |
| if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) return value; | |
| return value.slice(1, -1); | |
| } | |
| // ββ Write a simple KEY=VALUE env file βββββββββββββββββββββββββββββββββββββββ | |
| function writeEnvFile(filePath, env) { | |
| const lines = [ | |
| "# Auto-generated by OmniRoute bootstrap β do not delete", | |
| `# Created: ${new Date().toISOString()}`, | |
| "", | |
| ...Object.entries(env).map(([k, v]) => `${k}=${v}`), | |
| "", | |
| ]; | |
| writeFileSync(filePath, lines.join("\n"), "utf8"); | |
| } | |
| // ββ Main bootstrap function ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * @param {{ dataDirOverride?: string; quiet?: boolean }} options | |
| * @returns {Record<string, string>} merged env to pass to child process | |
| */ | |
| export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) { | |
| const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`); | |
| const preferredEnvPath = getPreferredEnvFilePath(process.env); | |
| const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {}; | |
| const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env }); | |
| const serverEnvPath = join(dataDir, "server.env"); | |
| // ββ Layer 1: Load persisted server.env ββββββββββββββββββββββββββββββββββββ | |
| let persisted = parseEnvFile(serverEnvPath); | |
| // ββ Layer 2: Load the same preferred .env that the CLI wrapper uses βββββββ | |
| // This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`. | |
| // | |
| // We strip empty values from preferredEnv so an empty placeholder | |
| // (e.g. `STORAGE_ENCRYPTION_KEY=` in the project .env template) does not | |
| // override the real value persisted in server.env. Only the .env entries | |
| // that the operator actually set should win. | |
| const preferredEnvFiltered = Object.fromEntries( | |
| Object.entries(preferredEnv).filter(([, v]) => typeof v === "string" && v.length > 0) | |
| ); | |
| const merged = { ...persisted, ...preferredEnvFiltered, ...process.env }; | |
| // ββ Auto-generate required secrets ββββββββββββββββββββββββββββββββββββββββ | |
| let needsPersist = false; | |
| if (!merged.JWT_SECRET?.trim()) { | |
| persisted.JWT_SECRET = randomBytes(64).toString("hex"); | |
| merged.JWT_SECRET = persisted.JWT_SECRET; | |
| needsPersist = true; | |
| log("β¨ JWT_SECRET auto-generated (first run)"); | |
| } | |
| if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) { | |
| if (hasEncryptedCredentials(dataDir)) { | |
| throw new Error( | |
| `Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join( | |
| dataDir, | |
| "storage.sqlite" | |
| )}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.` | |
| ); | |
| } | |
| persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex"); | |
| merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY; | |
| needsPersist = true; | |
| log("β¨ STORAGE_ENCRYPTION_KEY auto-generated (first run)"); | |
| } | |
| if (!merged.STORAGE_ENCRYPTION_KEY_VERSION?.trim()) { | |
| persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1"; | |
| merged.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION; | |
| needsPersist = true; | |
| } | |
| if (!merged.API_KEY_SECRET?.trim()) { | |
| persisted.API_KEY_SECRET = randomBytes(32).toString("hex"); | |
| merged.API_KEY_SECRET = persisted.API_KEY_SECRET; | |
| needsPersist = true; | |
| log("β¨ API_KEY_SECRET auto-generated (first run)"); | |
| } | |
| // ββ Persist new secrets ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (needsPersist) { | |
| try { | |
| mkdirSync(dataDir, { recursive: true }); | |
| // Only persist keys that we auto-generated (not .env or process.env vals) | |
| writeEnvFile(serverEnvPath, persisted); | |
| log(`π Secrets persisted to: ${serverEnvPath}`); | |
| } catch (e) { | |
| log(`β οΈ Could not persist secrets to ${serverEnvPath}: ${e.message}`); | |
| } | |
| } | |
| // ββ Mark as bootstrapped βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (needsPersist) { | |
| merged.OMNIROUTE_BOOTSTRAPPED = "true"; | |
| } | |
| // ββ Warn about missing optional OAuth secrets ββββββββββββββββββββββββββββββ | |
| const missingOauth = OPTIONAL_OAUTH_SECRETS.filter( | |
| ({ keys }) => !keys.some((key) => merged[key]?.trim()) | |
| ); | |
| if (missingOauth.length > 0) { | |
| log("βΉοΈ The following OAuth integrations are not configured:"); | |
| for (const { keys, label } of missingOauth) { | |
| log(` β’ ${label} (${keys.join(" or ")}) β set in .env or ${serverEnvPath}`); | |
| } | |
| log(" These providers will not work until configured."); | |
| } | |
| // ββ Warn about default password ββββββββββββββββββββββββββββββββββββββββββββ | |
| if (merged.INITIAL_PASSWORD === "CHANGEME" || !merged.INITIAL_PASSWORD?.trim()) { | |
| log("β οΈ INITIAL_PASSWORD is not set β using default 'CHANGEME'. Change it in Settings!"); | |
| } | |
| // ββ Decrypt-probe: verify STORAGE_ENCRYPTION_KEY matches encrypted data (#1622) β | |
| if (merged.STORAGE_ENCRYPTION_KEY?.trim() && hasEncryptedCredentials(dataDir)) { | |
| try { | |
| const Database = require("better-sqlite3"); | |
| const db = new Database(join(dataDir, "storage.sqlite"), { | |
| readonly: true, | |
| fileMustExist: true, | |
| }); | |
| try { | |
| const row = db | |
| .prepare( | |
| `SELECT api_key, access_token, refresh_token, id_token | |
| FROM provider_connections | |
| WHERE api_key LIKE 'enc:v1:%' | |
| OR access_token LIKE 'enc:v1:%' | |
| OR refresh_token LIKE 'enc:v1:%' | |
| OR id_token LIKE 'enc:v1:%' | |
| LIMIT 1` | |
| ) | |
| .get(); | |
| if (row) { | |
| const ciphertext = row.api_key || row.access_token || row.refresh_token || row.id_token; | |
| if (ciphertext?.startsWith("enc:v1:")) { | |
| const parts = ciphertext.split(":"); | |
| // enc:v1:<iv>:<ct>:<tag> | |
| if (parts.length >= 5) { | |
| const iv = Buffer.from(parts[2], "hex"); | |
| const ct = Buffer.from(parts[3], "hex"); | |
| const tag = Buffer.from(parts[4], "hex"); | |
| // Try decrypting with both key derivation methods matching encryption.ts | |
| const tryDecrypt = (derivedKey) => { | |
| const decipher = createDecipheriv("aes-256-gcm", derivedKey, iv); | |
| decipher.setAuthTag(tag); | |
| decipher.update(ct); | |
| decipher.final(); | |
| }; | |
| // Dynamic salt (current): scryptSync(secret, sha256(secret).slice(0,16), 32) | |
| const dynamicSalt = createHash("sha256") | |
| .update(merged.STORAGE_ENCRYPTION_KEY) | |
| .digest() | |
| .slice(0, 16); | |
| const dynamicKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, dynamicSalt, 32); | |
| // Legacy salt (fallback): scryptSync(secret, "omniroute-field-encryption-v1", 32) | |
| const legacySalt = "omniroute-field-encryption-v1"; | |
| const legacyKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, legacySalt, 32); | |
| let keyMatched = false; | |
| try { | |
| tryDecrypt(dynamicKey); | |
| keyMatched = true; | |
| } catch { | |
| // Try legacy key as fallback | |
| try { | |
| tryDecrypt(legacyKey); | |
| keyMatched = true; | |
| } catch { | |
| // Both failed β key truly doesn't match | |
| } | |
| } | |
| if (!keyMatched) { | |
| log( | |
| "β STORAGE_ENCRYPTION_KEY does not match the key used to encrypt your stored credentials." | |
| ); | |
| log( | |
| " Either restore your previous key via ~/.omniroute/server.env or ~/.omniroute/.env," | |
| ); | |
| log( | |
| " or run: omniroute reset-encrypted-columns --force (wipes credentials, keeps provider config)" | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| } finally { | |
| db.close(); | |
| } | |
| } catch { | |
| // Non-fatal β probe is best-effort | |
| } | |
| } | |
| return merged; | |
| } | |
| // ββ CLI usage: node scripts/build/bootstrap-env.mjs ββββββββββββββββββββββββββββββ | |
| if (process.argv[1] && process.argv[1].endsWith("bootstrap-env.mjs")) { | |
| const env = bootstrapEnv(); | |
| process.stderr.write(`[bootstrap] Done. DATA_DIR resolved to: ${resolveDataDir()}\n`); | |
| process.stderr.write(`[bootstrap] JWT_SECRET length: ${env.JWT_SECRET?.length ?? 0}\n`); | |
| process.stderr.write( | |
| `[bootstrap] STORAGE_ENCRYPTION_KEY length: ${env.STORAGE_ENCRYPTION_KEY?.length ?? 0}\n` | |
| ); | |
| } | |