File size: 14,536 Bytes
5448d8b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
#!/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" },
  {
    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`
  );
}