Changeset 0.9.4 (#54)

This commit is contained in:
2026-02-24 10:44:12 +00:00
parent 90021157e6
commit 5e416d3726
26 changed files with 1333 additions and 108 deletions

View File

@@ -1,6 +1,6 @@
# .gitea/workflows/ci.yaml # .gitea/workflows/ci.yaml
# ============================================ # ============================================
# Chat Switchboard - CI/CD Pipeline (v0.9.0) # Chat Switchboard - CI/CD Pipeline (v0.9.4)
# ============================================ # ============================================
# Cluster deployments use SEPARATE FE + BE images. # Cluster deployments use SEPARATE FE + BE images.
# Unified image is for Docker Hub only (docker-compose use). # Unified image is for Docker Hub only (docker-compose use).
@@ -36,6 +36,7 @@
# POSTGRES_USER, POSTGRES_PASSWORD # POSTGRES_USER, POSTGRES_PASSWORD
# POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD # POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD
# SWITCHBOARD_ADMIN_USERNAME, SWITCHBOARD_ADMIN_PASSWORD, SWITCHBOARD_ADMIN_EMAIL # SWITCHBOARD_ADMIN_USERNAME, SWITCHBOARD_ADMIN_PASSWORD, SWITCHBOARD_ADMIN_EMAIL
# ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32)
# VENICE_API_KEY (live provider integration tests — optional, tests skip if missing) # VENICE_API_KEY (live provider integration tests — optional, tests skip if missing)
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional) # DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
# #
@@ -416,6 +417,7 @@ jobs:
SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }} SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }}
SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }} SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }}
SEED_USERS: ${{ vars.SEED_USERS }} SEED_USERS: ${{ vars.SEED_USERS }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
run: | run: |
kubectl create secret generic switchboard-db-credentials \ kubectl create secret generic switchboard-db-credentials \
--namespace=${NAMESPACE} \ --namespace=${NAMESPACE} \
@@ -435,6 +437,11 @@ jobs:
--from-literal=users="${SEED_USERS}" \ --from-literal=users="${SEED_USERS}" \
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
kubectl create secret generic switchboard-encryption \
--namespace=${NAMESPACE} \
--from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Render and apply manifests - name: Render and apply manifests
env: env:
ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }} ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }}

View File

@@ -45,6 +45,66 @@ Three Docker images support different deployment scenarios:
5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB (provider API sync, per-provider authoritative) → heuristic inference (regex patterns on model ID). No static model table — the same model can have different capabilities on different providers. The catalog is populated by auto-fetch on provider creation and manual refresh. 5. **Capabilities Resolution**: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB (provider API sync, per-provider authoritative) → heuristic inference (regex patterns on model ID). No static model table — the same model can have different capabilities on different providers. The catalog is populated by auto-fetch on provider creation and manual refresh.
6. **Channels as Execution Context**: Channels are the universal container for conversation state — messages, tool activity, notes, and artifacts all hang off a channel. Today channels are single-user direct chats. The architecture anticipates multi-participant channels (team members, anonymous visitors, AI personas) for workflow execution, without requiring changes to the message tree, tool framework, or streaming infrastructure.
## Workflow Architecture (Future — v0.21.0+)
The platform's existing primitives (teams, personas, channels, tools, notes)
compose into a workflow engine where the channel is the execution context
that moves through defined stages.
### Conceptual Model
```
Team
└─ Workflow (team-admin defined)
├─ name, description
├─ entry conditions (public link, team-internal, API trigger)
└─ Stages[]
├─ persona_id (which AI drives this stage)
├─ assignment_team_id (who can be assigned)
├─ form_template (structured note schema, optional)
└─ transitions[] (conditions → next stage)
Channel (workflow instance)
├─ workflow_id + current_stage
├─ participants[]
│ ├─ anonymous visitor (mTLS fingerprint / session token)
│ ├─ AI persona (per-stage, from workflow definition)
│ └─ assigned team member (claimed or auto-routed)
├─ messages (existing tree structure)
├─ notes (channel-scoped artifacts, intake forms)
└─ tool activity (existing execution framework)
```
### How Existing Primitives Map
| Existing Primitive | Workflow Role |
|-------------------|---------------|
| **Persona** (system prompt + model) | Drives a workflow stage — the AI knows what to collect, when to route |
| **Team** (members + roles) | Owns the workflow definition; members are assignable to channels |
| **Channel** (messages + tree) | Execution instance of a workflow; full conversation history |
| **Notes + Tools** | Structured data collection; the persona's system prompt *is* the form definition, the note *is* the filled form |
| **EventBus + WebSocket** | Real-time notifications for assignment, stage transitions, new messages |
### Design Constraints for Current Development
These invariants keep the workflow path open without building it prematurely:
- **Channels**: Don't assume single-owner. If touching channel queries, keep
room for `team_id`, `type` (beyond `direct`), and multi-participant access
patterns alongside `user_id`.
- **Notes**: Currently user-scoped. Future channel-scoped notes (attached to
a conversation, not a personal notebook) need a `channel_id` FK option.
- **Personas**: Already team-scoped. Don't couple to "user picks from dropdown"
— a workflow stage references a persona programmatically.
- **Tool ExecutionContext**: Already carries `UserID` + `ChannelID`. Will need
`TeamID` and `WorkflowID` — the struct is easily extended.
- **Auth**: mTLS anonymous users need identity to participate in channels.
Lightest version: a `participants` table that can reference a `user_id` or
an opaque session identifier (cert fingerprint). Don't assume every channel
participant has a row in `users`.
## Package Structure ## Package Structure
``` ```

View File

@@ -49,11 +49,17 @@ v0.17.0 Extension Surfaces + Modes
v0.18.0 Smart Routing (policy on model roles) v0.18.0 Smart Routing (policy on model roles)
v0.19.0 Live Collaboration (presence, co-editing) v0.19.0 Multi-Participant Channels + Presence
v0.20.0 Auth Strategy (mTLS/OIDC) + Full RBAC v0.20.0 Auth Strategy (mTLS/OIDC) + Full RBAC
v0.21.0 Tasks / Autonomous Agents ┌───────┴──────────────┐
│ │
v0.21.0 Workflow v0.22.0 Tasks /
Engine Autonomous Agents
(team-owned, (service channels,
staged processes, scheduler, unattended
human + AI collab) execution)
``` ```
--- ---
@@ -108,7 +114,7 @@ and the model pipeline made real for end users.
--- ---
## v0.9.2 — Quick UX Wins + Hardening ## v0.9.2 — Quick UX Wins + Hardening
Polish that doesn't need new infrastructure. Ship fast, stabilize. Polish that doesn't need new infrastructure. Ship fast, stabilize.
@@ -127,30 +133,55 @@ Polish that doesn't need new infrastructure. Ship fast, stabilize.
--- ---
## v0.9.3 — Content Visibility & Block Controls ## v0.9.3 — Content Visibility & Block Controls
User control over what's expanded/collapsed in the message flow. Thinking User control over what's expanded/collapsed in the message flow. Thinking
blocks, tool calls, and code blocks all get consistent collapse controls blocks, tool calls, and code blocks all get consistent collapse controls
instead of being hidden or auto-formatted without user agency. instead of being hidden or auto-formatted without user agency.
**Code Blocks** **Code Blocks**
- [ ] Collapse/expand toggle button on code block header (alongside Copy/Preview) - [x] Collapse/expand toggle button on code block header (alongside Copy/Preview)
- [ ] Auto-collapse at threshold (>15 lines), user can always toggle - [x] Auto-collapse at threshold (>15 lines), user can always toggle
- [ ] Smooth max-height transition instead of `<details>` wrapping - [x] Smooth max-height transition instead of `<details>` wrapping
**Thinking Blocks** **Thinking Blocks**
- [ ] Always render thinking blocks (never hide from DOM) - [x] Always render thinking blocks (never hide from DOM)
- [ ] `showThinking` setting controls default open/closed, not visibility - [x] `showThinking` setting controls default open/closed (default: false), not visibility
- [ ] User can always click to expand regardless of setting - [x] User can always click to expand regardless of setting
- [x] `reasoning_content` delta field support for models that stream thinking separately (Grok, etc.)
- [x] Reasoning accumulated during streaming, wrapped in `<think>` tags before persistence
**Tool Calls** **Tool Calls**
- [ ] Persist tool call metadata in stored messages - [x] Persist tool call metadata in stored messages (`tool_calls` JSONB column)
- [ ] Render tool calls on history load (not just during streaming) - [x] Render tool calls on history load (not just during streaming)
- [ ] Collapsed by default: "🔧 tool_name → done" with toggle for full I/O - [x] Collapsed by default: "🔧 tool_name → done" with toggle for full I/O
- [ ] Notes tool: "📝 View note" link that navigates to Notes panel - [x] Notes tool: "📝 View note" link that navigates to Notes panel
- [x] Tool result rendering fix (operator precedence bug showing "undefined results")
**Notes Panel** **Notes Panel → Side Panel**
- [ ] Copy button on note cards (same pattern as code block copy) - [x] Copy button on note cards (same pattern as code block copy)
- [x] Unified side panel replaces inline HTML preview and notes modal
- [x] Tabbed interface: Preview and Notes tabs
- [x] Desktop: slides in from right (480px), chat area shrinks via flexbox
- [x] Mobile: full-width overlay at z-index 200
- [x] Escape key integration (generation → command palette → side panel → modals)
**Streaming Refactor**
- [x] Extracted `streamWithToolLoop()` into `stream_loop.go` as single canonical streaming implementation
- [x] Both `streamCompletion` (normal chat) and `Regenerate` call shared function — only persistence differs
- [x] Regenerate handler now has full feature parity: tools, reasoning, tool_calls persistence
- [x] Fixed nil `json.RawMessage` causing PostgreSQL JSONB rejection on regen persistence
**Regenerate Fix**
- [x] Regen streams in-place (truncates display to parent before streaming)
- [x] No more "collapse to original branch" after completion
**Favicon**
- [x] Animated SVG favicon during generation (cascading opacity pulse on 4 dots)
**Admin Scope**
- [x] Admin models endpoint returns only global-scope models (not user BYOK or team models)
- [x] Bulk visibility toggle scoped to global providers only
--- ---
@@ -480,15 +511,31 @@ Rules-based routing engine — policy, not ML.
--- ---
## v0.19.0 — Live Collaboration ## v0.19.0 — Multi-Participant Channels + Presence
The channel foundation for workflows and real-time collaboration. Today
channels are single-user direct chats. This release makes channels a
shared space that multiple actors can inhabit.
Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exists). Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exists).
**Channel Participants**
- [ ] `channel_participants` table: `channel_id`, `participant_type` (user, session, persona), `participant_id`, `role` (owner, member, observer), `joined_at`
- [ ] Channel access queries rewritten: `WHERE user_id = $1` → participant-based lookup
- [ ] Backward compatible: existing single-user channels auto-have one participant (owner)
- [ ] Channel `type` column: `direct` (current), `group` (multi-user), `workflow` (staged)
**Presence + Real-time**
- [ ] Typing indicators and presence (who's online, who's in this channel) - [ ] Typing indicators and presence (who's online, who's in this channel)
- [ ] Participant join/leave events via WebSocket
- [ ] Co-editing for extension surfaces (editor mode, article mode) - [ ] Co-editing for extension surfaces (editor mode, article mode)
- [ ] Enables: IDE extension with pair programming, collaborative doc editing
- [ ] Cursor sharing, selection highlighting - [ ] Cursor sharing, selection highlighting
**Channel-Scoped Notes**
- [ ] Optional `channel_id` FK on notes (NULL = personal notebook, set = channel-attached)
- [ ] Notes created by tool calls within a channel auto-attach to that channel
- [ ] Channel notes visible to all participants (not just creator)
--- ---
## v0.20.0 — Auth Strategy (mTLS/OIDC) + Full RBAC ## v0.20.0 — Auth Strategy (mTLS/OIDC) + Full RBAC
@@ -506,17 +553,74 @@ personal providers under external auth).
- [ ] Vault passphrase prompt for mTLS/OIDC users (v0.9.3 conditional unlock) - [ ] Vault passphrase prompt for mTLS/OIDC users (v0.9.3 conditional unlock)
- [ ] Fine-grained permissions: model access, KB write, task create, token budgets - [ ] Fine-grained permissions: model access, KB write, task create, token budgets
**Anonymous / Session Participants**
- [ ] Session-based identity for unauthenticated visitors (workflow intake)
- [ ] mTLS: cert CN/fingerprint as stable session identity (no `users` row required)
- [ ] Non-mTLS: ephemeral session token (cookie or URL token)
- [ ] Session participants can send messages and trigger tool calls within workflow channels
- [ ] Session identity visible to assigned team members (cert DN, display name, or "Anonymous #N")
--- ---
## v0.21.0 — Tasks / Autonomous Agents ## v0.21.0 — Workflow Engine
The capstone: everything combined into autonomous workflows. Team-owned, stage-based process execution. The channel is the runtime,
personas drive each stage, and the existing tool/notes infrastructure
handles structured data collection. See [ARCHITECTURE.md — Workflow
Architecture](ARCHITECTURE.md#workflow-architecture-future--v0210) for
the conceptual model.
- [ ] Scheduler + task runner Depends on: multi-participant channels (v0.19.0), anonymous identity (v0.20.0).
- [ ] `task_create` tool
- [ ] `type: 'service'` channels with no human members **Workflow Definitions**
- [ ] Depends on: completion handler, tool execution, notes, web search - [ ] `workflows` table: `team_id` (owner), `name`, `description`, `is_active`, `entry_mode` (public_link, team_internal, api)
- [ ] Admin controls for resource limits, execution budgets - [ ] `workflow_stages` table: `workflow_id`, `ordinal`, `persona_id`, `assignment_team_id`, `form_template` (JSONB, structured note schema), `transition_rules` (JSONB)
- [ ] Team-admin CRUD: create/edit/delete workflows and stages
- [ ] Workflow versioning: edits create new version, active instances continue on their version
**Workflow Instances (Channels)**
- [ ] Workflow-typed channels: `type='workflow'`, `workflow_id`, `current_stage`, `stage_version`
- [ ] Entry point: public link generates workflow channel, adds anonymous participant, binds stage 1 persona
- [ ] Stage transitions: triggered by AI (tool call), team member (button), or rule (form complete)
- [ ] On transition: swap active persona, notify assignment team, update channel metadata
**AI Intake**
- [ ] Stage persona drives structured collection via system prompt
- [ ] Form template → persona system prompt injection ("collect these fields: ...")
- [ ] Tool calls create channel-scoped notes as form responses
- [ ] AI determines readiness: "I have everything needed" → trigger transition
**Assignment + Queue**
- [ ] Assignment queue: unassigned workflow channels visible to team members
- [ ] Claim model: team member claims channel (becomes participant with `member` role)
- [ ] Round-robin / rule-based auto-assignment (optional, per-stage config)
- [ ] Assignment notifications via WebSocket + optional webhook
**Team Member Collaboration**
- [ ] Assigned member sees full history (AI intake + artifacts + notes)
- [ ] Persona remains active — assists both visitor and team member
- [ ] Member can trigger stage transitions, add notes, invoke tools
- [ ] Member can reassign to different team member or escalate to different team
**Frontend**
- [ ] Workflow builder UI in team admin panel (stage list, persona picker, form template editor)
- [ ] Queue view: sidebar section showing unassigned workflow channels for user's teams
- [ ] Channel header: workflow stage indicator, transition controls
- [ ] Anonymous visitor view: minimal UI, persona-driven conversation (public link entry)
---
## v0.22.0 — Tasks / Autonomous Agents
Unattended execution — workflows without a human in the loop.
Depends on: workflow engine (v0.21.0).
- [ ] Scheduler + task runner (cron-like triggers for workflow instantiation)
- [ ] `task_create` tool (AI can spawn sub-workflows)
- [ ] `type: 'service'` channels: workflow instances with no human participants
- [ ] Execution budgets: max tokens, max tool calls, max wall-clock per stage
- [ ] Admin controls for resource limits and kill switches
- [ ] Completion webhooks (notify external systems when workflow completes)
--- ---
@@ -545,7 +649,6 @@ based on need.
- Rate limiting per user/team/tier (token budgets) - Rate limiting per user/team/tier (token budgets)
- Provider health monitoring + key rotation - Provider health monitoring + key rotation
- Multi-tenant SaaS mode - Multi-tenant SaaS mode
- Workflow builder (visual DAG for chaining models + tools)
- Plugin/extension marketplace - Plugin/extension marketplace
- Virtual scroll for long conversations - Virtual scroll for long conversations
- SQLite backend option (single-user / dev) - SQLite backend option (single-user / dev)

View File

@@ -1 +1 @@
0.9.3 0.9.4

View File

@@ -5,6 +5,7 @@
# Secrets: # Secrets:
# switchboard-db-credentials - POSTGRES_USER, POSTGRES_PASSWORD # switchboard-db-credentials - POSTGRES_USER, POSTGRES_PASSWORD
# switchboard-admin - username, password, email (optional) # switchboard-admin - username, password, email (optional)
# switchboard-encryption - ENCRYPTION_KEY (required for v0.9.4+)
# #
# Admin bootstrap: on every pod start, if SWITCHBOARD_ADMIN_* env vars # Admin bootstrap: on every pod start, if SWITCHBOARD_ADMIN_* env vars
# are set, the backend upserts an admin user. Change the secret and # are set, the backend upserts an admin user. Change the secret and
@@ -89,6 +90,12 @@ spec:
name: switchboard-seed-users name: switchboard-seed-users
key: users key: users
optional: true optional: true
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: switchboard-encryption
key: ENCRYPTION_KEY
optional: true
resources: resources:
requests: requests:
memory: "${BE_MEMORY_REQUEST}" memory: "${BE_MEMORY_REQUEST}"

View File

@@ -21,6 +21,11 @@ type Config struct {
// Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2" // Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2"
SeedUsers string SeedUsers string
// API key encryption (required for v0.9.4+)
// Used to derive AES-256 key for global/team provider API keys.
// Personal keys use per-user UEK (derived from password).
EncryptionKey string
} }
// Load reads configuration from environment variables. // Load reads configuration from environment variables.
@@ -39,6 +44,7 @@ func Load() *Config {
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""), AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""), AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
SeedUsers: getEnv("SEED_USERS", ""), SeedUsers: getEnv("SEED_USERS", ""),
EncryptionKey: getEnv("ENCRYPTION_KEY", ""),
} }
} }

131
server/crypto/backfill.go Normal file
View File

