From 8292a6efa86b69237c55e5279de70d70e3485a15 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Wed, 25 Feb 2026 22:52:19 +0000 Subject: [PATCH] Changeset 0.13.0 (#64) --- admin.go | 668 --------- api.js | 636 -------- channels.go | 458 ------ chat.js | 632 -------- index.html | 931 ------------ main.go | 508 ------- rekey.go | 115 -- server/database/testhelper.go | 2 + src/css/styles.css | 174 ++- src/index.html | 177 ++- src/js/admin-handlers.js | 17 +- src/js/ui-admin.js | 147 +- src/js/ui-primitives.js | 6 +- src/js/ui-settings.js | 4 +- src/js/ui.js | 2582 --------------------------------- status.go | 46 - styles.css | 1893 ------------------------ ui-admin.js | 727 ---------- 18 files changed, 360 insertions(+), 9363 deletions(-) delete mode 100644 admin.go delete mode 100644 api.js delete mode 100644 channels.go delete mode 100644 chat.js delete mode 100644 index.html delete mode 100644 main.go delete mode 100644 rekey.go delete mode 100644 src/js/ui.js delete mode 100644 status.go delete mode 100644 styles.css delete mode 100644 ui-admin.js diff --git a/admin.go b/admin.go deleted file mode 100644 index c08d2d6..0000000 --- a/admin.go +++ /dev/null @@ -1,668 +0,0 @@ -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 deleted file mode 100644 index d56393f..0000000 --- a/api.js +++ /dev/null @@ -1,636 +0,0 @@ -// ========================================== -// 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
-
-

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

-
-
- - -
-
-
-
-
- -
-
-
- - -
- -
-
-
- - - - - - - - - - - - - - - -
-
-
- - - esc -
-
- -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/main.go b/main.go deleted file mode 100644 index 2631767..0000000 --- a/main.go +++ /dev/null @@ -1,508 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "strings" - "time" - - "github.com/gin-gonic/gin" - - "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/events" - "git.gobha.me/xcaliber/chat-switchboard/extraction" - "git.gobha.me/xcaliber/chat-switchboard/handlers" - "git.gobha.me/xcaliber/chat-switchboard/middleware" - "git.gobha.me/xcaliber/chat-switchboard/providers" - "git.gobha.me/xcaliber/chat-switchboard/roles" - "git.gobha.me/xcaliber/chat-switchboard/storage" - "git.gobha.me/xcaliber/chat-switchboard/store" - postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" - _ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init() -) - -func main() { - // ── Subcommand dispatch ────────────────── - // Usage: switchboard vault rekey - if len(os.Args) > 2 && os.Args[1] == "vault" { - runVaultCommand(os.Args[2]) - return - } - if len(os.Args) > 1 && os.Args[1] == "version" { - fmt.Println("switchboard", Version) - return - } - - // ── Server startup ────────────────────── - cfg := config.Load() - - // Register LLM providers - providers.Init() - - var stores store.Stores - uekCache := crypto.NewUEKCache() - var keyResolver *crypto.KeyResolver - var objStore storage.ObjectStore - - if err := database.Connect(cfg); err != nil { - log.Printf("⚠ Database unavailable: %v", err) - log.Println(" Running in unmanaged mode (no persistence)") - } else { - // Schema check: init if fresh, upgrade if behind, proceed if current - if err := database.Migrate(); err != nil { - 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 - stores = postgres.NewStores(database.DB) - - // Bootstrap admin from env (K8s secret) — upserts on every restart - handlers.BootstrapAdmin(cfg, stores) - - // Seed additional users from env (dev/test only, skipped in production) - handlers.SeedUsers(cfg, stores) - - // Seed builtin extensions from disk (idempotent, version-aware) - handlers.SeedBuiltinExtensions(stores, "extensions/builtin") - } - defer database.Close() - - // ── File Storage ───────────────────────── - // Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND - // overrides auto-detection. nil objStore = storage features disabled. - if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath); err != nil { - if cfg.StorageBackend != "" { - // Explicit backend requested but failed — fatal - log.Fatalf("❌ Storage init failed: %v", err) - } - log.Printf("⚠ Storage init failed: %v", err) - } else { - objStore = s - } - handlers.SetStorageConfigured(objStore != nil) - - // ── Extraction Queue ──────────────────── - // Filesystem-based queue for document text extraction (PDF, DOCX, etc.) - // Nil if storage is disabled. - var extQueue *extraction.Queue - if objStore != nil && cfg.StoragePath != "" { - q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency) - if err != nil { - log.Printf("⚠ Extraction queue init failed: %v", err) - } else { - extQueue = q - // Recover items stuck in "processing" from previous crash - if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil { - log.Printf("⚠ Extraction recovery failed: %v", err) - } else if recovered > 0 { - log.Printf(" 📋 Recovered %d stale extraction items", recovered) - } - } - } - - // Role resolver for model role dispatch (needs stores + vault) - roleResolver := roles.NewResolver(stores, keyResolver) - - r := gin.Default() - r.Use(middleware.CORS()) - - // ── Base path group ────────────────────── - base := r.Group(cfg.BasePath) - - // ── EventBus + WebSocket Hub ───────────── - bus := events.NewBus() - hub := events.NewHub(bus) - - // Health check (k8s probes hit this directly) - base.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ - "status": "ok", - "version": Version, - "database": database.IsConnected(), - "schema_version": database.SchemaVersion(), - }) - }) - - // WebSocket endpoint - base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket) - - // ── Auth routes (rate limited) ────────────── - auth := handlers.NewAuthHandler(cfg, stores, uekCache) - authLimiter := middleware.NewRateLimiter(1, 5) - - api := base.Group("/api/v1") - { - // Health (routable through ingress) - api.GET("/health", func(c *gin.Context) { - info := gin.H{ - "status": "ok", - "version": Version, - "schema_version": database.SchemaVersion(), - "database": database.IsConnected(), - "providers": providers.List(), - } - if database.IsConnected() { - info["registration_enabled"] = handlers.IsRegistrationEnabled(stores) - } - c.JSON(200, info) - }) - - authGroup := api.Group("/auth") - authGroup.Use(authLimiter.Limit()) - { - authGroup.POST("/register", auth.Register) - authGroup.POST("/login", auth.Login) - authGroup.POST("/refresh", auth.Refresh) - authGroup.POST("/logout", auth.Logout) - } - - // ── Public extension assets ──────────────── - // Script tags can't send Authorization headers, so asset serving must be public. - // Extension JS is the same for all users — no user-specific data. - extH := handlers.NewExtensionHandler(stores) - api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset) - - // ── Protected routes ──────────────────── - protected := api.Group("") - protected.Use(middleware.Auth(cfg)) - { - // Channels - channels := handlers.NewChannelHandler() - protected.GET("/channels", channels.ListChannels) - protected.POST("/channels", channels.CreateChannel) - protected.GET("/channels/:id", channels.GetChannel) - protected.PUT("/channels/:id", channels.UpdateChannel) - protected.DELETE("/channels/:id", channels.DeleteChannel) - - // Messages - msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore) - protected.GET("/channels/:id/messages", msgs.ListMessages) - protected.POST("/channels/:id/messages", msgs.CreateMessage) - - // Message tree (forking) - protected.GET("/channels/:id/path", msgs.GetActivePath) - protected.PUT("/channels/:id/cursor", msgs.UpdateCursor) - protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage) - protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate) - protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) - - // Chat Completions - comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore) - protected.POST("/chat/completions", comp.Complete) - - // Summarize & Continue - summarize := handlers.NewSummarizeHandler(stores, roleResolver) - protected.POST("/channels/:id/summarize", summarize.Summarize) - - // Provider Configs (user-facing — replaces /api-configs) - provCfg := handlers.NewProviderConfigHandler(stores, keyResolver) - protected.GET("/api-configs", provCfg.ListConfigs) // backward compat - protected.POST("/api-configs", provCfg.CreateConfig) - protected.GET("/api-configs/:id", provCfg.GetConfig) - protected.PUT("/api-configs/:id", provCfg.UpdateConfig) - protected.DELETE("/api-configs/:id", provCfg.DeleteConfig) - protected.GET("/api-configs/:id/models", provCfg.ListModels) - protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) - - // Models (unified resolver — replaces scattered endpoints) - modelH := handlers.NewModelHandler(stores) - protected.GET("/models/enabled", modelH.ListEnabledModels) - protected.GET("/models", modelH.ListEnabledModels) // alias - - // Model Preferences - modelPrefs := handlers.NewModelPrefsHandler(stores) - protected.GET("/models/preferences", modelPrefs.GetPreferences) - protected.PUT("/models/preferences", modelPrefs.SetPreference) - protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) - - // User Settings & Profile - settings := handlers.NewSettingsHandler(uekCache) - protected.GET("/profile", settings.GetProfile) - protected.PUT("/profile", settings.UpdateProfile) - protected.POST("/profile/password", settings.ChangePassword) - protected.POST("/profile/avatar", settings.UploadAvatar) - protected.DELETE("/profile/avatar", settings.DeleteAvatar) - protected.GET("/settings", settings.GetSettings) - protected.PUT("/settings", settings.UpdateSettings) - - // Usage (personal) - usage := handlers.NewUsageHandler(stores) - protected.GET("/usage", usage.PersonalUsage) - - // Personas (replaces /presets) - personas := handlers.NewPersonaHandler(stores) - protected.GET("/presets", personas.ListUserPersonas) // backward compat - protected.POST("/presets", personas.CreateUserPersona) - protected.PUT("/presets/:id", personas.UpdateUserPersona) - protected.DELETE("/presets/:id", personas.DeleteUserPersona) - protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar) - protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) - - // Notes - notes := handlers.NewNoteHandler() - protected.GET("/notes", notes.List) - protected.POST("/notes", notes.Create) - protected.GET("/notes/search", notes.Search) - protected.GET("/notes/folders", notes.ListFolders) - protected.POST("/notes/bulk-delete", notes.BulkDelete) - protected.GET("/notes/:id", notes.Get) - protected.PUT("/notes/:id", notes.Update) - protected.DELETE("/notes/:id", notes.Delete) - - // Attachments (file upload/download) - attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue) - protected.POST("/channels/:id/attachments", attachH.Upload) - protected.GET("/channels/:id/attachments", attachH.ListByChannel) - protected.GET("/attachments/:id", attachH.GetMetadata) - protected.GET("/attachments/:id/download", attachH.Download) - protected.DELETE("/attachments/:id", attachH.DeleteAttachment) - - // Hook: clean up storage files when channels are deleted - handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage) - - // Teams (user: my teams) - teams := handlers.NewTeamHandler(keyResolver) - protected.GET("/teams/mine", teams.MyTeams) - - // Team admin self-service - teamScoped := protected.Group("/teams/:teamId") - teamScoped.Use(middleware.RequireTeamAdmin()) - { - teamScoped.GET("/members", teams.ListMembers) - teamScoped.POST("/members", teams.AddMember) - teamScoped.PUT("/members/:memberId", teams.UpdateMember) - teamScoped.DELETE("/members/:memberId", teams.RemoveMember) - teamScoped.GET("/models", teams.ListAvailableModels) - - // Team providers - teamScoped.GET("/providers", teams.ListTeamProviders) - teamScoped.POST("/providers", teams.CreateTeamProvider) - teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider) - teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) - teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels) - - // Team audit log (team admins only — RequireTeamAdmin on group) - teamScoped.GET("/audit", teams.ListTeamAuditLog) - teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) - - // Team usage (team admins only — usage against team-owned providers) - teamUsage := handlers.NewUsageHandler(stores) - teamScoped.GET("/usage", teamUsage.TeamUsage) - - // Team personas - teamPersonas := handlers.NewPersonaHandler(stores) - teamScoped.GET("/presets", teamPersonas.ListTeamPersonas) - teamScoped.POST("/presets", teamPersonas.CreateTeamPersona) - teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona) - - // Team role overrides - teamRoles := handlers.NewRolesHandler(stores, roleResolver) - teamScoped.GET("/roles", teamRoles.ListTeamRoles) - teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole) - teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole) - } - - // Public global settings (non-admin users can read safe subset) - adm := handlers.NewAdminHandler(stores, keyResolver, uekCache) - protected.GET("/settings/public", adm.PublicSettings) - - // Extensions (user-facing) - protected.GET("/extensions", extH.ListUserExtensions) - protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings) - protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest) - protected.GET("/extensions/tools", extH.ListBrowserToolSchemas) - } - - // ── Admin routes ──────────────────────── - admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg)) - admin.Use(middleware.RequireAdmin()) - { - adm := handlers.NewAdminHandler(stores, keyResolver, uekCache) - - // User management - admin.GET("/users", adm.ListUsers) - admin.POST("/users", adm.CreateUser) - admin.PUT("/users/:id/role", adm.UpdateUserRole) - admin.PUT("/users/:id/active", adm.ToggleUserActive) - admin.POST("/users/:id/reset-password", adm.ResetPassword) - admin.DELETE("/users/:id", adm.DeleteUser) - - // Global settings - admin.GET("/settings", adm.ListGlobalSettings) - admin.GET("/settings/:key", adm.GetGlobalSetting) - admin.PUT("/settings/:key", adm.UpdateGlobalSetting) - - // Stats - admin.GET("/stats", adm.GetStats) - - // Global Provider Configs - admin.GET("/configs", adm.ListGlobalConfigs) - admin.POST("/configs", adm.CreateGlobalConfig) - admin.PUT("/configs/:id", adm.UpdateGlobalConfig) - admin.DELETE("/configs/:id", adm.DeleteGlobalConfig) - - // Model Catalog - admin.GET("/models", adm.ListModelConfigs) - admin.POST("/models/fetch", adm.FetchModels) - admin.PUT("/models/bulk", adm.BulkUpdateModels) - admin.PUT("/models/:id", adm.UpdateModelConfig) - admin.DELETE("/models/:id", adm.DeleteModelConfig) - - // Personas (admin global) - personaAdm := handlers.NewPersonaHandler(stores) - admin.GET("/presets", personaAdm.ListAdminPersonas) - admin.POST("/presets", personaAdm.CreateAdminPersona) - admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona) - admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona) - admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar) - admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) - - // Teams (admin) - teamAdm := handlers.NewTeamHandler(keyResolver) - admin.GET("/teams", teamAdm.ListTeams) - admin.POST("/teams", teamAdm.CreateTeam) - admin.GET("/teams/:id", teamAdm.GetTeam) - admin.PUT("/teams/:id", teamAdm.UpdateTeam) - admin.DELETE("/teams/:id", teamAdm.DeleteTeam) - admin.GET("/teams/:id/members", teamAdm.ListMembers) - admin.POST("/teams/:id/members", teamAdm.AddMember) - admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember) - admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember) - - // Audit log - admin.GET("/audit", adm.ListAuditLog) - admin.GET("/audit/actions", adm.ListAuditActions) - - // Model Roles - rolesH := handlers.NewRolesHandler(stores, roleResolver) - admin.GET("/roles", rolesH.ListRoles) - admin.GET("/roles/:role", rolesH.GetRole) - admin.PUT("/roles/:role", rolesH.UpdateRole) - admin.POST("/roles/:role/test", rolesH.TestRole) - - // Usage & Pricing - usageH := handlers.NewUsageHandler(stores) - admin.GET("/usage", usageH.AdminUsage) - admin.GET("/usage/users/:id", usageH.AdminUserUsage) - admin.GET("/usage/teams/:id", usageH.AdminTeamUsage) - admin.GET("/pricing", usageH.ListPricing) - admin.PUT("/pricing", usageH.UpsertPricing) - admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing) - - // Storage status - storageH := handlers.NewStorageHandler(objStore) - admin.GET("/storage/status", storageH.Status) - - // Vault - admin.GET("/vault/status", adm.VaultStatus) - - // Storage management (orphan cleanup) - attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue) - admin.GET("/storage/orphans", attachAdm.OrphanCount) - admin.POST("/storage/cleanup", attachAdm.CleanupOrphans) - admin.GET("/storage/extraction", attachAdm.ExtractionStatus) - - // Extensions (admin) - extAdm := handlers.NewExtensionHandler(stores) - admin.GET("/extensions", extAdm.AdminListExtensions) - admin.POST("/extensions", extAdm.AdminInstallExtension) - admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension) - admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension) - } - } - - bp := cfg.BasePath - if bp == "" { - bp = "/" - } - log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port) - log.Printf(" Base path: %s", bp) - log.Printf(" Schema: %s", database.SchemaVersion()) - log.Printf(" Providers: %v", providers.List()) - if objStore != nil { - log.Printf(" Storage: %s", objStore.Backend()) - if extQueue != nil { - log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency) - } else { - log.Printf(" Extraction: disabled") - } - } else { - log.Printf(" Storage: disabled") - } - log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) - if err := r.Run(":" + cfg.Port); err != nil { - log.Fatalf("Failed to start server: %v", err) - } -} - -// ── Vault CLI Commands ────────────────────── - -func runVaultCommand(subcmd string) { - cfg := config.Load() - - switch strings.ToLower(subcmd) { - case "rekey": - if cfg.DatabaseURL == "" { - log.Fatal("DATABASE_URL is required for vault operations") - } - if err := database.Connect(cfg); err != nil { - log.Fatalf("❌ Database connection failed: %v", err) - } - defer database.Close() - - oldKey := os.Getenv("ENCRYPTION_KEY") - newKey := os.Getenv("NEW_ENCRYPTION_KEY") - - if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil { - log.Fatalf("❌ Vault rekey failed: %v", err) - } - - case "status": - if cfg.DatabaseURL == "" { - log.Fatal("DATABASE_URL is required for vault operations") - } - if err := database.Connect(cfg); err != nil { - log.Fatalf("❌ Database connection failed: %v", err) - } - defer database.Close() - - status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey) - if err != nil { - log.Fatalf("❌ Failed to get vault status: %v", err) - } - fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet) - fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys) - fmt.Printf("Vault users (active): %d\n", status.VaultUsers) - - default: - fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd) - fmt.Fprintf(os.Stderr, "Usage: switchboard vault \n") - os.Exit(1) - } -} diff --git a/rekey.go b/rekey.go deleted file mode 100644 index 9e75aa8..0000000 --- a/rekey.go +++ /dev/null @@ -1,115 +0,0 @@ -package crypto - -import ( - "database/sql" - "fmt" - "log" -) - -// Rekey re-encrypts all global/team API keys from oldKey to newKey. -// Personal BYOK keys are unaffected (they're keyed to the user's UEK, -// not the environment-derived key). -// -// Runs in a single transaction — all-or-nothing. On failure, no keys -// are modified. -// -// Usage: -// -// switchboard vault rekey -// ENCRYPTION_KEY = current/old key -// NEW_ENCRYPTION_KEY = new key to re-encrypt with -func Rekey(db *sql.DB, oldEnvKey, newEnvKey string) error { - if oldEnvKey == "" { - return fmt.Errorf("ENCRYPTION_KEY (current key) is required") - } - if newEnvKey == "" { - return fmt.Errorf("NEW_ENCRYPTION_KEY is required") - } - if oldEnvKey == newEnvKey { - return fmt.Errorf("ENCRYPTION_KEY and NEW_ENCRYPTION_KEY are identical — nothing to do") - } - - oldKey, err := DeriveKeyFromEnv(oldEnvKey) - if err != nil { - return fmt.Errorf("failed to derive old key: %w", err) - } - - newKey, err := DeriveKeyFromEnv(newEnvKey) - if err != nil { - return fmt.Errorf("failed to derive new key: %w", err) - } - - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() // no-op after commit - - // Find all global/team keys that are encrypted - rows, err := tx.Query(` - SELECT id, api_key_enc, key_nonce - FROM provider_configs - WHERE key_scope IN ('global', 'team') - AND api_key_enc IS NOT NULL - `) - if err != nil { - return fmt.Errorf("failed to query encrypted keys: %w", err) - } - - type rekeyRow struct { - id string - ciphertext []byte - nonce []byte - } - - var toRekey []rekeyRow - for rows.Next() { - var r rekeyRow - if err := rows.Scan(&r.id, &r.ciphertext, &r.nonce); err != nil { - rows.Close() - return fmt.Errorf("failed to scan row: %w", err) - } - toRekey = append(toRekey, r) - } - rows.Close() - if err := rows.Err(); err != nil { - return fmt.Errorf("row iteration error: %w", err) - } - - if len(toRekey) == 0 { - log.Println("No global/team encrypted keys found — nothing to rekey.") - return nil - } - - log.Printf("Rekeying %d provider config(s)...", len(toRekey)) - - // Decrypt with old key, re-encrypt with new key, update in place - for _, r := range toRekey { - plaintext, err := Decrypt(r.ciphertext, r.nonce, oldKey) - if err != nil { - return fmt.Errorf("failed to decrypt config %s with old key: %w (is ENCRYPTION_KEY correct?)", r.id, err) - } - - newCiphertext, newNonce, err := Encrypt(plaintext, newKey) - if err != nil { - return fmt.Errorf("failed to re-encrypt config %s: %w", r.id, err) - } - - _, err = tx.Exec(` - UPDATE provider_configs - SET api_key_enc = $1, key_nonce = $2 - WHERE id = $3 - `, newCiphertext, newNonce, r.id) - if err != nil { - return fmt.Errorf("failed to update config %s: %w", r.id, err) - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit rekey transaction: %w", err) - } - - log.Printf("✅ Successfully rekeyed %d provider config(s).", len(toRekey)) - log.Println("Update ENCRYPTION_KEY to the new value and restart the server.") - return nil -} diff --git a/server/database/testhelper.go b/server/database/testhelper.go index 1f948ed..547296e 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -176,6 +176,8 @@ func TruncateAll(t *testing.T) { "teams", "refresh_tokens", "users", + "platform_policies", + "global_config", } for _, table := range tables { DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)) diff --git a/src/css/styles.css b/src/css/styles.css index 8a34859..5185826 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -6,6 +6,7 @@ ========================================== */ :root { + /* ── Core palette ────────────────────────── */ --bg: #0e0e10; --bg-surface: #18181b; --bg-raised: #222227; @@ -23,6 +24,27 @@ --warning: #eab308; --purple: #a78bfa; --purple-dim: rgba(167,139,250,0.10); + + /* ── Contrast text for solid-color backgrounds ── */ + --text-on-color: #fff; + --text-on-warning: #000; + + /* ── Light variants (badge/pill text on dim backgrounds) ── */ + --accent-light: #93c5fd; + --danger-light: #f87171; + --success-light: #4ade80; + --warning-light: #fbbf24; + + /* ── Dim variants (badge/pill backgrounds) ── */ + --danger-dim: rgba(239,68,68,0.2); + --success-dim: rgba(34,197,94,0.2); + --warning-dim: rgba(234,179,8,0.2); + + /* ── Surfaces ────────────────────────────── */ + --overlay: rgba(0,0,0,0.6); + --glass: rgba(255,255,255,0.03); + + /* ── Layout ──────────────────────────────── */ --radius: 8px; --radius-lg: 12px; --sidebar-w: 260px; @@ -406,7 +428,7 @@ a:hover { text-decoration: underline; } .messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; } .message { padding: 1.25rem 0; } -.message:not(:last-child) { border-bottom: 1px solid rgba(255,255,255,0.03); } +.message:not(:last-child) { border-bottom: 1px solid var(--glass); } .msg-inner { max-width: 768px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 1rem; } @@ -506,7 +528,7 @@ a:hover { text-decoration: underline; } .msg-edit-cancel:hover { background: var(--bg-hover); color: var(--text); } .msg-edit-submit { background: var(--accent); - color: #fff; + color: var(--text-on-color); border-color: var(--accent); } .msg-edit-submit:hover { background: var(--accent-hover); } @@ -701,7 +723,7 @@ a:hover { text-decoration: underline; } /* Thinking blocks */ .thinking-block { - border: 1px solid rgba(108,159,255,0.12); border-radius: var(--radius); + border: 1px solid var(--accent-dim); border-radius: var(--radius); margin: 0.5rem 0; background: rgba(108,159,255,0.03); } .thinking-block summary { @@ -831,15 +853,15 @@ a:hover { text-decoration: underline; } .context-warning-icon { flex-shrink: 0; } .context-warning-text { flex: 1; } .context-warning-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 0 2px; font-size: 0.85rem; } -.context-warning-dismiss:hover { color: var(--text-1); } +.context-warning-dismiss:hover { color: var(--text); } .context-warning-action { flex-shrink: 0; padding: 3px 10px; border-radius: 6px; border: 1px solid var(--border); - background: var(--bg-surface); color: var(--text-1); cursor: pointer; + background: var(--bg-surface); color: var(--text); cursor: pointer; font-size: 0.75rem; white-space: nowrap; transition: all var(--transition); } -.context-warning-action:hover { background: var(--accent); color: #fff; border-color: var(--accent); } +.context-warning-action:hover { background: var(--accent); color: var(--text-on-color); border-color: var(--accent); } .context-warning-action:disabled { opacity: 0.5; cursor: not-allowed; } -.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text-1); border-color: var(--border); } +.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text); border-color: var(--border); } /* Summary message node */ .message-summary { border: 1px dashed color-mix(in srgb, var(--accent) 40%, transparent); @@ -855,7 +877,7 @@ a:hover { text-decoration: underline; } background: none; border: none; color: var(--accent); cursor: pointer; font-size: 0.75rem; padding: 0; text-decoration: underline; } -.message-summary .summary-toggle:hover { color: var(--text-1); } +.message-summary .summary-toggle:hover { color: var(--text); } .msg-dimmed { opacity: 0.5; } .message-summary-divider { text-align: center; padding: 8px 0; margin: 4px 0; @@ -918,7 +940,7 @@ a:hover { text-decoration: underline; } font-size: 12px; max-width: 220px; position: relative; } .att-chip.att-ready { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); } -.att-chip.att-error { border-color: color-mix(in srgb, var(--error) 40%, var(--border)); } +.att-chip.att-error { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); } .att-chip.att-pending { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); } .att-thumb { @@ -1005,7 +1027,7 @@ a:hover { text-decoration: underline; } } .lightbox-close { position: absolute; top: 16px; right: 20px; - background: rgba(255,255,255,0.15); border: none; color: #fff; + background: rgba(255,255,255,0.15); border: none; color: var(--text-on-color); font-size: 22px; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.15s; @@ -1032,7 +1054,7 @@ a:hover { text-decoration: underline; } button { font-family: var(--font); cursor: pointer; } .btn-primary { - background: var(--accent); color: #fff; border: none; + background: var(--accent); color: var(--text-on-color); border: none; padding: 8px 16px; border-radius: var(--radius); font-weight: 500; font-size: 13px; transition: background var(--transition); @@ -1046,8 +1068,8 @@ button { font-family: var(--font); cursor: pointer; } font-size: 12px; transition: all var(--transition); } .btn-small:hover { background: var(--bg-hover); color: var(--text); } -.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } -.btn-danger { background: var(--danger); color: #fff; border: none; border-radius: var(--radius); } +.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-color); } +.btn-danger { background: var(--danger); color: var(--text-on-color); border: none; border-radius: var(--radius); } /* ── Auth Splash ─────────────────────────── */ @@ -1147,7 +1169,7 @@ button { font-family: var(--font); cursor: pointer; } .auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; } .splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; line-height: 1.5; } .splash-error strong { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; } -.splash-error-hint { color: var(--text-muted); font-size: 0.72rem; } +.splash-error-hint { color: var(--text-3); font-size: 0.72rem; } .splash-error-hint a { color: var(--accent); text-decoration: underline; cursor: pointer; } .auth-actions { margin-top: 1.5rem; } .auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; } @@ -1200,14 +1222,26 @@ button { font-family: var(--font); cursor: pointer; } /* ── Forms ────────────────────────────────── */ -.form-group { margin-bottom: 0.75rem; } -.form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; } -.form-group input, .form-group select, .form-group textarea { - width: 100%; background: var(--bg-raised); border: 1px solid var(--border); +/* Base form element styling — ensures selects/inputs match the dark theme + even when not wrapped in .form-group (e.g. role config, inline toolbars) */ +select, input[type="text"], input[type="email"], input[type="password"], +input[type="number"], input[type="search"], textarea { + background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 8px 12px; border-radius: var(--radius); font-family: var(--font); font-size: 13px; transition: border-color var(--transition); } +select:focus, input[type="text"]:focus, input[type="email"]:focus, +input[type="password"]:focus, textarea:focus { + outline: none; border-color: var(--accent); +} +select option { background: var(--bg-surface); color: var(--text); } + +.form-group { margin-bottom: 0.75rem; } +.form-group label { display: block; font-size: 13px; color: var(--text-2); margin-bottom: 4px; font-weight: 500; } +.form-group input, .form-group select, .form-group textarea { + width: 100%; +} .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--accent); } @@ -1233,7 +1267,7 @@ button { font-family: var(--font); cursor: pointer; } /* ── Modal ────────────────────────────────── */ .modal-overlay { - position: fixed; inset: 0; background: rgba(0,0,0,0.6); + position: fixed; inset: 0; background: var(--overlay); display: none; align-items: center; justify-content: center; z-index: 1000; backdrop-filter: blur(2px); padding: 2rem; /* keeps modal away from viewport edges at any zoom */ @@ -1324,7 +1358,7 @@ button { font-family: var(--font); cursor: pointer; } .settings-tabs .settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .settings-notice { - background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.2); + background: rgba(234,179,8,0.08); border: 1px solid var(--warning-dim); border-radius: var(--radius); padding: 10px 14px; font-size: 13px; color: var(--warning); display: flex; align-items: center; gap: 8px; margin-top: 1rem; @@ -1340,17 +1374,66 @@ button { font-family: var(--font); cursor: pointer; } .provider-actions { display: flex; align-items: center; gap: 8px; } .badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; } -/* Admin tabs */ -.admin-tabs { } +/* Admin panel (fullscreen) */ +.admin-panel { + position: fixed; z-index: 1000; + top: var(--banner-top-height); right: 0; + bottom: var(--banner-bottom-height); left: 0; + background: var(--bg); display: flex; flex-direction: column; +} +.admin-topbar { + display: flex; align-items: center; padding: 0 1rem; height: 48px; + border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0; +} +.admin-back { + background: none; border: none; color: var(--text-3); cursor: pointer; + font-size: 14px; padding: 4px 10px; border-radius: 4px; font-family: var(--font); +} +.admin-back:hover { color: var(--accent); background: var(--bg-raised); } +.admin-title { font-size: 16px; font-weight: 600; margin-left: 1rem; flex: 1; } +.admin-user { font-size: 12px; color: var(--text-3); } + +.admin-categories { + display: flex; gap: 0; border-bottom: 1px solid var(--border); + background: var(--bg-surface); padding: 0 1rem; flex-shrink: 0; +} +.admin-cat { + background: none; border: none; border-bottom: 2px solid transparent; + color: var(--text-3); padding: 10px 20px; cursor: pointer; + font-size: 14px; font-weight: 500; font-family: var(--font); + transition: color 0.15s, border-color 0.15s; outline: none; +} +.admin-cat:hover { color: var(--text-2); } +.admin-cat.active { color: var(--accent); border-bottom-color: var(--accent); } + +.admin-body { display: flex; flex: 1; overflow: hidden; } + +.admin-sidebar { + width: 160px; flex-shrink: 0; border-right: 1px solid var(--border); + background: var(--bg-surface); padding: 0.75rem 0; overflow-y: auto; +} +.admin-sidebar button { + display: block; width: 100%; text-align: left; background: none; border: none; + padding: 8px 20px; color: var(--text-3); cursor: pointer; + font-size: 13px; font-family: var(--font); border-left: 3px solid transparent; + transition: color 0.1s, background 0.1s; +} +.admin-sidebar button:hover { color: var(--text-2); background: var(--bg-raised); } +.admin-sidebar button.active { color: var(--accent); border-left-color: var(--accent); background: var(--bg-raised); } + +.admin-main { flex: 1; overflow-y: auto; padding: 1.5rem 2rem; } +.admin-section-content { /* replaces .admin-tab-content */ } + +/* Admin shared */ +/* admin-tab: still used by team admin modal */ .admin-tab { padding: 10px 14px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; transition: color var(--transition); - outline: none; + outline: none; font-family: var(--font); } .admin-tab:hover { color: var(--text-2); } -.admin-tab:focus { color: var(--text-3); } -.admin-tab.active, .admin-tab.active:focus { color: var(--accent); border-bottom-color: var(--accent); } +.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; } .admin-hint { font-size: 12px; color: var(--text-3); } @@ -1387,7 +1470,7 @@ button { font-family: var(--font); cursor: pointer; } .admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; } .admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); } .admin-model-toggle.enabled { border-color: var(--success); color: var(--success); } -.admin-model-toggle.team { border-color: #60a5fa; color: #60a5fa; } +.admin-model-toggle.team { border-color: var(--accent); color: var(--accent); } .model-list-item.model-hidden { opacity: 0.5; } .model-list-item.model-hidden .model-name { text-decoration: line-through; } @@ -1411,7 +1494,7 @@ button { font-family: var(--font); cursor: pointer; } .admin-preset-row .preset-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; } .admin-preset-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; } .admin-preset-row .btn-delete:hover { color: var(--danger); } -.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-bg); color: var(--accent); } +.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-dim); color: var(--accent); } /* Stats cards */ .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; } @@ -1464,15 +1547,15 @@ button { font-family: var(--font); cursor: pointer; } .model-list-item .model-provider { font-size: 12px; color: var(--text-3); } .model-list-item .model-caps-inline { display: flex; gap: 3px; } -.badge-admin { background: var(--accent); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-admin { background: var(--accent); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; } .badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-pending { background: rgba(234,179,8,0.85); color: #000; font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-team { background: rgba(59,130,246,0.2); color: #93c5fd; font-size: 10px; padding: 1px 8px; border-radius: 4px; margin-left: 2px; } -.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: rgba(255,255,255,0.03); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); } +.badge-inactive { background: var(--danger); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-pending { background: var(--warning); color: var(--text-on-warning); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-team { background: var(--accent-dim); color: var(--accent-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; margin-left: 2px; } +.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: var(--glass); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); } .admin-approve-form .checkbox-label { display: block; margin: 2px 0; } -.btn-approve { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; } -.team-card { padding: 8px 10px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 6px; } +.btn-approve { background: var(--success-dim); color: var(--success-light); border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; } +.team-card { padding: 8px 10px; background: var(--glass); border-radius: 6px; margin-bottom: 6px; } .team-card-info { display: flex; align-items: center; gap: 8px; } .team-admin-back { cursor: pointer; margin-right: 4px; opacity: 0.5; @@ -1480,10 +1563,10 @@ button { font-family: var(--font); cursor: pointer; } } .team-admin-back:hover { opacity: 1; } .team-tab-content { padding: 0; } -.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-success { background: rgba(34,197,94,0.2); color: #4ade80; font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-warning { background: rgba(234,179,8,0.2); color: #fbbf24; font-size: 10px; padding: 1px 8px; border-radius: 4px; } -.badge-danger { background: rgba(239,68,68,0.2); color: #f87171; font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-private { background: var(--purple); color: var(--text-on-color); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-success { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-warning { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; } +.badge-danger { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; } .admin-user-row.user-inactive { opacity: 0.7; } .loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; } .error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; } @@ -1687,8 +1770,8 @@ button { font-family: var(--font); cursor: pointer; } .notes-editor input, .notes-editor textarea { width: 100%; background: var(--bg-surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); - padding: 6px 10px; font-family: var(--font); outline: none; - transition: border-color var(--transition); + padding: 6px 10px; font-family: var(--font); font-size: var(--msg-font, 14px); + outline: none; transition: border-color var(--transition); } .notes-editor input:focus, .notes-editor textarea:focus { border-color: var(--accent); @@ -1730,7 +1813,7 @@ button { font-family: var(--font); cursor: pointer; } /* ── Command Palette ─────────────────────── */ .cmd-palette-overlay { - position: fixed; inset: 0; background: rgba(0,0,0,0.5); + position: fixed; inset: 0; background: var(--overlay); display: none; align-items: flex-start; justify-content: center; z-index: 2000; backdrop-filter: blur(4px); padding-top: min(20vh, 160px); @@ -1817,7 +1900,7 @@ button { font-family: var(--font); cursor: pointer; } .confirm-overlay { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; - background: rgba(0,0,0,0.55); + background: var(--overlay); animation: fade-in 0.12s ease; } .confirm-dialog { @@ -1876,7 +1959,7 @@ button { font-family: var(--font); cursor: pointer; } .mobile-menu-btn:hover { color: var(--text); } .sidebar-overlay { display: none; position: fixed; inset: 0; z-index: 99; - background: rgba(0,0,0,0.5); + background: var(--overlay); } @media (max-width: 768px) { @@ -1891,4 +1974,9 @@ button { font-family: var(--font); cursor: pointer; } .modal { width: 100%; } .tab-arrow { width: 36px; font-size: 20px; } /* larger touch target */ .modal-tabs { padding: 0 12px; } + .admin-sidebar { width: 48px; padding: 0.5rem 0; } + .admin-sidebar button { padding: 8px 0; text-align: center; font-size: 11px; border-left: none; border-bottom: 2px solid transparent; } + .admin-sidebar button.active { border-bottom-color: var(--accent); border-left: none; } + .admin-main { padding: 1rem; } + .admin-cat { padding: 10px 12px; font-size: 13px; } } diff --git a/src/index.html b/src/index.html index 0393f82..e23c471 100644 --- a/src/index.html +++ b/src/index.html @@ -425,7 +425,7 @@ -

Toggle visibility in your model selector. Hidden models are still accessible through presets.

+

Toggle visibility in your model selector. Hidden models are still accessible through personas.

Loading models...
@@ -433,7 +433,7 @@ -