feat(keys): first-class BYOK key management — store, catalog, /api/keys

The auth/keys wave (2026-09-19): the server now owns the flat auth.json
map directly (LH_AUTH_FILE from main.cjs; repo-root auth.json in dev,
gitignored). The store reflects itself into the child's env at boot with
fill-gap precedence — real env (incl. .env.local in dev) always wins —
so every consumer keeps its plain process.env reads and saved keys take
effect immediately, no restart; main.cjs's spawn-time injection retired
(an env copy would shadow every settings-page change).

Curated 4-key catalog (OpenRouter, Deepgram, Google search pair) with
shape-only validation (no live pings — owner decision), 400s on bad
pastes, last-4+length masking (plaintext never leaves the store),
unknown file vars preserved + surfaced. Routes: GET /api/keys,
PUT/DELETE /api/keys/:id. Tests: catalog/store/composition, with the
env-sanitizing prologue (a dev shell may export real keys; tests use
throwaway values only).
This commit is contained in:
amosblomqvist
2026-09-19 20:54:16 +03:00
parent c32915b232
commit cbaf6df454
15 changed files with 831 additions and 21 deletions
+63
View File
@@ -0,0 +1,63 @@
/**
* The AuthStore adapter (auth-keys wave, 2026-09-19): the flat {VAR: value}
* auth.json map at config.AUTH_FILE — the SAME file the Electron app has
* always hand-edited (owner decision: option A — same file, env-located,
* zero migration). 0600, atomic writes, hand-edits keep working (the file
* is re-read from disk on every access).
*
* The server (src/server/keys.ts) is the only intended writer: it applies
* shape validation before setAuthVar and env precedence on top of
* authVars(). This adapter persists what it is given, never throws
* (absent/corrupt file = {} = "no keys yet", the documented first-boot
* state, matching settings doctrine), and preserves unknown vars verbatim
* (forward-compat: future entries — e.g. a plans-login token — ride along
* untouched until a catalog entry adopts them).
*/
import fs from "node:fs";
import path from "node:path";
import { AUTH_FILE } from "../../config.ts";
import type { AuthStore } from "../core/ports.ts";
/** The flat string→string map, or {} for absent/corrupt/non-object. */
function readMap(): Record<string, string> {
try {
const raw = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
return Object.fromEntries(
Object.entries(raw).filter(([, v]) => typeof v === "string") as [string, string][],
);
} catch {
return {}; // absent or corrupt — the documented state is "no keys yet"
}
}
/** Write the map atomically at mode 0600: a partial write must never
* leave a 0644 file (secrets) or a truncated JSON (looks like "no keys").
* tmp-in-same-dir + rename keeps the swap atomic; chmod BEFORE the
* rename so the mode never exists relaxed on the final path. */
function writeMap(map: Record<string, string>): void {
const dir = path.dirname(AUTH_FILE);
fs.mkdirSync(dir, { recursive: true });
const tmp = path.join(dir, `.auth.json.tmp-${process.pid}`);
fs.writeFileSync(tmp, `${JSON.stringify(map, null, "\t")}\n`, { mode: 0o600 });
fs.chmodSync(tmp, 0o600); // umask may have relaxed the create mode
fs.renameSync(tmp, AUTH_FILE);
}
export const authStore: AuthStore = {
authVars() {
return readMap();
},
setAuthVar(name, value) {
const map = readMap();
map[name] = value;
writeMap(map);
},
deleteAuthVar(name) {
const map = readMap();
if (!(name in map)) return; // absent = silent success, idempotent
delete map[name];
writeMap(map);
},
};