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
+88
View File
@@ -0,0 +1,88 @@
/**
* The curated auth-key catalog + shape rules (auth-keys wave, 2026-09-19)
* — PURE module, so this file owns no fs. Owner decisions being pinned:
* - the catalog is exactly the four known keys, ids = env-var names;
* - validation is SHAPE-ONLY (no live pings) and bad pastes are 400s;
* - masking is last-4 + length — plaintext never leaves the store.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { AUTH_KEYS, isKnownAuthKey, maskKey, validateKeyShape } from "../src/core/auth-keys.ts";
describe("the catalog", () => {
it("is exactly the four curated keys, ids = env-var names", () => {
assert.deepEqual(
AUTH_KEYS.map((k) => k.id),
["OPENROUTER_API_KEY", "DEEPGRAM_API_KEY", "GOOGLE_SEARCH_API_KEY", "GOOGLE_CSE_ID"],
);
for (const k of AUTH_KEYS) {
assert.ok(k.label.length > 0, `${k.id} has a label`);
assert.ok(k.description.length > 0, `${k.id} has a description`);
assert.ok(k.docsUrl.startsWith("https://"), `${k.id} has a docs link`);
}
});
it("isKnownAuthKey: catalog yes, everything else no", () => {
assert.ok(isKnownAuthKey("OPENROUTER_API_KEY"));
assert.ok(isKnownAuthKey("GOOGLE_CSE_ID"));
assert.ok(!isKnownAuthKey("OPENROUTER"));
assert.ok(!isKnownAuthKey("FUTURE_PLANS_TOKEN"));
assert.ok(!isKnownAuthKey(""));
});
});
describe("validateKeyShape (the 400 gate)", () => {
it("OpenRouter: sk-or- prefix required, length floor past it", () => {
assert.deepEqual(validateKeyShape("OPENROUTER_API_KEY", "sk-or-v1-abc123def456ghi789jkl012"), { ok: true });
assert.ok(!validateKeyShape("OPENROUTER_API_KEY", "sk-proj-abcdefghij").ok, "wrong family (OpenAI paste)");
assert.ok(!validateKeyShape("OPENROUTER_API_KEY", "sk-or-").ok, "prefix alone is a truncation");
assert.ok(!validateKeyShape("OPENROUTER_API_KEY", " ").ok, "empty fails");
});
it("Google API key: AIza prefix required", () => {
assert.ok(validateKeyShape("GOOGLE_SEARCH_API_KEY", "AIzaSyD-abcdef1234").ok);
assert.ok(!validateKeyShape("GOOGLE_SEARCH_API_KEY", "sk-or-v1-abcdefghij").ok, "wrong family");
assert.ok(!validateKeyShape("GOOGLE_SEARCH_API_KEY", "AIza").ok, "prefix alone fails");
});
it("CSE ID: legacy digits:digits OR plain token form", () => {
assert.ok(validateKeyShape("GOOGLE_CSE_ID", "017576662512468239146:omuauf_lfve").ok);
assert.ok(validateKeyShape("GOOGLE_CSE_ID", "a1b2c3d4e5f6g7h8j9k0lmnop").ok);
assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "17:short").ok, "short legacy form fails");
assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "has whitespace here").ok);
assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "sk-or-v1-abcdefg").ok, "wrong family");
});
it("Deepgram: no public format — a ≥20-char truncation guard, no whitespace", () => {
assert.ok(validateKeyShape("DEEPGRAM_API_KEY", "a".repeat(40)).ok);
assert.ok(!validateKeyShape("DEEPGRAM_API_KEY", "a".repeat(19)).ok, "truncated paste");
assert.ok(!validateKeyShape("DEEPGRAM_API_KEY", "a b c d e f g h i j k l m n o p q r s").ok, "whitespace");
});
it("trims surrounding paste whitespace before checking", () => {
assert.ok(validateKeyShape("OPENROUTER_API_KEY", " sk-or-v1-abc123def456 \n").ok);
});
it("unknown ids are rejected with the supported list", () => {
const r = validateKeyShape("SOMETHING_ELSE", "whatever");
assert.ok(!r.ok);
if (!r.ok) assert.match(r.error, /SOMETHING_ELSE.*OPENROUTER_API_KEY/);
});
});
describe("maskKey (plaintext never leaves)", () => {
it("shows the last 4 only", () => {
assert.equal(maskKey("sk-or-v1-abcdefgh1234"), "…1234");
});
it("never reveals a degenerate short value in full", () => {
assert.equal(maskKey("ab"), "…ab");
assert.equal(maskKey("abcd"), "…abcd");
assert.equal(maskKey("abcde"), "…bcde");
});
it("trims before masking", () => {
assert.equal(maskKey(" sk-or-v1-xyzw9 "), "…yzw9");
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* The AuthStore fs adapter (auth-keys wave, 2026-09-19) — flat {VAR: value}
* map at config.AUTH_FILE. THIS FILE is the sole owner of its auth file:
* LH_AUTH_FILE is redirected to a fresh tmp dir BEFORE the first config
* import (the seam is module-load-bound — data-dir-seam precedent), so no
* hand-edited dev/desktop auth.json is ever touched.
*
* Pinning: absent/corrupt = {} ("no keys yet"); set/delete round-trip with
* unknown vars preserved verbatim; the file is ALWAYS mode 0600 (secrets);
* writes are atomic (tmp+rename — no partial JSON state).
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, before, describe, it } from "node:test";
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lh-auth-store-"));
process.env.LH_AUTH_FILE = path.join(TMP, "auth.json");
// Import AFTER the env redirect — module-load-bound seam.
const { authStore } = await import("../src/store/auth.ts");
const { AUTH_FILE } = await import("../config.ts");
after(() => {
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("AuthStore adapter", () => {
it("absent file = {} (the documented first-boot state), never throws", () => {
assert.deepEqual(authStore.authVars(), {});
});
it("set → read round-trip; trim is the CALLER's (server's) job, verbatim here", () => {
authStore.setAuthVar("OPENROUTER_API_KEY", "sk-or-v1-abc");
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-abc");
});
it("the file is mode 0600 (secrets), also after rewrites", () => {
authStore.setAuthVar("DEEPGRAM_API_KEY", "d".repeat(40));
const first = fs.statSync(AUTH_FILE).mode & 0o777;
assert.equal(first, 0o600);
authStore.setAuthVar("DEEPGRAM_API_KEY", "e".repeat(40));
assert.equal(fs.statSync(AUTH_FILE).mode & 0o777, 0o600, "rewrite keeps 0600");
});
it("set preserves unknown vars verbatim (forward-compat)", () => {
fs.writeFileSync(AUTH_FILE, JSON.stringify({ FUTURE_PLANS_TOKEN: "opaque-xyz", KEEP: "me" }));
authStore.setAuthVar("OPENROUTER_API_KEY", "sk-or-v1-new");
const vars = authStore.authVars();
assert.equal(vars.FUTURE_PLANS_TOKEN, "opaque-xyz");
assert.equal(vars.KEEP, "me");
assert.equal(vars.OPENROUTER_API_KEY, "sk-or-v1-new");
});
it("corrupt/non-object file = {} — never throws, looks like first boot", () => {
fs.writeFileSync(AUTH_FILE, "{not json");
assert.deepEqual(authStore.authVars(), {});
fs.writeFileSync(AUTH_FILE, JSON.stringify(["an", "array"]));
assert.deepEqual(authStore.authVars(), {});
fs.writeFileSync(AUTH_FILE, JSON.stringify({ OK: "fine", bad: 42 }));
assert.deepEqual(authStore.authVars(), { OK: "fine" }, "non-string values drop");
});
it("delete removes just that var; absent delete = silent no-op", () => {
fs.writeFileSync(AUTH_FILE, JSON.stringify({ A: "1", B: "2" }));
authStore.deleteAuthVar("A");
assert.deepEqual(authStore.authVars(), { B: "2" });
authStore.deleteAuthVar("NOT_THERE");
assert.deepEqual(authStore.authVars(), { B: "2" });
});
it("atomic writes: no tmp leftovers, valid JSON on disk every time", () => {
authStore.setAuthVar("OPENROUTER_API_KEY", "sk-or-v1-final");
const files = fs.readdirSync(TMP).filter((f) => f.startsWith(".auth.json.tmp"));
assert.deepEqual(files, [], "no tmp file survives the rename");
JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")); // must parse
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* The keys composition (auth-keys wave, 2026-09-19): env-reflecting store +
* env-wins precedence + the masked status view. Owner decisions pinned:
* - real env always wins; a PUT to an env-owned var persists to the file
* but never overwrites the env copy (status reports shadowedByEnv);
* - saves/deletes take effect immediately (file + process.env together);
* - unknown file vars are preserved and surfaced, never editable.
*
* LH_AUTH_FILE redirects to a fresh tmp dir BEFORE the imports (the seam is
* module-load bound); the \"real env\" var is set before src/server/keys.ts
* first loads so the boot snapshot treats it as real environment. All
* values here are throwaway test strings — never real secrets.
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { after, describe, it } from "node:test";
// THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog vars
// (a developer shell may export REAL keys; tests use throwaway values only,
// never real secrets). The strip affects only this disposable child process.
for (const varName of [
"OPENROUTER_API_KEY",
"DEEPGRAM_API_KEY",
"GOOGLE_SEARCH_API_KEY",
"GOOGLE_CSE_ID",
"FUTURE_PLANS_TOKEN",
]) {
delete process.env[varName];
}
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lh-server-keys-"));
const AUTH = path.join(TMP, "auth.json");
process.env.LH_AUTH_FILE = AUTH;
// The file BEFORE first boot: a shadowed OpenRouter (env will differ) + an
// unknown var that must survive every write and surface as a generic row.
fs.writeFileSync(
AUTH,
JSON.stringify({
OPENROUTER_API_KEY: "sk-or-v1-filefilefile",
FUTURE_PLANS_TOKEN: "opaque-plans-token",
}),
);
// Real env (set BEFORE the module's first import → boot snapshots it):
process.env.OPENROUTER_API_KEY = "sk-or-v1-envvalue99";
// Import AFTER env setup — module init runs the boot fill-gap + snapshot.
const { deleteKey, initAuthEnv, keyStatuses, keyValue, resolveKeyStatuses, saveKey } = await import(
"../src/server/keys.ts"
);
const { authStore } = await import("../src/store/auth.ts");
const ENV_OWNED = "sk-or-v1-envvalue99";
after(() => {
delete process.env.OPENROUTER_API_KEY;
delete process.env.DEEPGRAM_API_KEY;
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("boot fill-gap + precedence", () => {
it("env-owned var: env wins, shadow reported, file copy kept", () => {
const row = keyStatuses().find((s) => s.id === "OPENROUTER_API_KEY")!;
assert.equal(row.source, "env");
assert.ok(row.shadowedByEnv, "file value differs from the env value");
assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`);
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-filefilefile", "file copy untouched");
});
it("unknown file var: fill-gapped into env, surfaced as a generic row", () => {
assert.equal(process.env.FUTURE_PLANS_TOKEN, "opaque-plans-token", "reflected at boot");
const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!;
assert.equal(row.info, null, "no curated form");
assert.equal(row.source, "file");
assert.ok(row.present);
});
it("absent curated keys: not present, source none", () => {
const row = keyStatuses().find((s) => s.id === "DEEPGRAM_API_KEY")!;
assert.ok(!row.present);
assert.equal(row.source, "none");
assert.equal(row.masked, null);
assert.ok(row.info, "catalog metadata still present for the page form");
});
it("keyValue resolves env-first", () => {
assert.equal(keyValue("OPENROUTER_API_KEY"), ENV_OWNED);
assert.equal(keyValue("FUTURE_PLANS_TOKEN"), "opaque-plans-token");
assert.equal(keyValue("DEEPGRAM_API_KEY"), undefined);
});
});
describe("saveKey / deleteKey (immediate effect, no restart)", () => {
it("save reflects into env + file (fill-gap set)", () => {
const value = "d".repeat(40); // throwaway, ≥ Deepgram floor
const r = saveKey("DEEPGRAM_API_KEY", value);
assert.ok(r.ok);
assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately");
assert.equal(authStore.authVars().DEEPGRAM_API_KEY, value, "file updated");
assert.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth");
assert.ok(!r.status.shadowedByEnv);
});
it("save to an env-owned var: file updated, env NOT overwritten, shadow reported", () => {
const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345");
assert.ok(r.ok);
assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched");
assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later");
assert.ok(r.status.shadowedByEnv);
});
it("bad shape: rejected, nothing stored or reflected", () => {
const before = JSON.stringify(authStore.authVars());
const r = saveKey("DEEPGRAM_API_KEY", "short");
assert.ok(!r.ok);
if (!r.ok) assert.match(r.error, /at least 20/);
assert.equal(JSON.stringify(authStore.authVars()), before);
assert.equal(process.env.DEEPGRAM_API_KEY, "d".repeat(40));
});
it("unknown id: rejected with the supported list", () => {
const r = saveKey("FUTURE_PLANS_TOKEN", "anything-at-all-here");
assert.ok(!r.ok);
if (!r.ok) assert.match(r.error, /FUTURE_PLANS_TOKEN/);
});
it("delete removes env + file for fill-gap vars", () => {
const row = deleteKey("DEEPGRAM_API_KEY");
assert.ok(!row.present);
assert.equal(process.env.DEEPGRAM_API_KEY, undefined, "env copy was ours — removed");
assert.ok(!("DEEPGRAM_API_KEY" in authStore.authVars()));
});
it("delete of an env-owned var: file copy gone, env remains (env wins)", () => {
const row = deleteKey("OPENROUTER_API_KEY");
assert.equal(row.source, "env");
assert.ok(row.present, "still live from the real env");
assert.ok(!row.shadowedByEnv, "nothing left in the file to shadow");
assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED);
});
it("re-running initAuthEnv is idempotent — deleted vars never resurrect", () => {
initAuthEnv();
assert.equal(process.env.DEEPGRAM_API_KEY, undefined, "file entry gone → no re-fill");
});
});
describe("resolveKeyStatuses (pure projection)", () => {
it("same value in env and file is NOT shadowed (redundant, not misleading)", () => {
const row = resolveKeyStatuses(
{ GOOGLE_SEARCH_API_KEY: "AIzaSame1234" },
{ GOOGLE_SEARCH_API_KEY: "AIzaSame1234" },
new Set(["GOOGLE_SEARCH_API_KEY"]),
).find((s) => s.id === "GOOGLE_SEARCH_API_KEY")!;
assert.ok(row, "the curated id is always in the projection");
assert.equal(row.source, "env");
assert.ok(!row.shadowedByEnv, "same value both places: redundant, not shadowed");
});
});