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

@@ -202,20 +202,20 @@ Depends on: v0.29.1.
- [x] Schema creation on install, drop on uninstall - [x] Schema creation on install, drop on uninstall
- [x] Server-side tool execution in completion handler (deferred from v0.29.1) - [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. `form_template` renders as real UI. LLM is optional for data collection.
Depends on: v0.29.2, v0.29.0 (Starlark validators). 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 `date`, `textarea`, `checkbox`, `file`) with validation rules
- [ ] Stage renders as form when `form_template` has typed fields - [x] Stage renders as form when `form_template` has typed fields
- [ ] LLM-optional stages: form-only, form+chat, chat-only - [x] LLM-optional stages: form-only, form+chat, chat-only
- [ ] Starlark `validate` / `on_submit` hooks - [x] Starlark `validate` / `on_submit` hooks
- [ ] Visitor form entry (branded page, no chat widget) - [x] Visitor form entry (branded page, no chat widget)
- [ ] Form builder in workflow admin (visual field editor) - [x] Form builder in workflow admin (visual field editor)
- [ ] Cross-visitor isolation E2E test (deferred from v0.28.4) - [x] Cross-visitor isolation E2E test (deferred from v0.28.4)
### v0.30.0 — Package Lifecycle ### v0.30.0 — Package Lifecycle
@@ -223,11 +223,11 @@ Lifecycle sophistication for `.pkg` format.
Depends on: v0.29.2. Depends on: v0.29.2.
- [ ] Schema versioning + migrations in manifest - [x] Schema versioning + migrations in manifest
- [ ] Settings extension point (packages declare settings sections) - [x] Settings extension point (packages declare settings sections)
- [ ] Export/import format for cross-instance sharing - [x] Export/import format for cross-instance sharing
- [ ] Package marketplace (discovery, not hosting) - [x] Package marketplace (discovery, not hosting)
- [ ] User-installable packages (RBAC-gated, team/personal scope) - [x] User-installable packages (RBAC-gated, team/personal scope)
### v0.30.1 — SDK Adoption ### v0.30.1 — SDK Adoption

View File

@@ -29,8 +29,10 @@ CREATE TABLE IF NOT EXISTS packages (
enabled BOOLEAN NOT NULL DEFAULT true, enabled BOOLEAN NOT NULL DEFAULT true,
status TEXT NOT NULL DEFAULT 'active' status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'pending_review', 'suspended')), 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' 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(), installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );

View File

