Provider-keys interlude (owner spec, 2026-09-19): any pi-compatible
provider's key is manageable, not just the curated set.
GET /api/keys now also returns providers: {id, envVar, label}[] — every
pi-ai provider that takes a plain api-key env var (33 today), derived
from pi-ai's own provider→env-var table. No-drift mechanism: the table
is generated from pi-ai (builtinProviders + findEnvKeys over a
truthy-for-every-var proxy env) and pinned by
tests/auth-provider-keys.test.ts, which re-derives the truth at test
time and asserts EXACT equality — a pi-ai upgrade fails the gate loudly.
Owner-spec exclusions: github-copilot (OAuth), amazon-bedrock (AWS),
google-vertex (ADC), azure-openai-responses (base URL), the cloudflare
pair; anthropic keeps ANTHROPIC_API_KEY only. OPENROUTER_API_KEY
graduates from the curated catalog to the provider table (dedupe — the
UI pins it always-visible); curated stays Deepgram + the Google search
pair. Statuses: curated + present vars + unknown file vars — absent
provider keys never spam rows; the dropdown is discovery.
Write side: PUT/DELETE accept provider env vars (generic truncation
guard — trim, no whitespace, ≥12; family checks stay on the curated
three). Present-in-env provider vars surface as status rows so
env-wins/shadow semantics stay honest.
51 lines
2.1 KiB
TypeScript
51 lines
2.1 KiB
TypeScript
/**
|
||
* 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). Writable ids are the CURATED CATALOG ∪ THE
|
||
* MODEL-PROVIDER env vars (provider-keys interlude); anything else is 404
|
||
* — unknown file vars are preserved, not editable.
|
||
*/
|
||
import { isWritableAuthKey } from "@/src/server";
|
||
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 (!isWritableAuthKey(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 (!isWritableAuthKey(id)) {
|
||
return Response.json({ error: `unknown key "${id}"` }, { status: 404 });
|
||
}
|
||
return Response.json({ status: deleteKey(id) });
|
||
}
|