feat(api): per-user LLM provider config with encrypted API-key storage #334

Merged
james merged 1 commit from 333-llm-provider-config into main 2026-06-28 23:29:51 +00:00
Owner

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 LlmProvider adapters 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-GCM encrypt/decrypt. Key loaded lazily from CAROL_ENCRYPTION_KEY (base64/hex → 32 bytes) so the app boots without it; only set/decrypt paths require it. Typed EncryptionKeyError/DecryptionError; blob format v1:iv:tag:ciphertext, fresh 12-byte IV per op.
  • Migration 024_user_llm_credentials (one row/user, FK cascade) + entity + ciphertext-only repository (never en/decrypts).
  • DTOs + servicegetLlmConfig/setLlmConfig/clearLlmConfig + a server-only getDecryptedLlmKey(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 → 503 encryption_key_unavailable (clear operator message, not a stack trace).
  • "Carol's brain" account card (every user, not admin-gated) + @carol/api-client hooks + i18n.

Security

  • The plaintext key never crosses the API boundary: responses carry only keyConfigured; apiKey appears solely on the SetLlmConfigRequest schema in openapi.json (verified), LlmConfiguredDto has only keyConfigured. No console.* in the new files; decrypt errors scrub the OpenSSL message. Tests assert the secret never appears in any GET/PUT response.

Verification

  • @carol/api typecheck + lint clean (fixed 3 non-null-assertion lint errors in the blob parser); @carol/api-client + @carol/client typecheck + client lint clean.
  • openapi:check up to date · coverage 111 pairs.
  • Full suite on BOTH engines: 1098 passed / 0 skipped — ran an ephemeral Postgres so the 024 migration + 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_compatible without baseUrl → 400, cross-user isolation, unauth → 401.
  • README Configuration documents CAROL_ENCRYPTION_KEY (openssl rand -base64 32, keep stable).

Closes #333

🤖 Generated with Claude Code

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 `LlmProvider` adapters 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-GCM `encrypt`/`decrypt`. Key loaded **lazily** from `CAROL_ENCRYPTION_KEY` (base64/hex → 32 bytes) so the app boots without it; only set/decrypt paths require it. Typed `EncryptionKeyError`/`DecryptionError`; blob format `v1:iv:tag:ciphertext`, fresh 12-byte IV per op. - **Migration `024_user_llm_credentials`** (one row/user, FK cascade) + entity + **ciphertext-only** repository (never en/decrypts). - **DTOs + service** — `getLlmConfig`/`setLlmConfig`/`clearLlmConfig` + a **server-only `getDecryptedLlmKey(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 → **503 `encryption_key_unavailable`** (clear operator message, not a stack trace). - **"Carol's brain" account card** (every user, not admin-gated) + `@carol/api-client` hooks + i18n. ## Security - The plaintext key never crosses the API boundary: responses carry only `keyConfigured`; **`apiKey` appears solely on the `SetLlmConfigRequest` schema** in `openapi.json` (verified), `LlmConfiguredDto` has only `keyConfigured`. No `console.*` in the new files; decrypt errors scrub the OpenSSL message. Tests assert the secret never appears in any GET/PUT response. ## Verification - `@carol/api` typecheck + lint clean (fixed 3 non-null-assertion lint errors in the blob parser); `@carol/api-client` + `@carol/client` typecheck + client lint clean. - `openapi:check` up to date · coverage 111 pairs. - **Full suite on BOTH engines: 1098 passed / 0 skipped** — ran an ephemeral Postgres so the `024` migration + 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_compatible` without baseUrl → 400, cross-user isolation, unauth → 401. - README Configuration documents `CAROL_ENCRYPTION_KEY` (`openssl rand -base64 32`, keep stable). Closes #333 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(api): per-user LLM provider config with encrypted API-key storage
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 5s
PR / Lint (pull_request) Successful in 2m9s
PR / Static analysis (pull_request) Failing after 2m8s
PR / Typecheck (pull_request) Successful in 2m14s
PR / pnpm audit (pull_request) Successful in 3m16s
PR / OpenAPI (pull_request) Successful in 3m34s
PR / Client (web export smoke) (pull_request) Successful in 3m50s
PR / Test (postgres) (pull_request) Successful in 4m1s
PR / OSV-Scanner (pull_request) Successful in 1m52s
PR / Test (sqlite) (pull_request) Successful in 4m12s
PR / Build (pull_request) Successful in 4m16s
PR / Package age policy (soft) (pull_request) Successful in 52s
Secrets / gitleaks (pull_request) Successful in 46s
PR / Coverage (soft) (pull_request) Successful in 2m47s
PR / Trivy (image) (pull_request) Successful in 2m56s
PR / E2E (Playwright) (pull_request) Successful in 5m15s
c0c7a0d9dc
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. Config +
storage + crypto + settings UI only — the LlmProvider adapters and the
agent loop (which consume getDecryptedLlmKey) land in a later ticket.

- lib/crypto: AES-256-GCM encrypt/decrypt; key loaded lazily from
  CAROL_ENCRYPTION_KEY (base64/hex → 32 bytes) so the app boots without
  it; typed EncryptionKeyError / DecryptionError; blob format
  v1:iv:tag:ciphertext with a fresh IV per op.
- Migration 024 user_llm_credentials (one row/user, FK cascade) + entity
  + ciphertext-only repository.
- DTOs + service (getLlmConfig/setLlmConfig/clearLlmConfig + a
  server-only getDecryptedLlmKey).
- GET/PUT/DELETE /api/settings/llm (session or PAT). The key is
  write-only and never returned — responses carry only keyConfigured;
  apiKey appears solely on the request schema. Missing encryption key →
  503 encryption_key_unavailable.
- "Carol's brain" account card + @carol/api-client hooks + i18n.
- Both-engine tests (crypto round-trip, storage, key-never-returned,
  cross-user isolation, unauth 401); OpenAPI + drift gate green; README
  documents CAROL_ENCRYPTION_KEY.

Closes #333

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 78.4% ≥ 50%
Branches 70.0% ⚠️ ≥ 75%
Functions 78.9% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 78.4% ✅ | ≥ 50% | | Branches | 70.0% ⚠️ | ≥ 75% | | Functions | 78.9% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james force-pushed 333-llm-provider-config from c0c7a0d9dc
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 5s
PR / Lint (pull_request) Successful in 2m9s
PR / Static analysis (pull_request) Failing after 2m8s
PR / Typecheck (pull_request) Successful in 2m14s
PR / pnpm audit (pull_request) Successful in 3m16s
PR / OpenAPI (pull_request) Successful in 3m34s
PR / Client (web export smoke) (pull_request) Successful in 3m50s
PR / Test (postgres) (pull_request) Successful in 4m1s
PR / OSV-Scanner (pull_request) Successful in 1m52s
PR / Test (sqlite) (pull_request) Successful in 4m12s
PR / Build (pull_request) Successful in 4m16s
PR / Package age policy (soft) (pull_request) Successful in 52s
Secrets / gitleaks (pull_request) Successful in 46s
PR / Coverage (soft) (pull_request) Successful in 2m47s
PR / Trivy (image) (pull_request) Successful in 2m56s
PR / E2E (Playwright) (pull_request) Successful in 5m15s
to 39d43b9f07
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 13s
PR / Static analysis (pull_request) Successful in 1m25s
PR / OSV-Scanner (pull_request) Successful in 31s
PR / Typecheck (pull_request) Successful in 3m6s
PR / OpenAPI (pull_request) Successful in 3m41s
PR / Lint (pull_request) Successful in 4m31s
PR / Package age policy (soft) (pull_request) Successful in 58s
PR / Trivy (image) (pull_request) Successful in 2m40s
PR / Client (web export smoke) (pull_request) Successful in 4m40s
PR / Test (postgres) (pull_request) Successful in 4m50s
PR / Test (sqlite) (pull_request) Successful in 4m53s
PR / pnpm audit (pull_request) Successful in 4m41s
PR / Build (pull_request) Successful in 5m1s
Secrets / gitleaks (pull_request) Successful in 34s
PR / E2E (Playwright) (pull_request) Successful in 5m42s
PR / Coverage (soft) (pull_request) Successful in 3m12s
2026-06-28 22:55:03 +00:00
Compare
james merged commit 4b68097289 into main 2026-06-28 23:29:51 +00:00
james deleted branch 333-llm-provider-config 2026-06-28 23:29:51 +00:00
Sign in to join this conversation.
No description provided.