Changeset 0.30.0 (#199)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-18 12:06:55 +00:00
committed by xcaliber
parent 7f191e18cd
commit 7d14e6a439
15 changed files with 1625 additions and 77 deletions

View File

@@ -3,6 +3,7 @@ package handlers
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
@@ -14,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -25,7 +27,8 @@ var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// Replaces SurfaceHandler (v0.28.7).
type PackageHandler struct {
stores store.Stores
packagesDir string // e.g. /data/packages — where archives are extracted
packagesDir string // e.g. /data/packages — where archives are extracted
sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts
}
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
@@ -36,6 +39,11 @@ func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
return &PackageHandler{stores: s, packagesDir: dir}
}
// SetSandbox attaches a Starlark sandbox for schema migrations (v0.30.0).
func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
h.sandbox = sb
}
// ListPackages returns all registered packages.
// GET /api/v1/admin/packages
// GET /api/v1/admin/surfaces (alias)
@@ -142,44 +150,61 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
// InstallPackage uploads and installs a .pkg/.surface archive.
// POST /api/v1/admin/packages/install
// POST /api/v1/admin/surfaces/install (alias)
//
// 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) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
var tmpPath string
var cleanupTmp bool
// Validate extension
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
}
// v0.30.0: Check for pre-downloaded file from registry install
if regFile, ok := c.Get("_registry_file"); ok {
tmpPath = regFile.(string)
// Don't remove — caller manages lifecycle
} else {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
// Size limit: 50MB
if header.Size > 50*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
return
}
// Validate extension
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
}
// Read into temp file for zip processing
tmpFile, err := os.CreateTemp("", "package-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
// Size limit: 50MB
if header.Size > 50*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
return
}
if _, err := io.Copy(tmpFile, file); err != nil {
// Read into temp file for zip processing
tmpFile, err := os.CreateTemp("", "package-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpPath = tmpFile.Name()
cleanupTmp = true
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
}
tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
return
}
tmpFile.Close()
if cleanupTmp {
defer os.Remove(tmpPath)
}
// Open as zip
zr, err := zip.OpenReader(tmpPath)
@@ -332,8 +357,14 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
userID := c.GetString("user_id")
// v0.30.0: use registry source if set by registry install handler
pkgSource := "extension"
if src, ok := c.Get("_registry_source"); ok {
pkgSource = src.(string)
}
// Register in database via Seed (upsert — handles re-install)
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, "extension", manifest); err != nil {
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
return
}
@@ -371,12 +402,41 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
// v0.30.0: Run schema migrations if declared.
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
}
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
}
}
}
c.JSON(http.StatusOK, gin.H{
"id": pkgID,
"title": title,
"type": pkgType,
"version": version,
"source": "extension",
"source": pkgSource,
"enabled": preservedEnabled,
})
}
@@ -408,6 +468,100 @@ func extractableRelPath(name string) string {
return ""
}
// ── Package settings (v0.30.0 CS2) ──────────────
// 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{"values": 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) {