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

@@ -0,0 +1,127 @@
package handlers
// package_export.go — v0.30.0 CS3
//
// Exports an installed package as a downloadable .pkg archive for
// cross-instance sharing. Includes manifest, static assets, and
// Starlark scripts. Does NOT include extension table data or secrets.
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// PackageExportHandler handles package export operations.
type PackageExportHandler struct {
stores store.Stores
packagesDir string
}
// NewPackageExportHandler creates a new export handler.
func NewPackageExportHandler(s store.Stores, packagesDir string) *PackageExportHandler {
return &PackageExportHandler{stores: s, packagesDir: packagesDir}
}
// ExportPackage downloads an installed package as a .pkg archive.
// GET /api/v1/admin/packages/:id/export
func (h *PackageExportHandler) ExportPackage(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": "cannot export core packages"})
return
}
// Build manifest for export
manifest := make(map[string]any, len(pkg.Manifest))
for k, v := range pkg.Manifest {
// Skip internal fields that shouldn't be exported
if k == "_script" {
continue
}
manifest[k] = v
}
// Include package_settings as default_settings so importing instance
// gets admin defaults (but not secrets)
if len(pkg.PackageSettings) > 0 && string(pkg.PackageSettings) != "{}" {
var ps map[string]any
if json.Unmarshal(pkg.PackageSettings, &ps) == nil && len(ps) > 0 {
manifest["default_settings"] = ps
}
}
// Set response headers
filename := fmt.Sprintf("%s-%s.pkg", id, pkg.Version)
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
// Create zip writer directly to response
zw := zip.NewWriter(c.Writer)
defer zw.Close()
// Write manifest.json
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize manifest"})
return
}
mf, err := zw.Create("manifest.json")
if err != nil {
return
}
mf.Write(manifestJSON)
// Include Starlark script as script.star if present
if script, ok := pkg.Manifest["_starlark_script"].(string); ok && script != "" {
sf, err := zw.Create("script.star")
if err == nil {
sf.Write([]byte(script))
}
}
// Walk packagesDir/{id}/ for static assets
if h.packagesDir != "" {
assetsDir := filepath.Join(h.packagesDir, id)
if info, err := os.Stat(assetsDir); err == nil && info.IsDir() {
filepath.Walk(assetsDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
relPath, err := filepath.Rel(assetsDir, path)
if err != nil {
return nil
}
// Normalize path separators for zip
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
f, err := zw.Create(relPath)
if err != nil {
return nil
}
src, err := os.Open(path)
if err != nil {
return nil
}
defer src.Close()
io.Copy(f, src)
return nil
})
}
}
}

View File

