| #!/usr/bin/env node
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| 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);
|
|
|
|
|
| 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",
|
| },
|
| ];
|
|
|
|
|
| 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}`);
|
| }
|
| }
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
| 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");
|
| }
|
|
|
|
|
| |
| |
| |
|
|
| 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");
|
|
|
|
|
| let persisted = parseEnvFile(serverEnvPath);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const preferredEnvFiltered = Object.fromEntries(
|
| Object.entries(preferredEnv).filter(([, v]) => typeof v === "string" && v.length > 0)
|
| );
|
| const merged = { ...persisted, ...preferredEnvFiltered, ...process.env };
|
|
|
|
|
| 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)");
|
| }
|
|
|
|
|
| if (needsPersist) {
|
| try {
|
| mkdirSync(dataDir, { recursive: true });
|
|
|
| writeEnvFile(serverEnvPath, persisted);
|
| log(`π Secrets persisted to: ${serverEnvPath}`);
|
| } catch (e) {
|
| log(`β οΈ Could not persist secrets to ${serverEnvPath}: ${e.message}`);
|
| }
|
| }
|
|
|
|
|
| if (needsPersist) {
|
| merged.OMNIROUTE_BOOTSTRAPPED = "true";
|
| }
|
|
|
|
|
| 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.");
|
| }
|
|
|
|
|
| if (merged.INITIAL_PASSWORD === "CHANGEME" || !merged.INITIAL_PASSWORD?.trim()) {
|
| log("β οΈ INITIAL_PASSWORD is not set β using default 'CHANGEME'. Change it in Settings!");
|
| }
|
|
|
|
|
| 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(":");
|
|
|
| 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");
|
|
|
|
|
| const tryDecrypt = (derivedKey) => {
|
| const decipher = createDecipheriv("aes-256-gcm", derivedKey, iv);
|
| decipher.setAuthTag(tag);
|
| decipher.update(ct);
|
| decipher.final();
|
| };
|
|
|
|
|
| const dynamicSalt = createHash("sha256")
|
| .update(merged.STORAGE_ENCRYPTION_KEY)
|
| .digest()
|
| .slice(0, 16);
|
| const dynamicKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, dynamicSalt, 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 {
|
| tryDecrypt(legacyKey);
|
| keyMatched = true;
|
| } catch {
|
|
|
| }
|
| }
|
|
|
| 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 {
|
|
|
| }
|
| }
|
|
|
| return merged;
|
| }
|
|
|
|
|
| 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`
|
| );
|
| }
|
|
|