@@ -23,8 +23,10 @@ CREATE TABLE IF NOT EXISTS packages (
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active' status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'pending_review', 'suspended')), 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' 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')), installed_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );

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 ( import (
"archive/zip" "archive/zip"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
@@ -14,6 +15,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store" "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). // Replaces SurfaceHandler (v0.28.7).
type PackageHandler struct { type PackageHandler struct {
stores store.Stores 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 { 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} 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. // ListPackages returns all registered packages.
// GET /api/v1/admin/packages // GET /api/v1/admin/packages
// GET /api/v1/admin/surfaces (alias) // 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. // InstallPackage uploads and installs a .pkg/.surface archive.
// POST /api/v1/admin/packages/install // POST /api/v1/admin/packages/install
// POST /api/v1/admin/surfaces/install (alias) // 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) { func (h *PackageHandler) InstallPackage(c *gin.Context) {
file, header, err := c.Request.FormFile("file") var tmpPath string
if err != nil { var cleanupTmp bool
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
// Validate extension // v0.30.0: Check for pre-downloaded file from registry install
validExt := strings.HasSuffix(header.Filename, ".pkg") || if regFile, ok := c.Get("_registry_file"); ok {
strings.HasSuffix(header.Filename, ".surface") || tmpPath = regFile.(string)
strings.HasSuffix(header.Filename, ".zip") // Don't remove — caller manages lifecycle
if !validExt { } else {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"}) file, header, err := c.Request.FormFile("file")
return if err != nil {
} c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
// Size limit: 50MB // Validate extension
if header.Size > 50*1024*1024 { validExt := strings.HasSuffix(header.Filename, ".pkg") ||
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"}) strings.HasSuffix(header.Filename, ".surface") ||
return 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 // Size limit: 50MB
tmpFile, err := os.CreateTemp("", "package-*.zip") if header.Size > 50*1024*1024 {
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) return
return }
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
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() 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 // Open as zip
zr, err := zip.OpenReader(tmpPath) zr, err := zip.OpenReader(tmpPath)
@@ -332,8 +357,14 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
userID := c.GetString("user_id") 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) // 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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
return 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{ c.JSON(http.StatusOK, gin.H{
"id": pkgID, "id": pkgID,
"title": title, "title": title,
"type": pkgType, "type": pkgType,
"version": version, "version": version,
"source": "extension", "source": pkgSource,
"enabled": preservedEnabled, "enabled": preservedEnabled,
}) })
} }
@@ -408,6 +468,100 @@ func extractableRelPath(name string) string {
return "" 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. // ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
// GET /api/v1/surfaces // GET /api/v1/surfaces
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) { 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",
})
}

View File

@@ -707,6 +707,16 @@ func main() {
pkgH := handlers.NewPackageHandler(stores) pkgH := handlers.NewPackageHandler(stores)
protected.GET("/surfaces", pkgH.ListEnabledSurfaces) 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) // Summarize & Continue (backed by compaction service)
compactionSvc := compaction.NewService(stores, roleResolver) compactionSvc := compaction.NewService(stores, roleResolver)
summarize := handlers.NewSummarizeHandler(stores, compactionSvc) summarize := handlers.NewSummarizeHandler(stores, compactionSvc)
@@ -934,6 +944,11 @@ func main() {
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels) 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) // Team audit log (team admins only — RequireTeamAdmin on group)
teamScoped.GET("/audit", teams.ListTeamAuditLog) teamScoped.GET("/audit", teams.ListTeamAuditLog)
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
@@ -1185,12 +1200,25 @@ func main() {
packagesDir = cfg.StoragePath + "/packages" packagesDir = cfg.StoragePath + "/packages"
} }
pkgAdm := handlers.NewPackageHandler(stores, packagesDir) 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", pkgAdm.ListPackages)
admin.GET("/packages/:id", pkgAdm.GetPackage) admin.GET("/packages/:id", pkgAdm.GetPackage)
admin.POST("/packages/install", pkgAdm.InstallPackage) admin.POST("/packages/install", pkgAdm.InstallPackage)
admin.PUT("/packages/:id/enable", pkgAdm.EnablePackage) admin.PUT("/packages/:id/enable", pkgAdm.EnablePackage)
admin.PUT("/packages/:id/disable", pkgAdm.DisablePackage) admin.PUT("/packages/:id/disable", pkgAdm.DisablePackage)
admin.DELETE("/packages/:id", pkgAdm.DeletePackage) 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) // Surface aliases (backward compat — same handlers)
admin.GET("/surfaces", pkgAdm.ListPackages) admin.GET("/surfaces", pkgAdm.ListPackages)

View File

@@ -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 return modules, nil
} }

View File

@@ -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
}
}

View File

