package handlers import ( "database/sql" "encoding/json" "log" "github.com/gin-gonic/gin" "strings" "armature/database" "armature/models" "armature/store" "armature/triggers" ) // ExtensionHandler serves extension management endpoints. type ExtensionHandler struct { stores store.Stores capabilities map[string]bool } func NewExtensionHandler(stores store.Stores) *ExtensionHandler { return &ExtensionHandler{stores: stores} } // SetCapabilities stores the detected environment capabilities map. func (h *ExtensionHandler) SetCapabilities(caps map[string]bool) { h.capabilities = caps } // validTiers is the set of accepted extension tier values. var validTiers = map[string]bool{ models.ExtTierBrowser: true, models.ExtTierStarlark: true, models.ExtTierSidecar: true, } // ValidateComposabilityFields checks that manifest slots and contributes // fields follow the expected conventions. Slots values must have a // "description" string. Contributes keys must follow "pkg:slot" naming. // Returns nil if valid or missing; returns an error string otherwise. func ValidateComposabilityFields(manifest map[string]any) string { // Validate slots: map of name → {description, ...} if slotsRaw, ok := manifest["slots"]; ok { slots, ok := slotsRaw.(map[string]any) if !ok { return "slots must be an object" } for name, v := range slots { entry, ok := v.(map[string]any) if !ok { return "slots." + name + " must be an object" } if _, ok := entry["description"]; !ok { return "slots." + name + " must have a description" } } } // Validate contributes: map of "pkg:slot" → {label, ...} if contribRaw, ok := manifest["contributes"]; ok { contribs, ok := contribRaw.(map[string]any) if !ok { return "contributes must be an object" } for key := range contribs { if !strings.Contains(key, ":") { return "contributes key " + key + " must follow pkg:slot convention" } } } return "" } // ── 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") pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID) if err != nil { c.JSON(500, gin.H{"error": "failed to list extensions"}) return } if tier != "" { filtered := make([]store.UserPackage, 0) for _, p := range pkgs { if p.Tier == tier { filtered = append(filtered, p) } } pkgs = filtered } c.JSON(200, gin.H{"data": pkgs}) } // 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") pkgID := c.Param("id") // Verify package exists pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) if err != nil || pkg == nil { c.JSON(404, gin.H{"error": "extension not found"}) return } // System packages 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 pkg.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 { schema := store.ParseSettingsSchema(pkg.Manifest) if len(schema) > 0 { var incoming map[string]any if json.Unmarshal(body.Settings, &incoming) == nil && len(incoming) > 0 { filtered, rejected := store.FilterOverridableKeys(incoming, schema) if len(filtered) == 0 && len(rejected) > 0 { c.JSON(400, gin.H{"error": "these settings cannot be overridden: " + strings.Join(rejected, ", ")}) return } if b, err := json.Marshal(filtered); err == nil { settings = json.RawMessage(b) } else { settings = body.Settings } } else { settings = body.Settings } } else { settings = body.Settings } } pus := &store.PackageUserSettings{ PackageID: pkgID, UserID: userID, Settings: settings, IsEnabled: enabled, } if err := h.stores.Packages.SetUserSettings(c.Request.Context(), pus); 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 extension-type packages. // GET /api/v1/admin/extensions func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) { pkgs, err := h.stores.Packages.ListByType(c.Request.Context(), "extension") if err != nil { c.JSON(500, gin.H{"error": "failed to list extensions"}) return } // Also include 'full' type packages fullPkgs, err := h.stores.Packages.ListByType(c.Request.Context(), "full") if err == nil { pkgs = append(pkgs, fullPkgs...) } if pkgs == nil { pkgs = []store.PackageRegistration{} } c.JSON(200, gin.H{"data": pkgs}) } // AdminInstallExtension installs an extension from a JSON body (legacy path). // POST /api/v1/admin/extensions // New installs should use POST /admin/packages/install with a .pkg archive. 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("{}") } // Validate tier if !validTiers[body.Tier] { c.JSON(400, gin.H{"error": "invalid tier: must be browser, starlark, or sidecar"}) return } // Check for duplicate (ext_id is now the package ID) existing, _ := h.stores.Packages.Get(c.Request.Context(), body.ExtID) if existing != nil { c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"}) return } // Parse manifest into map var manifestMap map[string]any if err := json.Unmarshal(body.Manifest, &manifestMap); err != nil { manifestMap = map[string]any{} } // Validate composability fields (slots / contributes) if errMsg := ValidateComposabilityFields(manifestMap); errMsg != "" { c.JSON(400, gin.H{"error": "manifest: " + errMsg}) return } pkg := &store.PackageRegistration{ ID: body.ExtID, Title: body.Name, Type: "extension", Version: body.Version, Description: body.Description, Author: body.Author, Tier: body.Tier, IsSystem: body.IsSystem, Scope: "global", Manifest: manifestMap, Enabled: body.IsEnabled, Source: "extension", InstalledBy: &userID, } if err := h.stores.Packages.Create(c.Request.Context(), pkg); err != nil { if database.IsUniqueViolation(err) { c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"}) return } c.JSON(500, gin.H{"error": "failed to install extension"}) return } // If permissions are declared, package moves to pending_review. SyncManifestPermissions(c, h.stores, pkg.ID, manifestMap) if tables, ok := ParseDBTables(manifestMap); ok { if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkg.ID, tables, h.capabilities["pgvector"]); err != nil { log.Printf("[ext-db] schema create failed for %s: %v", pkg.ID, err) } } SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkg.ID, manifestMap) c.JSON(201, gin.H{"data": pkg}) } // AdminUpdateExtension updates an extension-type package. // PUT /api/v1/admin/extensions/:id func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) { id := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), id) if err != nil || pkg == nil { c.JSON(404, gin.H{"error": "extension not found"}) return } var body struct { Name *string `json:"name"` Version *string `json:"version"` Description *string `json:"description"` Author *string `json:"author"` 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 { pkg.Title = *body.Name } if body.Version != nil { pkg.Version = *body.Version } if body.Description != nil { pkg.Description = *body.Description } if body.Author != nil { pkg.Author = *body.Author } if body.IsSystem != nil { pkg.IsSystem = *body.IsSystem } if body.IsEnabled != nil { pkg.Enabled = *body.IsEnabled } if body.Manifest != nil { var manifestMap map[string]any if err := json.Unmarshal(*body.Manifest, &manifestMap); err == nil { pkg.Manifest = manifestMap } } if err := h.stores.Packages.Update(c.Request.Context(), id, pkg); err != nil { c.JSON(500, gin.H{"error": "failed to update extension"}) return } c.JSON(200, gin.H{"data": pkg}) } // ServeExtensionAsset serves browser extension JS files. // GET /api/v1/extensions/:id/assets/*path func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) { pkgID := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) if err != nil || pkg == nil { c.JSON(404, gin.H{"error": "extension not found"}) return } if !pkg.Enabled { c.JSON(404, gin.H{"error": "extension not enabled"}) return } // Extract inline script from manifest manifestBytes := marshalManifest(pkg.Manifest) var manifest map[string]json.RawMessage if err := json.Unmarshal(manifestBytes, &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 } 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) { pkgID := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) if err != nil || pkg == nil { c.JSON(404, gin.H{"error": "extension not found"}) return } c.JSON(200, gin.H{"data": pkg.Manifest}) } // ListBrowserToolSchemas returns tool schemas from all enabled browser extensions. // GET /api/v1/extensions/tools func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) { userID := c.GetString("user_id") pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID) if err != nil { c.JSON(500, gin.H{"error": "failed to list extensions"}) return } var toolSchemas []json.RawMessage for _, pkg := range pkgs { if pkg.Tier != "browser" { continue } manifestBytes := marshalManifest(pkg.Manifest) var manifest struct { Tools []json.RawMessage `json:"tools"` } if err := json.Unmarshal(manifestBytes, &manifest); err != nil { log.Printf("[extensions] failed to parse manifest for %s: %v", pkg.ID, err) 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-type package. // DELETE /api/v1/admin/extensions/:id func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) { id := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), id) if err != nil || pkg == nil { c.JSON(404, gin.H{"error": "extension not found"}) return } if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil { log.Printf("[ext-db] schema drop failed for %s: %v", id, err) } if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil { if err == sql.ErrNoRows { c.JSON(400, gin.H{"error": "core packages cannot be deleted"}) return } c.JSON(500, gin.H{"error": "failed to uninstall extension"}) return } c.JSON(200, gin.H{"ok": true}) } // marshalManifest converts map[string]any → []byte for struct unmarshaling. // PackageRegistration.Manifest is map[string]any (from JSONB scan), but // several callsites need to unmarshal into typed structs. func marshalManifest(m map[string]any) []byte { if m == nil { return []byte("{}") } b, err := json.Marshal(m) if err != nil { return []byte("{}") } return b }