@@ -0,0 +1,131 @@
package crypto
import (
"database/sql"
"fmt"
"log"
)
// BackfillEncryptedKeys encrypts any remaining plaintext API keys in
// provider_configs.api_key_plain using the env-derived key, writing the
// result to api_key_enc (BYTEA) + key_nonce. Clears api_key_plain after.
//
// This runs once on upgrade from v0.9.3 → v0.9.4. Subsequent starts are
// a no-op (api_key_plain will be NULL for all rows).
//
// Personal-scope keys are temporarily encrypted with the env key. They get
// re-encrypted with the user's UEK on next login (see VaultUpgradeOnLogin).
func BackfillEncryptedKeys(db *sql.DB, encryptionKey string) error {
envKey, err := DeriveKeyFromEnv(encryptionKey)
if err != nil {
return fmt.Errorf("vault backfill: %w", err)
}
// Check if api_key_plain column exists (it won't on fresh installs after
// the column is eventually dropped)
var colExists bool
db.QueryRow(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'provider_configs' AND column_name = 'api_key_plain'
)
`).Scan(&colExists)
if !colExists {
return nil // Column already dropped — nothing to backfill
}
// Find rows with plaintext keys that haven't been encrypted yet
rows, err := db.Query(`
SELECT id, api_key_plain
FROM provider_configs
WHERE api_key_plain IS NOT NULL
AND api_key_plain != ''
AND api_key_enc IS NULL
`)
if err != nil {
return fmt.Errorf("vault backfill query: %w", err)
}
defer rows.Close()
var count int
for rows.Next() {
var id, plaintext string
if err := rows.Scan(&id, &plaintext); err != nil {
return fmt.Errorf("vault backfill scan: %w", err)
}
ciphertext, nonce, err := Encrypt(plaintext, envKey)
if err != nil {
return fmt.Errorf("vault backfill encrypt %s: %w", id, err)
}
_, err = db.Exec(`
UPDATE provider_configs
SET api_key_enc = $1, key_nonce = $2, api_key_plain = NULL
WHERE id = $3
`, ciphertext, nonce, id)
if err != nil {
return fmt.Errorf("vault backfill update %s: %w", id, err)
}
count++
}
if count > 0 {
log.Printf(" 🔐 Vault backfill: encrypted %d API key(s)", count)
}
return nil
}
// EnforceEncryptionKey checks startup preconditions:
// - If encrypted keys exist in DB and ENCRYPTION_KEY is empty → fatal.
// - If no keys exist and ENCRYPTION_KEY is empty → warn (fresh install OK).
func EnforceEncryptionKey(db *sql.DB, encryptionKey string) error {
if db == nil {
return nil // No DB, no keys to protect
}
// Check if any encrypted keys exist
var hasEncrypted bool
db.QueryRow(`
SELECT EXISTS (
SELECT 1 FROM provider_configs WHERE api_key_enc IS NOT NULL
)
`).Scan(&hasEncrypted)
// Check if any plaintext keys exist (pre-backfill)
var hasPlaintext bool
// api_key_plain column may not exist on fresh v0.9.4+ installs
var colExists bool
db.QueryRow(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'provider_configs' AND column_name = 'api_key_plain'
)
`).Scan(&colExists)
if colExists {
db.QueryRow(`
SELECT EXISTS (
SELECT 1 FROM provider_configs
WHERE api_key_plain IS NOT NULL AND api_key_plain != ''
)
`).Scan(&hasPlaintext)
}
if encryptionKey == "" {
if hasEncrypted {
return fmt.Errorf(
"ENCRYPTION_KEY is not set but encrypted API keys exist in the database. " +
"Set ENCRYPTION_KEY to the value used when keys were encrypted, or data will be unrecoverable",
)
}
if hasPlaintext {
return fmt.Errorf(
"ENCRYPTION_KEY is not set but plaintext API keys need encryption. " +
"Set ENCRYPTION_KEY before starting (required for v0.9.4+)",
)
}
log.Println(" ⚠ ENCRYPTION_KEY not set — API key encryption disabled (OK for fresh install with no providers)")
}
return nil
}

64
server/crypto/cache.go Normal file
View File

@@ -0,0 +1,64 @@
package crypto
import (
"sync"
)
// UEKCache holds decrypted User Encryption Keys in memory, keyed by user ID.
// Keys are loaded on login and evicted on logout / token expiry.
//
// This is the trust boundary: UEKs exist in plaintext only in server memory,
// never on disk or in the database.
type UEKCache struct {
mu sync.RWMutex
store map[string][]byte
}
// NewUEKCache creates a new empty cache.
func NewUEKCache() *UEKCache {
return &UEKCache{store: make(map[string][]byte)}
}
// Store caches a UEK for the given user ID.
func (c *UEKCache) Store(userID string, uek []byte) {
c.mu.Lock()
defer c.mu.Unlock()
// Copy to avoid caller mutation
key := make([]byte, len(uek))
copy(key, uek)
c.store[userID] = key
}
// Load retrieves the UEK for a user. Returns (uek, true) if present.
func (c *UEKCache) Load(userID string) ([]byte, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
uek, ok := c.store[userID]
if !ok {
return nil, false
}
// Return a copy
out := make([]byte, len(uek))
copy(out, uek)
return out, true
}
// Evict removes the UEK for a user (logout / session expiry).
func (c *UEKCache) Evict(userID string) {
c.mu.Lock()
defer c.mu.Unlock()
// Zero the memory before deleting
if uek, ok := c.store[userID]; ok {
for i := range uek {
uek[i] = 0
}
}
delete(c.store, userID)
}
// Size returns the number of cached UEKs (for diagnostics).
func (c *UEKCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.store)
}

72
server/crypto/resolver.go Normal file
View File

@@ -0,0 +1,72 @@
package crypto
// KeyResolver decrypts API keys using the appropriate tier:
// - global/team → env-derived key
// - personal → user's UEK from cache
type KeyResolver struct {
envKey []byte // derived from ENCRYPTION_KEY
uekCache *UEKCache
}
// NewKeyResolver creates a resolver. envKey may be nil if ENCRYPTION_KEY is
// not set (fresh install with no providers — decryption will fail gracefully).
func NewKeyResolver(envKey []byte, cache *UEKCache) *KeyResolver {
return &KeyResolver{envKey: envKey, uekCache: cache}
}
// Decrypt resolves an API key given its encrypted form, nonce, scope, and
// the requesting user's ID (needed for personal-scope keys).
func (r *KeyResolver) Decrypt(ciphertext, nonce []byte, keyScope, userID string) (string, error) {
if len(ciphertext) == 0 {
return "", nil // No key stored
}
switch keyScope {
case "global", "team":
if r.envKey == nil {
return "", ErrNoEncryptionKey
}
return Decrypt(ciphertext, nonce, r.envKey)
case "personal":
uek, ok := r.uekCache.Load(userID)
if !ok {
return "", ErrVaultLocked
}
return Decrypt(ciphertext, nonce, uek)
default:
// Fallback: try env key (covers pre-migration or unknown scopes)
if r.envKey == nil {
return "", ErrNoEncryptionKey
}
return Decrypt(ciphertext, nonce, r.envKey)
}
}
// EncryptForScope encrypts an API key with the appropriate key for the scope.
func (r *KeyResolver) EncryptForScope(plaintext, keyScope, userID string) ([]byte, []byte, error) {
if plaintext == "" {
return nil, nil, nil
}
switch keyScope {
case "personal":
uek, ok := r.uekCache.Load(userID)
if !ok {
return nil, nil, ErrVaultLocked
}
return Encrypt(plaintext, uek)
default: // global, team
if r.envKey == nil {
return nil, nil, ErrNoEncryptionKey
}
return Encrypt(plaintext, r.envKey)
}
}
// HasEnvKey returns true if the platform encryption key is available.
func (r *KeyResolver) HasEnvKey() bool {
return r.envKey != nil
}

165
server/crypto/vault.go Normal file
View File

@@ -0,0 +1,165 @@
// Package crypto provides API key encryption primitives for Chat Switchboard.
//
// Two-tier model:
//
// - Global/Team keys: AES-256-GCM with a key derived from ENCRYPTION_KEY env var.
// - Personal (BYOK) keys: AES-256-GCM with a per-user encryption key (UEK).
// The UEK is itself encrypted with a key derived from the user's password
// via Argon2id.
//
// The UEK lives in server memory for the session duration and is never
// persisted in plaintext.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/hkdf"
)
// Argon2id parameters — tuned for server-side derivation.
// Memory: 64 MiB, Iterations: 3, Parallelism: 4, Key length: 32 bytes.
const (
argonMemory = 64 * 1024 // 64 MiB
argonTime = 3
argonThreads = 4
argonKeyLen = 32
saltLen = 16
nonceLen = 12 // AES-256-GCM standard nonce size
uekLen = 32 // 256-bit UEK
)
var (
ErrDecryptionFailed = errors.New("decryption failed: wrong key or corrupted data")
ErrVaultLocked = errors.New("vault locked: user encryption key not in session")
ErrNoEncryptionKey = errors.New("ENCRYPTION_KEY not set: cannot encrypt/decrypt platform keys")
)
// --- Key Derivation ---
// DeriveKeyFromPassword derives a 256-bit key from a password and salt using Argon2id.
// Used for wrapping/unwrapping the per-user UEK.
func DeriveKeyFromPassword(password string, salt []byte) []byte {
return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
}
// DeriveKeyFromEnv derives a 256-bit key from an environment variable value
// using HKDF-SHA256 with domain separation. Deterministic — same input always
// produces the same key.
func DeriveKeyFromEnv(envValue string) ([]byte, error) {
if envValue == "" {
return nil, ErrNoEncryptionKey
}
// HKDF with SHA-256: extract + expand with context string for domain separation.
// No salt (nil) — the env value itself is the input keying material.
hkdfReader := hkdf.New(sha256.New, []byte(envValue), nil, []byte("switchboard-api-key-encryption-v1"))
key := make([]byte, 32)
if _, err := io.ReadFull(hkdfReader, key); err != nil {
return nil, fmt.Errorf("HKDF derivation failed: %w", err)
}
return key, nil
}
// --- Salt / Nonce Generation ---
// GenerateSalt returns a cryptographically random salt for Argon2id.
func GenerateSalt() ([]byte, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
return salt, nil
}
// --- UEK Lifecycle ---
// GenerateUEK creates a new random 256-bit User Encryption Key.
func GenerateUEK() ([]byte, error) {
uek := make([]byte, uekLen)
if _, err := rand.Read(uek); err != nil {
return nil, fmt.Errorf("failed to generate UEK: %w", err)
}
return uek, nil
}
// WrapUEK encrypts a UEK with a password-derived key (PDK).
// Returns (ciphertext, nonce, error).
func WrapUEK(uek, pdk []byte) ([]byte, []byte, error) {
return encrypt(uek, pdk)
}
// UnwrapUEK decrypts a UEK using the password-derived key.
func UnwrapUEK(ciphertext, nonce, pdk []byte) ([]byte, error) {
return decrypt(ciphertext, nonce, pdk)
}
// --- API Key Encryption ---
// Encrypt encrypts plaintext with the given 256-bit key using AES-256-GCM.
// Returns (ciphertext, nonce, error).
func Encrypt(plaintext string, key []byte) ([]byte, []byte, error) {
if plaintext == "" {
return nil, nil, nil // Nothing to encrypt
}
return encrypt([]byte(plaintext), key)
}
// Decrypt decrypts ciphertext with the given 256-bit key and nonce.
func Decrypt(ciphertext, nonce, key []byte) (string, error) {
if len(ciphertext) == 0 {
return "", nil // Nothing to decrypt
}
plain, err := decrypt(ciphertext, nonce, key)
if err != nil {
return "", err
}
return string(plain), nil
}
// --- Internal AES-256-GCM ---
func encrypt(plaintext, key []byte) ([]byte, []byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, fmt.Errorf("aes.NewCipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, fmt.Errorf("cipher.NewGCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, nil, fmt.Errorf("nonce generation: %w", err)
}
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
return ciphertext, nonce, nil
}
func decrypt(ciphertext, nonce, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes.NewCipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("cipher.NewGCM: %w", err)
}
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, ErrDecryptionFailed
}
return plaintext, nil
}

