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,131 @@
|
||||
/**
|
||||
* The curated auth-key catalog + shape rules (auth-keys wave, 2026-09-19).
|
||||
* PURE, browser-safe — no fs, no env. The catalog drives the auth page's
|
||||
* key list (label, description, where-to-get docs link) and the write-side
|
||||
* shape validation (owner decision: SHAPE-ONLY validation, no live pings —
|
||||
* a revoked key looks fine until a real call fails; that trade was chosen
|
||||
* over phoning providers).
|
||||
*
|
||||
* The store itself is the long-standing flat {VAR: value} auth.json map
|
||||
* (see config.AUTH_FILE): keys are identified BY their environment-variable
|
||||
* name — the same name consumers read today (OPENROUTER_API_KEY, …), so the
|
||||
* catalog id IS the file key and the env var, one name everywhere.
|
||||
*
|
||||
* Unknown vars found in the file (outside the catalog) are PRESERVED
|
||||
* verbatim and surfaced as generic entries — forward-compat for future
|
||||
* entries (e.g. a plans-login token) without a schema change — but they
|
||||
* are not hand-writable through the curated API (only the catalog's keys
|
||||
* have shapes worth checking and docs worth showing).
|
||||
*/
|
||||
|
||||
/** One curated key entry. `id` is the env-var/file key name. */
|
||||
export interface AuthKeyInfo {
|
||||
id: string;
|
||||
/** Human label for the settings page. */
|
||||
label: string;
|
||||
/** One-sentence what-is-this-for. */
|
||||
description: string;
|
||||
/** Where the user gets a key (opens in the real browser via the shell). */
|
||||
docsUrl: string;
|
||||
}
|
||||
|
||||
export const AUTH_KEYS: readonly AuthKeyInfo[] = [
|
||||
{
|
||||
id: "OPENROUTER_API_KEY",
|
||||
label: "OpenRouter",
|
||||
description: "The main model key — every teacher, prober and sub-agent call goes through it.",
|
||||
docsUrl: "https://openrouter.ai/settings/keys",
|
||||
},
|
||||
{
|
||||
id: "DEEPGRAM_API_KEY",
|
||||
label: "Deepgram",
|
||||
description: "Speech-to-text key for dictation — needs ≥ Member permission in the Deepgram console to mint tokens.",
|
||||
docsUrl: "https://console.deepgram.com/",
|
||||
},
|
||||
{
|
||||
id: "GOOGLE_SEARCH_API_KEY",
|
||||
label: "Google Search API key",
|
||||
description: "Web-search half of the Google pair (with the CSE ID) — powers the researcher sub-agent.",
|
||||
docsUrl: "https://developers.google.com/custom-search/v1/introduction",
|
||||
},
|
||||
{
|
||||
id: "GOOGLE_CSE_ID",
|
||||
label: "Google CSE ID",
|
||||
description: "Programmable Search Engine ID — which engine the search API queries.",
|
||||
docsUrl: "https://programmablesearchengine.google.com/controlpanel",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const AUTH_KEY_IDS: ReadonlySet<string> = new Set(AUTH_KEYS.map((k) => k.id));
|
||||
|
||||
export function isKnownAuthKey(id: string): boolean {
|
||||
return AUTH_KEY_IDS.has(id);
|
||||
}
|
||||
|
||||
/** Shape-check result: ok, or a 400-ready message naming the expectation. */
|
||||
export type KeyShapeResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* The write-side shape gate (owner decision: reject bad pastes with 400 —
|
||||
* a truncated or wrong-family key can never land in the store). Values are
|
||||
* TRIMMED first (paste hygiene); empty-after-trim always fails.
|
||||
*
|
||||
* Families: OpenRouter keys are `sk-or-…`; Google API keys are `AIza…`;
|
||||
* Programmable Search Engine IDs are either the legacy `digits:digits`
|
||||
* form or a plain alphanumeric token; Deepgram publishes no key format, so
|
||||
* the check is a truncation guard (non-empty, ≥ 20 chars, no whitespace).
|
||||
*/
|
||||
export function validateKeyShape(id: string, rawValue: string): KeyShapeResult {
|
||||
const value = rawValue.trim();
|
||||
const nonEmpty = (min: number) =>
|
||||
value.length >= min && !/\s/.test(value)
|
||||
? { ok: true as const }
|
||||
: { ok: false as const, error: `${id} must be at least ${min} characters with no whitespace` };
|
||||
switch (id) {
|
||||
case "OPENROUTER_API_KEY":
|
||||
return value.startsWith("sk-or-")
|
||||
? nonEmpty(12)
|
||||
: { ok: false, error: `${id} must start with "sk-or-" (OpenRouter's key format)` };
|
||||
case "GOOGLE_SEARCH_API_KEY":
|
||||
return value.startsWith("AIza")
|
||||
? nonEmpty(10)
|
||||
: { ok: false, error: `${id} must start with "AIza" (Google's API-key format)` };
|
||||
case "GOOGLE_CSE_ID":
|
||||
return (
|
||||
/^[0-9]{6,}:[0-9A-Za-z_-]+$/.test(value) || /^[0-9A-Za-z]{12,}$/.test(value)
|
||||
)
|
||||
? { ok: true }
|
||||
: { ok: false, error: `${id} must be the Programmable Search Engine ID (digits:digits, or the plain alphanumeric form)` };
|
||||
case "DEEPGRAM_API_KEY":
|
||||
// No public format — the length floor only catches truncated pastes.
|
||||
return nonEmpty(20);
|
||||
default:
|
||||
return { ok: false, error: `unknown key "${id}" (supported: ${[...AUTH_KEY_IDS].join(", ")})` };
|
||||
}
|
||||
}
|
||||
|
||||
/** A masked key never leaves the server as plaintext (owner decision:
|
||||
* last 4 chars + length — enough to identify which key is stored after
|
||||
* rotating between several, and to spot a mis-pasted family). */
|
||||
export function maskKey(value: string): string {
|
||||
const v = value.trim();
|
||||
if (v.length <= 4) return `…${v}`; // degenerate paste — still never the whole value
|
||||
return `…${v.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** One key's status row for the GET view (never carries plaintext). */
|
||||
export interface AuthKeyStatus {
|
||||
id: string;
|
||||
/** Catalog metadata when the key is curated; null for unknown-file-var
|
||||
* entries (preserved + surfaced, but no curated form to edit through). */
|
||||
info: AuthKeyInfo | null;
|
||||
/** A key is present when EITHER the environment or the file has it. */
|
||||
present: boolean;
|
||||
/** The masked tail + character count, e.g. "…ab12 (64 chars)". */
|
||||
masked: string | null;
|
||||
/** Where the live value comes from, given env-wins precedence. */
|
||||
source: "env" | "file" | "none";
|
||||
/** True when the file holds a value that the environment shadows — the
|
||||
* settings page explains why a saved key is not the live one. */
|
||||
shadowedByEnv: boolean;
|
||||
}
|
||||
@@ -40,6 +40,10 @@ export * from "./fuzzy.ts";
|
||||
// view. Browser-safe — the Wave-4 panel imports these directly.
|
||||
export * from "./settings.ts";
|
||||
export * from "./settings-resolution.ts";
|
||||
// auth-keys.ts (auth-keys wave, 2026-09-19): the curated key catalog +
|
||||
// shape rules + masking — pure, browser-safe (the auth page binds to the
|
||||
// /api/keys routes built on it).
|
||||
export * from "./auth-keys.ts";
|
||||
// keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe).
|
||||
export * from "./keyboard.ts";
|
||||
export * from "./coverage.ts";
|
||||
|
||||
@@ -190,3 +190,23 @@ export interface PromptOverrideStore {
|
||||
* on-switch with no file composes the source default, inert). */
|
||||
deleteOverride(segmentPath: string): void;
|
||||
}
|
||||
|
||||
// ─── AuthStore — BYOK provider keys (auth-keys wave, 2026-09-19) ────────────
|
||||
|
||||
/**
|
||||
* The long-standing flat {VAR: value} auth.json map (config.AUTH_FILE) —
|
||||
* the same file the Electron app has always hand-edited, now first-class
|
||||
* server-managed. Values are opaque strings: shape validation is a
|
||||
* server-band concern (core/auth-keys.ts validateKeyShape — the store
|
||||
* persists what it is given); env precedence is a server-band concern
|
||||
* too (src/server/keys.ts). Unknown vars ride along verbatim.
|
||||
*/
|
||||
export interface AuthStore {
|
||||
/** The whole map — absent or corrupt file = {} ("no keys yet", the
|
||||
* documented first-boot state). Never throws. */
|
||||
authVars(): Record<string, string>;
|
||||
/** Set one VAR (atomic write, mode 0600 always, other vars preserved). */
|
||||
setAuthVar(name: string, value: string): void;
|
||||
/** Remove one VAR if present (absent = no-op, still a silent success). */
|
||||
deleteAuthVar(name: string): void;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ export { isValidSegmentPath } from "../core/prompt-segments.ts";
|
||||
// (composition is an agent concern); the barrel re-exports it for the
|
||||
// routes — server→agent is the lawful direction (chat-handler precedent).
|
||||
export { effectivePromptSegments, allSegmentPaths, readPromptOverride } from "../agent/prompt-segments.ts";
|
||||
|
||||
// The auth/keys composition (auth-keys wave, 2026-09-19): the curated
|
||||
// key catalog + the env-reflecting store behind GET/PUT/DELETE /api/keys.
|
||||
// Importing this module runs the idempotent boot fill-gap — every consumer
|
||||
// keeps its plain process.env reads (that seam is the point).
|
||||
export * from "./keys.ts";
|
||||
export { authStore } from "../store/auth.ts";
|
||||
export * from "./settings-body.ts";
|
||||
export * from "./turn-liveness.ts";
|
||||
export * from "./observability.ts";
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* The auth/keys composition (auth-keys wave, 2026-09-19): the server-side
|
||||
* key manager behind GET/PUT/DELETE /api/keys.
|
||||
*
|
||||
* THE ENV SEAM (the architecture keystone): every consumer — models.ts's
|
||||
* openRouterApiKey(), the sub-agents, web.ts, the STT route — reads
|
||||
* process.env directly, and the agent layer may not import the store
|
||||
* layer. Instead of rewiring five consumers across lanes, the store
|
||||
* REFLECTS itself into the child's env: at boot (idempotent, anchored on
|
||||
* globalThis like the extraction registry) every file var UNSET in the
|
||||
* real env is fill-gapped into process.env, and each save/delete updates
|
||||
* file + env together. Keys therefore take effect immediately — no restart
|
||||
* — which is also why main.cjs's old spawn-time injection HAD to retire:
|
||||
* an env copy made at spawn would shadow every settings-page change until
|
||||
* restart (env wins over file).
|
||||
*
|
||||
* PRECEDENCE (owner decision): real environment variables (incl.
|
||||
* .env.local in dev) always win over the file. Boot-time real-env vars
|
||||
* are snapshotted once per process and never mutated; a PUT to one of
|
||||
* them persists to the file (for when the env goes away) and the status
|
||||
* honestly reports shadowedByEnv — the page explains why the saved key
|
||||
* is not the live one.
|
||||
*
|
||||
* Unknown vars in the file are preserved verbatim and surfaced as
|
||||
* generic status rows (no catalog form to edit through — only curated
|
||||
* keys have shapes and docs; core/auth-keys.ts).
|
||||
*/
|
||||
|
||||
import {
|
||||
AUTH_KEYS,
|
||||
type AuthKeyInfo,
|
||||
type AuthKeyStatus,
|
||||
isKnownAuthKey,
|
||||
maskKey,
|
||||
validateKeyShape,
|
||||
} from "../core/auth-keys.ts";
|
||||
import { authStore } from "../store/auth.ts";
|
||||
|
||||
// The once-per-process boot record — globalThis anchor (HMR re-imports the
|
||||
// module; the snapshot must not re-run and misread fill-gapped vars as real
|
||||
// env). Same pattern as globalThis.__lhExtractionRuns.
|
||||
interface AuthBoot {
|
||||
/** Vars that were in the REAL env at first init — env precedence's
|
||||
* untouchable set. Fill-gapped vars are everything else we set. */
|
||||
realEnv: Set<string>;
|
||||
/** Vars WE put into process.env (the file's reflection) — deletable. */
|
||||
fillGap: Set<string>;
|
||||
}
|
||||
const BOOT_KEY = Symbol.for("lh.authBoot");
|
||||
|
||||
function boot(): AuthBoot {
|
||||
const g = globalThis as Record<symbol, AuthBoot | undefined>;
|
||||
if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() };
|
||||
return g[BOOT_KEY];
|
||||
}
|
||||
|
||||
/**
|
||||
* The boot fill-gap — idempotent. First call snapshots which managed names
|
||||
* are real env; later calls (HMR re-import, or after a hand-edit) only fill
|
||||
* vars still unset in the env, so a var deleted through the API never
|
||||
* resurrects (its file entry is gone too).
|
||||
*/
|
||||
export function initAuthEnv(): void {
|
||||
const record = boot();
|
||||
const fileVars = authStore.authVars();
|
||||
const managed = new Set<string>([...Object.keys(fileVars), ...AUTH_KEYS.map((k) => k.id)]);
|
||||
if (record.realEnv.size === 0 && record.fillGap.size === 0) {
|
||||
// First init in this process: what's already in the env is REAL env.
|
||||
for (const name of managed) {
|
||||
if (process.env[name] !== undefined) record.realEnv.add(name);
|
||||
}
|
||||
}
|
||||
for (const [name, value] of Object.entries(fileVars)) {
|
||||
if (!isManagedName(name)) continue;
|
||||
if (process.env[name] === undefined) {
|
||||
process.env[name] = value;
|
||||
record.fillGap.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The managed name-space: catalog keys + whatever the file holds (unknown
|
||||
* vars are managed too — preserved, surfaced, env-reflected — just not
|
||||
* editable through the curated API). */
|
||||
function isManagedName(name: string): boolean {
|
||||
return isKnownAuthKey(name) || name in authStore.authVars();
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure status projection — exported for tests. `env` is the live
|
||||
* process env view; `realEnv` the boot snapshot (env precedence).
|
||||
*/
|
||||
export function resolveKeyStatuses(
|
||||
fileVars: Record<string, string>,
|
||||
env: Record<string, string | undefined>,
|
||||
realEnv: ReadonlySet<string>,
|
||||
): AuthKeyStatus[] {
|
||||
const ids = new Set<string>([...AUTH_KEYS.map((k) => k.id), ...Object.keys(fileVars)]);
|
||||
const rows: AuthKeyStatus[] = [];
|
||||
for (const id of ids) {
|
||||
const envValue = env[id];
|
||||
const fileValue = fileVars[id];
|
||||
const isRealEnv = realEnv.has(id);
|
||||
// Env wins: the live value is the env's when set (real or reflected —
|
||||
// post fill-gap the file's values LIVE in env, so realEnv is what
|
||||
// distinguishes a REAL env var from the store's own reflection).
|
||||
const live = envValue !== undefined ? envValue : fileValue;
|
||||
rows.push({
|
||||
id,
|
||||
info: AUTH_KEYS.find((k) => k.id === id) ?? null,
|
||||
present: live !== undefined,
|
||||
masked: live !== undefined ? `${maskKey(live)} (${live.trim().length} chars)` : null,
|
||||
source: isRealEnv ? "env" : live !== undefined ? "file" : "none",
|
||||
shadowedByEnv: isRealEnv && fileValue !== undefined && envValue !== fileValue,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The GET view: every curated key (present or not) + unknown file vars. */
|
||||
export function keyStatuses(): AuthKeyStatus[] {
|
||||
return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv);
|
||||
}
|
||||
|
||||
/** The live value for one var, env-first (the env-wins rule) — the canonical
|
||||
* read for consumers that don't live behind the @/src/server barrel (the
|
||||
* STT route was the one cold-boot hole: a first-ever request to it would
|
||||
* otherwise read env before any fill-gap ran). */
|
||||
export function keyValue(id: string): string | undefined {
|
||||
return process.env[id] ?? authStore.authVars()[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save one key (PUT). Shape gate first (owner decision: bad pastes are
|
||||
* 400s, never stored), then the file write, then the env reflection. A
|
||||
* var that the REAL env owns is not reflected (env wins — the status row
|
||||
* reports shadowedByEnv so the page can explain it).
|
||||
*/
|
||||
export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } {
|
||||
const shape = validateKeyShape(id, value);
|
||||
if (!shape.ok) return { ok: false, error: shape.error };
|
||||
const trimmed = value.trim();
|
||||
authStore.setAuthVar(id, trimmed);
|
||||
const record = boot();
|
||||
if (!record.realEnv.has(id)) {
|
||||
process.env[id] = trimmed;
|
||||
record.fillGap.add(id);
|
||||
}
|
||||
// validateKeyShape guarantees catalog membership, so the row exists.
|
||||
return { ok: true, status: keyStatuses().find((s) => s.id === id)! };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one key (DELETE): gone from the file always; gone from the env
|
||||
* only when the env copy was OURS (fill-gap) — a real env var stays (env
|
||||
* wins) and the status shows the key still present from env.
|
||||
*/
|
||||
export function deleteKey(id: string): AuthKeyStatus {
|
||||
authStore.deleteAuthVar(id);
|
||||
const record = boot();
|
||||
if (record.fillGap.has(id) && !record.realEnv.has(id)) {
|
||||
delete process.env[id];
|
||||
record.fillGap.delete(id);
|
||||
}
|
||||
return keyStatuses().find((s) => s.id === id)!;
|
||||
}
|
||||
|
||||
// Reflect the file into the env at first import (idempotent; the module is
|
||||
// imported by every route/handler path that touches keys).
|
||||
initAuthEnv();
|
||||
@@ -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