package handlers import ( "archive/zip" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "regexp" "strings" "time" "github.com/gin-gonic/gin" "go.starlark.net/starlark" "armature/auth" "armature/database" "armature/events" "armature/models" "armature/sandbox" "armature/store" "armature/triggers" ) // validPackageID matches lowercase alphanumeric slugs with optional hyphens. // No leading/trailing hyphens, 2-64 chars. var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`) // PackageHandler manages unified package lifecycle (admin-only). // Replaces SurfaceHandler. type PackageHandler struct { stores store.Stores packagesDir string // e.g. /data/packages — where archives are extracted bundledDir string // e.g. /app/bundled-packages — .pkg archive source sandbox *sandbox.Sandbox runner *sandbox.Runner hub *events.Hub capabilities map[string]bool // detected environment capabilities } func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler { dir := "" if len(packagesDir) > 0 { dir = packagesDir[0] } return &PackageHandler{stores: s, packagesDir: dir} } // SetBundledDir sets the path to the bundled packages directory. // Used by dependency auto-activation to resolve missing dependencies. func (h *PackageHandler) SetBundledDir(dir string) { h.bundledDir = dir } // SetSandbox attaches a Starlark sandbox for schema migrations. func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) { h.sandbox = sb } // SetRunner attaches the Starlark runner for test-tool execution. func (h *PackageHandler) SetRunner(r *sandbox.Runner) { h.runner = r } // SetHub attaches the event hub for broadcasting package lifecycle events. func (h *PackageHandler) SetHub(hub *events.Hub) { h.hub = hub } // SetCapabilities stores the detected environment capabilities map. // Used at install time to validate manifest capabilities.required. func (h *PackageHandler) SetCapabilities(caps map[string]bool) { h.capabilities = caps } // broadcastPackageChanged emits a package.changed event to all clients. func (h *PackageHandler) broadcastPackageChanged(action, id string) { if h.hub == nil { return } h.hub.Broadcast(events.Event{ Label: "package.changed", Payload: events.MustJSON(map[string]string{"action": action, "id": id}), Ts: time.Now().UnixMilli(), }) } // ListPackages returns all registered packages. // GET /api/v1/admin/packages func (h *PackageHandler) ListPackages(c *gin.Context) { typeFilter := c.Query("type") var pkgs []store.PackageRegistration var err error if typeFilter != "" { pkgs, err = h.stores.Packages.ListByType(c.Request.Context(), typeFilter) } else { pkgs, err = h.stores.Packages.List(c.Request.Context()) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"}) return } if pkgs == nil { pkgs = []store.PackageRegistration{} } c.JSON(http.StatusOK, gin.H{"data": pkgs}) } // GetPackage returns a single package by ID. // GET /api/v1/admin/packages/:id func (h *PackageHandler) GetPackage(c *gin.Context) { id := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), id) if err != nil || pkg == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } c.JSON(http.StatusOK, pkg) } // EnablePackage enables a package. // PUT /api/v1/admin/packages/:id/enable func (h *PackageHandler) EnablePackage(c *gin.Context) { id := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), id) if err != nil || pkg == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } if pkg.Status == "dormant" { c.JSON(http.StatusConflict, gin.H{ "error": "package is dormant — it requires capabilities not yet available (e.g. chat)", }) return } if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true}) h.broadcastPackageChanged("enabled", id) } // DisablePackage disables a package. Admin cannot be disabled. // PUT /api/v1/admin/packages/:id/disable func (h *PackageHandler) DisablePackage(c *gin.Context) { id := c.Param("id") if id == "admin" { c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"}) return } if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, false); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false}) h.broadcastPackageChanged("disabled", id) } // DeletePackage uninstalls a package. Core packages cannot be deleted. // DELETE /api/v1/admin/packages/:id func (h *PackageHandler) DeletePackage(c *gin.Context) { id := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), id) if err != nil || pkg == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } if pkg.Source == "core" { c.JSON(http.StatusBadRequest, gin.H{"error": "core packages cannot be uninstalled"}) return } if pkg.Type == "library" && h.stores.Dependencies != nil { hasConsumers, _ := h.stores.Dependencies.HasConsumers(c.Request.Context(), id) if hasConsumers { c.JSON(http.StatusConflict, gin.H{"error": "cannot uninstall: library has active consumers"}) return } } if h.stores.Dependencies != nil { h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), id) } 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"}) return } // Unregister user-facing permissions from the dynamic registry auth.UnregisterExtensionPermissions(id) // Clean up extracted static assets if h.packagesDir != "" { assetDir := filepath.Join(h.packagesDir, id) if err := os.RemoveAll(assetDir); err != nil { log.Printf("⚠️ Failed to clean up package assets at %s: %v", assetDir, err) } } c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true}) h.broadcastPackageChanged("deleted", id) } // InstallPackage uploads and installs a .pkg/.surface archive. // POST /api/v1/admin/packages/install // // Also supports pre-downloaded files via gin context: // // c.Set("_registry_file", "/path/to/file.pkg") — skip form upload // c.Set("_registry_source", "registry") — override source func (h *PackageHandler) InstallPackage(c *gin.Context) { // Phase 1: Receive upload → temp file tmpPath, cleanupTmp, err := h.receiveUpload(c) if err != nil { return // error already sent } if cleanupTmp { defer os.Remove(tmpPath) } // Phase 2: Parse and validate archive zr, manifest, mInfo, err := h.parseAndValidateArchive(c, tmpPath) if err != nil { return // error already sent } defer zr.Close() pkgID := mInfo.ID // Phase 3: Check for conflicts with core packages existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID) if existing != nil && existing.Source == "core" { c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID}) return } // Phase 4: Extract static assets to disk h.extractPackageAssets(pkgID, zr) // Phase 5: Register in database userID := c.GetString("user_id") pkgSource := "extension" if src, ok := c.Get("_registry_source"); ok { pkgSource = src.(string) } preservedEnabled, err := h.registerPackage(c, pkgID, mInfo, pkgSource, userID, manifest) if err != nil { return // error already sent } // Phase 6: Apply DDL, permissions, triggers, schema migrations if err := h.applySchemaAndPermissions(c, pkgID, mInfo, manifest, existing); err != nil { return // error already sent } // Phase 7: Install workflow definition (if workflow type) if mInfo.Type == "workflow" { if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil { log.Printf("[packages] workflow install failed for %s: %v", pkgID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()}) return } } // Phase 8: Resolve dependencies if err := h.resolveDependencies(c, pkgID, manifest); err != nil { return // error already sent } // Phase 9: Auto-set default surface if (mInfo.Type == "surface" || mInfo.Type == "full") && pkgSource != "core" { if raw, err := h.stores.GlobalConfig.Get(c.Request.Context(), "default_surface"); err != nil || raw == nil { dflt := models.JSONMap{"id": pkgID} if setErr := h.stores.GlobalConfig.Set(c.Request.Context(), "default_surface", dflt, ""); setErr != nil { log.Printf("[packages] failed to auto-set default_surface to %s: %v", pkgID, setErr) } else { log.Printf("[packages] Auto-set default_surface to %s (first extension surface)", pkgID) } } } // Phase 10: Validate required capabilities if caps, ok := ParseCapabilities(manifest); ok { missing := CheckRequiredCapabilities(caps.Required, h.capabilities) if len(missing) > 0 { h.stores.Packages.Delete(c.Request.Context(), pkgID) c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": "missing required capabilities", "missing": missing, "help": capabilityHelpText(missing), }) return } unmetOpt := CheckRequiredCapabilities(caps.Optional, h.capabilities) if len(unmetOpt) > 0 { log.Printf("[packages] %s: optional capabilities unavailable: %v", pkgID, unmetOpt) } } resp := gin.H{ "id": pkgID, "title": mInfo.Title, "type": mInfo.Type, "version": mInfo.Version, "source": pkgSource, "enabled": preservedEnabled, } c.JSON(http.StatusOK, resp) h.broadcastPackageChanged("installed", pkgID) } // ── InstallPackage phases ──────────────────────── // receiveUpload reads the uploaded file into a temp file. // Returns the path, whether to clean up, and any error (already sent to client). func (h *PackageHandler) receiveUpload(c *gin.Context) (string, bool, error) { if regFile, ok := c.Get("_registry_file"); ok { return regFile.(string), false, nil } file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"}) return "", false, err } defer file.Close() validExt := strings.HasSuffix(header.Filename, ".pkg") || strings.HasSuffix(header.Filename, ".surface") || strings.HasSuffix(header.Filename, ".zip") if !validExt { c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"}) return "", false, fmt.Errorf("invalid extension") } if header.Size > 50*1024*1024 { c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"}) return "", false, fmt.Errorf("too large") } tmpFile, err := os.CreateTemp("", "package-*.zip") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) return "", false, err } tmpPath := tmpFile.Name() if _, err := io.Copy(tmpFile, file); err != nil { tmpFile.Close() os.Remove(tmpPath) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"}) return "", false, err } tmpFile.Close() return tmpPath, true, nil } // parseAndValidateArchive opens the zip, parses manifest.json, validates // starlark entry points, runs unicode security scans, and validates manifest structure. func (h *PackageHandler) parseAndValidateArchive(c *gin.Context, tmpPath string) (*zip.ReadCloser, map[string]any, *ManifestInfo, error) { zr, err := zip.OpenReader(tmpPath) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"}) return nil, nil, nil, err } // Find and parse manifest.json var manifest map[string]any for _, f := range zr.File { name := filepath.Base(f.Name) if name == "manifest.json" && !f.FileInfo().IsDir() { rc, err := f.Open() if err != nil { continue } data, err := io.ReadAll(rc) rc.Close() if err != nil { continue } if err := json.Unmarshal(data, &manifest); err != nil { zr.Close() c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()}) return nil, nil, nil, err } break } } if manifest == nil { zr.Close() c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"}) return nil, nil, nil, fmt.Errorf("missing manifest") } // Validate starlark entry point if manifestTier, _ := manifest["tier"].(string); manifestTier == "starlark" { entryPoint := "script.star" if ep, ok := manifest["entry_point"].(string); ok && ep != "" { entryPoint = ep } found := false for _, f := range zr.File { base := filepath.Base(f.Name) if base == entryPoint && !f.FileInfo().IsDir() { found = true break } } if !found { zr.Close() c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("starlark package missing entry point %q", entryPoint)}) return nil, nil, nil, fmt.Errorf("missing entry point") } } // Unicode security scan scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true} for _, f := range zr.File { if f.FileInfo().IsDir() { continue } ext := strings.ToLower(filepath.Ext(f.Name)) if !scanExts[ext] { continue } rc, err := f.Open() if err != nil { continue } data, err := io.ReadAll(rc) rc.Close() if err != nil { continue } findings := sandbox.ScanSource(string(data), f.Name) if len(findings) > 0 { if blocked, reason := sandbox.Verdict(findings); blocked { zr.Close() c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": "extension_blocked", "reason": fmt.Sprintf("%s (file: %s)", reason, f.Name), }) return nil, nil, nil, fmt.Errorf("blocked") } } } // Validate manifest structure mInfo, err := ValidateManifest(manifest) if err != nil { zr.Close() c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return nil, nil, nil, err } // Signing hook (reserved) if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" { if mInfo.Signature != "" { log.Printf("[packages] %s: signature field present (%s) — verification not yet implemented", mInfo.ID, mInfo.Signature) } else { log.Printf("[packages] %s: unsigned package (PACKAGE_VERIFY_SIGNATURES is enabled)", mInfo.ID) } } return zr, manifest, mInfo, nil } // extractPackageAssets extracts js/, css/, assets/, star/ files to the packages directory. func (h *PackageHandler) extractPackageAssets(pkgID string, zr *zip.ReadCloser) { if h.packagesDir == "" { return } destDir := filepath.Join(h.packagesDir, pkgID) os.MkdirAll(destDir, 0755) for _, f := range zr.File { if f.FileInfo().IsDir() { continue } relPath := extractableRelPath(f.Name) if relPath == "" { continue } destPath := filepath.Join(destDir, relPath) if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) { continue // path traversal } os.MkdirAll(filepath.Dir(destPath), 0755) rc, err := f.Open() if err != nil { continue } out, err := os.Create(destPath) if err != nil { rc.Close() continue } io.Copy(out, rc) out.Close() rc.Close() } log.Printf("[packages] Extracted %s to %s", pkgID, destDir) } // registerPackage seeds the package in the database and updates metadata fields. // Returns the preserved enabled state and any error (already sent to client). func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *ManifestInfo, pkgSource, userID string, manifest map[string]any) (bool, error) { if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, mInfo.Title, pkgSource, manifest); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"}) return false, err } existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID) preservedEnabled := true if existing != nil { preservedEnabled = existing.Enabled } pkg := &store.PackageRegistration{ Title: mInfo.Title, Type: mInfo.Type, Version: mInfo.Version, Description: mInfo.Description, Author: mInfo.Author, Tier: mInfo.Tier, IsSystem: false, Enabled: preservedEnabled, Manifest: manifest, } if userID != "" { pkg.InstalledBy = &userID } if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil { log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err) } return preservedEnabled, nil } // applySchemaAndPermissions creates extension tables, syncs permissions/triggers, // and runs schema migrations. func (h *PackageHandler) applySchemaAndPermissions(c *gin.Context, pkgID string, mInfo *ManifestInfo, manifest map[string]any, existing *store.PackageRegistration) error { if tables, ok := ParseDBTables(manifest); ok { if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables, h.capabilities["pgvector"]); err != nil { log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err) } } SyncManifestPermissions(c, h.stores, pkgID, manifest) SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest) newSchemaVersion := ParseSchemaVersion(manifest) if newSchemaVersion > 0 && h.sandbox != nil { oldSchemaVersion := 0 if existing != nil { oldSchemaVersion = existing.SchemaVersion } if newSchemaVersion < oldSchemaVersion { c.JSON(http.StatusConflict, gin.H{ "error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first", oldSchemaVersion, newSchemaVersion), }) return fmt.Errorf("downgrade") } if newSchemaVersion > oldSchemaVersion { if err := RunSchemaMigrations( c.Request.Context(), h.sandbox, h.stores, database.DB, database.IsPostgres(), pkgID, manifest, oldSchemaVersion, newSchemaVersion, ); err != nil { log.Printf("[pkg-migrate] migration failed for %s: %v", pkgID, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "schema migration failed: " + err.Error(), }) return err } } } return nil } // resolveDependencies validates and records library dependencies. func (h *PackageHandler) resolveDependencies(c *gin.Context, pkgID string, manifest map[string]any) error { deps, ok := manifest["dependencies"].(map[string]any) if !ok || len(deps) == 0 { return nil } if h.stores.Dependencies != nil { h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID) } for libID, vSpec := range deps { lib, err := h.stores.Packages.Get(c.Request.Context(), libID) if err != nil || lib == nil { // Attempt auto-install from bundled packages if h.bundledDir != "" { pkgPath := filepath.Join(h.bundledDir, libID+".pkg") if _, statErr := os.Stat(pkgPath); statErr == nil { log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID) if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id"), h.capabilities); installErr != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr), }) return installErr } lib, err = h.stores.Packages.Get(c.Request.Context(), libID) } } if lib == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID + " (not installed and not available as a bundled package)"}) return fmt.Errorf("missing dep: %s", libID) } } if lib.Type != "library" { c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID}) return fmt.Errorf("not a library: %s", libID) } if lib.Status == "suspended" || lib.Status == "dormant" { c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID}) return fmt.Errorf("dep %s is %s", libID, lib.Status) } versionSpec, _ := vSpec.(string) if versionSpec == "" { versionSpec = ">=0.0.0" } if h.stores.Dependencies != nil { if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{ ConsumerID: pkgID, LibraryID: libID, VersionSpec: versionSpec, ResolvedVer: lib.Version, }); err != nil { log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"}) return err } } } log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps)) return nil } // extractableRelPath returns the relative path for a zip entry if it // belongs to an extractable directory (js/, css/, assets/, star/) or // is a bare .star file at the archive root. // Returns "" if the file should be skipped. func extractableRelPath(name string) string { staticPrefixes := []string{"js/", "css/", "assets/", "star/"} // Direct match (flat archive without package-id/ wrapper) for _, p := range staticPrefixes { if strings.HasPrefix(name, p) { return name } } // Bare .star file at root (e.g. "script.star") if strings.HasSuffix(name, ".star") && !strings.Contains(name, "/") { return name } // Nested inside package-id/ directory idx := strings.Index(name, "/") if idx <= 0 { return "" } rest := name[idx+1:] for _, p := range staticPrefixes { if strings.HasPrefix(rest, p) { return rest } } // Bare .star file inside package-id/ (e.g. "gitea-client/script.star") if strings.HasSuffix(rest, ".star") && !strings.Contains(rest, "/") { return rest } return "" } // ── Package settings ────────────── // GetPackageSettings returns the settings schema from the manifest merged // with current admin-configured values. // GET /api/v1/admin/packages/:id/settings func (h *PackageHandler) GetPackageSettings(c *gin.Context) { id := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), id) if err != nil || pkg == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } // Extract settings schema from manifest schema, _ := pkg.Manifest["settings"].([]any) // Get current values current, err := h.stores.Packages.GetPackageSettings(c.Request.Context(), id) if err != nil { current = json.RawMessage("{}") } c.JSON(http.StatusOK, gin.H{ "schema": schema, "values": json.RawMessage(current), }) } // UpdatePackageSettings validates and stores admin-configured package settings. // PUT /api/v1/admin/packages/:id/settings func (h *PackageHandler) UpdatePackageSettings(c *gin.Context) { id := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), id) if err != nil || pkg == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } var body map[string]any if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) return } // Validate keys against declared schema schemaArr, _ := pkg.Manifest["settings"].([]any) validKeys := make(map[string]map[string]any, len(schemaArr)) for _, s := range schemaArr { if entry, ok := s.(map[string]any); ok { if key, ok := entry["key"].(string); ok { validKeys[key] = entry } } } // Filter to only declared keys and type-check filtered := make(map[string]any, len(body)) for k, v := range body { schemaDef, ok := validKeys[k] if !ok { continue // skip undeclared keys } settingType, _ := schemaDef["type"].(string) if validateSettingType(v, settingType) { filtered[k] = v } } settingsJSON, _ := json.Marshal(filtered) if err := h.stores.Packages.SetPackageSettings(c.Request.Context(), id, json.RawMessage(settingsJSON)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save settings"}) return } c.JSON(http.StatusOK, gin.H{"data": json.RawMessage(settingsJSON)}) } // validateSettingType checks if a value matches the expected setting type. func validateSettingType(v any, settingType string) bool { switch settingType { case "string", "select": _, ok := v.(string) return ok case "number": _, ok := v.(float64) return ok case "boolean": _, ok := v.(bool) return ok default: return true // unknown types pass through } } // ListEnabledSurfaces returns enabled surface/full packages for nav rendering. // GET /api/v1/surfaces func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) { pkgs, err := h.stores.Packages.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"}) return } type navSurface struct { ID string `json:"id"` Title string `json:"title"` Route string `json:"route"` Icon string `json:"icon,omitempty"` } var enabled []navSurface for _, p := range pkgs { if !p.Enabled { continue } // Only surface and full types have routes if p.Type != "surface" && p.Type != "full" { continue } // Welcome is a fallback surface, not a navigable destination if p.ID == "welcome" { continue } route, _ := p.Manifest["route"].(string) icon, _ := p.Manifest["icon"].(string) enabled = append(enabled, navSurface{ ID: p.ID, Title: p.Title, Route: route, Icon: icon, }) } if enabled == nil { enabled = []navSurface{} } c.JSON(http.StatusOK, gin.H{"data": enabled}) } // ── Test Tool ───────────────────────── // POST /admin/packages/:id/test-tool // Invokes a starlark extension's on_tool_call entry point directly, // without going through the AI chat loop. Admin-only test harness. // TestToolRequest is the JSON body for test-tool invocation. type TestToolRequest struct { ToolName string `json:"tool_name" binding:"required"` Arguments map[string]any `json:"arguments"` } // TestTool invokes a package's on_tool_call entry point for testing. func (h *PackageHandler) TestTool(c *gin.Context) { if h.runner == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "starlark runner not configured"}) return } pkgID := c.Param("id") pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } if pkg.Tier != "starlark" { c.JSON(http.StatusBadRequest, gin.H{"error": "test-tool requires tier=starlark"}) return } if !pkg.Enabled { c.JSON(http.StatusBadRequest, gin.H{"error": "package is disabled"}) return } var req TestToolRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Build a call dict identical to what executeExtensionTool sends. // Build the arguments as an interface{} map for jsonToStarlark. var args interface{} if req.Arguments != nil { argsJSON, _ := json.Marshal(req.Arguments) _ = json.Unmarshal(argsJSON, &args) } callDict := starlark.NewDict(3) _ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName)) _ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID)) _ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(args)) userID, _ := c.Get("user_id") rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)} val, output, err := h.runner.CallEntryPoint( c.Request.Context(), pkg, "on_tool_call", starlark.Tuple{callDict}, nil, rc, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), "output": output, }) return } // Serialize the Starlark return value to Go types. result := starlarkValueToGo(val) c.JSON(http.StatusOK, gin.H{ "result": result, "output": output, }) } // ── Package update ────────────────────── // UpdatePackage applies an in-place update to an existing package. // POST /api/v1/admin/packages/:id/update // // Validates version bump (semver), applies additive schema migration, // merges settings, replaces assets, and re-syncs permissions/triggers. func (h *PackageHandler) UpdatePackage(c *gin.Context) { ctx := c.Request.Context() pkgID := c.Param("id") // 1. Lookup existing package existing, err := h.stores.Packages.Get(ctx, pkgID) if err != nil || existing == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } if existing.Source == "core" { c.JSON(http.StatusForbidden, gin.H{"error": "cannot update core packages"}) return } // 2. Accept file upload file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"}) return } defer file.Close() validExt := strings.HasSuffix(header.Filename, ".pkg") || strings.HasSuffix(header.Filename, ".surface") || strings.HasSuffix(header.Filename, ".zip") if !validExt { c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"}) return } if header.Size > 50*1024*1024 { c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"}) return } tmpFile, err := os.CreateTemp("", "package-update-*.zip") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) return } tmpPath := tmpFile.Name() defer os.Remove(tmpPath) if _, err := io.Copy(tmpFile, file); err != nil { tmpFile.Close() c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"}) return } tmpFile.Close() zr, err := zip.OpenReader(tmpPath) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"}) return } defer zr.Close() // 3. Extract and validate manifest manifest, err := extractManifest(zr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } newID, _ := manifest["id"].(string) if newID != pkgID { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("manifest id %q does not match package %q", newID, pkgID)}) return } newType, _ := manifest["type"].(string) if newType == "" { newType = "surface" } if newType != existing.Type { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("cannot change package type from %q to %q", existing.Type, newType)}) return } // 4. Version comparison — reject same or older newVersion, _ := manifest["version"].(string) if newVersion == "" { newVersion = "0.0.0" } oldSemver, errOld := ParseSemver(existing.Version) newSemver, errNew := ParseSemver(newVersion) if errOld != nil || errNew != nil { // If either version is unparseable, allow the update (legacy data) if errNew != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version in manifest: " + errNew.Error()}) return } } else if newSemver.Compare(oldSemver) <= 0 { c.JSON(http.StatusConflict, gin.H{ "error": fmt.Sprintf("version %s is not newer than installed %s", newVersion, existing.Version), }) return } // 5. Unicode security scan scanExts := map[string]bool{".star": true, ".json": true, ".js": true, ".html": true} for _, f := range zr.File { if f.FileInfo().IsDir() { continue } ext := strings.ToLower(filepath.Ext(f.Name)) if !scanExts[ext] { continue } rc, err := f.Open() if err != nil { continue } data, err := io.ReadAll(rc) rc.Close() if err != nil { continue } findings := sandbox.ScanSource(string(data), f.Name) if len(findings) > 0 { if blocked, reason := sandbox.Verdict(findings); blocked { c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": "extension_blocked", "reason": fmt.Sprintf("%s (file: %s)", reason, f.Name), }) return } } } // 6. Additive schema migration var schemaChanges []string if tables, ok := ParseDBTables(manifest); ok { changes, err := MigrateExtTables(ctx, database.DB, database.IsPostgres(), h.stores, pkgID, tables, h.capabilities["pgvector"]) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "schema migration failed: " + err.Error()}) return } schemaChanges = changes } // 7. Settings merge — add new keys with defaults, preserve existing mergedSettings := mergePackageSettings(existing, manifest) if mergedSettings != nil { if err := h.stores.Packages.SetPackageSettings(ctx, pkgID, mergedSettings); err != nil { log.Printf("[packages] settings merge failed for %s: %v", pkgID, err) } } // 8. Replace assets if h.packagesDir != "" { if err := extractPackageAssets(zr, h.packagesDir, pkgID); err != nil { log.Printf("[packages] asset extraction failed for %s: %v", pkgID, err) } } // 9. Update DB record description, _ := manifest["description"].(string) author, _ := manifest["author"].(string) tier, _ := manifest["tier"].(string) if tier == "" { tier = "browser" } pkg := &store.PackageRegistration{ Title: manifest["title"].(string), Type: newType, Version: newVersion, Description: description, Author: author, Tier: tier, IsSystem: existing.IsSystem, Enabled: existing.Enabled, Manifest: manifest, } if err := h.stores.Packages.Update(ctx, pkgID, pkg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package record"}) return } // 10. Re-sync permissions, triggers, dependencies SyncManifestPermissions(c, h.stores, pkgID, manifest) SyncManifestTriggers(ctx, h.stores, triggers.GlobalEngine(), pkgID, manifest) if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 { if h.stores.Dependencies != nil { h.stores.Dependencies.DeleteAllForConsumer(ctx, pkgID) } for libID, vSpec := range deps { lib, err := h.stores.Packages.Get(ctx, libID) if err != nil || lib == nil { log.Printf("[packages] update dependency %s not found for %s", libID, pkgID) continue } versionSpec, _ := vSpec.(string) if versionSpec == "" { versionSpec = ">=0.0.0" } if h.stores.Dependencies != nil { h.stores.Dependencies.Create(ctx, &models.ExtDependency{ ConsumerID: pkgID, LibraryID: libID, VersionSpec: versionSpec, ResolvedVer: lib.Version, }) } } } // 11. Re-install workflow definition if applicable if newType == "workflow" { if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil { log.Printf("[packages] workflow update failed for %s: %v", pkgID, err) } } previousVersion := existing.Version log.Printf("[packages] Updated %s from v%s to v%s", pkgID, previousVersion, newVersion) c.JSON(http.StatusOK, gin.H{ "id": pkgID, "version": newVersion, "previous_version": previousVersion, "changes": schemaChanges, }) h.broadcastPackageChanged("updated", pkgID) } // mergePackageSettings merges existing settings with a new manifest's settings schema. // New keys get their declared default value; existing keys are preserved. func mergePackageSettings(existing *store.PackageRegistration, manifest map[string]any) json.RawMessage { newSchema, _ := manifest["settings"].([]any) if len(newSchema) == 0 { return nil } // Parse existing settings var current map[string]any if existing.PackageSettings != nil { if err := json.Unmarshal(existing.PackageSettings, ¤t); err != nil { current = make(map[string]any) } } if current == nil { current = make(map[string]any) } changed := false for _, entry := range newSchema { e, ok := entry.(map[string]any) if !ok { continue } key, _ := e["key"].(string) if key == "" { continue } if _, exists := current[key]; !exists { current[key] = e["default"] changed = true } } if !changed { return nil } data, _ := json.Marshal(current) return data } // ── Package export ────────────────────── // ExportPackage exports an installed package as a downloadable .pkg archive. // GET /api/v1/admin/packages/:id/export func (h *PackageHandler) ExportPackage(c *gin.Context) { ctx := c.Request.Context() pkgID := c.Param("id") pkg, err := h.stores.Packages.Get(ctx, pkgID) if err != nil || pkg == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } // Set response headers filename := fmt.Sprintf("%s-%s.pkg", pkgID, pkg.Version) c.Header("Content-Type", "application/zip") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) zw := zip.NewWriter(c.Writer) defer zw.Close() // Write manifest.json from DB manifestJSON, err := json.MarshalIndent(pkg.Manifest, "", " ") if err != nil { log.Printf("[packages] export: failed to marshal manifest for %s: %v", pkgID, err) return } mw, err := zw.Create("manifest.json") if err != nil { return } mw.Write(manifestJSON) // Walk packagesDir/{id}/ and add all asset files if h.packagesDir == "" { log.Printf("[packages] export: packagesDir not set, exporting manifest only for %s", pkgID) c.Header("X-Export-Warning", "no-assets") return } pkgDir := filepath.Join(h.packagesDir, pkgID) if _, statErr := os.Stat(pkgDir); os.IsNotExist(statErr) { log.Printf("[packages] export: asset directory missing for %s, exporting manifest only", pkgID) c.Header("X-Export-Warning", "no-assets") return } filepath.Walk(pkgDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } relPath, err := filepath.Rel(pkgDir, path) if err != nil { return nil } relPath = filepath.ToSlash(relPath) // normalize to forward slashes in zip fw, err := zw.Create(relPath) if err != nil { return nil } f, err := os.Open(path) if err != nil { return nil } defer f.Close() io.Copy(fw, f) return nil }) }