diff --git a/server/handlers/packages.go b/server/handlers/packages.go index 8344a48..6850b85 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -897,3 +897,343 @@ func (h *PackageHandler) TestTool(c *gin.Context) { "output": output, }) } + +// ── Package update (v0.5.4) ────────────────────── + +// 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) + 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, + }) +} + +// 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 (v0.5.4) ────────────────────── + +// 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 == "" { + return + } + pkgDir := filepath.Join(h.packagesDir, pkgID) + 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 + }) +}