Add UpdatePackage and ExportPackage handlers

UpdatePackage (POST /packages/:id/update) validates semver bump, applies
additive schema migration, merges settings (new keys with defaults,
existing preserved), replaces assets, and re-syncs permissions/triggers.

ExportPackage (GET /packages/:id/export) streams the installed package
as a .pkg ZIP archive containing manifest.json and all assets. Completes
the manual rollback story: export before update, re-install old .pkg if
needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 18:32:50 +00:00
parent ecaeb8bec4
commit b8cd8832ff

View File

@@ -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, &current); 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
})
}