From bd66b31853634b43f0a7299bc752d2f2bff7c7f5 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 22:49:52 +0300 Subject: [PATCH] Keys section (auth/provider-keys wave): BYOK keys in the settings dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-approved design (2026-09-20): a Keys section right after General, two groups over the merged auth API (GET /api/keys masked statuses, PUT/DELETE /api/keys/:id, immediate effect, shape-gated 400s): - Model providers: OpenRouter pinned as an always-visible row (the app's lifeline — synthesized when absent so first boot shows it), stored provider keys render as they appear, and an Add-provider dropdown lists every pi-ai provider that takes an API key. The dropdown reads GET /api/keys' optional providers array — the Platform interlude (master-routed) derives it from pi-ai's own env-key table; until it lands the group shows a pending note (rolePins pattern — absent field, no drift, no guessed routes). - Tools & services: the curated rows — Deepgram (dictation) and the Google pair (search key + CSE ID) — with description hints and docs links (external links route through the Electron shell to the real browser). - Rows: always-visible paste inputs (owner choice; no reveal — plaintext never returns from the store), masked status line (…tail · chars · environment|saved), the shadowedByEnv explanation when a real env var owns the name, per-row inline 400 messages, Save on Enter/button, Delete when present (env-owned keys honestly keep their env status after a file delete). - Unknown auth.json vars surface as read-only dimmed rows — preserved by the store, not editable through the curated surface. classifyKeys/keyStatusLine pure exports drive the two-group projection; tests: SSR loading state, first-boot synthesis, provider row classification, addable filtering, unknown-var read-only status line formatting. Nav grows to six sections (Keys second). Live-verified: nav order, structure, bad-paste 400s inline, save round-trip with env-shadow explanation, delete round-trip, env-owned OpenRouter status stability. Gates: typecheck, lint, 748/748, build. --- components/settings/keys-section.tsx | 379 ++++++++++++++++++++++++ components/settings/settings-dialog.tsx | 7 + tests/render-settings.test.tsx | 12 +- tests/settings-pure.test.ts | 76 ++++- 4 files changed, 471 insertions(+), 3 deletions(-) create mode 100644 components/settings/keys-section.tsx diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx new file mode 100644 index 0000000..5c41c5b --- /dev/null +++ b/components/settings/keys-section.tsx @@ -0,0 +1,379 @@ +"use client"; +/** + * The Keys section (auth/provider-keys wave, 2026-09-20): BYOK keys over + * the merged auth API — GET /api/keys (masked statuses, never plaintext), + * PUT /api/keys/:id (shape-gated 400s), DELETE /api/keys/:id. Saves take + * effect immediately (file + env reflection, no restart). + * + * Two groups (owner design): + * - Model providers — free-form, pi-compatible. OpenRouter is pinned as + * an always-visible row (the app's lifeline: every model call today), + * other providers appear when stored, and an Add-provider dropdown + * lists every pi-ai provider that takes an API key. The list arrives as + * GET /api/keys' optional `providers` array (Platform interlude; the + * rolePins pattern — absent = the dropdown waits, everything else + * works). Provider ids/env vars come from pi-ai's own table, so this + * UI cannot drift from what pi actually reads. + * - Tools & services — the curated rows: Deepgram (dictation) and the + * Google pair (search key + CSE ID), each with description + docs + * link (opens in the real browser; the Electron shell routes it). + * + * Owner UX decisions: always-visible paste inputs (no reveal affordance — + * plaintext never returns from the store, masked tail + length only); + * bad pastes show the server's format message inline; a dev .env.local + * variable beats the saved file value and the row says so honestly. + * + * Unknown auth.json vars surface as read-only dimmed rows — preserved by + * the store (forward-compat), not editable through the curated surface. + */ + +import { useEffect, useState } from "react"; +import { type AuthKeyStatus } from "@/src/core"; +import { SettingsGroup, SettingsRow } from "./controls.tsx"; + +/** One pi-ai provider with plain API-key auth (the Platform seam). */ +export interface ProviderKeyInfo { + /** pi-ai provider id ("groq", "deepseek", …). */ + id: string; + /** The env var / auth.json key a paste writes ("GROQ_API_KEY", …). */ + envVar: string; + label: string; +} + +/** GET /api/keys — merged shape plus the interlude's optional array. */ +interface KeysResponse { + keys: AuthKeyStatus[]; + providers?: ProviderKeyInfo[]; +} + +/** The OpenRouter row survives its curated-catalog graduation: the label + * is pinned here so the row reads correctly even when the catalog no + * longer carries it (first boot: absent, not even a status row). */ +const OPENROUTER_ID = "OPENROUTER_API_KEY"; +const OPENROUTER_FALLBACK = { + label: "OpenRouter", + description: "The main model key — every teacher, prober and sub-agent call goes through it.", + docsUrl: "https://openrouter.ai/settings/keys", +}; + +/** The curated tools group, pinned by id (the catalog owns the copy; + * these ids are the contract). */ +const TOOL_IDS = ["DEEPGRAM_API_KEY", "GOOGLE_SEARCH_API_KEY", "GOOGLE_CSE_ID"] as const; + +/** The classification that drives the section's two groups. Pure — + * exported for tests. */ +export function classifyKeys( + statuses: readonly AuthKeyStatus[], + providers: readonly ProviderKeyInfo[] | undefined, +): { + openrouter: AuthKeyStatus; + /** Stored non-OpenRouter provider keys, with their provider entry. */ + providerRows: { status: AuthKeyStatus; provider?: ProviderKeyInfo }[]; + /** The curated tools rows (synthesized when absent = first boot). */ + toolRows: AuthKeyStatus[]; + /** Unknown file vars — preserved, read-only. */ + unknownRows: AuthKeyStatus[]; + /** Providers without a stored key — the Add dropdown's options. */ + addable: ProviderKeyInfo[]; +} { + const byId = new Map(statuses.map((s) => [s.id, s])); + const providerByEnv = new Map((providers ?? []).map((p) => [p.envVar, p])); + const synth = (id: string): AuthKeyStatus => ({ + id, + info: null, + present: false, + masked: null, + source: "none", + shadowedByEnv: false, + }); + const isTool = (id: string) => (TOOL_IDS as readonly string[]).includes(id); + const providerRows: { status: AuthKeyStatus; provider?: ProviderKeyInfo }[] = []; + const unknownRows: AuthKeyStatus[] = []; + for (const s of statuses) { + if (s.id === OPENROUTER_ID || isTool(s.id)) continue; + const provider = providerByEnv.get(s.id); + if (provider) providerRows.push({ status: s, provider }); + else if (!s.info) unknownRows.push(s); + // a curated-but-present status outside the pinned ids: treat as a + // provider row (pre-interlude the catalog only has the pinned ids). + else providerRows.push({ status: s }); + } + return { + openrouter: byId.get(OPENROUTER_ID) ?? synth(OPENROUTER_ID), + providerRows, + toolRows: TOOL_IDS.map((id) => byId.get(id) ?? synth(id)), + unknownRows, + addable: (providers ?? []).filter((p) => p.envVar !== OPENROUTER_ID && !byId.has(p.envVar)), + }; +} + +/** The masked status line, e.g. "…ab12 · 64 chars · saved" — the row's + * at-a-glance state. Pure — tests. */ +export function keyStatusLine(status: AuthKeyStatus): string | null { + if (!status.present || !status.masked) return null; + const where = status.source === "env" ? "environment" : "saved"; + return `${status.masked.replace(/ \(\d+ chars\)$/, "")} · ${status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?"} chars · ${where}`; +} + +/** One editable key row: status + paste input + Save + (Delete when + * present). The 400 message lands inline under the input. */ +function KeyEditorRow({ + label, + hint, + docsUrl, + status, + onSave, + onDelete, + testid, +}: { + label: string; + hint?: string; + docsUrl?: string; + status: AuthKeyStatus; + onSave: (value: string) => Promise; + onDelete?: () => Promise; + testid: string; +}) { + const [value, setValue] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const statusLine = keyStatusLine(status); + + async function save() { + if (!value.trim() || busy) return; + setBusy(true); + setError(null); + const err = await onSave(value); + if (err) setError(err); + else setValue(""); + setBusy(false); + } + + return ( +
+ + {statusLine && ( + + {statusLine} + {status.shadowedByEnv && ( + — an environment variable owns this name; the saved value is stored but not live + )} + + )} + e.preventDefault()} + className={`text-[11px] underline-offset-2 hover:underline ${docsUrl ? "text-muted-foreground hover:text-foreground" : "invisible"}`} + > + Get a key ↗ + + +
+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void save(); + }} + placeholder="Paste key…" + aria-label={`${label} key`} + data-testid={`${testid}-input`} + disabled={busy} + className="h-7 w-72 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> + + {status.present && onDelete && ( + + )} +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} + +export function KeysSection() { + const [statuses, setStatuses] = useState(null); + const [providers, setProviders] = useState(undefined); + /** The provider chosen in the Add dropdown, if any. */ + const [adding, setAdding] = useState(null); + const [loading, setLoading] = useState(true); + + const reload = () => + fetch("/api/keys") + .then((r) => r.json()) + .then((body: KeysResponse) => { + setStatuses(body.keys ?? []); + if (body.providers) setProviders(body.providers); + }) + .catch(() => setStatuses([])) + .finally(() => setLoading(false)); + + useEffect(() => { + void reload(); + }, []); + + async function saveKey(id: string, value: string): Promise { + const res = await fetch(`/api/keys/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + return body.error ?? `Save failed (${res.status})`; + } + await reload(); + return null; + } + + async function deleteKey(id: string): Promise { + await fetch(`/api/keys/${id}`, { method: "DELETE" }).catch(() => undefined); + await reload(); + } + + if (loading || statuses === null) { + return

