All checks were successful
CI/CD / detect-changes (pull_request) Successful in 5s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m42s
CI/CD / test-sqlite (pull_request) Successful in 2m58s
CI/CD / build-and-deploy (pull_request) Successful in 1m33s
Manifest declarations for slots (host surfaces declare injection points) and contributes (extensions declare UI contributions). lib.require() relaxed from library-only to any package with exports. Admin slots aggregation endpoint. SDK renderAll() helper. 7 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
480 lines
13 KiB
Go
480 lines
13 KiB
Go
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
|
|
}
|