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,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
|
||||
* keys return FORBIDDEN here.
|
||||
*/
|
||||
import { keyValue } from "@/src/server";
|
||||
|
||||
export async function GET() {
|
||||
const key = process.env.DEEPGRAM_API_KEY;
|
||||
const key = keyValue("DEEPGRAM_API_KEY");
|
||||
if (!key) {
|
||||
return Response.json(
|
||||
{ message: "No DEEPGRAM_API_KEY — add it to auth.json (desktop) or .env.local (dev)." },
|
||||
|
||||
Reference in New Issue
Block a user