mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 12:52:25 +03:00
* mcp/audit: fall back when better-sqlite3 export is not callable
Dashboard MCP status polls reopen a failed native sqlite load every 30s
because a minified TypeError ("a is not a function") was not treated as
a native load failure and a failed open was not cached. Classify that
shape, fall back to node:sqlite, cache the miss, and refuse to ship a
Docker image without better_sqlite3.node.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* mcp/audit: force native better-sqlite3 compile in Docker
better-sqlite3 13 ships a linux prebuild. Bare `node-gyp rebuild`
then only TOUCHes stamp files and never writes
build/Release/better_sqlite3.node, so the new test -f gate fails the
image build. Pass --force_build=1, matching the package's own
build-release script.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* db/core: keep native-load classification under the file-size cap
The audit fallback added two TypeError fingerprints in core.ts and
crossed the frozen 1788-line cap. Move the classifier into
sqliteLoadError.ts and re-export it so existing importers stay stable.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* build/bootstrap: keep the encrypted-credentials probe narrow
The native-load classifier was copied into scripts/build/bootstrap-env.mjs
alongside the runtime one, but the two files consume its verdict in opposite
directions. In src/lib/db/sqliteLoadError.ts a true verdict means "the driver
is unusable, cascade to node:sqlite", so treating a non-callable export as a
load failure is what we want. In the bootstrap the verdict feeds
hasEncryptedCredentials, where true means "no encrypted credentials found" and
clears the way to generate a fresh STORAGE_ENCRYPTION_KEY.
With the TypeError patterns in the bootstrap copy, a binding that loads but
exports something non-callable over a database full of enc:v1: rows reads as an
empty database, and the operator silently loses access to every stored
credential. Drop those two patterns from the bootstrap copy only, and note in
both files why the pair is deliberately not identical.
A corrupt binding still fails loudly there, now with the database path, the
underlying message, and a rebuild hint, so the narrower classifier does not
cost any diagnosability.
Signed-off-by: Minxi Hou <houminxi@gmail.com>
* fix(mcp): keep audit logging recoverable when the database is created later
getDb() cached a null for the "storage.sqlite does not exist yet" branch, and
closeAuditDb() returns before clearing a falsy cache — so an MCP server started
before the app created the database stayed without audit logging for the whole
process lifetime. Only a genuine driver-load failure is cached now; the
not-found branch retries, which is how it recovers when the file appears.
Covered by a new test that fails without the change.
Also replace the fabricated minified TypeError text ("a is not a function")
thrown by the loader with "better-sqlite3 export is not a function": the
operator sees a diagnosable message and isNativeSqliteLoadError() still
classifies it (it matches on "is not a function").
---------
Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
393 lines
16 KiB
JavaScript
393 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 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" },
|
||
];
|
||
|
||
// ── 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;
|
||
|
||
// Deliberately narrower than src/lib/db/sqliteLoadError.ts. There, a
|
||
// non-callable export means "fall back to another driver". Here, the only
|
||
// consumer treats a match as "no encrypted credentials exist", which lets
|
||
// STORAGE_ENCRYPTION_KEY be regenerated over a database that still holds
|
||
// enc:v1: rows. A generic TypeError must stay loud on this path.
|
||
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 isLikelyBrokenNativeBinding(error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
return message.includes("is not a function") || message.includes("is not a constructor");
|
||
}
|
||
|
||
function hasEncryptedCredentials(dataDir) {
|
||
const dbPath = join(dataDir, "storage.sqlite");
|
||
if (!existsSync(dbPath)) return false;
|
||
|
||
if (process.versions.bun) {
|
||
try {
|
||
const { Database } = require("bun:sqlite");
|
||
const db = new Database(dbPath, { readonly: true, create: false });
|
||
try {
|
||
const row = db
|
||
.query(
|
||
`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) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||
}
|
||
}
|
||
|
||
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);
|
||
const hint = isLikelyBrokenNativeBinding(error)
|
||
? " The better-sqlite3 native binding loaded but did not expose a usable constructor; try `npm rebuild better-sqlite3`."
|
||
: "";
|
||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}${hint}`);
|
||
}
|
||
}
|
||
|
||
// ── 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)
|
||
);
|
||
// Filter empty strings from process.env so that Docker `-e KEY=` (which sets an
|
||
// empty string) does not override real values persisted in server.env or set
|
||
// in .env. Only shell/Docker vars that the operator actually set should win.
|
||
// Mirrors the filtering already applied to preferredEnv above. (fixes #6824)
|
||
const processEnvFiltered = Object.fromEntries(
|
||
Object.entries(process.env).filter(([, v]) => typeof v === "string" && v.length > 0)
|
||
);
|
||
const merged = { ...persisted, ...preferredEnvFiltered, ...processEnvFiltered };
|
||
|
||
// ── 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`
|
||
);
|
||
}
|