diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index 7c916ad..9eff9d9 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -1,6 +1,6 @@
# .gitea/workflows/ci.yaml
# ============================================
-# Chat Switchboard - CI/CD Pipeline (v0.9.4)
+# Chat Switchboard - CI/CD Pipeline (v0.12.0)
# ============================================
# Cluster deployments use SEPARATE FE + BE images.
# Unified image is for Docker Hub only (docker-compose use).
@@ -31,6 +31,9 @@
# Required Gitea Variables:
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
# SEED_USERS (optional) — CSV: "user:pass:role,..." for dev/test seed accounts
+# STORAGE_CLASS — StorageClass for PVC (e.g. "cephfs", required for file storage)
+# STORAGE_SIZE — PVC capacity (e.g. "10Gi", default: "10Gi")
+# STORAGE_BACKEND — "pvc" or "s3" (default: "pvc")
#
# Required Gitea Secrets:
# POSTGRES_USER, POSTGRES_PASSWORD
@@ -39,6 +42,7 @@
# 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)
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
+# S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY (only when STORAGE_BACKEND=s3)
#
# Global Variables (Gitea org-level):
# CERT_ISSUER_PROD
@@ -450,6 +454,26 @@ jobs:
--from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
--dry-run=client -o yaml | kubectl apply -f -
+ - name: Sync S3 secrets
+ if: vars.STORAGE_BACKEND == 's3'
+ env:
+ S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
+ S3_BUCKET: ${{ vars.S3_BUCKET }}
+ S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
+ S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
+ S3_REGION: ${{ vars.S3_REGION || 'us-east-1' }}
+ S3_PREFIX: ${{ vars.S3_PREFIX }}
+ run: |
+ kubectl create secret generic switchboard-s3 \
+ --namespace=${NAMESPACE} \
+ --from-literal=S3_ENDPOINT="${S3_ENDPOINT}" \
+ --from-literal=S3_BUCKET="${S3_BUCKET}" \
+ --from-literal=S3_ACCESS_KEY="${S3_ACCESS_KEY}" \
+ --from-literal=S3_SECRET_KEY="${S3_SECRET_KEY}" \
+ --from-literal=S3_REGION="${S3_REGION}" \
+ --from-literal=S3_PREFIX="${S3_PREFIX}" \
+ --dry-run=client -o yaml | kubectl apply -f -
+
- name: Render and apply manifests
env:
ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }}
@@ -468,12 +492,31 @@ jobs:
BE_MEMORY_LIMIT: ${{ steps.env.outputs.BE_MEMORY_LIMIT }}
BE_CPU_REQUEST: ${{ steps.env.outputs.BE_CPU_REQUEST }}
BE_CPU_LIMIT: ${{ steps.env.outputs.BE_CPU_LIMIT }}
+ # Storage (v0.12.0+)
+ STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
+ STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
+ STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
+ EXTRACTION_CONCURRENCY: ${{ vars.EXTRACTION_CONCURRENCY || '3' }}
run: |
+ # Render PVC first (must exist before backend references it)
+ if [[ -n "${STORAGE_CLASS}" ]]; then
+ envsubst < k8s/storage-pvc.yaml > /tmp/storage-pvc.yaml
+ fi
+
envsubst < k8s/backend.yaml > /tmp/backend.yaml
envsubst < k8s/frontend.yaml > /tmp/frontend.yaml
envsubst < k8s/ingress.yaml > /tmp/ingress.yaml
echo "━━━ Applying manifests for ${DEPLOY_HOST} ━━━"
+
+ # Apply PVC (idempotent — won't recreate if exists)
+ if [[ -n "${STORAGE_CLASS}" ]]; then
+ kubectl apply -f /tmp/storage-pvc.yaml
+ echo " ✓ Storage PVC (class: ${STORAGE_CLASS}, size: ${STORAGE_SIZE})"
+ else
+ echo " ⚠ STORAGE_CLASS not set — file storage disabled"
+ fi
+
kubectl apply -f /tmp/backend.yaml
kubectl apply -f /tmp/frontend.yaml
kubectl apply -f /tmp/ingress.yaml
@@ -536,6 +579,15 @@ jobs:
echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **DB Wipe** | ${{ steps.env.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY
+ if [[ -n "${{ vars.STORAGE_CLASS }}" ]]; then
+ if [[ "${{ vars.STORAGE_BACKEND }}" == "s3" ]]; then
+ echo "| **Storage** | S3 (bucket: ${{ vars.S3_BUCKET }}, PVC scratch: ${{ vars.STORAGE_CLASS }}) |" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "| **Storage** | PVC (class: ${{ vars.STORAGE_CLASS }}, size: ${{ vars.STORAGE_SIZE || '10Gi' }}) |" >> $GITHUB_STEP_SUMMARY
+ fi
+ else
+ echo "| **Storage** | Disabled (STORAGE_CLASS not set) |" >> $GITHUB_STEP_SUMMARY
+ fi
if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then
echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY
fi
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 27fb5f9..3f85d4d 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -234,13 +234,33 @@ Communication: `app.js` calls `API.*` methods, updates state, then calls `UI.*`
- **Auth**: JWT access tokens (short-lived) + refresh tokens (DB-stored, revocable)
- **Admin Bootstrap**: `SWITCHBOARD_ADMIN_USERNAME`/`PASSWORD` env vars create/update admin on every startup (K8s secret pattern)
-- **API Key Storage**: Provider API keys stored in `api_key_enc` column (TODO: at-rest encryption)
+- **API Key Storage**: Provider API keys encrypted with AES-256-GCM. Two-tier: org keys use `ENCRYPTION_KEY` env var, personal BYOK keys use per-user encryption keys (admins cannot recover)
- **Policies**: Boolean flags in `global_settings` table control registration, BYOK, team providers, etc.
- **Audit**: All admin operations logged to `audit_log` with actor, action, resource type/ID, and diff
- **Banner**: Environment classification banner configurable via admin settings (text, color, position)
- **CORS**: Configurable allowed origins via env var
- **No sensitive terminology**: Banner system avoids classification-related terms for security compliance
+## File Storage
+
+Blob storage for attachments, extracted text, and (future) knowledge base documents.
+
+**Interface**: `storage.ObjectStore` — `Put`, `Get`, `Delete`, `DeletePrefix`, `Exists`, `Healthy`, `Stats`, `Backend`. All handlers use the interface; backend is selected at startup.
+
+**Backends**:
+
+| Backend | Config | Use Case |
+|---------|--------|----------|
+| **PVC** | `STORAGE_BACKEND=pvc` + `STORAGE_PATH` | Single-node, dev, docker-compose. Local filesystem with atomic writes (temp+rename). |
+| **S3** | `STORAGE_BACKEND=s3` + `S3_ENDPOINT`, `S3_BUCKET`, credentials | Multi-node production. MinIO, Ceph RGW, AWS S3. Uses minio-go v7. |
+| **Auto** | `STORAGE_BACKEND=` (empty) | Tries PVC at `STORAGE_PATH`; disables if not writable. |
+
+**Key layout**: `attachments/{channel_id}/{attachment_id}_{filename}`. S3 prefix (`S3_PREFIX`) prepended for shared buckets.
+
+**PVC always mounted**: Even with S3 backend, the PVC mount at `STORAGE_PATH` is needed for the extraction queue's local scratch directory (`processing/{id}/status.json`). With S3, the PVC can be small (1Gi) — only transient coordination state, not blobs.
+
+**Admin panel**: Settings → Storage tab shows backend type, health status, file count, total size, endpoint/bucket (S3) or path (PVC), orphan detection and cleanup.
+
## Backward Compatibility
v0.9 maintains backward-compatible API routes:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e001ec1..08b4501 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,42 @@
All notable changes to Chat Switchboard.
+## [0.12.0] — 2026-02-25
+
+### Added
+- **File handling and vision support.** Upload images, PDFs, and documents
+ into chat via 📎 button, drag-and-drop, or paste. Multimodal message
+ assembly injects base64 images for vision-capable models and extracted
+ text for documents. Staged attachment strip shows upload progress and
+ extraction status. Auth-aware blob rendering with Bearer tokens. Image
+ lightbox viewer. Vision capability hints on image attachments.
+- **Storage backend abstraction.** `ObjectStore` interface with two
+ implementations: PVC (local filesystem) for single-node/dev and S3
+ (minio-go) for multi-node production. S3 backend works with any
+ S3-compatible API: MinIO, Ceph RGW, AWS S3. Auto-detection when
+ `STORAGE_BACKEND` is not set (tries PVC, disables if not writable).
+ Both backends share identical semantics — all handlers, attachment
+ CRUD, multimodal assembly, and orphan cleanup work regardless of
+ backend.
+- **S3 configuration.** `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`,
+ `S3_SECRET_KEY`, `S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` env
+ vars. Endpoint auto-detects SSL from scheme. Path-style URLs default
+ on (required for MinIO/Ceph). Optional key prefix for shared buckets.
+ CI pipeline syncs S3 secrets to K8s when `STORAGE_BACKEND=s3`.
+- **Text extraction pipeline.** PDF, DOCX, XLSX, PPTX, ODT, RTF text
+ extraction via filesystem-based queue. Inline and sidecar modes.
+ Crash recovery for items stuck in processing state.
+- **Admin storage panel.** Backend health, file count, total size,
+ orphan detection and cleanup. Shows PVC path or S3 endpoint/bucket
+ depending on active backend. Extraction queue status.
+- **Vault CLI commands.** `switchboard vault rekey` re-encrypts all
+ provider API keys when rotating the encryption key.
+ `switchboard vault status` shows encryption health. Admin UI
+ encryption status indicator in Settings tab.
+- **Per-chat model persistence.** Server-side `channels.settings`
+ JSONB field stores `last_selector_id`. Roams across devices with
+ localStorage write-through cache as fallback.
+
## [0.11.0] — 2026-02-25
### Added
diff --git a/DESIGN-0.12.0.md b/DESIGN-0.12.0.md
new file mode 100644
index 0000000..98f8c4b
--- /dev/null
+++ b/DESIGN-0.12.0.md
@@ -0,0 +1,1304 @@
+# v0.12.0 — File Handling + Vision (Design)
+
+## Overview
+
+File input into chat — table stakes for serious use. Image uploads for
+vision-capable models, document uploads for text extraction into context,
+and the storage backend that v0.14.0 (Knowledge Bases) and v0.15.0
+(Compaction snapshots) will reuse.
+
+Also picks up stragglers deferred from earlier releases.
+
+**Depends on:** v0.11.0 (extension foundation — complete).
+**Reused by:** v0.14.0 (KBs), v0.15.0 (compaction), v0.22.0 (exports).
+
+---
+
+## Stragglers (from v0.9.4 deferred)
+
+Items deferred during vault work that were never scheduled:
+
+### `switchboard vault rekey` CLI command
+Re-encrypts all global/team API keys when `ENCRYPTION_KEY` is rotated.
+Personal BYOK keys are unaffected (keyed to UEK, not env var).
+
+Implementation: standalone CLI entrypoint that reads old + new env vars,
+iterates `api_configs` and `team_providers` where `key_scope IN ('global',
+'team')`, decrypts with old key, re-encrypts with new key, updates in a
+transaction.
+
+### Admin UI: encryption status indicator
+Badge in admin Settings showing whether `ENCRYPTION_KEY` is set and how
+many keys are encrypted vs. plaintext (migration stragglers). Single
+`GET /admin/storage/status` endpoint.
+
+### Per-chat model/preset persistence (server-side)
+Currently localStorage bandaid from v0.10.2 — doesn't roam across devices.
+
+**Fix:** Store `last_selector_id` in `channels.settings` JSONB on each
+completion success. On chat selection, resolve:
+`channel.settings.last_selector_id` → match against available
+models/presets → fall back to `channel.model` → global default.
+
+No migration needed (`channels.settings` JSONB column already exists).
+Single `PATCH /channels/:id` with settings merge on completion success.
+
+---
+
+## Track 1: Storage Backend
+
+### Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. If not set: `pvc` when `STORAGE_PATH` is writable, disabled otherwise. |
+| `STORAGE_PATH` | `/data/storage` | Mount point for PVC backend. Also scratch dir for extraction with S3. |
+| `STORAGE_CLASS` | — | K8s only. Gitea CI variable → PVC manifest. Value: `cephfs` (RWX for multi-pod). |
+| `S3_ENDPOINT` | — | S3-compatible endpoint (e.g. `http://minio:9000`). Required when `STORAGE_BACKEND=s3`. |
+| `S3_BUCKET` | — | Bucket name. Must exist before first start. |
+| `S3_ACCESS_KEY` | — | S3 access key ID. |
+| `S3_SECRET_KEY` | — | S3 secret access key. |
+| `S3_REGION` | `us-east-1` | AWS region. |
+| `S3_PREFIX` | — | Optional key prefix for shared buckets (e.g. `switchboard/`). |
+| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs. Required for MinIO, Ceph RGW, most self-hosted. |
+| `EXTRACTION_MODE` | `inline` | `inline` (in-process, unified image) or `sidecar` (K8s, shared PVC). |
+| `EXTRACTION_CONCURRENCY` | `3` | Max concurrent extraction jobs. Caps LibreOffice memory usage. |
+
+Auto-detection when `STORAGE_BACKEND` is not set:
+
+```
+STORAGE_PATH writable → pvc (implicit)
+STORAGE_PATH missing → file features disabled
+ admin panel shows "Storage not configured"
+```
+
+When `STORAGE_BACKEND=pvc` is explicit, fail startup if path is not
+writable (fail-safe, same pattern as `ENCRYPTION_KEY` enforcement).
+
+S3 backend: reads `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
+`S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` from env vars. Uses minio-go v7.
+Works with MinIO, Ceph RGW, AWS S3. Same interface as PVC — all handlers are
+backend-agnostic. PVC still required as scratch dir for extraction queue.
+
+### Config Addition
+
+```go
+// In config.go
+StorageBackend string // "pvc", "s3", or "" (auto-detect)
+StoragePath string // mount point for PVC backend
+ExtractionMode string // "inline" or "sidecar"
+ExtractionConcurrency int // max concurrent extraction jobs
+```
+
+### Interface
+
+```go
+// server/storage/storage.go
+package storage
+
+import (
+ "context"
+ "io"
+)
+
+// ObjectStore is the abstraction for blob storage.
+// Implementations: PVC (filesystem), S3 (minio-go v7).
+type ObjectStore interface {
+ // Put writes data to the given key. Creates parent dirs as needed.
+ Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
+
+ // Get returns a reader for the given key. Caller must close.
+ // Returns ErrNotFound if key does not exist.
+ Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error)
+
+ // Delete removes the object at key. No error if already gone.
+ Delete(ctx context.Context, key string) error
+
+ // DeletePrefix removes all objects under the given prefix.
+ // Used for channel deletion (bulk cleanup).
+ DeletePrefix(ctx context.Context, prefix string) error
+
+ // Exists checks if an object exists at key without reading it.
+ Exists(ctx context.Context, key string) (bool, error)
+
+ // Healthy returns nil if the backend is operational.
+ Healthy(ctx context.Context) error
+}
+
+var ErrNotFound = errors.New("storage: object not found")
+```
+
+### PVC Implementation
+
+`server/storage/pvc.go` — ~100 lines. Thin wrapper around `os.*`:
+
+```go
+type PVCStore struct {
+ basePath string // e.g. "/data/storage"
+}
+
+func NewPVC(basePath string) (*PVCStore, error) {
+ // Validate basePath is writable (create test file, remove)
+ // Create top-level subdirs: attachments/
+}
+```
+
+Key → filesystem path: `filepath.Join(basePath, key)`. The key itself
+provides all the directory structure.
+
+### Filesystem Layout
+
+```
+/data/storage/ ← STORAGE_PATH mount point
+└── attachments/
+ └── {channel_id}/
+ └── {attachment_id}_{filename}
+```
+
+**Channel-first** because:
+- Channel deletion → `DeletePrefix("attachments/{channel_id}/")` — one call.
+- User deletion cascades to owned channels → same cleanup path.
+- Group chats / multi-participant channels: all files in one place
+ regardless of who uploaded them.
+- Quota enforcement uses PG (`SUM(size_bytes) WHERE user_id = $1`),
+ not filesystem walks.
+
+Future subdirs at the `STORAGE_PATH` level (not this release):
+- `knowledge-bases/` — v0.14.0
+- `compaction/` — v0.15.0
+- `exports/` — TBD
+
+### K8s Manifest (PVC)
+
+```yaml
+# k8s/storage-pvc.yaml
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: switchboard-storage
+ namespace: switchboard
+spec:
+ accessModes: [ReadWriteMany] # CephFS RWX — multi-pod safe
+ storageClassName: "${STORAGE_CLASS}"
+ resources:
+ requests:
+ storage: 10Gi # configurable per deployment
+```
+
+Backend deployment addition:
+
+```yaml
+# In k8s/backend.yaml
+volumes:
+ - name: storage
+ persistentVolumeClaim:
+ claimName: switchboard-storage
+containers:
+ - name: backend
+ volumeMounts:
+ - name: storage
+ mountPath: /data/storage
+ env:
+ - name: STORAGE_BACKEND
+ value: "pvc"
+ - name: STORAGE_PATH
+ value: "/data/storage"
+```
+
+### Docker Compose
+
+```yaml
+# In docker-compose.yml
+services:
+ switchboard:
+ volumes:
+ - ./data/storage:/data/storage
+ environment:
+ - STORAGE_PATH=/data/storage
+```
+
+### Admin Status Endpoint
+
+`GET /admin/storage/status`
+
+```json
+{
+ "backend": "pvc",
+ "path": "/data/storage",
+ "healthy": true,
+ "total_files": 142,
+ "total_bytes": 52428800,
+ "configured": true
+}
+```
+
+When storage is not configured, file upload endpoints return 503 with
+`{"error": "File storage not configured"}`. Frontend hides upload UI
+based on a capabilities flag in the boot payload.
+
+---
+
+## Track 2: Attachments Schema + API
+
+### Migration 007_attachments.sql
+
+```sql
+-- ── Attachments ────────────────────────────
+CREATE TABLE IF NOT EXISTS attachments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id),
+ message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
+ filename VARCHAR(255) NOT NULL,
+ content_type VARCHAR(127) NOT NULL,
+ size_bytes BIGINT NOT NULL,
+ storage_key TEXT NOT NULL,
+ extracted_text TEXT,
+ metadata JSONB DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Access pattern: always join through channel for auth
+CREATE INDEX idx_attachments_channel ON attachments(channel_id);
+-- Quota pattern: sum by user
+CREATE INDEX idx_attachments_user_size ON attachments(user_id);
+-- Message association: find attachments for a message
+CREATE INDEX idx_attachments_message ON attachments(message_id);
+```
+
+Notes on columns:
+- `message_id` is nullable — attachment is uploaded before the message
+ is persisted. Updated after message creation. If message is deleted,
+ attachment persists (belongs to channel, not message).
+- `storage_key` is the relative path within `STORAGE_PATH`, e.g.
+ `attachments/{channel_id}/{attachment_id}_{filename}`.
+- `extracted_text` is populated asynchronously for PDFs/DOCX. NULL for
+ images. Used for context injection into completions.
+- `metadata` JSONB holds dimensions (images), page count (PDFs),
+ extraction status, thumbnails, etc.
+
+### Store Layer
+
+```go
+// server/store/attachments.go
+type AttachmentStore interface {
+ Create(ctx context.Context, a *models.Attachment) error
+ GetByID(ctx context.Context, id string) (*models.Attachment, error)
+ GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error)
+ GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error)
+ SetMessageID(ctx context.Context, id, messageID string) error
+ Delete(ctx context.Context, id string) error
+ DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys for cleanup
+ UserUsageBytes(ctx context.Context, userID string) (int64, error)
+}
+```
+
+### API Endpoints
+
+```
+POST /api/v1/channels/:id/attachments Upload file
+GET /api/v1/attachments/:id Attachment metadata
+GET /api/v1/attachments/:id/download Download file content
+DELETE /api/v1/attachments/:id Delete attachment
+GET /api/v1/channels/:id/attachments List channel attachments
+```
+
+### Upload Flow
+
+```
+Client Backend Storage
+ │ │ │
+ ├── POST multipart ────────►│ │
+ │ (file + channel_id) │ │
+ │ ├── Auth check │
+ │ ├── Channel membership ─────│
+ │ │ verify │
+ │ ├── Size limit check │
+ │ ├── MIME type validation │
+ │ ├── Generate attachment_id │
+ │ ├── PUT ───────────────────►│
+ │ │ key: att/{ch}/{id}_{fn} │
+ │ ├── INSERT attachments row │
+ │ │ │
+ │◄── 201 { attachment } ────┤ │
+```
+
+The attachment is created *before* the message is sent. The frontend
+holds the attachment ID(s), includes them in the completion request,
+and the backend links them to the persisted message after creation.
+
+### Download Flow (Access Control)
+
+```
+GET /api/v1/attachments/:id/download
+
+ → SELECT a.*, c.id, c.user_id
+ FROM attachments a
+ JOIN channels c ON a.channel_id = c.id
+ WHERE a.id = $1
+
+ → Verify: requester is channel owner
+ (future RBAC: OR requester is channel participant
+ OR requester is team member with channel access
+ OR requester is admin with audit log)
+
+ → Stream file from ObjectStore with:
+ Content-Type: {content_type}
+ Content-Disposition: attachment; filename="{filename}"
+ Content-Length: {size_bytes}
+```
+
+**Critical:** Files are NEVER served directly from nginx or any static
+file path. Every byte goes through the Go backend with auth checks.
+No pre-signed URLs, no direct filesystem exposure.
+
+### Size & Type Limits
+
+Configured via global settings (see **Global Settings** section below).
+Enforced server-side on upload — frontend mirrors limits for UX but the
+backend is authoritative.
+
+Validation order on upload:
+1. `storage_allowed_types` — reject disallowed MIME types
+2. `storage_max_file_size` — reject oversized individual files
+3. `storage_max_upload_size` — reject if request total exceeds limit
+4. `storage_max_attachments_per_message` — reject if too many files
+5. `storage_user_quota_bytes` — reject if user quota exceeded (future)
+
+MIME type is detected server-side (`http.DetectContentType` + extension
+validation), not trusted from the client `Content-Type` header.
+
+---
+
+## Track 3: Multimodal Message Assembly
+
+### Provider Message Polymorphism
+
+Currently `providers.Message.Content` is a plain string. Vision requires
+content arrays with image parts. The change is additive — no existing
+callsites break.
+
+```go
+// In server/providers/provider.go
+
+// ContentPart represents one part of a multimodal message.
+type ContentPart struct {
+ Type string `json:"type"` // "text", "image_url"
+ Text string `json:"text,omitempty"` // for type="text"
+ ImageURL *ImageURL `json:"image_url,omitempty"` // for type="image_url"
+}
+
+type ImageURL struct {
+ URL string `json:"url"` // "data:image/jpeg;base64,..." or URL
+ Detail string `json:"detail,omitempty"` // "auto", "low", "high"
+}
+
+type Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+
+ // Multimodal content. When non-nil, providers use this instead of Content.
+ ContentParts []ContentPart `json:"content_parts,omitempty"`
+
+ // ... existing tool fields unchanged
+}
+```
+
+**Resolution rule:** if `ContentParts` is non-nil, providers build their
+wire format from it. If nil, they use `Content` (string) as today. This
+means every existing codepath works unchanged until an attachment is
+involved.
+
+### Provider Wire Format Mapping
+
+**OpenAI / OpenRouter / Venice** (OpenAI-compatible):
+
+```go
+// In openai.go — message conversion
+if len(m.ContentParts) > 0 {
+ // Content becomes array of objects
+ oaiMsg.Content = nil // clear string content
+ oaiMsg.ContentArray = toOpenAIContentParts(m.ContentParts)
+}
+```
+
+OpenAI wire format:
+```json
+{
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "What's in this image?"},
+ {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
+ ]
+}
+```
+
+The `openaiMessage` struct needs a `ContentArray` field with custom JSON
+marshaling — when `ContentArray` is set, marshal content as the array;
+otherwise marshal as string. This is the standard OpenAI multimodal
+format.
+
+**Anthropic:**
+
+```go
+// In anthropic.go — message conversion
+if len(m.ContentParts) > 0 {
+ antMsg.Content = toAnthropicContentBlocks(m.ContentParts)
+}
+```
+
+Anthropic wire format:
+```json
+{
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "What's in this image?"},
+ {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "..."}}
+ ]
+}
+```
+
+The `anthropicContentBlock` struct gets new fields:
+
+```go
+type anthropicContentBlock struct {
+ // ... existing fields ...
+ // Image support
+ Source *anthropicImageSource `json:"source,omitempty"`
+}
+
+type anthropicImageSource struct {
+ Type string `json:"type"` // "base64"
+ MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc.
+ Data string `json:"data"` // base64-encoded image
+}
+```
+
+### Image Delivery: Base64 Inline
+
+For v0.12.0, all images are sent as base64-encoded data inline in the
+API request. No pre-signed URLs.
+
+**Rationale:** The target audience is government/enterprise, often in
+air-gapped or restricted networks. Pre-signed URLs require the provider's
+API servers to reach the storage endpoint — impossible in disconnected
+environments. Base64 works everywhere.
+
+**Trade-off:** Larger request payloads. Acceptable for v0.12.0 — images
+are typically <10MB each and context windows are large. URL-based
+delivery can be an optimization in a future release for cloud deployments.
+
+### Completion Handler Changes
+
+```go
+// In completion.go — after loading conversation, before building provReq
+
+// If message has attachments, build multimodal content
+if len(req.AttachmentIDs) > 0 {
+ parts := []providers.ContentPart{
+ {Type: "text", Text: req.Content},
+ }
+ for _, attID := range req.AttachmentIDs {
+ att, err := h.store.Attachments().GetByID(c, attID)
+ // ... verify att.channel_id matches ...
+
+ if isImageType(att.ContentType) {
+ // Verify vision capability
+ if !caps.Vision {
+ c.JSON(400, gin.H{"error": "Selected model does not support image input"})
+ return
+ }
+
+ // Read from storage, base64 encode
+ reader, _, _, _ := h.objectStore.Get(c, att.StorageKey)
+ data, _ := io.ReadAll(reader)
+ reader.Close()
+ b64 := base64.StdEncoding.EncodeToString(data)
+ dataURL := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64)
+
+ parts = append(parts, providers.ContentPart{
+ Type: "image_url",
+ ImageURL: &providers.ImageURL{URL: dataURL, Detail: "auto"},
+ })
+ } else if att.ExtractedText != nil && *att.ExtractedText != "" {
+ // Document: inject extracted text as context
+ parts = append(parts, providers.ContentPart{
+ Type: "text",
+ Text: fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText),
+ })
+ } else {
+ // Extraction not yet complete or failed — include filename only
+ status := att.Metadata["extraction_status"]
+ parts = append(parts, providers.ContentPart{
+ Type: "text",
+ Text: fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status),
+ })
+ }
+ }
+
+ // Use ContentParts instead of Content string
+ userMsg := providers.Message{
+ Role: "user",
+ ContentParts: parts,
+ }
+ messages = append(messages, userMsg)
+} else {
+ // Existing text-only path — unchanged
+ messages = append(messages, providers.Message{
+ Role: "user",
+ Content: req.Content,
+ })
+}
+```
+
+**Frontend blocks send only during active upload** — the send button
+stays disabled while any staged attachment is in `uploading` state
+(bytes still in flight). Once the file is on disk and the attachment
+row exists in PG, send is enabled regardless of extraction status.
+
+The completion handler checks `extraction_status` at request time:
+- `complete` → inject extracted text as context
+- anything else → inject filename-only placeholder
+
+This avoids blocking users on slow extractions while still providing
+extracted context when available. For images (no extraction), the
+attachment is immediately usable.
+
+### Capability Gating
+
+Images are only assembled into content parts when the resolved model
+has `capabilities.vision == true`. If a user attaches an image to a
+non-vision model:
+
+- Documents (PDF, DOCX, TXT): always allowed — extracted text is
+ injected as a text content part. No vision needed.
+- Images to non-vision model: backend returns 400 with
+ `{"error": "Selected model does not support image input"}`.
+- Frontend disables image upload button when selected model lacks vision.
+
+### Text Extraction (Documents)
+
+#### License Constraint
+
+The project is Apache 2.0. Libraries with AGPL/GPL copyleft (e.g.
+unipdf) are incompatible — their license would infect the codebase.
+Pure Go PDF libraries under permissive licenses (`ledongthuc/pdf`,
+`pdfcpu`) handle simple cases but choke on complex layouts, scanned
+PDFs, and encrypted documents.
+
+#### Approach: Tiered Extraction
+
+**Tier 1 — Native Go (no external deps, handles ~80% of uploads):**
+
+| Format | Library | License |
+|--------|---------|---------|
+| TXT, Markdown, CSV | `io.ReadAll` | — |
+| PDF (simple) | `pdfcpu` | Apache 2.0 |
+| DOCX | `fumiama/go-docx` | MIT |
+
+**Tier 2 — LibreOffice Headless (handles everything else):**
+
+For PDF with complex layouts, ODT, ODS, ODP, RTF, DOC (legacy), XLS,
+and any format Tier 1 can't handle — shell out to LibreOffice headless:
+
+```bash
+libreoffice --headless --convert-to txt:Text /tmp/input.odt --outdir /tmp/
+```
+
+LibreOffice handles the full Office family:
+- OpenDocument: `.odt`, `.ods`, `.odp`
+- Microsoft: `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`
+- PDF (including scanned with basic OCR via hunspell)
+- RTF, HTML, and dozens more
+
+#### Extraction Queue (Filesystem-Backed)
+
+Extraction state lives on the PVC, not in memory. Pod restarts don't
+lose queue state. The queue is a directory convention:
+
+```
+/data/storage/
+├── attachments/ ← completed uploads (served to clients)
+└── processing/ ← extraction queue (internal only)
+ └── {attachment_id}/
+ ├── input ← original file (copy or hardlink)
+ ├── status.json ← queue state + metadata
+ ├── output.txt ← extracted text (when complete)
+ └── thumb.jpg ← thumbnail (when complete)
+```
+
+**status.json:**
+
+```json
+{
+ "state": "pending",
+ "queued_at": "2026-02-25T14:30:00Z",
+ "started_at": null,
+ "completed_at": null,
+ "content_type": "application/pdf",
+ "filename": "report.pdf",
+ "error": null
+}
+```
+
+States: `pending` → `processing` → `complete` | `failed`
+
+**Worker loop (runs in both inline and sidecar mode):**
+
+```
+1. Scan processing/ for dirs with status.state == "pending"
+ (ordered by queued_at — FIFO)
+2. Respect concurrency cap (semaphore, default 3)
+3. Pick job → flip state to "processing"
+4. Tier 1: try native Go extraction
+ ├── success → write output.txt, generate thumb, flip "complete"
+ └── fail or unsupported →
+5. Tier 2: LibreOffice headless
+ ├── success → write output.txt, generate thumb, flip "complete"
+ └── fail → flip "failed", write error
+6. Copy results to PG:
+ ├── UPDATE attachments SET extracted_text = ...,
+ │ metadata = jsonb_set(metadata, '{extraction_status}', '"complete"')
+ └── Thumbnail storage_key written to metadata
+7. Clean up processing/{id}/ dir
+```
+
+**In inline mode** (Unified image): the worker is a goroutine pool in
+the Go backend, scanning the queue directory on a 2-second tick.
+
+**In sidecar mode** (K8s): the sidecar runs the same loop as a
+standalone process. When extraction completes, it writes `output.txt`
+and `thumb.jpg` to the processing dir and flips status to `complete`.
+The Go backend polls the status file and copies results to PG.
+
+**Crash recovery:** On startup, scan for `state == "processing"` (stale
+from a crash). Reset to `pending` for re-processing. This is why the
+queue is on the filesystem — nothing is lost.
+
+**Concurrency:** Capped by semaphore. Default 3 concurrent extractions.
+Configurable via `EXTRACTION_CONCURRENCY` env var. LibreOffice is
+memory-hungry on complex docs — the cap prevents OOM on burst uploads.
+
+**Frontend queue awareness:**
+
+```
+Attachment status:
+ uploading → progress bar
+ pending → "Queued (2 ahead)" ← position from queue scan
+ processing → "Extracting..." ← spinner
+ complete → ✓ (ready)
+ failed → ⚠ (send still enabled, model sees filename only)
+```
+
+The frontend polls `GET /api/v1/attachments/:id` every 2s for
+non-terminal states. The response includes `extraction_status` and
+`queue_position` (0 = currently processing, N = N jobs ahead).
+
+Send is blocked while any attachment is `uploading`. Once all are at
+least `pending`, send is enabled — the extraction results will be
+available by the time the model processes the request (optimistic), or
+the model sees the filename-only fallback (acceptable degradation).
+
+**Correction to earlier design:** Send is blocked only during `uploading`
+phase. Once the file is on disk and queued, the user can send. The
+completion handler checks extraction status at request time and uses
+whatever is available.
+
+#### Extraction Metadata in PG
+
+Attachment `metadata` JSONB tracks status (mirrored from queue):
+
+```json
+{
+ "extraction_status": "complete",
+ "extraction_error": null,
+ "queue_position": 0,
+ "page_count": 12,
+ "dimensions": {"width": 1920, "height": 1080},
+ "thumbnail_key": "attachments/{channel_id}/{id}_thumb.jpg",
+ "thumb_dimensions": {"width": 280, "height": 147}
+}
+```
+
+States: `pending` → `processing` → `complete` | `failed` | `unavailable`
+
+#### LibreOffice Deployment Strategy
+
+**Two deployment modes, matching existing Docker architecture:**
+
+| Deployment | LibreOffice location | Reason |
+|-----------|---------------------|--------|
+| **Unified** (docker-compose, dev) | Baked into image | Single container, simplicity wins |
+| **K8s** (backend + frontend split) | Sidecar container | Separate scaling, lean backend image, independent version pinning |
+
+**Unified Dockerfile addition:**
+
+```dockerfile
+# In Dockerfile (unified)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libreoffice-core libreoffice-writer libreoffice-calc \
+ && rm -rf /var/lib/apt/lists/*
+```
+
+**K8s sidecar (backend.yaml addition):**
+
+```yaml
+# In k8s/backend.yaml
+containers:
+ - name: backend
+ image: switchboard-backend:0.12.0
+ volumeMounts:
+ - name: storage
+ mountPath: /data/storage
+ env:
+ - name: STORAGE_BACKEND
+ value: "pvc"
+ - name: STORAGE_PATH
+ value: "/data/storage"
+ - name: EXTRACTION_MODE
+ value: "sidecar" # "inline" for unified, "sidecar" for k8s
+
+ - name: extractor
+ image: switchboard-extractor:0.12.0 # minimal image: alpine + libreoffice
+ volumeMounts:
+ - name: storage
+ mountPath: /data/storage
+ resources:
+ requests:
+ memory: "256Mi"
+ limits:
+ memory: "1Gi" # LibreOffice can spike on complex docs
+
+volumes:
+ - name: storage
+ persistentVolumeClaim:
+ claimName: switchboard-storage
+```
+
+The sidecar image is small and purpose-built: Alpine + LibreOffice +
+a watcher script. No Go code, no API surface. Upgrades to LibreOffice
+are a tag bump on the sidecar image — backend image untouched.
+
+**Communication:** Filesystem-only via the shared PVC. No gRPC, no
+HTTP between containers. See Extraction Queue below.
+
+#### Allowed Types (Updated)
+
+```json
+{
+ "storage_allowed_types": [
+ "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
+ "application/pdf",
+ "text/plain", "text/markdown", "text/csv",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/vnd.oasis.opendocument.text",
+ "application/vnd.oasis.opendocument.spreadsheet",
+ "application/vnd.oasis.opendocument.presentation",
+ "application/rtf",
+ "application/msword",
+ "application/vnd.ms-excel"
+ ]
+}
+```
+
+### Thumbnail Generation
+
+Thumbnails are generated **asynchronously** alongside text extraction
+as part of the same post-upload processing pipeline.
+
+#### Image Thumbnails
+
+Use `golang.org/x/image/draw` (BSD, stdlib-adjacent) for resizing:
+
+```go
+// Resize to max 280px on longest edge, preserve aspect ratio
+// Output: JPEG at 80% quality
+// Stored at: attachments/{channel_id}/{attachment_id}_thumb.jpg
+```
+
+No external dependency. Pure Go. Fast enough for on-upload generation.
+
+#### Document Thumbnails
+
+When LibreOffice is available, generate a first-page preview:
+
+```bash
+# Convert first page to PDF, then to image
+libreoffice --headless --convert-to pdf /tmp/input.docx --outdir /tmp/
+# Then use Go image libraries to render first page
+# Or: convert to PNG directly via LibreOffice draw filter
+libreoffice --headless --convert-to png /tmp/input.docx --outdir /tmp/
+```
+
+For PDFs specifically, `pdfcpu` can extract page images under Apache 2.0.
+
+When LibreOffice is not available, documents get a generic file-type
+icon (handled in CSS, no thumbnail row needed).
+
+#### Storage
+
+Thumbnails stored alongside originals with `_thumb` suffix:
+
+```
+/data/storage/attachments/{channel_id}/
+├── {id}_report.pdf ← original
+├── {id}_report.pdf_thumb.jpg ← thumbnail
+├── {id}_photo.jpg ← original
+└── {id}_photo.jpg_thumb.jpg ← thumbnail
+```
+
+Thumbnail URL returned in attachment metadata:
+
+```json
+{
+ "thumbnail_key": "attachments/{channel_id}/{id}_photo.jpg_thumb.jpg",
+ "dimensions": {"width": 1920, "height": 1080},
+ "thumb_dimensions": {"width": 280, "height": 147}
+}
+```
+
+Download endpoint: `GET /api/v1/attachments/:id/thumbnail` — same
+channel-scoped auth check as the full download.
+
+---
+
+## Track 4: Frontend
+
+### Input Area Changes
+
+The `
` gets an attachment button and a preview
+strip:
+
+```
+┌──────────────────────────────────────────────┐
+│ ┌──────────┐ ┌──────────┐ │ ← attachment preview strip
+│ │ image.png│ │ report.pdf│ │ (visible only when files
+│ │ ✕ │ │ ✕ │ │ are staged)
+│ └──────────┘ └──────────┘ │
+├──────────────────────────────────────────────┤
+│ 📎 │ Send a message... │ ■ │ ▶ │ ← existing input + new clip btn
+└──────────────────────────────────────────────┘
+```
+
+### Upload Interactions
+
+| Trigger | Behavior |
+|---------|----------|
+| 📎 button click | File picker dialog (filtered by allowed types) |
+| Paste (Ctrl+V) binary | Auto-attach: screenshot/image → image attachment ({uuid}.{ext}) |
+| Paste (Ctrl+V) large text | Auto-attach: text > threshold → text file ({uuid}.txt) |
+| Paste (Ctrl+V) small text | Normal paste into textarea (below threshold) |
+| Drag-and-drop on chat area | Auto-attach file(s) as attachments (max 5 per message) |
+
+#### Smart Paste (Auto-Attach)
+
+Paste events are intercepted and routed automatically — no prompts, no
+user decisions. The clipboard content determines the behavior:
+
+**Detection logic:**
+
+```
+Clipboard paste event
+ │
+ ├── Contains files/images (binary)?
+ │ └── YES → auto-attach each item
+ │ ├── Image (screenshot, copied image) → upload as image attachment
+ │ ├── File (dragged from OS) → upload as file attachment
+ │ └── Filename: {uuid}.{ext} (ext from MIME type)
+ │
+ ├── Contains text?
+ │ ├── Length ≤ threshold → normal paste into textarea
+ │ └── Length > threshold → auto-attach as text file
+ │ ├── Upload as attachment (content_type: text/plain)
+ │ ├── Filename: {uuid}.txt
+ │ └── Textarea stays empty (or keeps existing draft)
+ │
+ └── Empty → no-op
+```
+
+**Binary detection:** The `paste` event's `clipboardData.items` array
+carries type information. Items with `type.startsWith('image/')` or
+any non-text type are binary — always auto-attached. Text items
+(`text/plain`, `text/html`) are checked against the threshold.
+
+**UUID filenames:** All auto-attached pastes get UUID-based filenames.
+No timestamps, no `pasted-text.txt` collision issues. The original
+content type and paste source are captured in attachment metadata:
+
+```json
+{
+ "source": "clipboard",
+ "original_type": "image/png",
+ "paste_length": 4200
+}
+```
+
+**Threshold:** `storage_paste_to_file_chars` global setting (default:
+2000 characters, admin-configurable). Setting to 0 disables text
+auto-attach (binary pastes still auto-attach regardless).
+
+**UX flow for a screenshot paste:**
+1. User hits Ctrl+V with a screenshot on clipboard
+2. Image appears instantly in the attachment preview strip (uploading...)
+3. Upload completes → thumbnail shown with checkmark
+4. User types their question, hits send
+5. Image goes to vision model as base64
+
+**UX flow for a large log paste:**
+1. User copies 5,000 chars of server logs, hits Ctrl+V
+2. Text file chip appears in attachment preview strip: `📄 {uuid}.txt (4.9 KB)`
+3. Extraction runs (trivial for plain text — near-instant)
+4. User types "what's wrong with these logs?", hits send
+5. Log content injected as extracted text context
+
+No prompts, no dialogs, no decisions. Paste just works.
+
+### State Management
+
+Staged attachments live in `App.stagedAttachments[]` — an array of
+`{id, filename, contentType, sizeBytes, previewUrl, extractionStatus}`
+populated after the upload API returns. On send, attachment IDs are
+included in the completion request body. On cancel/clear, staged
+attachments are deleted via the API.
+
+**Upload lifecycle per attachment:**
+
+```
+uploading → uploaded/queued → extracting → ready | failed
+```
+
+- `uploading`: progress bar visible, **send disabled**.
+- `uploaded/queued`: file on disk, extraction queued. Queue position
+ shown: "Queued (2 ahead)". **Send enabled** — extraction will
+ complete before or during model processing, or the model gets
+ the filename-only fallback.
+- `extracting`: spinner on thumbnail. Send remains enabled.
+- `ready`: extraction complete (or image — no extraction needed).
+ Green checkmark.
+- `failed`: extraction failed. Warning icon on thumbnail. Send still
+ enabled — model sees `[Attached file: X (extraction failed)]`.
+
+**Key principle:** Send is blocked **only during active upload** (bytes
+in flight). Once the file is on disk and the attachment row exists,
+the user can send. This prevents the frustrating case where a user
+types their message while a 50-page PDF extracts for 30 seconds.
+
+**Polling:** Frontend polls `GET /api/v1/attachments/:id` every 2s for
+any attachment with `extractionStatus == "pending" | "processing"`.
+Stops when all settle. WebSocket notification is a future optimization.
+Polling updates the queue position indicator and thumbnail status icon.
+
+### Message Rendering
+
+Attachments on sent/received messages render inline:
+
+- **Images:** Thumbnail (from `_thumb.jpg`) with click-to-expand
+ (lightbox or side panel). Rendered in the message bubble below/above
+ the text.
+- **Documents:** File chip with icon, filename, size, and download link.
+ `📄 report.pdf (2.3 MB) ⬇`
+- **Pasted text files:** Same as documents — `📄 {uuid}.txt (4.9 KB) ⬇`.
+ Metadata `source: "clipboard"` available for future UX refinement.
+
+Attachment data comes from `messages.metadata.attachment_ids` →
+batch-fetched on conversation load via
+`GET /channels/:id/attachments`.
+
+### Capability-Aware UI
+
+The frontend already knows model capabilities from the model selector.
+
+**Images are always auto-attached regardless of current model** — the
+user might switch models before sending. When the selected model has
+`vision: false`:
+
+- Image attachments show a subtle indicator: `👁️🗨️` icon dimmed with
+ tooltip "Current model doesn't support images"
+- File picker filters to documents only, but paste/drop still accepts
+ images (user can switch models)
+- The 📎 button tooltip says "Attach document" instead of "Attach file"
+- On send with image + non-vision model: backend returns 400 with
+ `"Selected model does not support image input — switch to a vision
+ model or remove image attachments"`
+- Document uploads (PDF, DOCX, ODT, etc.) always work — text
+ extraction doesn't require vision
+
+When storage is not configured (boot payload flag), the 📎 button is
+hidden entirely and all paste/drop auto-attach handlers are bypassed.
+
+---
+
+## Privacy & Access Control Model
+
+### Principle
+
+**Attachments have no access identity of their own. They inherit access
+from their parent channel.** The channel membership check is the single
+gate for all attachment operations.
+
+### Access Matrix (v0.12.0 — Current Auth Model)
+
+| Channel type | Who can upload | Who can download | Delete |
+|---|---|---|---|
+| Personal DM | Channel owner | Channel owner | Owner |
+| (Future) Team channel | Team members with access | Team members with access | Uploader or team admin |
+| (Future) Group chat | Channel participants | Channel participants | Uploader or channel owner |
+| (Future) Workflow | Assigned participants | Assigned participants | Stage rules |
+
+### Enforcement Points
+
+Every attachment endpoint performs channel-scoped authorization:
+
+```go
+// Pseudocode — every handler follows this pattern
+func (h *Handler) downloadAttachment(c *gin.Context) {
+ att := h.store.Attachments().GetByID(c, attID)
+ channel := h.store.Channels().GetByID(c, att.ChannelID)
+
+ // THE GATE: is the requester authorized for this channel?
+ if channel.UserID != requesterID {
+ c.JSON(403, gin.H{"error": "access denied"})
+ return
+ }
+
+ // Future RBAC (v0.20.0): replace the above with:
+ // if !h.rbac.CanAccess(requesterID, channel.ID, "attachment:read") {
+ // c.JSON(403, ...)
+ // }
+
+ h.objectStore.Get(c, att.StorageKey) // → stream to client
+}
+```
+
+### Threat Mitigations
+
+| Threat | Mitigation |
+|--------|-----------|
+| ID guessing / enumeration | Every download joins through channel + verifies requester membership. UUIDs are non-sequential. |
+| Direct filesystem access | Files never served by nginx. All bytes go through Go backend with auth. No static file routes for storage path. |
+| Cross-team data leak | Channel membership is the boundary. HR channel attachments are only accessible to HR channel participants. |
+| Admin overreach | v0.12.0: admin can access (consistent with existing admin trust model). Audit-logged. v0.20.0: RBAC with team-scoped admin. |
+| Metadata leak | Channel attachment list endpoint also requires channel membership. No global search across attachments. |
+| Storage key prediction | Keys include UUID attachment_id — not guessable from channel_id + filename alone. |
+| Upload as attack vector | MIME type validated against allowlist. File size enforced. Content-Type set from server-side detection, not client header. |
+
+### Consistency with Existing Scoped Resources
+
+The attachment access pattern is identical to the pattern used for all
+scoped data in the system:
+
+```
+Resource Scope Access Gate
+──────────────────────────────────────────────────
+Notes (personal) user_id user_id = requester
+Notes (team) team_id requester ∈ team members
+Attachments channel_id requester ∈ channel (owner/participant)
+Usage data user/team scoped queries
+API keys user/org vault (UEK) / env-derived key
+```
+
+The row knows its scope. The handler enforces the boundary. The query
+filters by it.
+
+### Future RBAC Hooks (v0.20.0)
+
+The v0.12.0 access checks are written as explicit `if` checks that
+can be trivially replaced by a centralized RBAC call:
+
+```go
+// v0.12.0
+if channel.UserID != requesterID { deny }
+
+// v0.20.0
+if !rbac.Can(requesterID, channel.ID, permission) { deny }
+```
+
+The schema, storage layer, and API surface don't change. Only the
+authorization check function gets swapped.
+
+---
+
+## Implementation Order
+
+```
+Phase 1: Storage Backend
+ ├── Config additions (STORAGE_BACKEND, STORAGE_PATH, EXTRACTION_MODE)
+ ├── ObjectStore interface
+ ├── PVC implementation (~100 lines)
+ ├── Startup validation (writable check, health)
+ ├── K8s manifest (PVC + volume mount + STORAGE_CLASS)
+ ├── Docker-compose volume mount
+ ├── Admin status endpoint
+ └── Boot payload: storage_configured flag
+
+Phase 2: Attachments CRUD + Extraction Pipeline
+ ├── Migration 007_attachments.sql
+ ├── models.Attachment struct
+ ├── AttachmentStore (PG CRUD)
+ ├── handlers/attachments.go (upload, download, delete, list, thumbnail)
+ ├── Channel-scoped access checks on all endpoints
+ ├── MIME validation + size enforcement (server-side detection)
+ ├── Filesystem cleanup on channel delete (CASCADE + DeletePrefix)
+ ├── Extraction queue (filesystem-backed):
+ │ ├── processing/ directory convention + status.json
+ │ ├── Worker loop with semaphore (default 3 concurrent)
+ │ ├── Tier 1: native Go (pdfcpu, go-docx, io.ReadAll)
+ │ ├── Tier 2: LibreOffice headless (inline or sidecar)
+ │ ├── Crash recovery (reset stale "processing" → "pending")
+ │ └── PG metadata mirror (extraction_status, queue_position)
+ ├── Thumbnail generation (images: Go resize, docs: LibreOffice)
+ ├── Unified Dockerfile: add libreoffice-core
+ ├── Sidecar Dockerfile: alpine + libreoffice + watcher
+ └── K8s backend.yaml: sidecar container definition
+
+Phase 3: Multimodal Message Assembly
+ ├── ContentPart + ImageURL types in providers/provider.go
+ ├── OpenAI message conversion (content array marshaling)
+ ├── Anthropic message conversion (image source blocks)
+ ├── OpenRouter / Venice (inherit OpenAI path)
+ ├── Completion handler: attachment → base64 → content parts
+ ├── Completion handler: extraction_status check at request time
+ ├── Capability gating (vision check before image assembly)
+ ├── Document text injection (extracted_text → text content part)
+ └── Usage logging for multimodal requests
+
+Phase 4: Frontend
+ ├── Attachment button (📎) in input area
+ ├── File picker with type filtering (capability-aware)
+ ├── Smart paste handler:
+ │ ├── Binary detection (images, files → auto-attach)
+ │ ├── Large text detection (> threshold → auto-attach as {uuid}.txt)
+ │ ├── Small text → normal textarea paste
+ │ └── UUID filename generation
+ ├── Drag-and-drop on chat area
+ ├── Staged attachment preview strip with status indicators:
+ │ ├── Upload progress bar
+ │ ├── Queue position ("Queued (2 ahead)")
+ │ ├── Extraction spinner
+ │ ├── Ready checkmark / failed warning
+ │ └── Send blocked only during active upload
+ ├── Extraction status polling (2s interval, stops at terminal state)
+ ├── Attachment rendering in messages (thumbnails + doc chips)
+ ├── Image lightbox / side panel expand
+ ├── Capability-aware UI (hide/filter by vision support)
+ ├── Storage-not-configured state (hide upload UI)
+ └── Admin Storage panel (status, orphan count, cleanup button)
+
+Phase 5: Stragglers
+ ├── vault rekey CLI command
+ ├── Admin encryption status indicator
+ └── Per-chat model/preset persistence (server-side)
+
+Phase 6: Admin Cleanup
+ ├── POST /admin/storage/cleanup endpoint
+ ├── Orphan detection query (message_id IS NULL, age > 24h)
+ ├── Admin Storage panel: orphan count + reclaimable space card
+ └── "Run Cleanup Now" button
+```
+
+### Testing
+
+- Integration tests: upload → send message with attachment → verify
+ multimodal completion request → verify download auth
+- Access control tests: upload to channel A, attempt download from
+ user who owns channel B → 403
+- Storage backend tests: put/get/delete/deletePrefix round-trip
+- Extraction tests: PDF/DOCX/TXT → extracted_text populated
+- Frontend tests: staged attachments state, capability gating
+
+---
+
+## Migration Summary
+
+| Migration | Tables/Columns |
+|-----------|---------------|
+| 007_attachments.sql | `attachments` table with indexes |
+
+No changes to existing tables. The `messages.metadata` JSONB carries
+`attachment_ids` without schema changes. The `channels.settings` JSONB
+carries `last_selector_id` without schema changes.
+
+---
+
+## Decisions Made
+
+1. **Text extraction** — Tiered: native Go (pdfcpu Apache 2.0, go-docx
+ MIT) for common cases, LibreOffice headless for everything else
+ including ODF formats. No AGPL dependencies.
+
+2. **Thumbnails** — Yes. Generated async post-upload. Images via Go
+ stdlib (`golang.org/x/image/draw`). Documents via LibreOffice
+ first-page render. Stored alongside originals with `_thumb` suffix.
+
+3. **Max attachments per message** — 5 files (admin-configurable).
+
+4. **Extraction timing** — Async with filesystem-backed queue. Upload
+ returns immediately, extraction runs in background via worker loop
+ with concurrency cap. Frontend polls for status. Send blocked only
+ during active upload (bytes in flight), not during extraction.
+
+5. **Smart paste** — Automatic, no prompts. Binary clipboard items
+ (screenshots, images) always auto-attach. Text exceeding threshold
+ (default 2000 chars) auto-attaches as `{uuid}.txt`. Below threshold
+ pastes normally into textarea. UUID filenames, MIME detection from
+ clipboard data. Zero user decisions.
+
+## Global Settings (New)
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| `storage_max_file_size` | `10485760` (10MB) | Per-file upload limit |
+| `storage_max_upload_size` | `52428800` (50MB) | Per-request total limit |
+| `storage_allowed_types` | *(see Allowed Types)* | MIME type allowlist |
+| `storage_max_attachments_per_message` | `5` | Max files per message |
+| `storage_paste_to_file_chars` | `2000` | Paste-to-file threshold (0=disabled) |
+| `storage_user_quota_bytes` | `0` | Per-user quota (0=unlimited, future enforcement) |
+
+## Decisions Made (Continued)
+
+6. **Sidecar base image** — Debian slim. Rock-solid LibreOffice packages,
+ ~80MB larger than Alpine but no flaky package issues.
+
+7. **Queue persistence** — Dual: filesystem `status.json` is source of
+ truth (crash recovery, `ls` debuggability), mirrored to PG metadata
+ for API serving. Worth the bookkeeping for operational transparency.
+
+## Open Questions
+
+*(None remaining — all design decisions resolved.)*
+
+## Orphan Cleanup (Admin Action)
+
+Attachments uploaded but never associated with a message (user closes
+tab, browser crash, abandoned upload) accumulate as orphans.
+
+**v0.12.0: manual admin action with "Run Now" button.**
+
+```
+POST /admin/storage/cleanup
+ → Scan attachments WHERE message_id IS NULL
+ AND created_at < NOW() - interval '24 hours'
+ → Delete PG rows + storage files + processing dirs
+ → Return { deleted: 3, freed_bytes: 15728640 }
+```
+
+**Admin Storage panel card:**
+
+```
+┌─ Orphan Files ──────────────────────────────┐
+│ 3 orphaned attachments (15 MB reclaimable) │
+│ │
+│ Files not linked to any message for >24hrs │
+│ │
+│ [Run Cleanup Now] │
+└─────────────────────────────────────────────┘
+```
+
+The 24-hour grace period prevents cleaning up files that are simply
+in the middle of a long compose session.
+
+**Future (v0.22.0 task runner):** This becomes a scheduled task running
+daily. The admin button stays as an on-demand override. The endpoint
+is identical — the scheduler just calls it on a cron.
diff --git a/README.md b/README.md
index ad002f8..4b367da 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,15 @@ All configuration via environment variables. See `server/.env.example` for the f
| `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password |
| `ENCRYPTION_KEY` | ` ` | AES key for API key encryption (required if encrypted keys exist) |
| `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` |
+| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. Auto-detects PVC if not set. |
+| `STORAGE_PATH` | `/data/storage` | PVC mount point (also scratch dir for S3 extraction) |
+| `S3_ENDPOINT` | ` ` | S3 endpoint (e.g. `http://minio:9000`, required for S3) |
+| `S3_BUCKET` | ` ` | S3 bucket name (must exist, required for S3) |
+| `S3_ACCESS_KEY` | ` ` | S3 access key ID |
+| `S3_SECRET_KEY` | ` ` | S3 secret access key |
+| `S3_REGION` | `us-east-1` | S3 region |
+| `S3_PREFIX` | ` ` | Optional key prefix within bucket |
+| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs (required for MinIO, Ceph) |
## Deployment
diff --git a/ROADMAP.md b/ROADMAP.md
index 2e51f82..64a9f18 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -43,19 +43,23 @@ v0.14.0 Knowledge Bases (embedding role + file storage + pgvector)
│
v0.15.0 Compaction (utility role + background job)
│
-v0.16.0 @mention Routing + Multi-model
+v0.15.1 Context Recall Tools (attachment_recall, conversation_search)
│
-v0.17.0 Extension Surfaces + Modes
+v0.16.0 Conversation Memory (cross-conversation user facts)
│
-v0.18.0 Smart Routing (policy on model roles)
+v0.17.0 @mention Routing + Multi-model
│
-v0.19.0 Multi-Participant Channels + Presence
+v0.18.0 Extension Surfaces + Modes
│
-v0.20.0 Auth Strategy (mTLS/OIDC) + Full RBAC
+v0.19.0 Smart Routing (policy on model roles)
+ │
+v0.20.0 Multi-Participant Channels + Presence
+ │
+v0.21.0 Auth Strategy (mTLS/OIDC) + Full RBAC
│
┌───────┴──────────────┐
│ │
-v0.21.0 Workflow v0.22.0 Tasks /
+v0.22.0 Workflow v0.23.0 Tasks /
Engine Autonomous Agents
(team-owned, (service channels,
staged processes, scheduler, unattended
@@ -254,7 +258,7 @@ after backfill. If `ENCRYPTION_KEY` is not set, migration refuses to run
derivation. No additional UX. On login, derive PDK → unwrap UEK → cache
in session. User never knows the vault exists.
-**mTLS / OIDC** (v0.20.0) — authentication is external (cert/token).
+**mTLS / OIDC** (v0.21.0) — authentication is external (cert/token).
Vault passphrase is conditionally required:
```
@@ -336,7 +340,7 @@ func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, err
- [x] UEK eviction on logout (memory zeroing)
- [ ] UEK re-wrap on password change _(deferred — Settings handler)_
- [ ] UEK destruction on admin password reset with confirmation _(deferred)_
-- [ ] Vault passphrase prompt for mTLS/OIDC _(deferred — v0.20.0 dependency)_
+- [ ] Vault passphrase prompt for mTLS/OIDC _(deferred — v0.21.0 dependency)_
**Provider key lifecycle**
- [x] Encrypt on provider create/update (all 6 write paths: admin global, personal, team)
@@ -344,8 +348,9 @@ func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, err
- [x] `ErrVaultLocked` handling (prompt user to unlock)
**Admin tooling**
-- [ ] `switchboard vault rekey` CLI command _(deferred)_
-- [ ] Admin UI: encryption status indicator _(deferred)_
+- [x] `switchboard vault rekey` CLI command _(v0.12.0)_
+- [x] `switchboard vault status` CLI command _(v0.12.0)_
+- [x] Admin UI: encryption status indicator _(v0.12.0 — Settings tab)_
- [x] Admin UI: password reset warning (v0.10.0 — vault destruction dialog)
**CI/CD**
@@ -579,17 +584,21 @@ The platform play. See [EXTENSIONS.md](EXTENSIONS.md) for full spec.
File input into chat — table stakes for serious use.
**Storage Backend**
-- [ ] S3-compatible API (MinIO, Ceph RGW, AWS — anything with S3 API)
-- [ ] Local PVC fallback (single-node / dev)
-- [ ] Admin config: backend selection, endpoint, credentials, size limits
-- [ ] Reused by KBs (v0.14.0) and compaction snapshots (v0.15.0)
+- [x] S3-compatible API (MinIO, Ceph RGW, AWS — anything with S3 API)
+- [x] Local PVC backend (single-node / dev)
+- [x] Admin config: backend health status, orphan cleanup panel
+- [x] Reused by KBs (v0.14.0) and compaction snapshots (v0.15.0)
**Chat Integration**
-- [ ] `attachments` table (metadata in PG, blobs in object store)
-- [ ] Image/file upload in chat messages
-- [ ] Multimodal message assembly for vision-capable models
-- [ ] Text extraction on upload (PDF, DOCX, TXT → tsvector)
-- [ ] Paste-to-upload (clipboard image support)
+- [x] `attachments` table (metadata in PG, blobs in object store)
+- [x] Image/file upload in chat messages (📎 button, drag-and-drop)
+- [x] Multimodal message assembly for vision-capable models
+- [x] Text extraction pipeline (PDF, DOCX, TXT — inline + sidecar modes)
+- [x] Paste-to-upload (clipboard images + large text auto-attach)
+- [x] Staged attachment strip with extraction status polling
+- [x] Auth-aware rendering (blob URLs with Bearer token)
+- [x] Image lightbox viewer
+- [x] Vision capability hints on image attachments
- [ ] Document generation output storage (extension → attachment → download)
---
@@ -633,7 +642,56 @@ Depends on: utility model role (v0.10.0).
---
-## v0.16.0 — @mention Routing + Multi-model
+## v0.15.1 — Context Recall Tools
+
+Depends on: file handling (v0.12.0), tool framework (v0.11.0).
+
+Attachments are injected into context once (at send time) and are not
+replayed on subsequent turns — the model's own response acts as a natural
+summary. After compaction (v0.15.0) or in long conversations, the model
+may need to re-read source material. These tools bridge that gap.
+
+**`attachment_recall` tool**
+- [ ] `attachment_recall(attachment_id)` — re-reads attachment content from storage
+- [ ] Images: re-inject as base64 content part (vision gating still applies)
+- [ ] Documents: return extracted text
+- [ ] Security: scoped to current channel's attachments only
+- [ ] Extend `ExecutionContext` with optional storage backend reference
+- [ ] Tool definition includes attachment list hint so model knows what's available
+
+**`conversation_search` tool**
+- [ ] Keyword search over compacted/archived messages in current channel
+- [ ] Returns matching message excerpts with timestamps
+- [ ] Useful post-compaction when summary dropped details the user asks about
+
+**Token estimator improvements**
+- [ ] Include attachment token estimates in context counter
+- [ ] Images: use provider-specific sizing rules (e.g. tile-based for OpenAI)
+- [ ] Documents: count extracted text tokens
+- [ ] Staged attachments reflected in input token counter before send
+
+---
+
+## v0.16.0 — Conversation Memory
+
+Depends on: compaction (v0.15.0), knowledge bases (v0.14.0).
+
+Long-term memory across conversations. Unlike KB (static documents) or
+compaction (within-conversation), this captures user-level facts and
+preferences that persist across channels.
+
+- [ ] `user_memory` table: `user_id`, `key`, `value`, `source_channel_id`, `confidence`, `created_at`, `updated_at`
+- [ ] `memory_save` tool: LLM can explicitly store a fact ("user prefers Python", "project deadline is March 15")
+- [ ] `memory_recall` tool: LLM queries stored facts relevant to current context
+- [ ] Auto-extraction: background job identifies memorable facts from conversations (opt-in)
+- [ ] Memory injection: relevant facts injected into system prompt at completion time
+- [ ] User controls: view, edit, delete memories in Settings
+- [ ] Admin controls: enable/disable, retention policies, per-team scoping
+- [ ] Privacy: memories never cross team boundaries
+
+---
+
+## v0.17.0 — @mention Routing + Multi-model
The channel schema already supports multiple models. This adds routing.
@@ -645,7 +703,7 @@ The channel schema already supports multiple models. This adds routing.
---
-## v0.17.0 — Extension Surfaces + Modes
+## v0.18.0 — Extension Surfaces + Modes
Depends on: extension foundation (v0.11.0).
See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes).
@@ -659,7 +717,7 @@ See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes).
---
-## v0.18.0 — Smart Routing
+## v0.19.0 — Smart Routing
Depends on: model roles (v0.10.0), usage tracking (v0.10.0).
Rules-based routing engine — policy, not ML.
@@ -672,13 +730,13 @@ Rules-based routing engine — policy, not ML.
---
-## v0.19.0 — Multi-Participant Channels + Presence
+## v0.20.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.18.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`
@@ -699,7 +757,7 @@ Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exis
---
-## v0.20.0 — Auth Strategy (mTLS/OIDC) + Full RBAC
+## v0.21.0 — Auth Strategy (mTLS/OIDC) + Full RBAC
Enterprise auth modes on top of the teams foundation.
Depends on: vault passphrase support from v0.9.3 (conditional unlock for
@@ -723,7 +781,7 @@ personal providers under external auth).
---
-## v0.21.0 — Workflow Engine
+## v0.22.0 — Workflow Engine
Team-owned, stage-based process execution. The channel is the runtime,
personas drive each stage, and the existing tool/notes infrastructure
@@ -731,7 +789,7 @@ handles structured data collection. See [ARCHITECTURE.md — Workflow
Architecture](ARCHITECTURE.md#workflow-architecture-future--v0210) for
the conceptual model.
-Depends on: multi-participant channels (v0.19.0), anonymous identity (v0.20.0).
+Depends on: multi-participant channels (v0.20.0), anonymous identity (v0.21.0).
**Workflow Definitions**
- [ ] `workflows` table: `team_id` (owner), `name`, `description`, `is_active`, `entry_mode` (public_link, team_internal, api)
@@ -771,10 +829,10 @@ Depends on: multi-participant channels (v0.19.0), anonymous identity (v0.20.0).
---
-## v0.22.0 — Tasks / Autonomous Agents
+## v0.23.0 — Tasks / Autonomous Agents
Unattended execution — workflows without a human in the loop.
-Depends on: workflow engine (v0.21.0).
+Depends on: workflow engine (v0.22.0).
- [ ] Scheduler + task runner (cron-like triggers for workflow instantiation)
- [ ] `task_create` tool (AI can spawn sub-workflows)
@@ -812,13 +870,7 @@ based on need.
- Backup/restore CronJob manifests
**UX / Multi-Seat**
-- Per-chat model/preset persistence (server-side). Currently a localStorage
- bandaid (v0.10.2) that doesn't roam across devices. Proper fix: store
- `last_selector_id` in `channels.settings` JSONB on each send. On chat
- selection, resolve from `channel.settings.last_selector_id` → match against
- available models/presets → fall back to `channel.model` → global default.
- Eliminates device-local state entirely. Needs `updateChannel` PATCH on
- completion success (single extra column merge, no migration).
+- ~~Per-chat model/preset persistence (server-side)~~ ✅ _(v0.12.0 — `channels.settings.last_selector_id` JSONB merge, localStorage write-through cache)_
**Platform**
- Rate limiting per user/team/tier (token budgets)
diff --git a/VERSION b/VERSION
index d9df1bb..ac454c6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.11.0
+0.12.0
diff --git a/admin.go b/admin.go
new file mode 100644
index 0000000..c08d2d6
--- /dev/null
+++ b/admin.go
@@ -0,0 +1,668 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "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/models"
+ "git.gobha.me/xcaliber/chat-switchboard/providers"
+ "git.gobha.me/xcaliber/chat-switchboard/store"
+)
+
+type AdminHandler struct {
+ stores store.Stores
+ vault *crypto.KeyResolver
+ uekCache *crypto.UEKCache
+}
+
+func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache) *AdminHandler {
+ return &AdminHandler{stores: s, vault: vault, uekCache: uekCache}
+}
+
+// ── User Management ─────────────────────────
+
+func (h *AdminHandler) ListUsers(c *gin.Context) {
+ opts := store.DefaultListOptions()
+
+ // Accept both limit/offset and page/per_page conventions
+ if limit, _ := strconv.Atoi(c.Query("limit")); limit > 0 {
+ opts.Limit = limit
+ }
+ if offset, _ := strconv.Atoi(c.Query("offset")); offset > 0 {
+ opts.Offset = offset
+ }
+ if perPage, _ := strconv.Atoi(c.Query("per_page")); perPage > 0 {
+ opts.Limit = perPage
+ }
+ if page, _ := strconv.Atoi(c.Query("page")); page > 1 {
+ opts.Offset = (page - 1) * opts.Limit
+ }
+
+ users, total, err := h.stores.Users.List(c.Request.Context(), opts)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"users": users, "total": total})
+}
+
+func (h *AdminHandler) CreateUser(c *gin.Context) {
+ var req struct {
+ Username string `json:"username" binding:"required"`
+ Email string `json:"email" binding:"required"`
+ Password string `json:"password" binding:"required,min=8"`
+ Role string `json:"role"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
+ role := models.UserRoleUser
+ if req.Role == models.UserRoleAdmin {
+ role = models.UserRoleAdmin
+ }
+
+ user := &models.User{
+ Username: strings.ToLower(req.Username),
+ Email: strings.ToLower(req.Email),
+ PasswordHash: string(hash),
+ Role: role,
+ IsActive: true,
+ }
+
+ if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
+ return
+ }
+
+ h.auditLog(c, "user.create", "user", user.ID, nil)
+ c.JSON(http.StatusCreated, user)
+}
+
+func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
+ id := c.Param("id")
+ var req struct {
+ Role string `json:"role" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"role": req.Role}); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
+ return
+ }
+
+ h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role})
+ c.JSON(http.StatusOK, gin.H{"message": "role updated"})
+}
+
+func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
+ id := c.Param("id")
+ var req struct {
+ IsActive bool `json:"is_active"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if err := h.stores.Users.SetActive(c.Request.Context(), id, req.IsActive); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"})
+ return
+ }
+
+ h.auditLog(c, "user.active_change", "user", id, gin.H{"is_active": req.IsActive})
+ c.JSON(http.StatusOK, gin.H{"message": "user status updated"})
+}
+
+func (h *AdminHandler) ResetPassword(c *gin.Context) {
+ id := c.Param("id")
+ var req struct {
+ Password string `json:"password" binding:"required,min=8"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
+ if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"password_hash": string(hash)}); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
+ return
+ }
+
+ // Destroy vault — old UEK is unrecoverable with new password.
+ // A fresh vault will be initialized on the user's next login.
+ h.destroyVault(c, id)
+
+ h.auditLog(c, "user.password_reset", "user", id, nil)
+ c.JSON(http.StatusOK, gin.H{"message": "password reset"})
+}
+
+// destroyVault nullifies a user's encrypted UEK and deletes their personal
+// provider keys. Called when an admin resets a user's password — the old
+// UEK cannot be recovered without the old password.
+func (h *AdminHandler) destroyVault(c *gin.Context, userID string) {
+ if h.uekCache == nil {
+ return
+ }
+
+ // Null out vault columns — user gets a fresh vault on next login
+ _, err := database.DB.Exec(`
+ UPDATE users
+ SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
+ WHERE id = $1
+ `, userID)
+ if err != nil {
+ log.Printf("⚠ Failed to clear vault for user %s: %v", userID, err)
+ }
+
+ // Evict from session cache (if user is currently logged in)
+ h.uekCache.Evict(userID)
+
+ // Delete personal provider configs — their keys are undecryptable now
+ result, err := database.DB.Exec(`
+ DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
+ `, userID)
+ if err != nil {
+ log.Printf("⚠ Failed to delete personal providers for user %s: %v", userID, err)
+ } else if rows, _ := result.RowsAffected(); rows > 0 {
+ log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", rows, userID)
+ }
+
+ h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{
+ "reason": "admin_password_reset",
+ })
+}
+
+func (h *AdminHandler) DeleteUser(c *gin.Context) {
+ id := c.Param("id")
+ if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
+ return
+ }
+ h.auditLog(c, "user.delete", "user", id, nil)
+ c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
+}
+
+// ── Global Settings ─────────────────────────
+
+func (h *AdminHandler) ListGlobalSettings(c *gin.Context) {
+ settings, err := h.stores.GlobalConfig.GetAll(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
+ return
+ }
+ // Also include policies
+ policies, _ := h.stores.Policies.GetAll(c.Request.Context())
+ c.JSON(http.StatusOK, gin.H{"settings": settings, "policies": policies})
+}
+
+func (h *AdminHandler) GetGlobalSetting(c *gin.Context) {
+ key := c.Param("key")
+ val, err := h.stores.GlobalConfig.Get(c.Request.Context(), key)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"key": key, "value": val})
+}
+
+func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) {
+ key := c.Param("key")
+ userID, _ := c.Get("user_id")
+ uid := userID.(string)
+
+ var req struct {
+ Value json.RawMessage `json:"value"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Determine if this is a policy (string value) or a global config (JSON value)
+ var strVal string
+ if err := json.Unmarshal(req.Value, &strVal); err == nil {
+ // String value → try as policy first
+ if _, ok := models.PolicyDefaults[key]; ok {
+ if err := h.stores.Policies.Set(c.Request.Context(), key, strVal, uid); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy"})
+ return
+ }
+ h.auditLog(c, "policy.update", "policy", key, gin.H{"value": strVal})
+ c.JSON(http.StatusOK, gin.H{"message": "policy updated"})
+ return
+ }
+ }
+
+ // JSON value → global config
+ var jsonVal models.JSONMap
+ json.Unmarshal(req.Value, &jsonVal)
+ if err := h.stores.GlobalConfig.Set(c.Request.Context(), key, jsonVal, uid); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"})
+ return
+ }
+
+ h.auditLog(c, "settings.update", "global_settings", key, nil)
+ c.JSON(http.StatusOK, gin.H{"message": "setting updated"})
+}
+
+func (h *AdminHandler) PublicSettings(c *gin.Context) {
+ // Banner config, branding, etc. — safe subset for non-admin users
+ banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner")
+ branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding")
+ systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
+ policies, _ := h.stores.Policies.GetAll(c.Request.Context())
+
+ // Only tell the user whether admin prompt exists — don't expose content
+ hasAdminPrompt := false
+ if content, ok := systemPrompt["content"].(string); ok && content != "" {
+ hasAdminPrompt = true
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "banner": banner,
+ "branding": branding,
+ "has_admin_prompt": hasAdminPrompt,
+ "storage_configured": storageConfigured,
+ "policies": gin.H{
+ "allow_registration": policies["allow_registration"],
+ "allow_user_byok": policies["allow_user_byok"],
+ "allow_user_personas": policies["allow_user_personas"],
+ },
+ })
+}
+
+// ── Vault Status ────────────────────────────
+
+func (h *AdminHandler) VaultStatus(c *gin.Context) {
+ hasKey := h.vault != nil && h.vault.HasEnvKey()
+ keyStr := ""
+ if hasKey {
+ keyStr = "set" // non-empty signals to VaultStatus that key is configured
+ }
+ status, err := crypto.VaultStatus(database.DB, keyStr)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"})
+ return
+ }
+ c.JSON(http.StatusOK, status)
+}
+
+// ── Provider Configs (Global) ───────────────
+
+func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
+ cfgs, err := h.stores.Providers.ListGlobal(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
+ return
+ }
+
+ // Redact API keys but expose has_key flag for UI
+ type configWithKey struct {
+ models.ProviderConfig
+ HasKey bool `json:"has_key"`
+ }
+ out := make([]configWithKey, len(cfgs))
+ for i, cfg := range cfgs {
+ out[i] = configWithKey{
+ ProviderConfig: cfg,
+ HasKey: cfg.HasKey(),
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{"configs": out})
+}
+
+func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
+ // Wrapper struct: models.ProviderConfig has APIKeyEnc tagged json:"-"
+ // so ShouldBindJSON would silently drop the api_key field.
+ var req struct {
+ Name string `json:"name" binding:"required"`
+ Provider string `json:"provider" binding:"required"`
+ Endpoint string `json:"endpoint" binding:"required"`
+ APIKey string `json:"api_key"`
+ ModelDefault string `json:"model_default,omitempty"`
+ Config map[string]interface{} `json:"config,omitempty"`
+ Headers map[string]interface{} `json:"headers,omitempty"`
+ Settings map[string]interface{} `json:"settings,omitempty"`
+ IsPrivate bool `json:"is_private,omitempty"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if _, err := providers.Get(req.Provider); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "unsupported provider: " + req.Provider,
+ "supported_providers": providers.List(),
+ })
+ return
+ }
+
+ cfg := &models.ProviderConfig{
+ Name: req.Name,
+ Provider: req.Provider,
+ Endpoint: req.Endpoint,
+ ModelDefault: req.ModelDefault,
+ Config: models.JSONMap(req.Config),
+ Headers: models.JSONMap(req.Headers),
+ Settings: models.JSONMap(req.Settings),
+ Scope: models.ScopeGlobal,
+ KeyScope: models.ScopeGlobal,
+ IsActive: true,
+ 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 {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
+ return
+ }
+
+ h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider})
+ c.JSON(http.StatusCreated, gin.H{
+ "id": cfg.ID,
+ "scope": cfg.Scope,
+ "name": cfg.Name,
+ "provider": cfg.Provider,
+ "endpoint": cfg.Endpoint,
+ "model_default": cfg.ModelDefault,
+ "config": cfg.Config,
+ "headers": cfg.Headers,
+ "settings": cfg.Settings,
+ "is_active": cfg.IsActive,
+ "is_private": cfg.IsPrivate,
+ "has_key": cfg.HasKey(),
+ "created_at": cfg.CreatedAt,
+ "updated_at": cfg.UpdatedAt,
+ })
+}
+
+func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
+ id := c.Param("id")
+
+ // Wrapper struct: ProviderConfigPatch has APIKeyEnc tagged json:"-"
+ var req struct {
+ models.ProviderConfigPatch
+ APIKey *string `json:"api_key,omitempty"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ patch := req.ProviderConfigPatch
+ // Transfer api_key → encrypted fields
+ if req.APIKey != nil && *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
+ }
+ 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 {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
+ return
+ }
+
+ h.auditLog(c, "provider.update", "provider_config", id, nil)
+ c.JSON(http.StatusOK, gin.H{"message": "config updated"})
+}
+
+func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) {
+ id := c.Param("id")
+ // Delete associated catalog entries first
+ h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
+ if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
+ return
+ }
+ h.auditLog(c, "provider.delete", "provider_config", id, nil)
+ c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
+}
+
+// ── Model Catalog ───────────────────────────
+
+func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
+ entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"models": entries})
+}
+
+func (h *AdminHandler) FetchModels(c *gin.Context) {
+ var req struct {
+ ProviderConfigID string `json:"provider_config_id"`
+ }
+ c.ShouldBindJSON(&req)
+
+ // If no specific provider, fetch from ALL global providers
+ if req.ProviderConfigID == "" {
+ configs, err := h.stores.Providers.ListGlobal(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list providers"})
+ return
+ }
+ if len(configs) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no providers configured — add one first"})
+ return
+ }
+
+ totalAdded, totalUpdated, totalFetched := 0, 0, 0
+ var errs []string
+ for _, cfg := range configs {
+ if !cfg.IsActive {
+ continue
+ }
+ added, updated, fetched, err := h.fetchModelsForProvider(c, &cfg)
+ if err != nil {
+ errs = append(errs, cfg.Provider+": "+err.Error())
+ continue
+ }
+ totalAdded += added
+ totalUpdated += updated
+ totalFetched += fetched
+ }
+
+ result := gin.H{
+ "message": "models synced",
+ "added": totalAdded,
+ "updated": totalUpdated,
+ "total": totalFetched,
+ }
+ if len(errs) > 0 {
+ result["errors"] = errs
+ }
+ c.JSON(http.StatusOK, result)
+ return
+ }
+
+ // Single provider fetch
+ cfg, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "provider config not found"})
+ return
+ }
+
+ added, updated, fetched, err := h.fetchModelsForProvider(c, cfg)
+ if err != nil {
+ c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "message": "models synced",
+ "added": added,
+ "updated": updated,
+ "total": fetched,
+ })
+}
+
+// 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) {
+ // 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 {
+ return 0, 0, 0, err
+ }
+
+ h.auditLog(c, "models.fetch", "provider_config", cfg.ID, gin.H{
+ "added": result.Added, "updated": result.Updated, "total": result.Total,
+ })
+
+ return result.Added, result.Updated, result.Total, nil
+}
+
+func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
+ id := c.Param("id")
+ var req struct {
+ Visibility *string `json:"visibility"`
+ DisplayName *string `json:"display_name"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if req.Visibility != nil {
+ if err := h.stores.Catalog.SetVisibility(c.Request.Context(), id, *req.Visibility); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"})
+ return
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "model updated"})
+}
+
+func (h *AdminHandler) BulkUpdateModels(c *gin.Context) {
+ var req struct {
+ ProviderConfigID string `json:"provider_config_id"`
+ Visibility string `json:"visibility" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ var err error
+ if req.ProviderConfigID != "" {
+ err = h.stores.Catalog.BulkSetVisibility(c.Request.Context(), req.ProviderConfigID, req.Visibility)
+ } else {
+ err = h.stores.Catalog.BulkSetVisibilityAll(c.Request.Context(), req.Visibility)
+ }
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "bulk update complete"})
+}
+
+func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
+ id := c.Param("id")
+ if err := h.stores.Catalog.Delete(c.Request.Context(), id); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "model deleted"})
+}
+
+// ── Stats ───────────────────────────────────
+
+func (h *AdminHandler) GetStats(c *gin.Context) {
+ ctx := c.Request.Context()
+ stats := gin.H{}
+
+ var userCount, channelCount, messageCount int
+ database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount)
+ database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&channelCount)
+ database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&messageCount)
+
+ stats["users"] = userCount
+ stats["channels"] = channelCount
+ stats["messages"] = messageCount
+
+ c.JSON(http.StatusOK, stats)
+}
+
+// ── Helpers ─────────────────────────────────
+// NOTE: ListAuditLog and ListAuditActions are in audit.go
+
+func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID string, metadata interface{}) {
+ userID, _ := c.Get("user_id")
+ uid, _ := userID.(string)
+
+ var meta models.JSONMap
+ if metadata != nil {
+ b, _ := json.Marshal(metadata)
+ json.Unmarshal(b, &meta)
+ }
+
+ entry := &models.AuditEntry{
+ ActorID: &uid,
+ Action: action,
+ ResourceType: resourceType,
+ ResourceID: resourceID,
+ Metadata: meta,
+ IPAddress: c.ClientIP(),
+ UserAgent: c.GetHeader("User-Agent"),
+ }
+
+ if err := h.stores.Audit.Log(context.Background(), entry); err != nil {
+ log.Printf("audit log error: %v", err)
+ }
+}
diff --git a/api.js b/api.js
new file mode 100644
index 0000000..d56393f
--- /dev/null
+++ b/api.js
@@ -0,0 +1,636 @@
+// ==========================================
+// Chat Switchboard – API Client
+// ==========================================
+// Backend-only mode. Handles auth tokens and
+// all HTTP calls. No offline fallback.
+//
+// BASE_PATH: injected via index.html
+
+
+
+ Chat Switchboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Chat Switchboard
+
Select a model and start chatting
+
+
+
+
+
+ ⚠️
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Chat Switchboard
+
+
One interface. Every AI model.
+
Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.
+
+
⚡ Streaming responses
+
Multi-provider routing
+
🔑 Bring your own API keys
+
🏠 Self-hosted
+
✈️ Air-gap ready
+
🧠 Thinking blocks
+
+
v%%APP_VERSION%%
+
+
+
+
+
+
Welcome back
+
Sign in to continue to your workspace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Profile
+
+
+ ?
+
+
+
+
+
+
Auto-resized to 128×128
+
+
+
+
+
+
+
+
+
+
+
+
+
My Teams
+
+
+
+
Chat
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Display
+
+
+
+
80%150%
+
+
+
+
+
12px20px
+
+
+
+
+
+
+
+
+
+
+
+
+ ⚠️ User-configured providers have been disabled by the administrator.
+
+
+
+
+
+
+
Available Models
+
+
+
+
+
+
Toggle visibility in your model selector. Hidden models are still accessible through presets.
+
Loading models...
+
+
+
+
+
+
My Personas
+
Personal model presets with custom system prompts and settings.
+
Loading...
+
+
+
+
+
+
+
+
My Usage
+
Token consumption and estimated costs across all your conversations.
+
+
+
+
+
+
+
+
+
+
+
+
Personal Model Roles
+
Override the org's default utility and embedding models with your own providers. Uses your personal API keys — no org cost.
+
+
+
+ ℹ️ Add a personal provider first to configure role overrides.
+
+
+
+
+
+
+
+
+
+
+
+
Team Management
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Admin Panel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Team members can only use providers marked as "private".
+
+
When disabled, team admins cannot add or modify team providers.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
System Prompt
+
Injected into every conversation before user/preset prompts. Users cannot override or disable this.
+
+
+
+
+
+
Registration
+
+
+
+
+
+
+
+
User Providers
+
+
When disabled, users can only use admin-configured global providers.
+
+
+
User Presets
+
+
When disabled, users can only use admin-created global presets.
+
+
+
Default Model
+
+
+
+
Model selected by default for new users or when a saved model is no longer available.
+
+
+
Environment Banner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PREVIEW
+
+
+
+
🔐 Encryption
+
Loading…
+
+
+
+
+
+
+
Configure system model roles. Each role has a primary and optional fallback model.