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
+49
View File
@@ -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) });
}
+17
View File
@@ -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() });
}