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).
163 lines
6.6 KiB
TypeScript
163 lines
6.6 KiB
TypeScript
/**
|
|
* 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");
|
|
});
|
|
});
|