package handlers import ( "database/sql" "encoding/json" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ExtensionHandler serves extension management endpoints. type ExtensionHandler struct { stores store.Stores } func NewExtensionHandler(stores store.Stores) *ExtensionHandler { return &ExtensionHandler{stores: stores} } // ── User endpoints ────────────────────────────── // ListUserExtensions returns all enabled extensions for the current user, // with user-specific settings and enabled state merged in. // GET /api/v1/extensions func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) { userID := c.GetString("user_id") tier := c.Query("tier") // optional filter exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID) if err != nil { c.JSON(500, gin.H{"error": "failed to list extensions"}) return } if tier != "" { filtered := make([]models.UserExtension, 0) for _, e := range exts { if e.Tier == tier { filtered = append(filtered, e) } } exts = filtered } c.JSON(200, gin.H{"data": exts}) } // UpdateUserExtensionSettings saves per-user settings for an extension. // POST /api/v1/extensions/:id/settings func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) { userID := c.GetString("user_id") extID := c.Param("id") // Verify extension exists ext, err := h.stores.Extensions.GetByID(c.Request.Context(), extID) if err == sql.ErrNoRows { c.JSON(404, gin.H{"error": "extension not found"}) return } if err != nil { c.JSON(500, gin.H{"error": "failed to fetch extension"}) return } // System extensions can't be disabled by users var body struct { Settings json.RawMessage `json:"settings"` IsEnabled *bool `json:"is_enabled"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "invalid request body"}) return } enabled := true if body.IsEnabled != nil { if ext.IsSystem && !*body.IsEnabled { c.JSON(403, gin.H{"error": "system extensions cannot be disabled"}) return } enabled = *body.IsEnabled } settings := json.RawMessage("{}") if body.Settings != nil { settings = body.Settings } eus := &models.ExtensionUserSettings{ ExtensionID: extID, UserID: userID, Settings: settings, IsEnabled: enabled, } if err := h.stores.Extensions.SetUserSettings(c.Request.Context(), eus); err != nil { c.JSON(500, gin.H{"error": "failed to save settings"}) return } c.JSON(200, gin.H{"ok": true}) } // ── Admin endpoints ───────────────────────────── // AdminListExtensions returns all extensions (enabled and disabled). // GET /api/v1/admin/extensions func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) { exts, err := h.stores.Extensions.ListAll(c.Request.Context()) if err != nil { c.JSON(500, gin.H{"error": "failed to list extensions"}) return } c.JSON(200, gin.H{"data": exts}) } // AdminInstallExtension installs an extension from a manifest. // POST /api/v1/admin/extensions func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) { userID := c.GetString("user_id") var body struct { ExtID string `json:"ext_id" binding:"required"` Name string `json:"name" binding:"required"` Version string `json:"version"` Tier string `json:"tier"` Description string `json:"description"` Author string `json:"author"` Manifest json.RawMessage `json:"manifest"` IsSystem bool `json:"is_system"` IsEnabled bool `json:"is_enabled"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "invalid request: " + err.Error()}) return } // Defaults if body.Version == "" { body.Version = "0.0.0" } if body.Tier == "" { body.Tier = models.ExtTierBrowser } if body.Manifest == nil { body.Manifest = json.RawMessage("{}") } // Check for duplicate ext_id existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID) if err != nil && err != sql.ErrNoRows { c.JSON(500, gin.H{"error": "failed to check existing extension"}) return } if existing != nil { c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"}) return } ext := &models.Extension{ ExtID: body.ExtID, Name: body.Name, Version: body.Version, Tier: body.Tier, Description: body.Description, Author: body.Author, Manifest: body.Manifest, IsSystem: body.IsSystem, IsEnabled: body.IsEnabled, Scope: models.ScopeGlobal, InstalledBy: &userID, } if err := h.stores.Extensions.Create(c.Request.Context(), ext); err != nil { c.JSON(500, gin.H{"error": "failed to install extension"}) return } c.JSON(201, gin.H{"data": ext}) } // AdminUpdateExtension updates an extension's config. // PUT /api/v1/admin/extensions/:id func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) { id := c.Param("id") ext, err := h.stores.Extensions.GetByID(c.Request.Context(), id) if err == sql.ErrNoRows { c.JSON(404, gin.H{"error": "extension not found"}) return } if err != nil { c.JSON(500, gin.H{"error": "failed to fetch extension"}) return } var body struct { Name *string `json:"name"` Description *string `json:"description"` IsSystem *bool `json:"is_system"` IsEnabled *bool `json:"is_enabled"` Manifest *json.RawMessage `json:"manifest"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "invalid request body"}) return } if body.Name != nil { ext.Name = *body.Name } if body.Description != nil { ext.Description = *body.Description } if body.IsSystem != nil { ext.IsSystem = *body.IsSystem } if body.IsEnabled != nil { ext.IsEnabled = *body.IsEnabled } if body.Manifest != nil { ext.Manifest = *body.Manifest } if err := h.stores.Extensions.Update(c.Request.Context(), id, ext); err != nil { c.JSON(500, gin.H{"error": "failed to update extension"}) return } c.JSON(200, gin.H{"data": ext}) } // ServeExtensionAsset serves browser extension JS files. // GET /api/v1/extensions/:id/assets/*path // // For the MVP, scripts are stored inline in manifest._script. // The :id param is the ext_id (e.g. "mermaid-renderer"), not the UUID. func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) { extID := c.Param("id") // path := c.Param("path") // reserved for future multi-file support ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID) if err != nil { c.JSON(404, gin.H{"error": "extension not found"}) return } if !ext.IsEnabled { c.JSON(404, gin.H{"error": "extension not enabled"}) return } // Extract inline script from manifest var manifest map[string]json.RawMessage if err := json.Unmarshal(ext.Manifest, &manifest); err != nil { c.JSON(500, gin.H{"error": "invalid manifest"}) return } scriptRaw, ok := manifest["_script"] if !ok { c.JSON(404, gin.H{"error": "no script in manifest"}) return } // _script is a JSON string — unquote it var script string if err := json.Unmarshal(scriptRaw, &script); err != nil { c.JSON(500, gin.H{"error": "invalid script in manifest"}) return } c.Header("Content-Type", "application/javascript; charset=utf-8") c.Header("Cache-Control", "public, max-age=3600") c.String(200, script) } // GetExtensionManifest returns the manifest for a specific extension. // GET /api/v1/extensions/:id/manifest func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) { extID := c.Param("id") ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID) if err != nil { c.JSON(404, gin.H{"error": "extension not found"}) return } c.JSON(200, gin.H{"data": ext.Manifest}) } // ListBrowserToolSchemas returns tool schemas from all enabled browser extensions. // Used by the completion handler to include browser tools in LLM requests. // GET /api/v1/extensions/tools func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) { userID := c.GetString("user_id") exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID) if err != nil { c.JSON(500, gin.H{"error": "failed to list extensions"}) return } var toolSchemas []json.RawMessage for _, ext := range exts { if ext.Tier != "browser" { continue } var manifest struct { Tools []json.RawMessage `json:"tools"` } if err := json.Unmarshal(ext.Manifest, &manifest); err != nil { continue } toolSchemas = append(toolSchemas, manifest.Tools...) } if toolSchemas == nil { toolSchemas = make([]json.RawMessage, 0) } c.JSON(200, gin.H{"data": toolSchemas}) } // AdminUninstallExtension removes an extension. // DELETE /api/v1/admin/extensions/:id func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) { id := c.Param("id") _, err := h.stores.Extensions.GetByID(c.Request.Context(), id) if err == sql.ErrNoRows { c.JSON(404, gin.H{"error": "extension not found"}) return } if err != nil { c.JSON(500, gin.H{"error": "failed to fetch extension"}) return } if err := h.stores.Extensions.Delete(c.Request.Context(), id); err != nil { c.JSON(500, gin.H{"error": "failed to uninstall extension"}) return } c.JSON(200, gin.H{"ok": true}) }