241
server/crypto/vault_test.go Normal file
View File

@@ -0,0 +1,241 @@
package crypto
import (
"bytes"
"testing"
)
func TestDeriveKeyFromPassword(t *testing.T) {
salt, err := GenerateSalt()
if err != nil {
t.Fatalf("GenerateSalt: %v", err)
}
key1 := DeriveKeyFromPassword("hunter2", salt)
key2 := DeriveKeyFromPassword("hunter2", salt)
if !bytes.Equal(key1, key2) {
t.Fatal("same password+salt should produce same key")
}
if len(key1) != 32 {
t.Fatalf("expected 32-byte key, got %d", len(key1))
}
// Different password → different key
key3 := DeriveKeyFromPassword("different", salt)
if bytes.Equal(key1, key3) {
t.Fatal("different passwords should produce different keys")
}
// Different salt → different key
salt2, _ := GenerateSalt()
key4 := DeriveKeyFromPassword("hunter2", salt2)
if bytes.Equal(key1, key4) {
t.Fatal("different salts should produce different keys")
}
}
func TestDeriveKeyFromEnv(t *testing.T) {
key1, err := DeriveKeyFromEnv("my-secret-key-123")
if err != nil {
t.Fatalf("DeriveKeyFromEnv: %v", err)
}
if len(key1) != 32 {
t.Fatalf("expected 32-byte key, got %d", len(key1))
}
// Deterministic
key2, _ := DeriveKeyFromEnv("my-secret-key-123")
if !bytes.Equal(key1, key2) {
t.Fatal("same input should produce same key")
}
// Empty → error
_, err = DeriveKeyFromEnv("")
if err != ErrNoEncryptionKey {
t.Fatalf("expected ErrNoEncryptionKey, got %v", err)
}
}
func TestUEKRoundTrip(t *testing.T) {
uek, err := GenerateUEK()
if err != nil {
t.Fatalf("GenerateUEK: %v", err)
}
if len(uek) != 32 {
t.Fatalf("expected 32-byte UEK, got %d", len(uek))
}
salt, _ := GenerateSalt()
pdk := DeriveKeyFromPassword("my-password", salt)
ciphertext, nonce, err := WrapUEK(uek, pdk)
if err != nil {
t.Fatalf("WrapUEK: %v", err)
}
recovered, err := UnwrapUEK(ciphertext, nonce, pdk)
if err != nil {
t.Fatalf("UnwrapUEK: %v", err)
}
if !bytes.Equal(uek, recovered) {
t.Fatal("UEK round-trip mismatch")
}
}
func TestUEKWrongPassword(t *testing.T) {
uek, _ := GenerateUEK()
salt, _ := GenerateSalt()
pdk := DeriveKeyFromPassword("correct-password", salt)
ciphertext, nonce, _ := WrapUEK(uek, pdk)
wrongPDK := DeriveKeyFromPassword("wrong-password", salt)
_, err := UnwrapUEK(ciphertext, nonce, wrongPDK)
if err != ErrDecryptionFailed {
t.Fatalf("expected ErrDecryptionFailed, got %v", err)
}
}
func TestAPIKeyRoundTrip(t *testing.T) {
key, _ := DeriveKeyFromEnv("test-encryption-key")
apiKey := "sk-ant-api03-very-secret-key-1234567890"
ciphertext, nonce, err := Encrypt(apiKey, key)
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
recovered, err := Decrypt(ciphertext, nonce, key)
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if recovered != apiKey {
t.Fatalf("API key round-trip mismatch: got %q", recovered)
}
}
func TestAPIKeyWrongKey(t *testing.T) {
key1, _ := DeriveKeyFromEnv("key-one")
key2, _ := DeriveKeyFromEnv("key-two")
ciphertext, nonce, _ := Encrypt("secret-api-key", key1)
_, err := Decrypt(ciphertext, nonce, key2)
if err != ErrDecryptionFailed {
t.Fatalf("expected ErrDecryptionFailed, got %v", err)
}
}
func TestAPIKeyCorruption(t *testing.T) {
key, _ := DeriveKeyFromEnv("test-key")
ciphertext, nonce, _ := Encrypt("my-key", key)
// Flip a byte in ciphertext
corrupted := make([]byte, len(ciphertext))
copy(corrupted, ciphertext)
corrupted[0] ^= 0xff
_, err := Decrypt(corrupted, nonce, key)
if err != ErrDecryptionFailed {
t.Fatalf("expected ErrDecryptionFailed for corrupted data, got %v", err)
}
}
func TestEncryptEmpty(t *testing.T) {
key, _ := DeriveKeyFromEnv("test-key")
ciphertext, nonce, err := Encrypt("", key)
if err != nil {
t.Fatalf("Encrypt empty: %v", err)
}
if ciphertext != nil || nonce != nil {
t.Fatal("encrypting empty string should return nil, nil")
}
result, err := Decrypt(nil, nil, key)
if err != nil {
t.Fatalf("Decrypt nil: %v", err)
}
if result != "" {
t.Fatalf("decrypting nil should return empty string, got %q", result)
}
}
func TestUEKUniqueness(t *testing.T) {
uek1, _ := GenerateUEK()
uek2, _ := GenerateUEK()
if bytes.Equal(uek1, uek2) {
t.Fatal("two generated UEKs should not be equal")
}
}
func TestFullPersonalKeyFlow(t *testing.T) {
// Simulate: user registers → UEK generated → UEK wrapped with password
// → user logs in → UEK unwrapped → personal API key encrypted with UEK
// → completion request decrypts API key with UEK
password := "user-secure-password-123"
apiKey := "sk-personal-byok-key-abc123"
// Registration: generate UEK and wrap with password
uek, _ := GenerateUEK()
salt, _ := GenerateSalt()
pdk := DeriveKeyFromPassword(password, salt)
wrappedUEK, uekNonce, _ := WrapUEK(uek, pdk)
// User adds a personal provider: encrypt API key with UEK
keyCiphertext, keyNonce, _ := Encrypt(apiKey, uek)
// --- simulate session boundary ---
// Login: derive PDK from password, unwrap UEK
loginPDK := DeriveKeyFromPassword(password, salt)
sessionUEK, err := UnwrapUEK(wrappedUEK, uekNonce, loginPDK)
if err != nil {
t.Fatalf("login UEK unwrap: %v", err)
}
// Completion request: decrypt API key with session UEK
recovered, err := Decrypt(keyCiphertext, keyNonce, sessionUEK)
if err != nil {
t.Fatalf("API key decrypt: %v", err)
}
if recovered != apiKey {
t.Fatalf("full flow mismatch: got %q", recovered)
}
}
func TestPasswordChangePreservesKeys(t *testing.T) {
// UEK stays the same — only the wrapping changes
oldPassword := "old-password"
newPassword := "new-password"
apiKey := "sk-my-key"
uek, _ := GenerateUEK()
salt, _ := GenerateSalt()
// Wrap with old password
oldPDK := DeriveKeyFromPassword(oldPassword, salt)
_, _, _ = WrapUEK(uek, oldPDK)
// Encrypt API key with UEK
keyCiphertext, keyNonce, _ := Encrypt(apiKey, uek)
// Password change: re-wrap UEK with new password (new salt too)
newSalt, _ := GenerateSalt()
newPDK := DeriveKeyFromPassword(newPassword, newSalt)
newWrappedUEK, newUEKNonce, _ := WrapUEK(uek, newPDK)
// Login with new password: unwrap UEK, decrypt API key
loginPDK := DeriveKeyFromPassword(newPassword, newSalt)
recoveredUEK, _ := UnwrapUEK(newWrappedUEK, newUEKNonce, loginPDK)
recovered, _ := Decrypt(keyCiphertext, keyNonce, recoveredUEK)
if recovered != apiKey {
t.Fatalf("password change broke key access: got %q", recovered)
}
}

View File

@@ -0,0 +1,48 @@
-- 003_vault.sql — API Key Encryption + User Vault
-- v0.9.4: Two-tier encryption for API keys
--
-- Phase 1 (this migration): Add new columns alongside existing plaintext.
-- Phase 2 (Go startup): Backfill — encrypt plaintext keys, generate UEKs.
-- Phase 3 (future): Drop api_key_plain after confirmed stable.
-- =========================================
-- 1. USERS — Vault support
-- =========================================
-- Per-user encryption key (UEK) wrapped with password-derived key (Argon2id).
-- UEK protects personal BYOK API keys. Platform admin cannot recover without
-- the user's password.
ALTER TABLE users ADD COLUMN IF NOT EXISTS encrypted_uek BYTEA;
ALTER TABLE users ADD COLUMN IF NOT EXISTS uek_salt BYTEA;
ALTER TABLE users ADD COLUMN IF NOT EXISTS uek_nonce BYTEA;
ALTER TABLE users ADD COLUMN IF NOT EXISTS vault_set BOOLEAN NOT NULL DEFAULT false;
COMMENT ON COLUMN users.encrypted_uek IS 'UEK encrypted with Argon2id(password)-derived key';
COMMENT ON COLUMN users.uek_salt IS 'Argon2id salt for UEK derivation';
COMMENT ON COLUMN users.uek_nonce IS 'AES-GCM nonce for UEK wrapping';
COMMENT ON COLUMN users.vault_set IS 'true once UEK has been generated and wrapped';
-- =========================================
-- 2. PROVIDER_CONFIGS — Encrypted key storage
-- =========================================
-- Rename current plaintext column, add encrypted BYTEA columns.
-- Backfill happens in Go (needs ENCRYPTION_KEY env var).
-- Keep plaintext temporarily for backfill; Go startup reads it, encrypts,
-- writes to new columns, then NULLs it out.
ALTER TABLE provider_configs RENAME COLUMN api_key_enc TO api_key_plain;
ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS api_key_enc BYTEA;
ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS key_nonce BYTEA;
ALTER TABLE provider_configs ADD COLUMN IF NOT EXISTS key_scope TEXT;
-- Backfill key_scope from existing scope column (safe default)
UPDATE provider_configs SET key_scope = scope WHERE key_scope IS NULL;
-- Now make it NOT NULL with default
ALTER TABLE provider_configs ALTER COLUMN key_scope SET NOT NULL;
ALTER TABLE provider_configs ALTER COLUMN key_scope SET DEFAULT 'global';
COMMENT ON COLUMN provider_configs.api_key_plain IS 'DEPRECATED: plaintext key, cleared after vault backfill';
COMMENT ON COLUMN provider_configs.api_key_enc IS 'AES-256-GCM encrypted API key (BYTEA)';
COMMENT ON COLUMN provider_configs.key_nonce IS 'AES-GCM nonce for api_key_enc';
COMMENT ON COLUMN provider_configs.key_scope IS 'Encryption tier: global/team use env key, personal uses UEK';

View File

@@ -3,6 +3,7 @@ package handlers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
@@ -11,6 +12,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
@@ -19,10 +21,11 @@ import (
type AdminHandler struct { type AdminHandler struct {
stores store.Stores stores store.Stores
vault *crypto.KeyResolver
} }
func NewAdminHandler(s store.Stores) *AdminHandler { func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver) *AdminHandler {
return &AdminHandler{stores: s} return &AdminHandler{stores: s, vault: vault}
} }
// ── User Management ───────────────────────── // ── User Management ─────────────────────────
@@ -254,7 +257,7 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
for i, cfg := range cfgs { for i, cfg := range cfgs {
out[i] = configWithKey{ out[i] = configWithKey{
ProviderConfig: cfg, ProviderConfig: cfg,
HasKey: cfg.APIKeyEnc != "", HasKey: cfg.HasKey(),
} }
} }
c.JSON(http.StatusOK, gin.H{"configs": out}) c.JSON(http.StatusOK, gin.H{"configs": out})
@@ -291,16 +294,32 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
Name: req.Name, Name: req.Name,
Provider: req.Provider, Provider: req.Provider,
Endpoint: req.Endpoint, Endpoint: req.Endpoint,
APIKeyEnc: req.APIKey, // TODO: encrypt
ModelDefault: req.ModelDefault, ModelDefault: req.ModelDefault,
Config: models.JSONMap(req.Config), Config: models.JSONMap(req.Config),
Headers: models.JSONMap(req.Headers), Headers: models.JSONMap(req.Headers),
Settings: models.JSONMap(req.Settings), Settings: models.JSONMap(req.Settings),
Scope: models.ScopeGlobal, Scope: models.ScopeGlobal,
KeyScope: models.ScopeGlobal,
IsActive: true, IsActive: true,
IsPrivate: req.IsPrivate, IsPrivate: req.IsPrivate,
} }
// Encrypt the API key
if req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
cfg.APIKeyEnc = enc
cfg.KeyNonce = nonce
} else {
// No vault configured — store raw bytes (unencrypted fallback)
cfg.APIKeyEnc = []byte(req.APIKey)
}
}
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil { if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return return
@@ -319,7 +338,7 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
"settings": cfg.Settings, "settings": cfg.Settings,
"is_active": cfg.IsActive, "is_active": cfg.IsActive,
"is_private": cfg.IsPrivate, "is_private": cfg.IsPrivate,
"has_key": cfg.APIKeyEnc != "", "has_key": cfg.HasKey(),
"created_at": cfg.CreatedAt, "created_at": cfg.CreatedAt,
"updated_at": cfg.UpdatedAt, "updated_at": cfg.UpdatedAt,
}) })
@@ -339,9 +358,19 @@ func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
} }
patch := req.ProviderConfigPatch patch := req.ProviderConfigPatch
// Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding) // Transfer api_key → encrypted fields
if req.APIKey != nil && *req.APIKey != "" { if req.APIKey != nil && *req.APIKey != "" {
patch.APIKeyEnc = req.APIKey // TODO: encrypt if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
patch.APIKeyEnc = enc
patch.KeyNonce = nonce
} else {
patch.APIKeyEnc = []byte(*req.APIKey)
}
} }
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil { if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
@@ -446,7 +475,21 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
// fetchModelsForProvider fetches and syncs models for a single provider config. // fetchModelsForProvider fetches and syncs models for a single provider config.
func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) { func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) {
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg) // Decrypt the API key for the provider API call
apiKey := ""
if len(cfg.APIKeyEnc) > 0 {
if h.vault != nil {
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err)
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
apiKey = string(cfg.APIKeyEnc)
}
}
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
if err != nil { if err != nil {
return 0, 0, 0, err return 0, 0, 0, err
} }

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
) )
@@ -13,10 +14,11 @@ import (
// ProviderConfigHandler handles user-facing provider config endpoints. // ProviderConfigHandler handles user-facing provider config endpoints.
type ProviderConfigHandler struct { type ProviderConfigHandler struct {
stores store.Stores stores store.Stores
vault *crypto.KeyResolver
} }
func NewProviderConfigHandler(s store.Stores) *ProviderConfigHandler { func NewProviderConfigHandler(s store.Stores, vault *crypto.KeyResolver) *ProviderConfigHandler {
return &ProviderConfigHandler{stores: s} return &ProviderConfigHandler{stores: s, vault: vault}
} }
// ListConfigs returns configs accessible to the user (global + personal + team). // ListConfigs returns configs accessible to the user (global + personal + team).
@@ -52,7 +54,7 @@ func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
Name: cfg.Name, Name: cfg.Name,
Provider: cfg.Provider, Provider: cfg.Provider,
Endpoint: cfg.Endpoint, Endpoint: cfg.Endpoint,
HasKey: cfg.APIKeyEnc != "", HasKey: cfg.HasKey(),
ModelDefault: cfg.ModelDefault, ModelDefault: cfg.ModelDefault,
Scope: cfg.Scope, Scope: cfg.Scope,
IsActive: cfg.IsActive, IsActive: cfg.IsActive,
@@ -111,13 +113,32 @@ func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
Name: req.Name, Name: req.Name,
Provider: req.Provider, Provider: req.Provider,
Endpoint: req.Endpoint, Endpoint: req.Endpoint,
APIKeyEnc: req.APIKey, // TODO: encrypt
ModelDefault: req.ModelDefault, ModelDefault: req.ModelDefault,
Scope: models.ScopePersonal, Scope: models.ScopePersonal,
KeyScope: models.ScopePersonal,
OwnerID: &userID, OwnerID: &userID,
IsActive: true, IsActive: true,
} }
// Encrypt the API key with user's UEK (personal scope)
if req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "personal", userID)
if err != nil {
if err == crypto.ErrVaultLocked {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
cfg.APIKeyEnc = enc
cfg.KeyNonce = nonce
} else {
cfg.APIKeyEnc = []byte(req.APIKey)
}
}
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil { if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return return
@@ -133,7 +154,7 @@ func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
"scope": cfg.Scope, "scope": cfg.Scope,
} }
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg) result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, req.APIKey)
if err != nil { if err != nil {
// Provider created successfully but model fetch failed. // Provider created successfully but model fetch failed.
// Return 201 (provider exists) with a warning so the frontend can show it. // Return 201 (provider exists) with a warning so the frontend can show it.
@@ -169,9 +190,23 @@ func (h *ProviderConfigHandler) UpdateConfig(c *gin.Context) {
} }
patch := req.ProviderConfigPatch patch := req.ProviderConfigPatch
// Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding) // Transfer api_key → encrypted fields
if req.APIKey != nil && *req.APIKey != "" { if req.APIKey != nil && *req.APIKey != "" {
patch.APIKeyEnc = req.APIKey // TODO: encrypt if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "personal", userID)
if err != nil {
if err == crypto.ErrVaultLocked {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
patch.APIKeyEnc = enc
patch.KeyNonce = nonce
} else {
patch.APIKeyEnc = []byte(*req.APIKey)
}
} }
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil { if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
@@ -228,7 +263,21 @@ func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
return return
} }
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg) // Decrypt the API key for the provider API call
apiKey := ""
if len(cfg.APIKeyEnc) > 0 {
if h.vault != nil {
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
} else {
apiKey = string(cfg.APIKeyEnc)
}
}
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
if err != nil { if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()}) c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return return

View File