@@ -66,6 +66,23 @@ type PackageStore interface {
// DeleteUserSettings removes per-user settings, reverting to defaults. // DeleteUserSettings removes per-user settings, reverting to defaults.
DeleteUserSettings(ctx context.Context, pkgID, userID string) error 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. // PackageRegistration is a row from the packages table.
@@ -84,9 +101,11 @@ type PackageRegistration struct {
Manifest map[string]any `json:"manifest" db:"manifest"` Manifest map[string]any `json:"manifest" db:"manifest"`
Enabled bool `json:"enabled" db:"enabled"` Enabled bool `json:"enabled" db:"enabled"`
Status string `json:"status" db:"status"` Status string `json:"status" db:"status"`
Source string `json:"source" db:"source"` SchemaVersion int `json:"schema_version" db:"schema_version"`
InstalledAt string `json:"installed_at" db:"installed_at"` PackageSettings json.RawMessage `json:"package_settings" db:"package_settings"`
UpdatedAt string `json:"updated_at" db:"updated_at"` 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. // UserPackage combines package info with per-user settings for API responses.

View File

@@ -106,13 +106,16 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
} }
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO packages (id, title, type, version, description, author, tier, 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,
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) 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`, RETURNING installed_at, updated_at`,
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author, pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
pkg.Tier, pkg.IsSystem, pkg.Scope, pkg.Tier, pkg.IsSystem, pkg.Scope,
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy), 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) ).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
} }
@@ -154,6 +157,13 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
WHERE p.enabled = true WHERE p.enabled = true
AND p.type IN ('extension', 'full') AND p.type IN ('extension', 'full')
AND (p.is_system = true OR COALESCE(pus.is_enabled, true) = true) 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) ORDER BY p.title`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -165,6 +175,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
var up store.UserPackage var up store.UserPackage
var teamID, installedBy sql.NullString var teamID, installedBy sql.NullString
var manifestJSON []byte var manifestJSON []byte
var pkgSettings []byte
var userEnabled sql.NullBool var userEnabled sql.NullBool
var userSettings []byte 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.ID, &up.Title, &up.Type, &up.Version, &up.Description,
&up.Author, &up.Tier, &up.IsSystem, &up.Scope, &up.Author, &up.Tier, &up.IsSystem, &up.Scope,
&teamID, &installedBy, &teamID, &installedBy,
&manifestJSON, &up.Enabled, &up.Status, &up.Source, &manifestJSON, &up.Enabled, &up.Status,
&up.InstalledAt, &up.UpdatedAt, &up.SchemaVersion, &pkgSettings,
&up.Source, &up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings, &userEnabled, &userSettings,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -181,6 +193,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
up.TeamID = NullableStringPtr(teamID) up.TeamID = NullableStringPtr(teamID)
up.InstalledBy = NullableStringPtr(installedBy) up.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal(manifestJSON, &up.Manifest) json.Unmarshal(manifestJSON, &up.Manifest)
up.PackageSettings = json.RawMessage(pkgSettings)
if userEnabled.Valid { if userEnabled.Valid {
up.UserEnabled = &userEnabled.Bool 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(). // so column additions don't silently break positional Scan().
const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author, 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.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) { func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
var pkg store.PackageRegistration var pkg store.PackageRegistration
var teamID, installedBy sql.NullString var teamID, installedBy sql.NullString
var manifestJSON []byte var manifestJSON []byte
var pkgSettings []byte
err := DB.QueryRowContext(ctx, query, args...).Scan( err := DB.QueryRowContext(ctx, query, args...).Scan(
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope, &pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
&teamID, &installedBy, &teamID, &installedBy,
&manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source, &manifestJSON, &pkg.Enabled, &pkg.Status,
&pkg.InstalledAt, &pkg.UpdatedAt, &pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@@ -257,6 +274,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
pkg.TeamID = NullableStringPtr(teamID) pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy) pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal(manifestJSON, &pkg.Manifest) json.Unmarshal(manifestJSON, &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
return &pkg, nil return &pkg, nil
} }
@@ -272,23 +290,83 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
var pkg store.PackageRegistration var pkg store.PackageRegistration
var teamID, installedBy sql.NullString var teamID, installedBy sql.NullString
var manifestJSON []byte var manifestJSON []byte
var pkgSettings []byte
if err := rows.Scan( if err := rows.Scan(
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope, &pkg.Author, &pkg.Tier, &pkg.IsSystem, &pkg.Scope,
&teamID, &installedBy, &teamID, &installedBy,
&manifestJSON, &pkg.Enabled, &pkg.Status, &pkg.Source, &manifestJSON, &pkg.Enabled, &pkg.Status,
&pkg.InstalledAt, &pkg.UpdatedAt, &pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
pkg.TeamID = NullableStringPtr(teamID) pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy) pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal(manifestJSON, &pkg.Manifest) json.Unmarshal(manifestJSON, &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
result = append(result, pkg) result = append(result, pkg)
} }
return result, rows.Err() 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. // nullStrPtr converts *string to sql.NullString for nullable FK columns.
func nullStrPtr(s *string) sql.NullString { func nullStrPtr(s *string) sql.NullString {
if s == nil { if s == nil {
@@ -296,3 +374,11 @@ func nullStrPtr(s *string) sql.NullString {
} }
return sql.NullString{String: *s, Valid: true} 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)
}

View File

@@ -111,13 +111,16 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
} }
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO packages (id, title, type, version, description, author, tier, 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) installed_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author, pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
pkg.Tier, pkg.IsSystem, pkg.Scope, pkg.Tier, pkg.IsSystem, pkg.Scope,
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy), 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), now.Format(timeFmt), now.Format(timeFmt),
) )
if err != nil { if err != nil {
@@ -165,7 +168,14 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
WHERE p.enabled = 1 WHERE p.enabled = 1
AND p.type IN ('extension', 'full') AND p.type IN ('extension', 'full')
AND (p.is_system = 1 OR COALESCE(pus.is_enabled, 1) = 1) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -176,6 +186,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
var up store.UserPackage var up store.UserPackage
var teamID, installedBy sql.NullString var teamID, installedBy sql.NullString
var manifestJSON string var manifestJSON string
var pkgSettings string
var enabledInt int var enabledInt int
var isSystemInt int var isSystemInt int
var userEnabled sql.NullBool 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.ID, &up.Title, &up.Type, &up.Version, &up.Description,
&up.Author, &up.Tier, &isSystemInt, &up.Scope, &up.Author, &up.Tier, &isSystemInt, &up.Scope,
&teamID, &installedBy, &teamID, &installedBy,
&manifestJSON, &enabledInt, &up.Status, &up.Source, &manifestJSON, &enabledInt, &up.Status,
&up.InstalledAt, &up.UpdatedAt, &up.SchemaVersion, &pkgSettings,
&up.Source, &up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings, &userEnabled, &userSettings,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -196,6 +208,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
up.TeamID = NullableStringPtr(teamID) up.TeamID = NullableStringPtr(teamID)
up.InstalledBy = NullableStringPtr(installedBy) up.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal([]byte(manifestJSON), &up.Manifest) json.Unmarshal([]byte(manifestJSON), &up.Manifest)
up.PackageSettings = json.RawMessage(pkgSettings)
if userEnabled.Valid { if userEnabled.Valid {
up.UserEnabled = &userEnabled.Bool 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, 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.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) { func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
var pkg store.PackageRegistration var pkg store.PackageRegistration
var teamID, installedBy sql.NullString var teamID, installedBy sql.NullString
var manifestJSON string var manifestJSON string
var pkgSettings string
var enabledInt int var enabledInt int
var isSystemInt int var isSystemInt int
err := DB.QueryRowContext(ctx, query, args...).Scan( err := DB.QueryRowContext(ctx, query, args...).Scan(
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope, &pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
&teamID, &installedBy, &teamID, &installedBy,
&manifestJSON, &enabledInt, &pkg.Status, &pkg.Source, &manifestJSON, &enabledInt, &pkg.Status,
&pkg.InstalledAt, &pkg.UpdatedAt, &pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@@ -274,6 +291,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
pkg.TeamID = NullableStringPtr(teamID) pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy) pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest) json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
return &pkg, nil return &pkg, nil
} }
@@ -289,14 +307,16 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
var pkg store.PackageRegistration var pkg store.PackageRegistration
var teamID, installedBy sql.NullString var teamID, installedBy sql.NullString
var manifestJSON string var manifestJSON string
var pkgSettings string
var enabledInt int var enabledInt int
var isSystemInt int var isSystemInt int
if err := rows.Scan( if err := rows.Scan(
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description, &pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope, &pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
&teamID, &installedBy, &teamID, &installedBy,
&manifestJSON, &enabledInt, &pkg.Status, &pkg.Source, &manifestJSON, &enabledInt, &pkg.Status,
&pkg.InstalledAt, &pkg.UpdatedAt, &pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -305,14 +325,80 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
pkg.TeamID = NullableStringPtr(teamID) pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy) pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest) json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
result = append(result, pkg) result = append(result, pkg)
} }
return result, rows.Err() 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 { func nullStrPtr(s *string) sql.NullString {
if s == nil { if s == nil {
return sql.NullString{} return sql.NullString{}
} }
return sql.NullString{String: *s, Valid: true} 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)
}

View File

@@ -3,6 +3,7 @@
// ========================================== // ==========================================
// Renders in the "Packages" admin section via ADMIN_LOADERS. // Renders in the "Packages" admin section via ADMIN_LOADERS.
// Replaces admin-surfaces.js (v0.28.7). // Replaces admin-surfaces.js (v0.28.7).
// v0.30.0: scope column, settings, export, registry browse.
// //
// Exports: window._loadAdminPackages // Exports: window._loadAdminPackages
@@ -20,6 +21,8 @@ async function _loadAdminPackages() {
'<button class="btn-small" id="pkgFilterSurface">Surfaces</button>' + '<button class="btn-small" id="pkgFilterSurface">Surfaces</button>' +
'<button class="btn-small" id="pkgFilterExtension">Extensions</button>' + '<button class="btn-small" id="pkgFilterExtension">Extensions</button>' +
'<button class="btn-small" id="pkgFilterFull">Full</button>' + '<button class="btn-small" id="pkgFilterFull">Full</button>' +
'<span style="flex:1"></span>' +
'<button class="btn-small" id="pkgBrowseRegistry">Browse Registry</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div id="adminPkgInstallForm" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px">' + '<div id="adminPkgInstallForm" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px">' +
@@ -30,6 +33,8 @@ async function _loadAdminPackages() {
'<button class="btn-small" id="pkgInstallCancel">Cancel</button>' + '<button class="btn-small" id="pkgInstallCancel">Cancel</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div id="adminPkgSettingsPanel" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px"></div>' +
'<div id="adminPkgRegistryPanel" style="display:none;margin-bottom:16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:8px"></div>' +
'<div id="adminPkgList" class="admin-list"><div class="loading">Loading...</div></div>'; '<div id="adminPkgList" class="admin-list"><div class="loading">Loading...</div></div>';
var base = document.body.dataset.basePath || ''; var base = document.body.dataset.basePath || '';
@@ -41,11 +46,18 @@ async function _loadAdminPackages() {
if (btn) { if (btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
currentFilter = label === 'All' ? '' : label.toLowerCase(); currentFilter = label === 'All' ? '' : label.toLowerCase();
document.getElementById('adminPkgRegistryPanel').style.display = 'none';
loadList(); loadList();
}); });
} }
}); });
// Browse Registry button
var browseBtn = document.getElementById('pkgBrowseRegistry');
if (browseBtn) {
browseBtn.addEventListener('click', loadRegistry);
}
// Install form // Install form
var installSubmit = document.getElementById('pkgInstallSubmit'); var installSubmit = document.getElementById('pkgInstallSubmit');
var installCancel = document.getElementById('pkgInstallCancel'); var installCancel = document.getElementById('pkgInstallCancel');
@@ -102,12 +114,13 @@ async function _loadAdminPackages() {
} }
var html = '<table class="admin-table" style="width:100%"><thead><tr>' + var html = '<table class="admin-table" style="width:100%"><thead><tr>' +
'<th style="width:30%">Package</th>' + '<th style="width:25%">Package</th>' +
'<th>Type</th>' + '<th>Type</th>' +
'<th>Version</th>' + '<th>Version</th>' +
'<th>Scope</th>' +
'<th>Source</th>' + '<th>Source</th>' +
'<th>Status</th>' + '<th>Status</th>' +
'<th style="width:100px">Actions</th>' + '<th style="width:140px">Actions</th>' +
'</tr></thead><tbody>'; '</tr></thead><tbody>';
packages.forEach(function(pkg) { packages.forEach(function(pkg) {
@@ -121,8 +134,15 @@ async function _loadAdminPackages() {
core: '<span class="text-muted">core</span>', core: '<span class="text-muted">core</span>',
builtin: '<span class="text-muted">builtin</span>', builtin: '<span class="text-muted">builtin</span>',
extension: '<span class="text-muted">installed</span>', extension: '<span class="text-muted">installed</span>',
registry: '<span class="text-muted">registry</span>',
}[pkg.source] || pkg.source; }[pkg.source] || pkg.source;
var scopeBadge = {
global: '<span class="text-muted">global</span>',
team: '<span class="badge badge-info">team</span>',
personal: '<span class="badge badge-accent">personal</span>',
}[pkg.scope] || pkg.scope;
var statusBadge = pkg.enabled var statusBadge = pkg.enabled
? '<span class="badge badge-success">enabled</span>' ? '<span class="badge badge-success">enabled</span>'
: '<span class="badge badge-muted">disabled</span>'; : '<span class="badge badge-muted">disabled</span>';
@@ -136,6 +156,14 @@ async function _loadAdminPackages() {
actions += '<button class="btn-small btn-ghost pkg-enable" data-id="' + pkg.id + '" title="Enable">Enable</button> '; actions += '<button class="btn-small btn-ghost pkg-enable" data-id="' + pkg.id + '" title="Enable">Enable</button> ';
} }
} }
// Settings (if manifest has settings)
if (pkg.manifest && pkg.manifest.settings && pkg.manifest.settings.length > 0) {
actions += '<button class="btn-small btn-ghost pkg-settings" data-id="' + pkg.id + '" title="Settings">Settings</button> ';
}
// Export (non-core only)
if (pkg.source !== 'core') {
actions += '<button class="btn-small btn-ghost pkg-export" data-id="' + pkg.id + '" title="Export">Export</button> ';
}
// Delete (non-core only) // Delete (non-core only)
if (pkg.source !== 'core') { if (pkg.source !== 'core') {
actions += '<button class="btn-small btn-danger-ghost pkg-delete" data-id="' + pkg.id + '" title="Delete">Delete</button>'; actions += '<button class="btn-small btn-danger-ghost pkg-delete" data-id="' + pkg.id + '" title="Delete">Delete</button>';
@@ -143,11 +171,13 @@ async function _loadAdminPackages() {
var desc = pkg.description ? '<div class="text-muted" style="font-size:11px">' + pkg.description + '</div>' : ''; var desc = pkg.description ? '<div class="text-muted" style="font-size:11px">' + pkg.description + '</div>' : '';
var system = pkg.is_system ? ' <span class="badge badge-muted" style="font-size:10px">system</span>' : ''; var system = pkg.is_system ? ' <span class="badge badge-muted" style="font-size:10px">system</span>' : '';
var schemaVer = pkg.schema_version > 0 ? ' <span class="text-muted" style="font-size:10px">v' + pkg.schema_version + '</span>' : '';
html += '<tr>' + html += '<tr>' +
'<td><strong>' + pkg.title + '</strong>' + system + '<div class="text-muted" style="font-size:11px">' + pkg.id + '</div>' + desc + '</td>' + '<td><strong>' + pkg.title + '</strong>' + system + schemaVer + '<div class="text-muted" style="font-size:11px">' + pkg.id + '</div>' + desc + '</td>' +
'<td>' + typeBadge + '</td>' + '<td>' + typeBadge + '</td>' +
'<td style="font-size:12px;font-family:monospace">' + (pkg.version || '') + '</td>' + '<td style="font-size:12px;font-family:monospace">' + (pkg.version || '\u2014') + '</td>' +
'<td>' + scopeBadge + '</td>' +
'<td>' + sourceBadge + '</td>' + '<td>' + sourceBadge + '</td>' +
'<td>' + statusBadge + '</td>' + '<td>' + statusBadge + '</td>' +
'<td>' + actions + '</td>' + '<td>' + actions + '</td>' +
@@ -180,11 +210,170 @@ async function _loadAdminPackages() {
loadList(); 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) { } catch (e) {
listEl.innerHTML = '<div style="padding:12px;color:var(--danger);">Failed to load packages: ' + e.message + '</div>'; listEl.innerHTML = '<div style="padding:12px;color:var(--danger);">Failed to load packages: ' + e.message + '</div>';
} }
} }
// ── Settings panel ──────────────────────────
async function loadSettings(pkgId) {
var panel = document.getElementById('adminPkgSettingsPanel');
panel.style.display = 'block';
panel.innerHTML = '<div class="loading">Loading settings...</div>';
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 = '<p class="text-muted" style="font-size:12px">No settings declared by this package.</p>' +
'<button class="btn-small" id="pkgSettingsClose">Close</button>';
document.getElementById('pkgSettingsClose').addEventListener('click', function() { panel.style.display = 'none'; });
return;
}
var html = '<h4 style="margin:0 0 12px 0;font-size:13px">Settings: ' + pkgId + '</h4>';
schema.forEach(function(s) {
var key = s.key;
var val = values[key] !== undefined ? values[key] : (s.default !== undefined ? s.default : '');
html += '<div style="margin-bottom:10px">' +
'<label style="font-size:12px;display:block;margin-bottom:4px">' + (s.label || key) + '</label>';
if (s.type === 'select' && s.options) {
html += '<select class="pkg-setting-input" data-key="' + key + '" style="font-size:12px;padding:4px 8px">';
s.options.forEach(function(opt) {
html += '<option value="' + opt + '"' + (opt === val ? ' selected' : '') + '>' + opt + '</option>';
});
html += '</select>';
} else if (s.type === 'boolean') {
html += '<input type="checkbox" class="pkg-setting-input" data-key="' + key + '" data-type="boolean"' + (val ? ' checked' : '') + '>';
} else if (s.type === 'number') {
html += '<input type="number" class="pkg-setting-input" data-key="' + key + '" data-type="number" value="' + val + '" style="font-size:12px;padding:4px 8px;width:100px">';
} else {
html += '<input type="text" class="pkg-setting-input" data-key="' + key + '" value="' + val + '" style="font-size:12px;padding:4px 8px;width:200px">';
}
html += '</div>';
});
html += '<div style="display:flex;gap:8px;margin-top:12px">' +
'<button class="btn-small btn-primary" id="pkgSettingsSave">Save</button>' +
'<button class="btn-small" id="pkgSettingsClose">Close</button>' +
'</div>';
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 = '<div style="color:var(--danger)">Failed to load settings: ' + e.message + '</div>';
}
}
// ── Registry browse ─────────────────────────
async function loadRegistry() {
var panel = document.getElementById('adminPkgRegistryPanel');
panel.style.display = 'block';
panel.innerHTML = '<div class="loading">Loading registry...</div>';
try {
var resp = await API._get('/api/v1/admin/packages/registry');
var packages = resp.packages || [];
var registryUrl = resp.registry_url || '';
var html = '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<h4 style="margin:0;font-size:13px">Package Registry</h4>' +
'<span style="flex:1"></span>' +
'<button class="btn-small" id="pkgRegistryClose">Close</button>' +
'</div>';
if (!registryUrl) {
html += '<p class="text-muted" style="font-size:12px">No registry URL configured. Set <code>package_registry</code> in Admin Settings with a <code>{"url": "https://..."}</code> value.</p>';
} else if (packages.length === 0) {
html += '<p class="text-muted" style="font-size:12px">Registry is empty or returned no packages.</p>';
} else {
html += '<table class="admin-table" style="width:100%"><thead><tr>' +
'<th style="width:30%">Package</th><th>Version</th><th>Author</th><th>Actions</th>' +
'</tr></thead><tbody>';
packages.forEach(function(pkg) {
var installBtn = pkg.installed
? '<span class="badge badge-success">Installed</span>'
: '<button class="btn-small btn-primary registry-install" data-url="' + pkg.download_url + '">Install</button>';
html += '<tr>' +
'<td><strong>' + pkg.title + '</strong><div class="text-muted" style="font-size:11px">' +
(pkg.description || '') + '</div></td>' +
'<td style="font-family:monospace;font-size:12px">' + (pkg.version || '') + '</td>' +
'<td style="font-size:12px">' + (pkg.author || '') + '</td>' +
'<td>' + installBtn + '</td>' +
'</tr>';
});
html += '</tbody></table>';
}
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 = '<div style="color:var(--danger)">Failed to load registry: ' + e.message + '</div>';
}
}
loadList(); loadList();
} }