Changeset 0.9.4 (#54)
This commit is contained in:
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
155
ROADMAP.md
155
ROADMAP.md
@@ -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)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
131
server/crypto/backfill.go
Normal 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
64
server/crypto/cache.go
Normal 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
72
server/crypto/resolver.go
Normal 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
165
server/crypto/vault.go
Normal 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
241
server/crypto/vault_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
server/database/migrations/003_vault.sql
Normal file
48
server/database/migrations/003_vault.sql
Normal 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';
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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 ─────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ───────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
33
src/js/ui.js
33
src/js/ui.js
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user