@@ -15,6 +15,8 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
) )
@@ -32,12 +34,13 @@ type Claims struct {
} }
type AuthHandler struct { type AuthHandler struct {
cfg *config.Config cfg *config.Config
stores store.Stores stores store.Stores
uekCache *crypto.UEKCache
} }
func NewAuthHandler(cfg *config.Config, s store.Stores) *AuthHandler { func NewAuthHandler(cfg *config.Config, s store.Stores, uekCache *crypto.UEKCache) *AuthHandler {
return &AuthHandler{cfg: cfg, stores: s} return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache}
} }
func (h *AuthHandler) Register(c *gin.Context) { func (h *AuthHandler) Register(c *gin.Context) {
@@ -90,6 +93,12 @@ func (h *AuthHandler) Register(c *gin.Context) {
return return
} }
// Generate and store the User Encryption Key (vault)
if err := h.initVault(c.Request.Context(), user.ID, req.Password); err != nil {
log.Printf("⚠ Failed to init vault for user %s: %v", user.ID, err)
// Non-fatal: user can still use the app, vault will init on next login
}
if !user.IsActive { if !user.IsActive {
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
"message": "Account created but requires admin approval", "message": "Account created but requires admin approval",
@@ -133,6 +142,9 @@ func (h *AuthHandler) Login(c *gin.Context) {
return return
} }
// Vault: unwrap UEK into session cache (or init if first login post-migration)
h.unlockVault(c.Request.Context(), user, req.Password)
h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID) h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID)
tokens, err := h.generateTokens(user) tokens, err := h.generateTokens(user)
@@ -189,6 +201,11 @@ func (h *AuthHandler) Logout(c *gin.Context) {
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash) h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
} }
// Evict UEK from session cache
if userID, exists := c.Get("user_id"); exists {
h.uekCache.Evict(userID.(string))
}
c.JSON(http.StatusOK, gin.H{"message": "logged out"}) c.JSON(http.StatusOK, gin.H{"message": "logged out"})
} }
@@ -239,6 +256,85 @@ func hashToken(token string) string {
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
// ── Vault Lifecycle ─────────────────────────
// initVault generates a UEK, wraps it with the user's password, and stores
// the encrypted UEK + salt + nonce on the user record. Called on registration
// and on first login for pre-migration users.
func (h *AuthHandler) initVault(ctx context.Context, userID, password string) error {
if h.uekCache == nil {
return nil // Vault not configured (e.g. tests)
}
uek, err := crypto.GenerateUEK()
if err != nil {
return err
}
salt, err := crypto.GenerateSalt()
if err != nil {
return err
}
pdk := crypto.DeriveKeyFromPassword(password, salt)
encryptedUEK, nonce, err := crypto.WrapUEK(uek, pdk)
if err != nil {
return err
}
_, err = database.DB.ExecContext(ctx, `
UPDATE users
SET encrypted_uek = $1, uek_salt = $2, uek_nonce = $3, vault_set = true
WHERE id = $4
`, encryptedUEK, salt, nonce, userID)
if err != nil {
return err
}
// Cache the UEK for the session
h.uekCache.Store(userID, uek)
log.Printf(" 🔐 Vault initialized for user %s", userID)
return nil
}
// unlockVault unwraps the UEK on login and caches it. If the user doesn't
// have a vault yet (pre-migration account), initializes one.
func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, password string) {
if h.uekCache == nil {
return
}
var vaultSet bool
var encryptedUEK, salt, nonce []byte
err := database.DB.QueryRowContext(ctx, `
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
FROM users WHERE id = $1
`, user.ID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
if err != nil {
log.Printf("⚠ Vault query failed for user %s: %v", user.ID, err)
return
}
if !vaultSet {
// Pre-migration user: initialize vault on first login
if err := h.initVault(ctx, user.ID, password); err != nil {
log.Printf("⚠ Vault init on login failed for user %s: %v", user.ID, err)
}
return
}
// Derive PDK from password and unwrap UEK
pdk := crypto.DeriveKeyFromPassword(password, salt)
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
if err != nil {
log.Printf("⚠ Vault unlock failed for user %s: %v", user.ID, err)
return
}
h.uekCache.Store(user.ID, uek)
}
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret). // BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
func BootstrapAdmin(cfg *config.Config, s store.Stores) { func BootstrapAdmin(cfg *config.Config, s store.Stores) {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" { if cfg.AdminUsername == "" || cfg.AdminPassword == "" {

View File

@@ -26,7 +26,7 @@ func testConfig() *config.Config {
// testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests). // testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests).
func testAuthHandler() *AuthHandler { func testAuthHandler() *AuthHandler {
return NewAuthHandler(testConfig(), store.Stores{}) return NewAuthHandler(testConfig(), store.Stores{}, nil)
} }
// ── JWT Token Tests ───────────────────────── // ── JWT Token Tests ─────────────────────────

View File

@@ -13,6 +13,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/tools" "git.gobha.me/xcaliber/chat-switchboard/tools"
) )
@@ -33,11 +34,13 @@ type completionRequest struct {
} }
// CompletionHandler proxies LLM requests through the backend. // CompletionHandler proxies LLM requests through the backend.
type CompletionHandler struct{} type CompletionHandler struct{
vault *crypto.KeyResolver
}
// NewCompletionHandler creates a new handler. // NewCompletionHandler creates a new handler.
func NewCompletionHandler() *CompletionHandler { func NewCompletionHandler(vault *crypto.KeyResolver) *CompletionHandler {
return &CompletionHandler{} return &CompletionHandler{vault: vault}
} }
// ── Chat Completion ───────────────────────── // ── Chat Completion ─────────────────────────
@@ -387,16 +390,20 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
// Load the config — allow personal, global, OR team configs the user belongs to // Load the config — allow personal, global, OR team configs the user belongs to
var providerID, endpoint string var providerID, endpoint string
var apiKey, modelDefault *string var modelDefault *string
var apiKeyEnc, keyNonce []byte
var keyScope string
var customHeadersJSON, providerSettingsJSON []byte var customHeadersJSON, providerSettingsJSON []byte
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_enc, model_default, headers, settings SELECT provider, endpoint, api_key_enc, key_nonce, key_scope,
model_default, headers, settings
FROM provider_configs FROM provider_configs
WHERE id = $1 AND is_active = true WHERE id = $1 AND is_active = true
AND (scope = 'global' AND (scope = 'global'
OR (scope = 'personal' AND owner_id = $2) OR (scope = 'personal' AND owner_id = $2)
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2))) OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON) `, configID, userID).Scan(&providerID, &endpoint, &apiKeyEnc, &keyNonce, &keyScope,
&modelDefault, &customHeadersJSON, &providerSettingsJSON)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("API config not found or not accessible") return providers.ProviderConfig{}, "", "", "", fmt.Errorf("API config not found or not accessible")
@@ -414,9 +421,22 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no model specified and no default model in config") return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no model specified and no default model in config")
} }
// Decrypt API key using the appropriate tier
key := "" key := ""
if apiKey != nil { if len(apiKeyEnc) > 0 {
key = *apiKey if h.vault != nil {
var err error
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID)
if err != nil {
if err == crypto.ErrVaultLocked {
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("personal vault is locked — please log in again")
}
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("failed to decrypt API key: %w", err)
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
key = string(apiKeyEnc)
}
} }
// Parse custom headers // Parse custom headers

View File

@@ -46,13 +46,13 @@ func setupHarness(t *testing.T) *testHarness {
api := r.Group("/api/v1") api := r.Group("/api/v1")
// Auth (unprotected) // Auth (unprotected)
auth := NewAuthHandler(cfg, stores) auth := NewAuthHandler(cfg, stores, nil)
authGroup := api.Group("/auth") authGroup := api.Group("/auth")
authGroup.POST("/register", auth.Register) authGroup.POST("/register", auth.Register)
authGroup.POST("/login", auth.Login) authGroup.POST("/login", auth.Login)
// Public settings // Public settings
adm := NewAdminHandler(stores) adm := NewAdminHandler(stores, nil)
api.GET("/settings/public", adm.PublicSettings) api.GET("/settings/public", adm.PublicSettings)
// Protected routes // Protected routes
@@ -70,7 +70,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// User providers // User providers
provCfg := NewProviderConfigHandler(stores) provCfg := NewProviderConfigHandler(stores, nil)
protected.GET("/api-configs", provCfg.ListConfigs) protected.GET("/api-configs", provCfg.ListConfigs)
protected.POST("/api-configs", provCfg.CreateConfig) protected.POST("/api-configs", provCfg.CreateConfig)
protected.GET("/api-configs/:id", provCfg.GetConfig) protected.GET("/api-configs/:id", provCfg.GetConfig)
@@ -80,7 +80,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
// Team self-service (same route group as production) // Team self-service (same route group as production)
teams := NewTeamHandler() teams := NewTeamHandler(nil)
protected.GET("/teams/mine", teams.MyTeams) protected.GET("/teams/mine", teams.MyTeams)
teamScoped := protected.Group("/teams/:teamId") teamScoped := protected.Group("/teams/:teamId")
@@ -116,7 +116,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.POST("/channels", channels.CreateChannel) protected.POST("/channels", channels.CreateChannel)
// Completions // Completions
completions := NewCompletionHandler() completions := NewCompletionHandler(nil)
protected.POST("/chat/completions", completions.Complete) protected.POST("/chat/completions", completions.Complete)
// Admin routes // Admin routes

View File

@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/tools" "git.gobha.me/xcaliber/chat-switchboard/tools"
@@ -54,11 +55,13 @@ type cursorRequest struct {
} }
// MessageHandler holds dependencies for message endpoints. // MessageHandler holds dependencies for message endpoints.
type MessageHandler struct{} type MessageHandler struct{
vault *crypto.KeyResolver
}
// NewMessageHandler creates a new message handler. // NewMessageHandler creates a new message handler.
func NewMessageHandler() *MessageHandler { func NewMessageHandler(vault *crypto.KeyResolver) *MessageHandler {
return &MessageHandler{} return &MessageHandler{vault: vault}
} }
// ── List Messages (flat, all branches) ────── // ── List Messages (flat, all branches) ──────
@@ -353,7 +356,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Resolve model + provider ── // ── Resolve model + provider ──
comp := NewCompletionHandler() comp := NewCompletionHandler(h.vault)
var presetSystemPrompt string var presetSystemPrompt string
model := req.Model model := req.Model

View File

@@ -20,7 +20,8 @@ type syncResult struct {
// syncProviderModels fetches models from a provider's API and syncs them into the catalog. // syncProviderModels fetches models from a provider's API and syncs them into the catalog.
// New models default to 'disabled' visibility (admin must explicitly enable for global providers). // New models default to 'disabled' visibility (admin must explicitly enable for global providers).
func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) { // apiKeyPlain is the decrypted API key — callers are responsible for decryption.
func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) {
prov, err := providers.Get(cfg.Provider) prov, err := providers.Get(cfg.Provider)
if err != nil { if err != nil {
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider) return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
@@ -28,7 +29,7 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
provCfg := providers.ProviderConfig{ provCfg := providers.ProviderConfig{
Endpoint: cfg.Endpoint, Endpoint: cfg.Endpoint,
APIKey: cfg.APIKeyEnc, APIKey: apiKeyPlain,
} }
if cfg.Headers != nil { if cfg.Headers != nil {
@@ -80,8 +81,8 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
// syncAndEnableProviderModels fetches models and auto-enables them all. // syncAndEnableProviderModels fetches models and auto-enables them all.
// Used for personal (BYOK) providers — the user explicitly added this provider to use it. // Used for personal (BYOK) providers — the user explicitly added this provider to use it.
func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) { func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) {
result, err := syncProviderModels(ctx, stores, cfg) result, err := syncProviderModels(ctx, stores, cfg, apiKeyPlain)
if err != nil { if err != nil {
return result, err return result, err
} }

View File

@@ -50,13 +50,13 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
configs := make([]teamProvider, 0) configs := make([]teamProvider, 0)
for rows.Next() { for rows.Next() {
var p teamProvider var p teamProvider
var apiKeyEnc *string var apiKeyEnc []byte
var configRaw string var configRaw string
if err := rows.Scan(&p.ID, &p.Name, &p.Provider, &p.Endpoint, &apiKeyEnc, if err := rows.Scan(&p.ID, &p.Name, &p.Provider, &p.Endpoint, &apiKeyEnc,
&p.ModelDefault, &configRaw, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt); err != nil { &p.ModelDefault, &configRaw, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt); err != nil {
continue continue
} }
p.HasKey = apiKeyEnc != nil && *apiKeyEnc != "" p.HasKey = len(apiKeyEnc) > 0
p.Config = parseJSONBConfig(configRaw) p.Config = parseJSONBConfig(configRaw)
configs = append(configs, p) configs = append(configs, p)
} }
@@ -104,12 +104,29 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
configJSON = string(b) configJSON = string(b)
} }
// Encrypt the API key for team scope
var apiKeyEnc, keyNonce []byte
if req.APIKey != "" {
if h.vault != nil {
var err error
apiKeyEnc, keyNonce, err = h.vault.EncryptForScope(req.APIKey, "team", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
} else {
apiKeyEnc = []byte(req.APIKey)
}
}
var id string var id string
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc, model_default, config, is_private) INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint,
VALUES ('team', $1, $2, $3, $4, $5, $6, $7::jsonb, $8) api_key_enc, key_nonce, key_scope, model_default, config, is_private)
VALUES ('team', $1, $2, $3, $4, $5, $6, 'team', $7, $8::jsonb, $9)
RETURNING id RETURNING id
`, teamID, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, configJSON, req.IsPrivate, `, teamID, req.Name, req.Provider, req.Endpoint,
apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate,
).Scan(&id) ).Scan(&id)
if err != nil { if err != nil {
log.Printf("[WARN] Failed to create team provider: %v", err) log.Printf("[WARN] Failed to create team provider: %v", err)
@@ -162,10 +179,24 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
args = append(args, *req.Endpoint) args = append(args, *req.Endpoint)
argN++ argN++
} }
if req.APIKey != nil { if req.APIKey != nil && *req.APIKey != "" {
query += ", api_key_enc = $" + strconv.Itoa(argN) if h.vault != nil {
args = append(args, *req.APIKey) enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "team", "")
argN++ if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
query += ", api_key_enc = $" + strconv.Itoa(argN)
args = append(args, enc)
argN++
query += ", key_nonce = $" + strconv.Itoa(argN)
args = append(args, nonce)
argN++
} else {
query += ", api_key_enc = $" + strconv.Itoa(argN)
args = append(args, []byte(*req.APIKey))
argN++
}
} }
if req.ModelDefault != nil { if req.ModelDefault != nil {
query += ", model_default = $" + strconv.Itoa(argN) query += ", model_default = $" + strconv.Itoa(argN)
@@ -229,13 +260,14 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
providerID := c.Param("id") providerID := c.Param("id")
var name, providerType, endpoint string var name, providerType, endpoint string
var apiKey *string var apiKeyEnc, keyNonce []byte
var keyScope string
var headersJSON []byte var headersJSON []byte
err := database.DB.QueryRow(` err := database.DB.QueryRow(`
SELECT name, provider, endpoint, api_key_enc, headers SELECT name, provider, endpoint, api_key_enc, key_nonce, key_scope, headers
FROM provider_configs FROM provider_configs
WHERE id = $1 AND scope = 'team' AND owner_id = $2 AND is_active = true WHERE id = $1 AND scope = 'team' AND owner_id = $2 AND is_active = true
`, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKey, &headersJSON) `, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKeyEnc, &keyNonce, &keyScope, &headersJSON)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return return
@@ -248,8 +280,17 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
} }
key := "" key := ""
if apiKey != nil { if len(apiKeyEnc) > 0 {
key = *apiKey if h.vault != nil {
var err error
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt API key"})
return
}
} else {
key = string(apiKeyEnc)
}
} }
var customHeaders map[string]string var customHeaders map[string]string

View File

@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/providers"
) )
@@ -40,9 +41,13 @@ type updateMemberRequest struct {
// ── Handler ───────────────────────────────── // ── Handler ─────────────────────────────────
type TeamHandler struct{} type TeamHandler struct{
vault *crypto.KeyResolver
}
func NewTeamHandler() *TeamHandler { return &TeamHandler{} } func NewTeamHandler(vault *crypto.KeyResolver) *TeamHandler {
return &TeamHandler{vault: vault}
}
// ── Admin: List All Teams ─────────────────── // ── Admin: List All Teams ───────────────────

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/handlers" "git.gobha.me/xcaliber/chat-switchboard/handlers"
@@ -23,6 +24,8 @@ func main() {
providers.Init() providers.Init()
var stores store.Stores var stores store.Stores
uekCache := crypto.NewUEKCache()
var keyResolver *crypto.KeyResolver
if err := database.Connect(cfg); err != nil { if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err) log.Printf("⚠ Database unavailable: %v", err)
@@ -33,6 +36,28 @@ func main() {
log.Fatalf("❌ Schema migration failed: %v", err) log.Fatalf("❌ Schema migration failed: %v", err)
} }
// Vault: enforce encryption key + backfill plaintext keys
if err := crypto.EnforceEncryptionKey(database.DB, cfg.EncryptionKey); err != nil {
log.Fatalf("❌ Vault check failed: %v", err)
}
if cfg.EncryptionKey != "" {
if err := crypto.BackfillEncryptedKeys(database.DB, cfg.EncryptionKey); err != nil {
log.Fatalf("❌ Vault backfill failed: %v", err)
}
}
// Derive env key for key resolver (nil if not set)
var envKey []byte
if cfg.EncryptionKey != "" {
var err error
envKey, err = crypto.DeriveKeyFromEnv(cfg.EncryptionKey)
if err != nil {
log.Fatalf("❌ Failed to derive encryption key: %v", err)
}
log.Println(" 🔐 API key encryption active")
}
keyResolver = crypto.NewKeyResolver(envKey, uekCache)
// Initialize store layer // Initialize store layer
stores = postgres.NewStores(database.DB) stores = postgres.NewStores(database.DB)
@@ -68,7 +93,7 @@ func main() {
base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket) base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
// ── Auth routes (rate limited) ────────────── // ── Auth routes (rate limited) ──────────────
auth := handlers.NewAuthHandler(cfg, stores) auth := handlers.NewAuthHandler(cfg, stores, uekCache)
authLimiter := middleware.NewRateLimiter(1, 5) authLimiter := middleware.NewRateLimiter(1, 5)
api := base.Group("/api/v1") api := base.Group("/api/v1")
@@ -110,7 +135,7 @@ func main() {
protected.DELETE("/channels/:id", channels.DeleteChannel) protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages // Messages
msgs := handlers.NewMessageHandler() msgs := handlers.NewMessageHandler(keyResolver)
protected.GET("/channels/:id/messages", msgs.ListMessages) protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage) protected.POST("/channels/:id/messages", msgs.CreateMessage)
@@ -122,11 +147,11 @@ func main() {
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Chat Completions // Chat Completions
comp := handlers.NewCompletionHandler() comp := handlers.NewCompletionHandler(keyResolver)
protected.POST("/chat/completions", comp.Complete) protected.POST("/chat/completions", comp.Complete)
// Provider Configs (user-facing — replaces /api-configs) // Provider Configs (user-facing — replaces /api-configs)
provCfg := handlers.NewProviderConfigHandler(stores) provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
protected.POST("/api-configs", provCfg.CreateConfig) protected.POST("/api-configs", provCfg.CreateConfig)
protected.GET("/api-configs/:id", provCfg.GetConfig) protected.GET("/api-configs/:id", provCfg.GetConfig)
@@ -177,7 +202,7 @@ func main() {
protected.DELETE("/notes/:id", notes.Delete) protected.DELETE("/notes/:id", notes.Delete)
// Teams (user: my teams) // Teams (user: my teams)
teams := handlers.NewTeamHandler() teams := handlers.NewTeamHandler(keyResolver)
protected.GET("/teams/mine", teams.MyTeams) protected.GET("/teams/mine", teams.MyTeams)
// Team admin self-service // Team admin self-service
@@ -209,7 +234,7 @@ func main() {
} }
// Public global settings (non-admin users can read safe subset) // Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler(stores) adm := handlers.NewAdminHandler(stores, keyResolver)
protected.GET("/settings/public", adm.PublicSettings) protected.GET("/settings/public", adm.PublicSettings)
} }
@@ -218,7 +243,7 @@ func main() {
admin.Use(middleware.Auth(cfg)) admin.Use(middleware.Auth(cfg))
admin.Use(middleware.RequireAdmin()) admin.Use(middleware.RequireAdmin())
{ {
adm := handlers.NewAdminHandler(stores) adm := handlers.NewAdminHandler(stores, keyResolver)
// User management // User management
admin.GET("/users", adm.ListUsers) admin.GET("/users", adm.ListUsers)
@@ -259,7 +284,7 @@ func main() {
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Teams (admin) // Teams (admin)
teamAdm := handlers.NewTeamHandler() teamAdm := handlers.NewTeamHandler(keyResolver)
admin.GET("/teams", teamAdm.ListTeams) admin.GET("/teams", teamAdm.ListTeams)
admin.POST("/teams", teamAdm.CreateTeam) admin.POST("/teams", teamAdm.CreateTeam)
admin.GET("/teams/:id", teamAdm.GetTeam) admin.GET("/teams/:id", teamAdm.GetTeam)

View File

@@ -119,7 +119,9 @@ type ProviderConfig struct {
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"` Provider string `json:"provider" db:"provider"`
Endpoint string `json:"endpoint" db:"endpoint"` Endpoint string `json:"endpoint" db:"endpoint"`
APIKeyEnc string `json:"-" db:"api_key_enc"` APIKeyEnc []byte `json:"-" db:"api_key_enc"`
KeyNonce []byte `json:"-" db:"key_nonce"`
KeyScope string `json:"-" db:"key_scope"`
ModelDefault string `json:"model_default,omitempty" db:"model_default"` ModelDefault string `json:"model_default,omitempty" db:"model_default"`
Config JSONMap `json:"config,omitempty" db:"config"` Config JSONMap `json:"config,omitempty" db:"config"`
Headers JSONMap `json:"headers,omitempty" db:"headers"` Headers JSONMap `json:"headers,omitempty" db:"headers"`
@@ -128,10 +130,16 @@ type ProviderConfig struct {
IsPrivate bool `json:"is_private" db:"is_private"` IsPrivate bool `json:"is_private" db:"is_private"`
} }
// HasKey returns true if an encrypted API key is stored.
func (p *ProviderConfig) HasKey() bool {
return len(p.APIKeyEnc) > 0
}
type ProviderConfigPatch struct { type ProviderConfigPatch struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Endpoint *string `json:"endpoint,omitempty"` Endpoint *string `json:"endpoint,omitempty"`
APIKeyEnc *string `json:"-"` APIKeyEnc []byte `json:"-"`
KeyNonce []byte `json:"-"`
ModelDefault *string `json:"model_default,omitempty"` ModelDefault *string `json:"model_default,omitempty"`
Config JSONMap `json:"config,omitempty"` Config JSONMap `json:"config,omitempty"`
Headers JSONMap `json:"headers,omitempty"` Headers JSONMap `json:"headers,omitempty"`

View File

@@ -14,16 +14,16 @@ type ProviderStore struct{}
func NewProviderStore() *ProviderStore { return &ProviderStore{} } func NewProviderStore() *ProviderStore { return &ProviderStore{} }
const providerCols = `id, scope, owner_id, name, provider, endpoint, api_key_enc, const providerCols = `id, scope, owner_id, name, provider, endpoint, api_key_enc,
model_default, config, headers, settings, is_active, is_private, created_at, updated_at` key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private, created_at, updated_at`
func (s *ProviderStore) Create(ctx context.Context, cfg *models.ProviderConfig) error { func (s *ProviderStore) Create(ctx context.Context, cfg *models.ProviderConfig) error {
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc, INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc,
model_default, config, headers, settings, is_active, is_private) key_nonce, key_scope, model_default, config, headers, settings, is_active, is_private)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, created_at, updated_at`, RETURNING id, created_at, updated_at`,
cfg.Scope, models.NullString(cfg.OwnerID), cfg.Name, cfg.Provider, cfg.Endpoint, cfg.Scope, models.NullString(cfg.OwnerID), cfg.Name, cfg.Provider, cfg.Endpoint,
cfg.APIKeyEnc, cfg.ModelDefault, cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, cfg.ModelDefault,
ToJSON(cfg.Config), ToJSON(cfg.Headers), ToJSON(cfg.Settings), ToJSON(cfg.Config), ToJSON(cfg.Headers), ToJSON(cfg.Settings),
cfg.IsActive, cfg.IsPrivate, cfg.IsActive, cfg.IsPrivate,
).Scan(&cfg.ID, &cfg.CreatedAt, &cfg.UpdatedAt) ).Scan(&cfg.ID, &cfg.CreatedAt, &cfg.UpdatedAt)
@@ -44,7 +44,8 @@ func (s *ProviderStore) Update(ctx context.Context, id string, patch models.Prov
b.Set("endpoint", *patch.Endpoint) b.Set("endpoint", *patch.Endpoint)
} }
if patch.APIKeyEnc != nil { if patch.APIKeyEnc != nil {
b.Set("api_key_enc", *patch.APIKeyEnc) b.Set("api_key_enc", patch.APIKeyEnc)
b.Set("key_nonce", patch.KeyNonce)
} }
if patch.ModelDefault != nil { if patch.ModelDefault != nil {
b.Set("model_default", *patch.ModelDefault) b.Set("model_default", *patch.ModelDefault)
@@ -149,11 +150,11 @@ func (s *ProviderStore) listByScope(ctx context.Context, scope, ownerID string)
func scanProvider(row *sql.Row) (*models.ProviderConfig, error) { func scanProvider(row *sql.Row) (*models.ProviderConfig, error) {
var p models.ProviderConfig var p models.ProviderConfig
var ownerID, modelDefault sql.NullString var ownerID, modelDefault, keyScope sql.NullString
var configJSON, headersJSON, settingsJSON []byte var configJSON, headersJSON, settingsJSON []byte
err := row.Scan( err := row.Scan(
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint, &p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
&p.APIKeyEnc, &modelDefault, &p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault,
&configJSON, &headersJSON, &settingsJSON, &configJSON, &headersJSON, &settingsJSON,
&p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt,
) )
@@ -162,6 +163,7 @@ func scanProvider(row *sql.Row) (*models.ProviderConfig, error) {
} }
p.OwnerID = NullableStringPtr(ownerID) p.OwnerID = NullableStringPtr(ownerID)
p.ModelDefault = modelDefault.String p.ModelDefault = modelDefault.String
p.KeyScope = keyScope.String
json.Unmarshal(configJSON, &p.Config) json.Unmarshal(configJSON, &p.Config)
json.Unmarshal(headersJSON, &p.Headers) json.Unmarshal(headersJSON, &p.Headers)
json.Unmarshal(settingsJSON, &p.Settings) json.Unmarshal(settingsJSON, &p.Settings)
@@ -172,11 +174,11 @@ func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) {
var result []models.ProviderConfig var result []models.ProviderConfig
for rows.Next() { for rows.Next() {
var p models.ProviderConfig var p models.ProviderConfig
var ownerID, modelDefault sql.NullString var ownerID, modelDefault, keyScope sql.NullString
var configJSON, headersJSON, settingsJSON []byte var configJSON, headersJSON, settingsJSON []byte
err := rows.Scan( err := rows.Scan(
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint, &p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
&p.APIKeyEnc, &modelDefault, &p.APIKeyEnc, &p.KeyNonce, &keyScope, &modelDefault,
&configJSON, &headersJSON, &settingsJSON, &configJSON, &headersJSON, &settingsJSON,
&p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt,
) )
@@ -185,6 +187,7 @@ func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) {
} }
p.OwnerID = NullableStringPtr(ownerID) p.OwnerID = NullableStringPtr(ownerID)
p.ModelDefault = modelDefault.String p.ModelDefault = modelDefault.String
p.KeyScope = keyScope.String
json.Unmarshal(configJSON, &p.Config) json.Unmarshal(configJSON, &p.Config)
json.Unmarshal(headersJSON, &p.Headers) json.Unmarshal(headersJSON, &p.Headers)
json.Unmarshal(settingsJSON, &p.Settings) json.Unmarshal(settingsJSON, &p.Settings)

View File

@@ -1823,7 +1823,9 @@ function formatMessage(content) {
text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => { text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => {
const id = 'think-' + Math.random().toString(36).slice(2, 9); const id = 'think-' + Math.random().toString(36).slice(2, 9);
thinkingBlocks.push({ id, content: inner.trim() }); thinkingBlocks.push({ id, content: inner.trim() });
return `THINK_PLACEHOLDER_${id}`; // Newlines ensure the placeholder is its own markdown block so adjacent
// code fences (e.g. "</think>```html") start on a fresh line for marked.
return `\n\nTHINK_PLACEHOLDER_${id}\n\n`;
}); });
let html; let html;
@@ -1845,10 +1847,35 @@ function formatMessage(content) {
} }
function _formatMarked(text) { function _formatMarked(text) {
// Close any unclosed code fences to prevent raw HTML injection during
// streaming (closing ``` hasn't arrived yet) or when the model forgets
// to close a fence. An odd number of ``` markers means one is unclosed.
const fences = text.match(/^```/gm);
if (fences && fences.length % 2 !== 0) {
text += '\n```';
}
const rendered = marked.parse(text, { breaks: true, gfm: true }); const rendered = marked.parse(text, { breaks: true, gfm: true });
let html = DOMPurify.sanitize(rendered, { let html = DOMPurify.sanitize(rendered, {
ADD_TAGS: ['details', 'summary', 'iframe'], // Strict allowlist: only elements that marked.js actually produces.
ADD_ATTR: ['id', 'class', 'onclick', 'sandbox', 'srcdoc'] // This prevents ANY raw HTML (canvas, style, script, iframe, form, etc.)
// from rendering live even if a code fence fails to parse.
ALLOWED_TAGS: [
// Block elements
'p', 'br', 'hr', 'pre', 'code', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
// Lists
'ul', 'ol', 'li',
// Tables
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
// Inline formatting
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'sub', 'sup',
'a', 'img', 'span', 'div',
// Think blocks (injected post-sanitize, but placeholders may be in <p>)
'details', 'summary',
],
ALLOWED_ATTR: ['id', 'class', 'href', 'target', 'rel', 'src', 'alt', 'title',
'colspan', 'rowspan', 'align'],
}); });
// Process code blocks: add copy button, collapse toggle, HTML preview // Process code blocks: add copy button, collapse toggle, HTML preview