Skip to content

fix(ai): server-only provider API key + write-only settings entry#111

Merged
eliotlim merged 2 commits into
mainfrom
feat/ai-key-server-only
Jul 6, 2026
Merged

fix(ai): server-only provider API key + write-only settings entry#111
eliotlim merged 2 commits into
mainfrom
feat/ai-key-server-only

Conversation

@eliotlim

@eliotlim eliotlim commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Problem

The paid-provider API key was echoed to the browser. Inference already runs entirely server-side (the client only calls /api/agent/chat + /api/ai/* SSE; AnthropicEngine/v1/messages with the server-held x-api-key), so the client never needs the key — yet GET /api/ai/status returned the un-redacted AiConfig (incl. providers.claude.apiKey) to any writer principal so the settings form could pre-fill its draft, and the client painted the raw key into the password input. That readback was the entire exposure.

Board: A · AI API key never leaves the server (Epic: Server-only AI keys + admin usage attribution).

Solution

  • Redact unconditionally. apiKey (legacy flat) + every providers.*.apiKey are stripped in GET /api/ai/status and the PUT /api/ai/config echo, for every principal (guest / viewer / writer / owner / loopback). The old canWrite ? full : redacted branch is gone.
  • apiKeySet signal. A per-provider (and legacy top-level) boolean tells the form a key exists without shipping the value.
  • Preserve-on-blank. setConfig overlays incoming keys onto the stored config: omitted/''/whitespace-only → preserve; non-empty → set; explicit null → clear. A partial PUT can't drop an unsent provider's key; a malformed {claude: null} entry is ignored (no 500).
  • Write-only settings field. AiSettings shows a masked "key set" placeholder + Clear key + a "leave blank to keep the current key" hint, with label/aria-describedby associations; the input value is never bound to a stored key.
  • AgentPanel provider availability keys off apiKeySet, not the (now absent) key value.

Key files: packages/server/src/ai/{routes,service}.ts, packages/sdk/src/ai.ts, packages/ui/src/components/{AiSettings,AgentPanel}.tsx, packages/ui/src/i18n/messages/en.ts. New deps: none.

Before / After

Behavior Before After
GET /api/ai/status for a writer/owner returned raw providers.claude.apiKey key stripped for all principals; only apiKeySet: true remains (server test)
Settings — no key set
Settings — key set field was seeded with the raw key value write-only, masked, Clear + "leave blank to keep":

Test procedure

  • pnpm verify → green.
  • Server contract packages/server/src/aiGating.test.ts: enumerates guest / viewer / jws-non-member / owner + loopback + PUT-echo and asserts the key string is absent everywhere (not.toContain), plus preserve / clear / whitespace-only / omitted-provider / malformed-null cases.
  • Manual: Settings → AI → Claude — set a key + save; reopen → field shows "key set", not the value; blank-save keeps it; Clear key removes it; AgentPanel still offers claude when a key is set.

Operational notes

  • No migration, no new env vars, no new deps. Behavior change: the settings form no longer reads the key back (write-only); existing stored keys are preserved and continue to work, but users won't see the value again — by design.
  • Widened SDK apiKey to string | null to carry the explicit-clear signal; createEngine coalesces (s.apiKey || '') so null is never treated as a real key, and the stored config never persists null.
  • Branch is based on 545fe1f; the only gap to main is the 1.75.0 release bump (CHANGELOG + version bumps) — no code conflict.

Verify: green — sdk 171 · ui 939 · server 596 unit + e2e (251 embedded / 40 MCP). Gates cleared: security ✅ · code ✅ · design ✅.

🤖 Generated with Claude Code

https://claude.ai/code/session_01X78XTAemKrFw1uJpDpCEaG

eliotlim and others added 2 commits July 6, 2026 11:16
Inference runs entirely server-side, so no client needs the provider key.
Previously GET /api/ai/status returned the un-redacted AiConfig (including
providers.claude.apiKey + the legacy flat apiKey) to writer principals, and
AiSettings painted it into a password input. Close the leak end to end.

- server: redactAiConfig now strips every provider key AND the legacy flat key
  for EVERY principal (no more canWrite readback branch), replacing each with an
  apiKeySet flag. Both GET /api/ai/status and the PUT /api/ai/config echo are
  redacted, so a blank-preserved key is never handed back.
- server: AiService.setConfig merges keys — omitted/empty apiKey PRESERVES the
  stored value, a non-empty string sets it, explicit null CLEARS it; the
  transport-only apiKeySet flag is never persisted.
- sdk: AiProviderSettings/AiConfig gain apiKeySet (response-only) and widen
  apiKey to string|null for the explicit-clear signal; providerSettings carries
  apiKeySet through the legacy branch.
- ui: AiSettings key field is write-only — masked "key set" placeholder over an
  empty input, blank-on-save is a no-op, a "Clear key" button does the explicit
  clear, and the raw entry is scrubbed from state after save. AgentPanel keys
  provider availability off apiKeySet.

Tests: aiGating enumerates guest/viewer/non-member/owner/loopback all redacted,
plus preserve/empty/replace/clear/flag-not-persisted for setConfig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X78XTAemKrFw1uJpDpCEaG
mergeStoredKeys now overlays the incoming providers onto the STORED map
instead of rebuilding from `next`, so a partial PUT that omits a provider
keeps that provider's stored key (previously dropped). resolveKey trims
the new key before the empty check, so a whitespace-only entry is a
blank/preserve (no unclearable ghost key) and a set value is stored
trimmed. A malformed `{claude: null}` provider entry is now ignored
rather than throwing.

AiSettings: associate the Claude key input with its label (id/htmlFor +
aria-label), and render a real, always-present "Key set · leave blank to
keep the current key" status node (aria-describedby) so a screen reader
announces the stored-key state instead of relying on the placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X78XTAemKrFw1uJpDpCEaG
@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
app.book.pub Ready Ready Preview, Comment Jul 6, 2026 4:25am

Request Review

@eliotlim eliotlim merged commit 5a2ac01 into main Jul 6, 2026
7 checks passed
eliotlim added a commit that referenced this pull request Jul 9, 2026
…flake (#116)

Test-only. Retargets stale e2e assertions to intended product behavior (unconditional API-key redaction #111, island-hydrated viewer e177f38, email-in-more-chrome 406d174) + fixes the update-preferences post-reload race. Both code gates cleared. See #116.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant