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 - - - -
Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.
-Sign in to continue to your workspace
-