Loading keys…

; + } + + const { openrouter, providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers); + const envWarning = openrouter.shadowedByEnv; + + return ( + <> + + saveKey(OPENROUTER_ID, v)} + onDelete={() => deleteKey(OPENROUTER_ID)} + /> + {providerRows.map(({ status, provider }) => ( + saveKey(status.id, v)} + onDelete={() => deleteKey(status.id)} + /> + ))} + {providers === undefined ? ( +

+ The full provider list ships with the server update. +

+ ) : ( +
+
+ Add provider key + +
+ {adding && ( +
+ s.id === adding.envVar) ?? { + id: adding.envVar, + info: null, + present: false, + masked: null, + source: "none" as const, + shadowedByEnv: false, + }} + testid={`key-${adding.id}`} + onSave={(v) => saveKey(adding.envVar, v)} + onDelete={() => deleteKey(adding.envVar)} + /> +
+ )} +
+ )} + {envWarning && ( +

+ The environment variable {OPENROUTER_ID} is set — it wins over any key saved here. +

+ )} +
+ + + {toolRows.map((status) => ( + saveKey(status.id, v)} + onDelete={() => deleteKey(status.id)} + /> + ))} + + + {unknownRows.length > 0 && ( + + {unknownRows.map((status) => ( + + {status.masked} + + ))} + + )} + + ); +} diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 9a5c9b1..1c151e7 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -39,6 +39,7 @@ import { import { useAppearance } from "./appearance.tsx"; import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx"; import { AgentSection } from "./agent-section.tsx"; +import { KeysSection } from "./keys-section.tsx"; import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; import { KeyboardSection } from "./keyboard-section.tsx"; @@ -47,6 +48,7 @@ import { KeyboardSection } from "./keyboard-section.tsx"; * real question users ask). */ const SECTIONS = [ { id: "general", label: "General" }, + { id: "keys", label: "Keys" }, { id: "agent", label: "Agent" }, { id: "appearance", label: "Appearance" }, { id: "graph", label: "Graph" }, @@ -257,6 +259,11 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) { )} + {active === "keys" && ( + + + + )} {active === "agent" && ( { assert.ok(!html.includes("settings-dialog")); }); - it("the dialog frame: role=dialog + the ✕ + the five-section nav, General active", () => { + it("the dialog frame: role=dialog + the ✕ + the six-section nav, General active", () => { const html = renderToStaticMarkup( {}} />); assert.ok(html.includes('role="dialog"')); assert.ok(html.includes('aria-modal="true"')); assert.ok(html.includes('aria-label="Close settings"')); - for (const id of ["general", "agent", "appearance", "graph", "keyboard"]) { + for (const id of ["general", "keys", "agent", "appearance", "graph", "keyboard"]) { assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id); } // General is the landing section; the others are not current. @@ -138,6 +139,13 @@ describe("GraphSection — the dependency ladder", () => { }); }); +describe("KeysSection (thin SSR)", () => { + it("SSR = the loading state (effects never fire)", () => { + const html = renderToStaticMarkup(); + assert.ok(html.includes("Loading keys…")); + }); +}); + describe("AppearanceSection (thin SSR)", () => { it("mode is rendered DISABLED — dark-only this wave, no lying control", () => { const html = renderToStaticMarkup( {}} />); diff --git a/tests/settings-pure.test.ts b/tests/settings-pure.test.ts index 1e8d6f4..d532936 100644 --- a/tests/settings-pure.test.ts +++ b/tests/settings-pure.test.ts @@ -10,6 +10,8 @@ import { describe, it } from "node:test"; import { appearanceVars, derivedPalette, resolveAppearance } from "../components/settings/appearance.tsx"; import { composePromptSwitch, composeRoleModel, composeTool } from "../components/settings/agent-section.tsx"; import { nextOverrides } from "../components/settings/keyboard-section.tsx"; +import { classifyKeys, keyStatusLine, type ProviderKeyInfo } from "../components/settings/keys-section.tsx"; +import type { AuthKeyStatus } from "../src/core"; describe("resolveAppearance — delta over factory", () => { it("absent everything = the factory view", () => { @@ -136,4 +138,76 @@ describe("nextOverrides — the rebind persistence map", () => { const next = nextOverrides({ "toggle-sidebar": "mod+b", find: "mod+f" }, "toggle-sidebar", null); assert.deepEqual(next, { find: "mod+f" }); }); -}); \ No newline at end of file +}); + +describe("classifyKeys — the Keys section's two-group projection", () => { + const synth = (id: string, present = false): AuthKeyStatus => ({ + id, + info: null, + present, + masked: present ? `…${id.slice(-4)} (32 chars)` : null, + source: present ? "file" : "none", + shadowedByEnv: false, + }); + const providers: ProviderKeyInfo[] = [ + { id: "groq", envVar: "GROQ_API_KEY", label: "Groq" }, + { id: "deepseek", envVar: "DEEPSEEK_API_KEY", label: "DeepSeek" }, + { id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" }, + ]; + + it("OpenRouter is pinned: synthesized when absent (first boot), found when present", () => { + const none = classifyKeys([], providers); + assert.equal(none.openrouter.id, "OPENROUTER_API_KEY"); + assert.ok(!none.openrouter.present); + const stored = classifyKeys([synth("OPENROUTER_API_KEY", true)], providers); + assert.ok(stored.openrouter.present); + }); + + it("stored provider keys land in providerRows with their provider entry", () => { + const out = classifyKeys([synth("GROQ_API_KEY", true)], providers); + assert.equal(out.providerRows.length, 1); + assert.equal(out.providerRows[0]!.provider?.id, "groq"); + // the Add dropdown no longer offers groq + assert.ok(!out.addable.some((p) => p.id === "groq")); + assert.ok(out.addable.some((p) => p.id === "deepseek")); + }); + + it("curated tool ids always render (synthesized when absent)", () => { + const out = classifyKeys([synth("DEEPGRAM_API_KEY", true)], undefined); + assert.deepEqual(out.toolRows.map((r) => r.id), [ + "DEEPGRAM_API_KEY", + "GOOGLE_SEARCH_API_KEY", + "GOOGLE_CSE_ID", + ]); + assert.ok(out.toolRows[0]!.present); + assert.ok(!out.toolRows[1]!.present); + }); + + it("unknown file vars surface read-only, out of the provider rows", () => { + const out = classifyKeys([synth("PLANS_LOGIN_TOKEN", true)], providers); + assert.equal(out.unknownRows.length, 1); + assert.equal(out.providerRows.length, 0); + }); + + it("providers seam absent = addable empty, nothing crashes", () => { + const out = classifyKeys([], undefined); + assert.deepEqual(out.addable, []); + }); +}); + +describe("keyStatusLine — the masked at-a-glance state", () => { + it("present file key: tail, chars, source", () => { + assert.equal( + keyStatusLine({ id: "X", info: null, present: true, masked: "…ab12 (64 chars)", source: "file", shadowedByEnv: false }), + "…ab12 · 64 chars · saved", + ); + }); + + it("env-sourced key reads 'environment'", () => { + assert.ok(keyStatusLine({ id: "X", info: null, present: true, masked: "…ab12 (64 chars)", source: "env", shadowedByEnv: false })?.endsWith("environment")); + }); + + it("absent key has no line", () => { + assert.equal(keyStatusLine({ id: "X", info: null, present: false, masked: null, source: "none", shadowedByEnv: false }), null); + }); +});