feat(api): per-user LLM provider config with encrypted API-key storage #334
No reviewers
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!334
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "333-llm-provider-config"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements per-user LLM provider configuration (ADR-0029 §1 + §5, #333): provider + model + base URL + a write-only API key, stored per-user and AES-256-GCM-encrypted at rest under a new
CAROL_ENCRYPTION_KEY. The foundational prerequisite the agent loop / chat UI consume.Config + storage + crypto + settings UI only — the
LlmProvideradapters and the agent loop (inference/SSE/conversations) land in a later ticket that reads the decrypted key via the server-only helper this PR provides.What's in it
lib/crypto— AES-256-GCMencrypt/decrypt. Key loaded lazily fromCAROL_ENCRYPTION_KEY(base64/hex → 32 bytes) so the app boots without it; only set/decrypt paths require it. TypedEncryptionKeyError/DecryptionError; blob formatv1:iv:tag:ciphertext, fresh 12-byte IV per op.024_user_llm_credentials(one row/user, FK cascade) + entity + ciphertext-only repository (never en/decrypts).getLlmConfig/setLlmConfig/clearLlmConfig+ a server-onlygetDecryptedLlmKey(userId, db)(the single decrypt path; not exposed by any route).GET/PUT/DELETE /api/settings/llm(session or PAT → 401 otherwise). Discriminated GET ({configured:false}vs the DTO). Missing encryption key when a key is supplied → 503encryption_key_unavailable(clear operator message, not a stack trace).@carol/api-clienthooks + i18n.Security
keyConfigured;apiKeyappears solely on theSetLlmConfigRequestschema inopenapi.json(verified),LlmConfiguredDtohas onlykeyConfigured. Noconsole.*in the new files; decrypt errors scrub the OpenSSL message. Tests assert the secret never appears in any GET/PUT response.Verification
@carol/apitypecheck + lint clean (fixed 3 non-null-assertion lint errors in the blob parser);@carol/api-client+@carol/clienttypecheck + client lint clean.openapi:checkup to date · coverage 111 pairs.024migration + repository are proven on Postgres too. New tests: crypto round-trip + missing/short-key errors; storage upsert/get/delete + scoping; route set→get→clear, key-never-returned,openai_compatiblewithout baseUrl → 400, cross-user isolation, unauth → 401.CAROL_ENCRYPTION_KEY(openssl rand -base64 32, keep stable).Closes #333
🤖 Generated with Claude Code
📊 Test coverage
Patch coverage: no testable lines changed.
Overall (
app/,lib/,db/, excluding UI per ADR-0019):Soft thresholds per ADR-0019. Coverage is informational and does not block merge.
c0c7a0d9dc39d43b9f07