feat: encrypted per-user LLM key storage + settings UI (BYOK 2/4)#407
Open
edspencer wants to merge 6 commits into
Open
feat: encrypted per-user LLM key storage + settings UI (BYOK 2/4)#407edspencer wants to merge 6 commits into
edspencer wants to merge 6 commits into
Conversation
Adds the storage and settings layer for BYOK (issue #405, PR B). A new user_llm_config table (llm_provider enum, one row per user+provider, partial unique index enforcing a single default per user) stores each user's LLM provider configuration, with query helpers exported from @bragdoc/database for list/get-default/upsert/delete, including default promotion on delete and default clearing on upsert. API keys are encrypted at rest with AES-256-GCM via WebCrypto (crypto.subtle, Cloudflare Workers compatible), using a key derived through HKDF-SHA256 from the new BYOK_ENCRYPTION_KEY secret with a fresh random 12-byte IV per encryption. Only an 8-char keyHint (last 4 characters) is ever returned to clients; plaintext keys never appear in API responses. Account deletion now explicitly hard-deletes user_llm_config rows since the user row is anonymized rather than deleted (FK cascade never fires). New routes: GET /api/user/llm-config lists masked configs, PUT verifies the config against the provider via verifyLLMConfig from @bragdoc/ai before encrypting and upserting (422 with the provider error on failure; stored keys are decrypted and re-verified when editing model/baseURL only), and DELETE /api/user/llm-config/[provider] removes a config. The Account page gains an "AI Provider" section with provider select (signup links), password key input, model and baseURL fields, Verify & Save flow, and a configured-provider list with set-default and delete actions backed by a new useLLMConfigs SWR hook. BYOK_ENCRYPTION_KEY is documented in both .env.example files and the deployment docs (generate with `openssl rand -base64 32`). Unit tests cover the crypto module (round-trip, unique IVs, tamper detection, missing env) and all three routes (auth, masking, verify-fail/success, keyless Ollama, delete paths). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
Braintrust eval reportNo experiments to report |
The web app now depends on @bragdoc/ai, which ships compiled dist/ output (unlike @bragdoc/database, which exports TS source directly). Vercel's build script bypasses turbo's dependency graph, so the shared package must be built explicitly first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The App Tests CI job installs at the workspace root but never built @bragdoc/ai, so jest could not resolve the package from the new llm-config route tests. Also fixes a TS2532 (object possibly undefined) in the tamper-detection crypto test under the standalone typecheck, which unlike next build includes test files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 7, 2026
The PUT /api/user/llm-config route runs a live verification probe against a user-supplied baseURL and echoed the provider error back to the caller, so an authenticated user could point it at internal HTTP services (cloud metadata at 169.254.169.254, loopback services, LAN hosts) and read parts of their responses. A new validation module (apps/web/lib/llm/ validate-base-url.ts) now rejects non-http(s) schemes, embedded credentials, and private/internal targets — localhost/*.local/*.internal names, loopback, RFC1918, link-local/metadata, and unspecified IPv4, plus IPv6 loopback, unique-local, link-local, unspecified, and IPv4-mapped forms. The check runs on both freshly supplied base URLs and stored ones reused during edits, and the 422 verification error echo is capped at 300 characters so upstream response bodies can't be exfiltrated wholesale. Self-hosted deployments where private Ollama URLs (http://localhost:11434, http://192.168.x.x:11434) are the primary use case can set BYOK_ALLOW_PRIVATE_BASEURLS=true to allow private/internal base URLs. Documented in both .env.example files and docs/SELF_HOSTING.md, with a hint added to the base URL field in the provider settings form. Also fixes demo reset leaving BYOK key material behind: the demo data cleanup (apps/web/lib/demo-data-cleanup.ts) now deletes the shadow user's userLLMConfig rows alongside the other per-user tables, matching the account-deletion behavior — the demo user row is preserved, so the FK cascade never fires and the encrypted keys had to be removed explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 2 of 4 of the BYOK stack (#405). Base:
byok/1-shared-ai-package(#406) — merge that first.Adds encrypted at-rest storage for per-user LLM API keys and the Account-page UI to manage them. Nothing consumes the stored keys yet — that's PR 3.
What's here
user_llm_configtable (migration0013_quiet_champions.sql): one row per (user, provider);llm_providerenum (openai / anthropic / google / deepseek / ollama / openai_compatible); AES-GCM ciphertext + IV (nullable for keyless Ollama);keyHint(last 4 chars, the only thing ever shown); model + baseURL;isDefaultwith a partial unique index;lastVerifiedAt. Query helpers inpackages/database/src/llm-config/queries.ts(upsert clears competing defaults; delete promotes a remaining row).apps/web/lib/crypto/llm-keys.ts): WebCrypto-only (Cloudflare Workers + Node parity) AES-256-GCM with a random 12-byte IV per encryption; key derived via HKDF-SHA256 from a new dedicatedBYOK_ENCRYPTION_KEYsecret. Unit-tested: round-trip, unique IVs, tamper detection, missing-secret error.GET/PUT /api/user/llm-config,DELETE /api/user/llm-config/[provider]. PUT verifies the key live viaverifyLLMConfig(@bragdoc/ai) before storing — 422 with the provider's error on failure. Responses are always masked; the plaintext key never appears in any response.user_llm_configrows (the user row is anonymized, not deleted, so the FK cascade alone never fires).Deploy notes (@edspencer)
openssl rand -base64 32 | wrangler secret put BYOK_ENCRYPTION_KEY --env staging(and--env productionbefore PR 3 deploys). Also added to both.env.examplefiles.Verification
pnpm build: 6/6 tasks passpnpm test: 20 suites, 352 passed (22 new: 7 crypto, 15 route)🤖 Generated with Claude Code