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:
@@ -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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user