@@ -0,0 +1,160 @@
package handlers
// package_migrations.go — v0.30.0 CS1
//
// Schema migration engine for extension packages. When a package declares
// schema_version and migrations in its manifest, this engine runs Starlark
// migration scripts on install (fresh or upgrade).
//
// Manifest format:
// {
// "schema_version": 2,
// "migrations": {
// "1": "def migrate(db):\n db.insert('settings', {'key': 'v1'})",
// "2": "def migrate(db):\n ..."
// }
// }
//
// On fresh install: runs migrations 1..schema_version.
// On upgrade: runs migrations (current+1)..new_schema_version.
// On downgrade: rejected (409).
import (
"context"
"database/sql"
"fmt"
"log"
"strconv"
"go.starlark.net/starlark"
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ParseSchemaVersion extracts the "schema_version" integer from a manifest.
// Returns 0 if not present or not a number.
func ParseSchemaVersion(manifest map[string]any) int {
raw, ok := manifest["schema_version"]
if !ok {
return 0
}
switch v := raw.(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}
// ParseMigrations extracts the "migrations" map from a manifest.
// Keys are string integers ("1", "2", ...), values are Starlark source code.
func ParseMigrations(manifest map[string]any) map[int]string {
raw, ok := manifest["migrations"]
if !ok {
return nil
}
migrationsRaw, ok := raw.(map[string]any)
if !ok || len(migrationsRaw) == 0 {
return nil
}
result := make(map[int]string, len(migrationsRaw))
for key, val := range migrationsRaw {
version, err := strconv.Atoi(key)
if err != nil {
log.Printf("[pkg-migrate] skipping non-integer migration key %q", key)
continue
}
script, ok := val.(string)
if !ok || script == "" {
log.Printf("[pkg-migrate] skipping empty migration for version %d", version)
continue
}
result[version] = script
}
return result
}
// RunSchemaMigrations executes Starlark migration scripts from fromVersion+1
// to toVersion, updating schema_version in the store after each successful step.
//
// The sandbox runs each migration script with a db module at write level,
// scoped to the package's namespace. This bypasses the permission system
// because migrations are admin-initiated.
//
// On error, returns immediately — partial migration state is tracked via
// the per-step schema_version update.
func RunSchemaMigrations(
ctx context.Context,
sb *sandbox.Sandbox,
stores store.Stores,
db *sql.DB,
isPostgres bool,
packageID string,
manifest map[string]any,
fromVersion int,
toVersion int,
) error {
if fromVersion >= toVersion {
return nil
}
migrations := ParseMigrations(manifest)
if migrations == nil && toVersion > 0 {
// No migration scripts but schema_version declared — just set the version
return stores.Packages.SetSchemaVersion(ctx, packageID, toVersion)
}
for step := fromVersion + 1; step <= toVersion; step++ {
script, ok := migrations[step]
if !ok {
// No script for this step — just bump the version
log.Printf("[pkg-migrate] %s: no migration script for version %d, skipping", packageID, step)
if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil {
return fmt.Errorf("set schema_version to %d: %w", step, err)
}
continue
}
log.Printf("[pkg-migrate] %s: running migration %d", packageID, step)
// Build a db module at write level for the migration
dbModule := sandbox.BuildDBModule(ctx, sandbox.DBModuleConfig{
PackageID: packageID,
CanWrite: true,
DB: db,
IsPostgres: isPostgres,
})
modules := map[string]starlark.Value{
"db": dbModule,
}
// Execute the migration script
result, err := sb.Exec(ctx, fmt.Sprintf("%s_migrate_%d.star", packageID, step), script, modules)
if err != nil {
return fmt.Errorf("migration %d for %s failed: %w", step, packageID, err)
}
// Call migrate(db) if defined
if migrateFn, ok := result.Globals["migrate"]; ok {
if callable, ok := migrateFn.(starlark.Callable); ok {
_, _, err := sb.Call(ctx, callable, starlark.Tuple{dbModule}, nil)
if err != nil {
return fmt.Errorf("migration %d for %s: migrate() failed: %w", step, packageID, err)
}
}
}
// Mark this step as complete
if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil {
return fmt.Errorf("set schema_version to %d: %w", step, err)
}
log.Printf("[pkg-migrate] %s: migration %d complete", packageID, step)
}
return nil
}

View File

@@ -0,0 +1,260 @@
package handlers
// package_registry.go — v0.30.0 CS4
//
// Package registry (marketplace discovery). Admin browses available
// packages from an external JSON registry and installs them by URL.
//
// Registry format (external JSON file):
// {
// "packages": [
// {"id": "...", "title": "...", "version": "...", "description": "...",
// "author": "...", "type": "...", "tier": "...",
// "download_url": "https://..."}
// ]
// }
//
// Registry URL is stored in global_config under key "package_registry".
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// RegistryEntry represents a single package in the registry.
type RegistryEntry struct {
ID string `json:"id"`
Title string `json:"title"`
Version string `json:"version"`
Description string `json:"description"`
Author string `json:"author"`
Type string `json:"type"`
Tier string `json:"tier"`
DownloadURL string `json:"download_url"`
Size int64 `json:"size,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// RegistryResponse is the top-level registry JSON structure.
type RegistryResponse struct {
Packages []RegistryEntry `json:"packages"`
}
// RegistryHandler handles registry browse and install operations.
type RegistryHandler struct {
stores store.Stores
packagesDir string
pkgHandler *PackageHandler
client *http.Client
cacheMu sync.RWMutex
cacheData *RegistryResponse
cacheURL string
cacheUntil time.Time
}
const (
registryCacheTTL = 5 * time.Minute
registryFetchTimeout = 10 * time.Second
registryMaxSize = 50 * 1024 * 1024 // 50MB
)
// NewRegistryHandler creates a new registry handler.
func NewRegistryHandler(s store.Stores, packagesDir string, pkgHandler *PackageHandler) *RegistryHandler {
return &RegistryHandler{
stores: s,
packagesDir: packagesDir,
pkgHandler: pkgHandler,
client: &http.Client{
Timeout: registryFetchTimeout,
},
}
}
// getRegistryURL reads the registry URL from global_config.
func (h *RegistryHandler) getRegistryURL(c *gin.Context) string {
if h.stores.GlobalConfig == nil {
return ""
}
val, err := h.stores.GlobalConfig.Get(c.Request.Context(), "package_registry")
if err != nil {
return ""
}
url, _ := val["url"].(string)
return url
}
// BrowseRegistry fetches and returns the registry package list.
// GET /api/v1/admin/packages/registry
func (h *RegistryHandler) BrowseRegistry(c *gin.Context) {
url := h.getRegistryURL(c)
if url == "" {
c.JSON(http.StatusOK, gin.H{
"packages": []RegistryEntry{},
"registry_url": "",
})
return
}
// Check cache
h.cacheMu.RLock()
if h.cacheData != nil && h.cacheURL == url && time.Now().Before(h.cacheUntil) {
data := h.cacheData
h.cacheMu.RUnlock()
c.JSON(http.StatusOK, gin.H{
"packages": data.Packages,
"registry_url": url,
})
return
}
h.cacheMu.RUnlock()
// Fetch registry
data, err := h.fetchRegistry(url)
if err != nil {
log.Printf("[registry] fetch failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch registry: " + err.Error()})
return
}
// Update cache
h.cacheMu.Lock()
h.cacheData = data
h.cacheURL = url
h.cacheUntil = time.Now().Add(registryCacheTTL)
h.cacheMu.Unlock()
// Mark installed packages
installed := make(map[string]bool)
pkgs, _ := h.stores.Packages.List(c.Request.Context())
for _, p := range pkgs {
installed[p.ID] = true
}
type enrichedEntry struct {
RegistryEntry
Installed bool `json:"installed"`
}
entries := make([]enrichedEntry, len(data.Packages))
for i, p := range data.Packages {
entries[i] = enrichedEntry{
RegistryEntry: p,
Installed: installed[p.ID],
}
}
c.JSON(http.StatusOK, gin.H{
"packages": entries,
"registry_url": url,
})
}
// InstallFromRegistry downloads a .pkg from a URL and installs it.
// POST /api/v1/admin/packages/registry/install
func (h *RegistryHandler) InstallFromRegistry(c *gin.Context) {
var body struct {
DownloadURL string `json:"download_url"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.DownloadURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "download_url is required"})
return
}
// Security: HTTPS only
if !strings.HasPrefix(body.DownloadURL, "https://") {
c.JSON(http.StatusBadRequest, gin.H{"error": "download_url must use HTTPS"})
return
}
// Download the package
resp, err := h.client.Get(body.DownloadURL)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to download package: " + err.Error()})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusBadGateway, gin.H{
"error": fmt.Sprintf("registry returned HTTP %d", resp.StatusCode),
})
return
}
// Write to temp file with size limit
tmpFile, err := os.CreateTemp("", "registry-pkg-*.pkg")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
limited := io.LimitReader(resp.Body, registryMaxSize)
n, err := io.Copy(tmpFile, limited)
tmpFile.Close()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to download package"})
return
}
log.Printf("[registry] downloaded %d bytes from %s", n, body.DownloadURL)
// Open and re-serve through the package install handler by creating
// a synthetic multipart request. Instead, we directly open the file
// and call the shared install logic.
file, err := os.Open(tmpPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read downloaded package"})
return
}
defer file.Close()
// Use the existing install flow by setting the file on the request
// We'll create a helper that accepts an io.ReadSeeker
c.Set("_registry_file", tmpPath)
c.Set("_registry_source", "registry")
h.pkgHandler.InstallPackage(c)
}
// fetchRegistry downloads and parses a registry JSON file.
func (h *RegistryHandler) fetchRegistry(url string) (*RegistryResponse, error) {
if !strings.HasPrefix(url, "https://") {
return nil, fmt.Errorf("registry URL must use HTTPS")
}
resp, err := h.client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
}
limited := io.LimitReader(resp.Body, 10*1024*1024) // 10MB for registry JSON
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
var reg RegistryResponse
if err := json.Unmarshal(data, &reg); err != nil {
return nil, fmt.Errorf("invalid registry JSON: %w", err)
}
return &reg, nil
}

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) {

View File

@@ -0,0 +1,353 @@
package handlers
// user_packages.go — v0.30.0 CS5
//
// Non-admin package management. Users install personal-scoped packages;
// team admins install team-scoped packages.
//
// Restrictions for non-global scope:
// - Tier: browser only (no starlark/sidecar)
// - Permissions: only notifications.send allowed
// - Prevents privilege escalation through user-installed packages
import (
"archive/zip"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// allowedNonGlobalPermissions is the set of permissions that non-admin
// (team/personal scope) packages may declare.
var allowedNonGlobalPermissions = map[string]bool{
"notifications.send": true,
}
// UserPackageHandler manages user and team scoped package operations.
type UserPackageHandler struct {
stores store.Stores
packagesDir string
}
// NewUserPackageHandler creates a new user package handler.
func NewUserPackageHandler(s store.Stores, packagesDir string) *UserPackageHandler {
return &UserPackageHandler{stores: s, packagesDir: packagesDir}
}
// ListVisiblePackages returns packages visible to the current user
// (global + user's teams + personal).
// GET /api/v1/packages
func (h *UserPackageHandler) ListVisiblePackages(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
pkgs, err := h.stores.Packages.ListVisiblePackages(c.Request.Context(), userID)
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})
}
// InstallPersonalPackage installs a package with personal scope.
// POST /api/v1/packages/install
func (h *UserPackageHandler) InstallPersonalPackage(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
h.installScoped(c, "personal", "", userID)
}
// InstallTeamPackage installs a package with team scope.
// POST /api/v1/teams/:teamId/packages/install
func (h *UserPackageHandler) InstallTeamPackage(c *gin.Context) {
userID := c.GetString("user_id")
teamID := c.Param("teamId")
if userID == "" || teamID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing user or team context"})
return
}
h.installScoped(c, "team", teamID, userID)
}
// DeletePersonalPackage deletes a personal-scoped package owned by the user.
// DELETE /api/v1/packages/:id
func (h *UserPackageHandler) DeletePersonalPackage(c *gin.Context) {
userID := c.GetString("user_id")
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.Scope != "personal" || pkg.InstalledBy == nil || *pkg.InstalledBy != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own personal packages"})
return
}
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
return
}
// Clean up assets
if h.packagesDir != "" {
os.RemoveAll(filepath.Join(h.packagesDir, id))
}
c.JSON(http.StatusOK, gin.H{"deleted": id})
}
// DeleteTeamPackage deletes a team-scoped package.
// DELETE /api/v1/teams/:teamId/packages/:id
func (h *UserPackageHandler) DeleteTeamPackage(c *gin.Context) {
teamID := c.Param("teamId")
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.Scope != "team" || pkg.TeamID == nil || *pkg.TeamID != teamID {
c.JSON(http.StatusForbidden, gin.H{"error": "package does not belong to this team"})
return
}
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
return
}
if h.packagesDir != "" {
os.RemoveAll(filepath.Join(h.packagesDir, id))
}
c.JSON(http.StatusOK, gin.H{"deleted": id})
}
// installScoped handles the common install flow for team/personal packages.
func (h *UserPackageHandler) installScoped(c *gin.Context, scope, teamID, userID string) {
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
}
// Read into temp file
tmpFile, err := os.CreateTemp("", "userpkg-*.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()
// Open as zip and extract manifest
zr, err := zip.OpenReader(tmpPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
return
}
defer zr.Close()
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, _ := io.ReadAll(rc)
rc.Close()
json.Unmarshal(data, &manifest)
break
}
}
if manifest == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
return
}
pkgID, _ := manifest["id"].(string)
title, _ := manifest["title"].(string)
if pkgID == "" || title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
return
}
if !validPackageID.MatchString(pkgID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug"})
return
}
// Enforce tier restriction: browser only for non-global
tier, _ := manifest["tier"].(string)
if tier == "" {
tier = "browser"
}
if tier != "browser" {
c.JSON(http.StatusForbidden, gin.H{
"error": "only browser-tier packages can be installed at team/personal scope",
})
return
}
// Enforce permission restriction
if permsRaw, ok := manifest["permissions"].([]any); ok {
for _, p := range permsRaw {
if perm, ok := p.(string); ok {
if !allowedNonGlobalPermissions[perm] {
c.JSON(http.StatusForbidden, gin.H{
"error": "permission " + perm + " is not allowed for " + scope + "-scoped packages",
})
return
}
}
}
}
// Check for conflicts
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"})
return
}
// Extract static assets
if h.packagesDir != "" {
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
}
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()
}
}
version, _ := manifest["version"].(string)
if version == "" {
version = "0.0.0"
}
pkgType, _ := manifest["type"].(string)
if pkgType == "" {
pkgType = "surface"
}
description, _ := manifest["description"].(string)
author, _ := manifest["author"].(string)
// Create the package with scope
pkg := &store.PackageRegistration{
ID: pkgID,
Title: title,
Type: pkgType,
Version: version,
Description: description,
Author: author,
Tier: tier,
IsSystem: false,
Scope: scope,
Enabled: true,
Status: "active",
Source: "extension",
Manifest: manifest,
}
if teamID != "" {
pkg.TeamID = &teamID
}
pkg.InstalledBy = &userID
if existing != nil {
// Update existing
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package"})
return
}
} else {
// Create new
if err := h.stores.Packages.Create(c.Request.Context(), pkg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create package"})
return
}
}
log.Printf("[packages] installed %s-scoped package %s by user %s", scope, pkgID, userID)
c.JSON(http.StatusOK, gin.H{
"id": pkgID,
"title": title,
"type": pkgType,
"version": version,
"scope": scope,
"source": "extension",
})
}