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:
@@ -10,3 +10,6 @@ SCOPE.md
|
|||||||
# raw real-data import sources (user directive 2026-09-13: never commit)
|
# raw real-data import sources (user directive 2026-09-13: never commit)
|
||||||
real-data-extraction/
|
real-data-extraction/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed
|
||||||
|
auth.json
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* PUT /api/keys/:id — save one key; DELETE — remove it (auth-keys wave,
|
||||||
|
* 2026-09-19). `id` is the env-var/file key name (OPENROUTER_API_KEY, …).
|
||||||
|
*
|
||||||
|
* PUT body {value}: shape-gated (owner decision — bad pastes are 400s
|
||||||
|
* naming the expected format, never stored; validation is SHAPE-ONLY, no
|
||||||
|
* live pings). Saves take effect immediately (file + env reflected; no
|
||||||
|
* restart). A var owned by the REAL env is persisted for later but NOT
|
||||||
|
* reflected — the response's status row carries shadowedByEnv so the page
|
||||||
|
* can explain why the saved key is not the live one.
|
||||||
|
*
|
||||||
|
* DELETE: gone from the file always; the env copy goes only when it was
|
||||||
|
* the store's own reflection (a real env var stays — env wins). Idempotent
|
||||||
|
* (absent key = success). Unknown ids are 404 — the curated catalog is
|
||||||
|
* the writable surface; unknown file vars are preserved, not editable.
|
||||||
|
*/
|
||||||
|
import { isKnownAuthKey } from "@/src/core";
|
||||||
|
import { deleteKey, saveKey } from "@/src/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PUT(req: Request, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
if (!isKnownAuthKey(id)) {
|
||||||
|
return Response.json({ error: `unknown key "${id}"` }, { status: 404 });
|
||||||
|
}
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "body must be JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body) || typeof (body as { value?: unknown }).value !== "string") {
|
||||||
|
return Response.json({ error: "body must be {value: string}" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const result = saveKey(id, (body as { value: string }).value);
|
||||||
|
if (!result.ok) return Response.json({ error: result.error }, { status: 400 });
|
||||||
|
return Response.json({ status: result.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
if (!isKnownAuthKey(id)) {
|
||||||
|
return Response.json({ error: `unknown key "${id}"` }, { status: 404 });
|
||||||
|
}
|
||||||
|
return Response.json({ status: deleteKey(id) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/keys — the auth page's key list (auth-keys wave, 2026-09-19).
|
||||||
|
*
|
||||||
|
* Every curated key (present or not) plus unknown file vars surfaced as
|
||||||
|
* generic rows. Masked only — last 4 + length; PLAINTEXT NEVER LEAVES THE
|
||||||
|
* STORE THROUGH THIS API (owner decision). `source` tells the page whether
|
||||||
|
* the live value comes from the environment (dev .env.local / launch env)
|
||||||
|
* or the file, and `shadowedByEnv` explains why a saved key is not the
|
||||||
|
* live one (env always wins).
|
||||||
|
*/
|
||||||
|
import { keyStatuses } from "@/src/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json({ keys: keyStatuses() });
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@
|
|||||||
* /auth/grant (their docs, token-based auth FAQ) — plain read/transcribe
|
* /auth/grant (their docs, token-based auth FAQ) — plain read/transcribe
|
||||||
* keys return FORBIDDEN here.
|
* keys return FORBIDDEN here.
|
||||||
*/
|
*/
|
||||||
|
import { keyValue } from "@/src/server";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const key = process.env.DEEPGRAM_API_KEY;
|
const key = keyValue("DEEPGRAM_API_KEY");
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ message: "No DEEPGRAM_API_KEY — add it to auth.json (desktop) or .env.local (dev)." },
|
{ message: "No DEEPGRAM_API_KEY — add it to auth.json (desktop) or .env.local (dev)." },
|
||||||
|
|||||||
@@ -182,3 +182,21 @@ export const SETTINGS_FILE = path.join(DATA_ROOT, "settings.json");
|
|||||||
* a vanished segment stops composing but stays on disk.
|
* a vanished segment stops composing but stays on disk.
|
||||||
*/
|
*/
|
||||||
export const PROMPTS_OVERRIDE_DIR = path.join(DATA_ROOT, "prompts");
|
export const PROMPTS_OVERRIDE_DIR = path.join(DATA_ROOT, "prompts");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE AUTH/KEY STORE (auth-keys wave, 2026-09-19): BYOK provider keys in a
|
||||||
|
* flat {VAR: value} JSON map — the SAME file the Electron app has always
|
||||||
|
* hand-edited (owner decision: option A, zero migration). Desktop main.cjs
|
||||||
|
* passes LH_AUTH_FILE pointing at userData/auth.json (mode 0600); in dev
|
||||||
|
* (no Electron) the default is the repo-root auth.json (gitignored, same
|
||||||
|
* pattern as .env.local — a worktree may symlink it to the master copy).
|
||||||
|
* The SERVER reads/writes this file directly at call time (no restart to
|
||||||
|
* change a key); it reflects the values into process.env at boot with
|
||||||
|
* FILL-GAP precedence — real environment variables (incl. .env.local in
|
||||||
|
* dev) always win (src/server/keys.ts). Hand-edits still work: the file is
|
||||||
|
* re-read from disk on every API access. Secrets NEVER go in settings.json
|
||||||
|
* — this file keeps that role, alongside .env.local.
|
||||||
|
*/
|
||||||
|
export const AUTH_FILE = process.env.LH_AUTH_FILE
|
||||||
|
? path.resolve(process.env.LH_AUTH_FILE)
|
||||||
|
: path.join(ROOT, "auth.json");
|
||||||
|
|||||||
+16
-20
@@ -4,7 +4,7 @@
|
|||||||
* What this process is and is not:
|
* What this process is and is not:
|
||||||
* IS: a thin launcher + security guard around the app's real server —
|
* IS: a thin launcher + security guard around the app's real server —
|
||||||
* `next build`'s standalone server.js (Next 16), spawned as a child
|
* `next build`'s standalone server.js (Next 16), spawned as a child
|
||||||
* on 127.0.0.1 with the BYOK keys and the LH_DATA_DIR seam.
|
* on 127.0.0.1 with the BYOK auth file and the LH_DATA_DIR seam.
|
||||||
* NOT: a host for any app logic. Everything — teacher turns, extraction,
|
* NOT: a host for any app logic. Everything — teacher turns, extraction,
|
||||||
* the graph — runs in the Next server, unchanged.
|
* the graph — runs in the Next server, unchanged.
|
||||||
*
|
*
|
||||||
@@ -17,8 +17,11 @@
|
|||||||
* - all mutable state under userData (never inside the .app bundle —
|
* - all mutable state under userData (never inside the .app bundle —
|
||||||
* translocated apps are read-only);
|
* translocated apps are read-only);
|
||||||
* - keys ride the child's ENV, never argv; auth.json (BYOK) is a flat
|
* - keys ride the child's ENV, never argv; auth.json (BYOK) is a flat
|
||||||
* {VAR: value} JSON map, plaintext, mode 0600, hand-edited until the
|
* {VAR: value} JSON map, plaintext, mode 0600 — server-managed since
|
||||||
* settings milestone builds the UI.
|
* the auth-keys wave (2026-09-19): the child reads/writes it DIRECTLY
|
||||||
|
* (LH_AUTH_FILE below) and reflects values into its own env. The old
|
||||||
|
* spawn-time env injection retired with that wave — a copy made here
|
||||||
|
* would shadow every settings-page key change until restart.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron");
|
const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron");
|
||||||
@@ -83,22 +86,12 @@ function freePort() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** BYOK keys: auth.json in userData, flat {VAR: value} map, 0600.
|
/** BYOK keys: auth.json in userData, flat {VAR: value} map, 0600 —
|
||||||
* Absent file = no keys injected — the app runs, model calls fail with the
|
* SERVER-MANAGED since the auth-keys wave (2026-09-19). This shell no
|
||||||
* engine's own clear error until the settings milestone adds the UI.
|
* longer reads or injects it: the child gets LH_AUTH_FILE below and does
|
||||||
* Real environment variables win over the file (process.env precedence is
|
* its own read/reflect/write, so a key saved in the settings page takes
|
||||||
* also Next's own env loading order, so this stays consistent). */
|
* effect on the next model call — no restart. Real environment variables
|
||||||
function authEnv() {
|
* still win over the file inside the child (its documented rule). */
|
||||||
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").map(([k, v]) => [k, v]),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return {}; // absent or corrupt — the documented state is "no keys yet"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function log(line) {
|
function log(line) {
|
||||||
const stamp = new Date().toISOString();
|
const stamp = new Date().toISOString();
|
||||||
@@ -123,12 +116,15 @@ function startServer(port) {
|
|||||||
cwd: path.dirname(serverEntry()),
|
cwd: path.dirname(serverEntry()),
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
...authEnv(),
|
|
||||||
ELECTRON_RUN_AS_NODE: "1",
|
ELECTRON_RUN_AS_NODE: "1",
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never
|
HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never
|
||||||
LH_DATA_DIR: DATA_DIR(),
|
LH_DATA_DIR: DATA_DIR(),
|
||||||
|
// The auth/keys store — the child reads AND writes this file itself
|
||||||
|
// (no env injection anymore: a spawn-time copy would shadow every
|
||||||
|
// settings-page key change until restart; env wins over file).
|
||||||
|
LH_AUTH_FILE: AUTH_FILE(),
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
// view. Browser-safe — the Wave-4 panel imports these directly.
|
||||||
export * from "./settings.ts";
|
export * from "./settings.ts";
|
||||||
export * from "./settings-resolution.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).
|
// keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe).
|
||||||
export * from "./keyboard.ts";
|
export * from "./keyboard.ts";
|
||||||
export * from "./coverage.ts";
|
export * from "./coverage.ts";
|
||||||
|
|||||||
@@ -190,3 +190,23 @@ export interface PromptOverrideStore {
|
|||||||
* on-switch with no file composes the source default, inert). */
|
* on-switch with no file composes the source default, inert). */
|
||||||
deleteOverride(segmentPath: string): void;
|
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
|
// (composition is an agent concern); the barrel re-exports it for the
|
||||||
// routes — server→agent is the lawful direction (chat-handler precedent).
|
// routes — server→agent is the lawful direction (chat-handler precedent).
|
||||||
export { effectivePromptSegments, allSegmentPaths, readPromptOverride } from "../agent/prompt-segments.ts";
|
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 "./settings-body.ts";
|
||||||
export * from "./turn-liveness.ts";
|
export * from "./turn-liveness.ts";
|
||||||
export * from "./observability.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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user