From 7d14e6a439c0cdb968fd79b7f1aa9fe6fdfe3831 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Wed, 18 Mar 2026 12:06:55 +0000 Subject: [PATCH] Changeset 0.30.0 (#199) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- docs/ROADMAP.md | 26 +- server/database/migrations/016_packages.sql | 4 +- .../migrations/sqlite/016_packages.sql | 4 +- server/handlers/package_export.go | 127 +++++++ server/handlers/package_migrations.go | 160 ++++++++ server/handlers/package_registry.go | 260 +++++++++++++ server/handlers/packages.go | 222 +++++++++-- server/handlers/user_packages.go | 353 ++++++++++++++++++ server/main.go | 28 ++ server/sandbox/runner.go | 8 + server/sandbox/settings_module.go | 74 ++++ server/store/package_iface.go | 25 +- server/store/postgres/packages.go | 106 +++++- server/store/sqlite/packages.go | 108 +++++- src/js/admin-packages.js | 197 +++++++++- 15 files changed, 1625 insertions(+), 77 deletions(-) create mode 100644 server/handlers/package_export.go create mode 100644 server/handlers/package_migrations.go create mode 100644 server/handlers/package_registry.go create mode 100644 server/handlers/user_packages.go create mode 100644 server/sandbox/settings_module.go diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index deeb591..e9f54de 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -202,20 +202,20 @@ Depends on: v0.29.1. - [x] Schema creation on install, drop on uninstall - [x] Server-side tool execution in completion handler (deferred from v0.29.1) -### v0.29.3 — Workflow Forms +### v0.29.3 — Workflow Forms ✅ `form_template` renders as real UI. LLM is optional for data collection. Depends on: v0.29.2, v0.29.0 (Starlark validators). -- [ ] Typed `form_template` schema (`text`, `email`, `select`, `number`, +- [x] Typed `form_template` schema (`text`, `email`, `select`, `number`, `date`, `textarea`, `checkbox`, `file`) with validation rules -- [ ] Stage renders as form when `form_template` has typed fields -- [ ] LLM-optional stages: form-only, form+chat, chat-only -- [ ] Starlark `validate` / `on_submit` hooks -- [ ] Visitor form entry (branded page, no chat widget) -- [ ] Form builder in workflow admin (visual field editor) -- [ ] Cross-visitor isolation E2E test (deferred from v0.28.4) +- [x] Stage renders as form when `form_template` has typed fields +- [x] LLM-optional stages: form-only, form+chat, chat-only +- [x] Starlark `validate` / `on_submit` hooks +- [x] Visitor form entry (branded page, no chat widget) +- [x] Form builder in workflow admin (visual field editor) +- [x] Cross-visitor isolation E2E test (deferred from v0.28.4) ### v0.30.0 — Package Lifecycle @@ -223,11 +223,11 @@ Lifecycle sophistication for `.pkg` format. Depends on: v0.29.2. -- [ ] Schema versioning + migrations in manifest -- [ ] Settings extension point (packages declare settings sections) -- [ ] Export/import format for cross-instance sharing -- [ ] Package marketplace (discovery, not hosting) -- [ ] User-installable packages (RBAC-gated, team/personal scope) +- [x] Schema versioning + migrations in manifest +- [x] Settings extension point (packages declare settings sections) +- [x] Export/import format for cross-instance sharing +- [x] Package marketplace (discovery, not hosting) +- [x] User-installable packages (RBAC-gated, team/personal scope) ### v0.30.1 — SDK Adoption diff --git a/server/database/migrations/016_packages.sql b/server/database/migrations/016_packages.sql index c1e97d0..663c980 100644 --- a/server/database/migrations/016_packages.sql +++ b/server/database/migrations/016_packages.sql @@ -29,8 +29,10 @@ CREATE TABLE IF NOT EXISTS packages ( enabled BOOLEAN NOT NULL DEFAULT true, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'pending_review', 'suspended')), + schema_version INTEGER NOT NULL DEFAULT 0, + package_settings JSONB NOT NULL DEFAULT '{}', source TEXT NOT NULL DEFAULT 'core' - CHECK (source IN ('core', 'builtin', 'extension')), + CHECK (source IN ('core', 'builtin', 'extension', 'registry')), installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/server/database/migrations/sqlite/016_packages.sql b/server/database/migrations/sqlite/016_packages.sql index f861ae6..6bcb03c 100644 --- a/server/database/migrations/sqlite/016_packages.sql +++ b/server/database/migrations/sqlite/016_packages.sql @@ -23,8 +23,10 @@ CREATE TABLE IF NOT EXISTS packages ( enabled INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'pending_review', 'suspended')), + schema_version INTEGER NOT NULL DEFAULT 0, + package_settings TEXT NOT NULL DEFAULT '{}', source TEXT NOT NULL DEFAULT 'core' - CHECK (source IN ('core', 'builtin', 'extension')), + CHECK (source IN ('core', 'builtin', 'extension', 'registry')), installed_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/server/handlers/package_export.go b/server/handlers/package_export.go new file mode 100644 index 0000000..fe963ef --- /dev/null +++ b/server/handlers/package_export.go @@ -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 + }) + } + } +} diff --git a/server/handlers/package_migrations.go b/server/handlers/package_migrations.go new file mode 100644 index 0000000..1b10212 --- /dev/null +++ b/server/handlers/package_migrations.go @@ -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 +} diff --git a/server/handlers/package_registry.go b/server/handlers/package_registry.go new file mode 100644 index 0000000..e7b3abe --- /dev/null +++ b/server/handlers/package_registry.go @@ -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, ®); err != nil { + return nil, fmt.Errorf("invalid registry JSON: %w", err) + } + + return ®, nil +} diff --git a/server/handlers/packages.go b/server/handlers/packages.go index e5da854..ce08196 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -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) { diff --git a/server/handlers/user_packages.go b/server/handlers/user_packages.go new file mode 100644 index 0000000..2523cba --- /dev/null +++ b/server/handlers/user_packages.go @@ -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", + }) +} diff --git a/server/main.go b/server/main.go index 0568d17..0dc5489 100644 --- a/server/main.go +++ b/server/main.go @@ -707,6 +707,16 @@ func main() { pkgH := handlers.NewPackageHandler(stores) protected.GET("/surfaces", pkgH.ListEnabledSurfaces) + // User package management (v0.30.0) + userPkgDir := "" + if cfg.StoragePath != "" { + userPkgDir = cfg.StoragePath + "/packages" + } + userPkgH := handlers.NewUserPackageHandler(stores, userPkgDir) + protected.GET("/packages", userPkgH.ListVisiblePackages) + protected.POST("/packages/install", userPkgH.InstallPersonalPackage) + protected.DELETE("/packages/:id", userPkgH.DeletePersonalPackage) + // Summarize & Continue (backed by compaction service) compactionSvc := compaction.NewService(stores, roleResolver) summarize := handlers.NewSummarizeHandler(stores, compactionSvc) @@ -934,6 +944,11 @@ func main() { teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels) + // Team package management (v0.30.0) + teamPkgH := handlers.NewUserPackageHandler(stores, userPkgDir) + teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage) + teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage) + // Team audit log (team admins only — RequireTeamAdmin on group) teamScoped.GET("/audit", teams.ListTeamAuditLog) teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) @@ -1185,12 +1200,25 @@ func main() { packagesDir = cfg.StoragePath + "/packages" } pkgAdm := handlers.NewPackageHandler(stores, packagesDir) + pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations + + // Package registry — must be registered before /packages/:id (v0.30.0) + registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm) + admin.GET("/packages/registry", registryH.BrowseRegistry) + admin.POST("/packages/registry/install", registryH.InstallFromRegistry) + admin.GET("/packages", pkgAdm.ListPackages) admin.GET("/packages/:id", pkgAdm.GetPackage) admin.POST("/packages/install", pkgAdm.InstallPackage) admin.PUT("/packages/:id/enable", pkgAdm.EnablePackage) admin.PUT("/packages/:id/disable", pkgAdm.DisablePackage) admin.DELETE("/packages/:id", pkgAdm.DeletePackage) + admin.GET("/packages/:id/settings", pkgAdm.GetPackageSettings) // v0.30.0 + admin.PUT("/packages/:id/settings", pkgAdm.UpdatePackageSettings) // v0.30.0 + + // Package export (v0.30.0) + pkgExport := handlers.NewPackageExportHandler(stores, packagesDir) + admin.GET("/packages/:id/export", pkgExport.ExportPackage) // Surface aliases (backward compat — same handlers) admin.GET("/surfaces", pkgAdm.ListPackages) diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index 7d1be17..8741749 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -201,5 +201,13 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma }) } + // v0.30.0: settings module — always injected, no permission required. + // A package reads its own admin + user settings. + userID := "" + if rc != nil { + userID = rc.UserID + } + modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID) + return modules, nil } diff --git a/server/sandbox/settings_module.go b/server/sandbox/settings_module.go new file mode 100644 index 0000000..91c25ac --- /dev/null +++ b/server/sandbox/settings_module.go @@ -0,0 +1,74 @@ +package sandbox + +// settings_module.go — v0.30.0 CS2 +// +// The settings module lets extensions read their admin-configured and +// per-user settings. Always injected (no permission required — a package +// reads its own settings). +// +// Starlark API: +// val = settings.get("key") # returns string, number, bool, or None +// val = settings.get("key", "default") # returns default if key not set + +import ( + "context" + "encoding/json" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" + + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +// BuildSettingsModule creates the "settings" Starlark module for a package. +// It reads from package_settings (admin-configured) merged with per-user +// settings when a userID is provided. Package-level settings are the base; +// user-level settings override. +func BuildSettingsModule(ctx context.Context, stores store.Stores, packageID string, userID string) *starlarkstruct.Module { + return MakeModule("settings", starlark.StringDict{ + "get": starlark.NewBuiltin("settings.get", settingsGet(ctx, stores, packageID, userID)), + }) +} + +func settingsGet(ctx context.Context, stores store.Stores, packageID string, userID string) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { + return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var key string + var defaultVal starlark.Value = starlark.None + if err := starlark.UnpackPositionalArgs("settings.get", args, kwargs, 1, &key, &defaultVal); err != nil { + return nil, err + } + + // Load package-level settings + merged := make(map[string]any) + if stores.Packages != nil { + pkgSettings, err := stores.Packages.GetPackageSettings(ctx, packageID) + if err == nil && len(pkgSettings) > 0 { + json.Unmarshal([]byte(pkgSettings), &merged) + } + } + + // Overlay user-level settings if a user context is available + if userID != "" && stores.Packages != nil { + userSettings, err := stores.Packages.GetUserSettings(ctx, packageID, userID) + if err == nil && userSettings != nil && len(userSettings.Settings) > 0 { + var userMap map[string]any + if json.Unmarshal([]byte(userSettings.Settings), &userMap) == nil { + for k, v := range userMap { + merged[k] = v + } + } + } + } + + val, ok := merged[key] + if !ok { + return defaultVal, nil + } + + sv, err := goToStarlark(val) + if err != nil { + return defaultVal, nil + } + return sv, nil + } +} diff --git a/server/store/package_iface.go b/server/store/package_iface.go index 84aa4f4..bc7f744 100644 --- a/server/store/package_iface.go +++ b/server/store/package_iface.go @@ -66,6 +66,23 @@ type PackageStore interface { // DeleteUserSettings removes per-user settings, reverting to defaults. DeleteUserSettings(ctx context.Context, pkgID, userID string) error + + // ── Scoped visibility (v0.30.0) ──────────────── + + // ListVisiblePackages returns packages visible to the given user: + // global packages + team packages for user's teams + personal packages. + ListVisiblePackages(ctx context.Context, userID string) ([]PackageRegistration, error) + + // ── Package lifecycle (v0.30.0) ───────────────── + + // SetSchemaVersion updates the current schema version for a package. + SetSchemaVersion(ctx context.Context, id string, version int) error + + // GetPackageSettings returns the admin-configured package-level settings. + GetPackageSettings(ctx context.Context, id string) (json.RawMessage, error) + + // SetPackageSettings stores admin-configured package-level settings. + SetPackageSettings(ctx context.Context, id string, settings json.RawMessage) error } // PackageRegistration is a row from the packages table. @@ -84,9 +101,11 @@ type PackageRegistration struct { Manifest map[string]any `json:"manifest" db:"manifest"` Enabled bool `json:"enabled" db:"enabled"` Status string `json:"status" db:"status"` - Source string `json:"source" db:"source"` - InstalledAt string `json:"installed_at" db:"installed_at"` - UpdatedAt string `json:"updated_at" db:"updated_at"` + SchemaVersion int `json:"schema_version" db:"schema_version"` + PackageSettings json.RawMessage `json:"package_settings" db:"package_settings"` + Source string `json:"source" db:"source"` + InstalledAt string `json:"installed_at" db:"installed_at"` + UpdatedAt string `json:"updated_at" db:"updated_at"` } // UserPackage combines package info with per-user settings for API responses. diff --git a/server/store/postgres/packages.go b/server/store/postgres/packages.go index b2d8794..f7c560d 100644 --- a/server/store/postgres/packages.go +++ b/server/store/postgres/packages.go @@ -106,13 +106,16 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio } return DB.QueryRowContext(ctx, ` INSERT INTO packages (id, title, type, version, description, author, tier, - is_system, scope, team_id, installed_by, manifest, enabled, status, source) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + is_system, scope, team_id, installed_by, manifest, enabled, status, + schema_version, package_settings, source) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING installed_at, updated_at`, pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author, pkg.Tier, pkg.IsSystem, pkg.Scope, nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy), - manifestJSON, pkg.Enabled, pkg.Status, pkg.Source, + manifestJSON, pkg.Enabled, pkg.Status, + pkg.SchemaVersion, defaultJSON(pkg.PackageSettings), + pkg.Source, ).Scan(&pkg.InstalledAt, &pkg.UpdatedAt) } @@ -154,6 +157,13 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. WHERE p.enabled = true AND p.type IN ('extension', 'full') AND (p.is_system = true OR COALESCE(pus.is_enabled, true) = true) + AND ( + p.scope = 'global' + OR (p.scope = 'personal' AND p.installed_by = $1) + OR (p.scope = 'team' AND p.team_id IN ( + SELECT team_id FROM team_members WHERE user_id = $1 + )) + ) ORDER BY p.title`, userID) if err != nil { return nil, err @@ -165,6 +175,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. var up store.UserPackage var teamID, installedBy sql.NullString var manifestJSON []byte + var pkgSettings []byte var userEnabled sql.NullBool var userSettings []byte @@ -172,8 +183,9 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. &up.ID, &up.Title, &up.Type, &up.Version, &up.Description, &up.Author, &up.Tier, &up.IsSystem, &up.Scope, &teamID, &installedBy, - &manifestJSON, &up.Enabled, &up.Status, &up.Source, - &up.InstalledAt, &up.UpdatedAt, + &manifestJSON, &up.Enabled, &up.Status, + &up.SchemaVersion, &pkgSettings, + &up.Source, &up.InstalledAt, &up.UpdatedAt, &userEnabled, &userSettings, ); err != nil { return nil, err @@ -181,6 +193,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. up.TeamID = NullableStringPtr(teamID) up.InstalledBy = NullableStringPtr(installedBy) json.Unmarshal(manifestJSON, &up.Manifest) + up.PackageSettings = json.RawMessage(pkgSettings) if userEnabled.Valid { up.UserEnabled = &userEnabled.Bool } @@ -235,18 +248,22 @@ func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID str // so column additions don't silently break positional Scan(). const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author, p.tier, p.is_system, p.scope, p.team_id, p.installed_by, - p.manifest, p.enabled, p.status, p.source, p.installed_at, p.updated_at` + p.manifest, p.enabled, p.status, + p.schema_version, p.package_settings, + p.source, p.installed_at, p.updated_at` func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) { var pkg store.PackageRegistration var teamID, installedBy sql.NullString var manifestJSON []byte + var pkgSettings []byte err := DB.QueryRowContext(ctx, query, args...).Scan( &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope, &teamID, &installedBy, - &manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source, - &pkg.InstalledAt, &pkg.UpdatedAt, + &manifestJSON, &pkg.Enabled, &pkg.Status, + &pkg.SchemaVersion, &pkgSettings, + &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil @@ -257,6 +274,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf pkg.TeamID = NullableStringPtr(teamID) pkg.InstalledBy = NullableStringPtr(installedBy) json.Unmarshal(manifestJSON, &pkg.Manifest) + pkg.PackageSettings = json.RawMessage(pkgSettings) return &pkg, nil } @@ -272,23 +290,83 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter var pkg store.PackageRegistration var teamID, installedBy sql.NullString var manifestJSON []byte + var pkgSettings []byte if err := rows.Scan( &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope, &teamID, &installedBy, - &manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source, - &pkg.InstalledAt, &pkg.UpdatedAt, + &manifestJSON, &pkg.Enabled, &pkg.Status, + &pkg.SchemaVersion, &pkgSettings, + &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, ); err != nil { return nil, err } pkg.TeamID = NullableStringPtr(teamID) pkg.InstalledBy = NullableStringPtr(installedBy) json.Unmarshal(manifestJSON, &pkg.Manifest) + pkg.PackageSettings = json.RawMessage(pkgSettings) result = append(result, pkg) } return result, rows.Err() } +// ── Scoped visibility (v0.30.0) ────────────────── + +func (s *PackageStore) ListVisiblePackages(ctx context.Context, userID string) ([]store.PackageRegistration, error) { + return s.scanMany(ctx, ` + SELECT `+pkgCols+` + FROM packages p + WHERE p.enabled = true + AND ( + p.scope = 'global' + OR (p.scope = 'personal' AND p.installed_by = $1) + OR (p.scope = 'team' AND p.team_id IN ( + SELECT team_id FROM team_members WHERE user_id = $1 + )) + ) + ORDER BY p.source, p.title`, userID) +} + +// ── Package lifecycle (v0.30.0) ────────────────── + +func (s *PackageStore) SetSchemaVersion(ctx context.Context, id string, version int) error { + result, err := DB.ExecContext(ctx, + `UPDATE packages SET schema_version = $2, updated_at = NOW() WHERE id = $1`, + id, version) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *PackageStore) GetPackageSettings(ctx context.Context, id string) (json.RawMessage, error) { + var settings []byte + err := DB.QueryRowContext(ctx, + `SELECT package_settings FROM packages WHERE id = $1`, id).Scan(&settings) + if err != nil { + return nil, err + } + return json.RawMessage(settings), nil +} + +func (s *PackageStore) SetPackageSettings(ctx context.Context, id string, settings json.RawMessage) error { + result, err := DB.ExecContext(ctx, + `UPDATE packages SET package_settings = $2, updated_at = NOW() WHERE id = $1`, + id, []byte(settings)) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + // nullStrPtr converts *string to sql.NullString for nullable FK columns. func nullStrPtr(s *string) sql.NullString { if s == nil { @@ -296,3 +374,11 @@ func nullStrPtr(s *string) sql.NullString { } return sql.NullString{String: *s, Valid: true} } + +// defaultJSON returns the raw message or '{}' if nil/empty. +func defaultJSON(raw json.RawMessage) []byte { + if len(raw) == 0 { + return []byte("{}") + } + return []byte(raw) +} diff --git a/server/store/sqlite/packages.go b/server/store/sqlite/packages.go index 7d6f47a..95cc469 100644 --- a/server/store/sqlite/packages.go +++ b/server/store/sqlite/packages.go @@ -111,13 +111,16 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio } _, err := DB.ExecContext(ctx, ` INSERT INTO packages (id, title, type, version, description, author, tier, - is_system, scope, team_id, installed_by, manifest, enabled, status, source, + is_system, scope, team_id, installed_by, manifest, enabled, status, + schema_version, package_settings, source, installed_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author, pkg.Tier, pkg.IsSystem, pkg.Scope, nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy), - manifestJSON, pkg.Enabled, pkg.Status, pkg.Source, + manifestJSON, pkg.Enabled, pkg.Status, + pkg.SchemaVersion, defaultJSON(pkg.PackageSettings), + pkg.Source, now.Format(timeFmt), now.Format(timeFmt), ) if err != nil { @@ -165,7 +168,14 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. WHERE p.enabled = 1 AND p.type IN ('extension', 'full') AND (p.is_system = 1 OR COALESCE(pus.is_enabled, 1) = 1) - ORDER BY p.title`, userID) + AND ( + p.scope = 'global' + OR (p.scope = 'personal' AND p.installed_by = ?) + OR (p.scope = 'team' AND p.team_id IN ( + SELECT team_id FROM team_members WHERE user_id = ? + )) + ) + ORDER BY p.title`, userID, userID, userID) if err != nil { return nil, err } @@ -176,6 +186,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. var up store.UserPackage var teamID, installedBy sql.NullString var manifestJSON string + var pkgSettings string var enabledInt int var isSystemInt int var userEnabled sql.NullBool @@ -185,8 +196,9 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. &up.ID, &up.Title, &up.Type, &up.Version, &up.Description, &up.Author, &up.Tier, &isSystemInt, &up.Scope, &teamID, &installedBy, - &manifestJSON, &enabledInt, &up.Status, &up.Source, - &up.InstalledAt, &up.UpdatedAt, + &manifestJSON, &enabledInt, &up.Status, + &up.SchemaVersion, &pkgSettings, + &up.Source, &up.InstalledAt, &up.UpdatedAt, &userEnabled, &userSettings, ); err != nil { return nil, err @@ -196,6 +208,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store. up.TeamID = NullableStringPtr(teamID) up.InstalledBy = NullableStringPtr(installedBy) json.Unmarshal([]byte(manifestJSON), &up.Manifest) + up.PackageSettings = json.RawMessage(pkgSettings) if userEnabled.Valid { up.UserEnabled = &userEnabled.Bool } @@ -248,20 +261,24 @@ func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID str const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author, p.tier, p.is_system, p.scope, p.team_id, p.installed_by, - p.manifest, p.enabled, p.status, p.source, p.installed_at, p.updated_at` + p.manifest, p.enabled, p.status, + p.schema_version, p.package_settings, + p.source, p.installed_at, p.updated_at` func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) { var pkg store.PackageRegistration var teamID, installedBy sql.NullString var manifestJSON string + var pkgSettings string var enabledInt int var isSystemInt int err := DB.QueryRowContext(ctx, query, args...).Scan( &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope, &teamID, &installedBy, - &manifestJSON, &enabledInt, &pkg.Status, &pkg.Source, - &pkg.InstalledAt, &pkg.UpdatedAt, + &manifestJSON, &enabledInt, &pkg.Status, + &pkg.SchemaVersion, &pkgSettings, + &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil @@ -274,6 +291,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf pkg.TeamID = NullableStringPtr(teamID) pkg.InstalledBy = NullableStringPtr(installedBy) json.Unmarshal([]byte(manifestJSON), &pkg.Manifest) + pkg.PackageSettings = json.RawMessage(pkgSettings) return &pkg, nil } @@ -289,14 +307,16 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter var pkg store.PackageRegistration var teamID, installedBy sql.NullString var manifestJSON string + var pkgSettings string var enabledInt int var isSystemInt int if err := rows.Scan( &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope, &teamID, &installedBy, - &manifestJSON, &enabledInt, &pkg.Status, &pkg.Source, - &pkg.InstalledAt, &pkg.UpdatedAt, + &manifestJSON, &enabledInt, &pkg.Status, + &pkg.SchemaVersion, &pkgSettings, + &pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt, ); err != nil { return nil, err } @@ -305,14 +325,80 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter pkg.TeamID = NullableStringPtr(teamID) pkg.InstalledBy = NullableStringPtr(installedBy) json.Unmarshal([]byte(manifestJSON), &pkg.Manifest) + pkg.PackageSettings = json.RawMessage(pkgSettings) result = append(result, pkg) } return result, rows.Err() } +// ── Scoped visibility (v0.30.0) ────────────────── + +func (s *PackageStore) ListVisiblePackages(ctx context.Context, userID string) ([]store.PackageRegistration, error) { + return s.scanMany(ctx, ` + SELECT `+pkgCols+` + FROM packages p + WHERE p.enabled = 1 + AND ( + p.scope = 'global' + OR (p.scope = 'personal' AND p.installed_by = ?) + OR (p.scope = 'team' AND p.team_id IN ( + SELECT team_id FROM team_members WHERE user_id = ? + )) + ) + ORDER BY p.source, p.title`, userID, userID) +} + +// ── Package lifecycle (v0.30.0) ────────────────── + +func (s *PackageStore) SetSchemaVersion(ctx context.Context, id string, version int) error { + result, err := DB.ExecContext(ctx, + `UPDATE packages SET schema_version = ?, updated_at = datetime('now') WHERE id = ?`, + version, id) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *PackageStore) GetPackageSettings(ctx context.Context, id string) (json.RawMessage, error) { + var settings string + err := DB.QueryRowContext(ctx, + `SELECT package_settings FROM packages WHERE id = ?`, id).Scan(&settings) + if err != nil { + return nil, err + } + return json.RawMessage(settings), nil +} + +func (s *PackageStore) SetPackageSettings(ctx context.Context, id string, settings json.RawMessage) error { + result, err := DB.ExecContext(ctx, + `UPDATE packages SET package_settings = ?, updated_at = datetime('now') WHERE id = ?`, + string(settings), id) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + func nullStrPtr(s *string) sql.NullString { if s == nil { return sql.NullString{} } return sql.NullString{String: *s, Valid: true} } + +// defaultJSON returns the raw message or '{}' if nil/empty. +func defaultJSON(raw json.RawMessage) string { + if len(raw) == 0 { + return "{}" + } + return string(raw) +} diff --git a/src/js/admin-packages.js b/src/js/admin-packages.js index a75acf8..f726c2c 100644 --- a/src/js/admin-packages.js +++ b/src/js/admin-packages.js @@ -3,6 +3,7 @@ // ========================================== // Renders in the "Packages" admin section via ADMIN_LOADERS. // Replaces admin-surfaces.js (v0.28.7). +// v0.30.0: scope column, settings, export, registry browse. // // Exports: window._loadAdminPackages @@ -20,6 +21,8 @@ async function _loadAdminPackages() { '' + '' + '' + + '' + + '' + '' + '' + '' + '' + + '' + + '' + '
Loading...
'; var base = document.body.dataset.basePath || ''; @@ -41,11 +46,18 @@ async function _loadAdminPackages() { if (btn) { btn.addEventListener('click', function() { currentFilter = label === 'All' ? '' : label.toLowerCase(); + document.getElementById('adminPkgRegistryPanel').style.display = 'none'; loadList(); }); } }); + // Browse Registry button + var browseBtn = document.getElementById('pkgBrowseRegistry'); + if (browseBtn) { + browseBtn.addEventListener('click', loadRegistry); + } + // Install form var installSubmit = document.getElementById('pkgInstallSubmit'); var installCancel = document.getElementById('pkgInstallCancel'); @@ -102,12 +114,13 @@ async function _loadAdminPackages() { } var html = '' + - '' + + '' + '' + '' + + '' + '' + '' + - '' + + '' + ''; packages.forEach(function(pkg) { @@ -121,8 +134,15 @@ async function _loadAdminPackages() { core: 'core', builtin: 'builtin', extension: 'installed', + registry: 'registry', }[pkg.source] || pkg.source; + var scopeBadge = { + global: 'global', + team: 'team', + personal: 'personal', + }[pkg.scope] || pkg.scope; + var statusBadge = pkg.enabled ? 'enabled' : 'disabled'; @@ -136,6 +156,14 @@ async function _loadAdminPackages() { actions += ' '; } } + // Settings (if manifest has settings) + if (pkg.manifest && pkg.manifest.settings && pkg.manifest.settings.length > 0) { + actions += ' '; + } + // Export (non-core only) + if (pkg.source !== 'core') { + actions += ' '; + } // Delete (non-core only) if (pkg.source !== 'core') { actions += ''; @@ -143,11 +171,13 @@ async function _loadAdminPackages() { var desc = pkg.description ? '
' + pkg.description + '
' : ''; var system = pkg.is_system ? ' system' : ''; + var schemaVer = pkg.schema_version > 0 ? ' v' + pkg.schema_version + '' : ''; html += '' + - '' + + '' + '' + - '' + + '' + + '' + '' + '' + '' + @@ -180,11 +210,170 @@ async function _loadAdminPackages() { loadList(); }); }); + listEl.querySelectorAll('.pkg-export').forEach(function(btn) { + btn.addEventListener('click', function() { + window.location = base + '/api/v1/admin/packages/' + btn.dataset.id + '/export'; + }); + }); + listEl.querySelectorAll('.pkg-settings').forEach(function(btn) { + btn.addEventListener('click', function() { + loadSettings(btn.dataset.id); + }); + }); } catch (e) { listEl.innerHTML = '
Failed to load packages: ' + e.message + '
'; } } + // ── Settings panel ────────────────────────── + + async function loadSettings(pkgId) { + var panel = document.getElementById('adminPkgSettingsPanel'); + panel.style.display = 'block'; + panel.innerHTML = '
Loading settings...
'; + + try { + var resp = await API._get('/api/v1/admin/packages/' + pkgId + '/settings'); + var schema = resp.schema || []; + var values = resp.values || {}; + + if (typeof values === 'string') { + try { values = JSON.parse(values); } catch(e) { values = {}; } + } + + if (schema.length === 0) { + panel.innerHTML = '

No settings declared by this package.

' + + ''; + document.getElementById('pkgSettingsClose').addEventListener('click', function() { panel.style.display = 'none'; }); + return; + } + + var html = '

Settings: ' + pkgId + '

'; + schema.forEach(function(s) { + var key = s.key; + var val = values[key] !== undefined ? values[key] : (s.default !== undefined ? s.default : ''); + html += '
' + + ''; + + if (s.type === 'select' && s.options) { + html += ''; + } else if (s.type === 'boolean') { + html += ''; + } else if (s.type === 'number') { + html += ''; + } else { + html += ''; + } + html += '
'; + }); + + html += '
' + + '' + + '' + + '
'; + + panel.innerHTML = html; + + document.getElementById('pkgSettingsSave').addEventListener('click', async function() { + var data = {}; + panel.querySelectorAll('.pkg-setting-input').forEach(function(el) { + var key = el.dataset.key; + if (el.dataset.type === 'boolean') { + data[key] = el.checked; + } else if (el.dataset.type === 'number') { + data[key] = parseFloat(el.value) || 0; + } else { + data[key] = el.value; + } + }); + try { + await API._put('/api/v1/admin/packages/' + pkgId + '/settings', data); + UI.toast('Settings saved', 'success'); + panel.style.display = 'none'; + } catch (e) { + UI.toast('Failed to save: ' + e.message, 'error'); + } + }); + document.getElementById('pkgSettingsClose').addEventListener('click', function() { panel.style.display = 'none'; }); + } catch (e) { + panel.innerHTML = '
Failed to load settings: ' + e.message + '
'; + } + } + + // ── Registry browse ───────────────────────── + + async function loadRegistry() { + var panel = document.getElementById('adminPkgRegistryPanel'); + panel.style.display = 'block'; + panel.innerHTML = '
Loading registry...
'; + + try { + var resp = await API._get('/api/v1/admin/packages/registry'); + var packages = resp.packages || []; + var registryUrl = resp.registry_url || ''; + + var html = '
' + + '

Package Registry

' + + '' + + '' + + '
'; + + if (!registryUrl) { + html += '

No registry URL configured. Set package_registry in Admin Settings with a {"url": "https://..."} value.

'; + } else if (packages.length === 0) { + html += '

Registry is empty or returned no packages.

'; + } else { + html += '
PackagePackageTypeVersionScopeSourceStatusActionsActions
' + pkg.title + '' + system + '
' + pkg.id + '
' + desc + '
' + pkg.title + '' + system + schemaVer + '
' + pkg.id + '
' + desc + '
' + typeBadge + '' + (pkg.version || '—') + '' + (pkg.version || '\u2014') + '' + scopeBadge + '' + sourceBadge + '' + statusBadge + '' + actions + '
' + + '' + + ''; + + packages.forEach(function(pkg) { + var installBtn = pkg.installed + ? 'Installed' + : ''; + + html += '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
PackageVersionAuthorActions
' + pkg.title + '
' + + (pkg.description || '') + '
' + (pkg.version || '') + '' + (pkg.author || '') + '' + installBtn + '
'; + } + + panel.innerHTML = html; + + document.getElementById('pkgRegistryClose').addEventListener('click', function() { panel.style.display = 'none'; }); + + panel.querySelectorAll('.registry-install').forEach(function(btn) { + btn.addEventListener('click', async function() { + btn.disabled = true; + btn.textContent = 'Installing...'; + try { + await API._post('/api/v1/admin/packages/registry/install', { + download_url: btn.dataset.url, + }); + UI.toast('Installed from registry', 'success'); + loadList(); + loadRegistry(); // refresh to show "Installed" badge + } catch (e) { + UI.toast('Install failed: ' + e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Install'; + } + }); + }); + } catch (e) { + panel.innerHTML = '
Failed to load registry: ' + e.message + '
'; + } + } + loadList(); }