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

@@ -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()
);

View File

@@ -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'))
);

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package handlers
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
@@ -14,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -25,7 +27,8 @@ var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// Replaces SurfaceHandler (v0.28.7).
type PackageHandler struct {
stores store.Stores
packagesDir string // e.g. /data/packages — where archives are extracted
packagesDir string // e.g. /data/packages — where archives are extracted
sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts
}
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
@@ -36,6 +39,11 @@ func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
return &PackageHandler{stores: s, packagesDir: dir}
}
// SetSandbox attaches a Starlark sandbox for schema migrations (v0.30.0).
func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
h.sandbox = sb
}
// ListPackages returns all registered packages.
// GET /api/v1/admin/packages
// GET /api/v1/admin/surfaces (alias)
@@ -142,44 +150,61 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
// InstallPackage uploads and installs a .pkg/.surface archive.
// POST /api/v1/admin/packages/install
// POST /api/v1/admin/surfaces/install (alias)
//
// Also supports pre-downloaded files via gin context:
// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
// c.Set("_registry_source", "registry") — override source
func (h *PackageHandler) InstallPackage(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
var tmpPath string
var cleanupTmp bool
// Validate extension
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
strings.HasSuffix(header.Filename, ".surface") ||
strings.HasSuffix(header.Filename, ".zip")
if !validExt {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
return
}
// v0.30.0: Check for pre-downloaded file from registry install
if regFile, ok := c.Get("_registry_file"); ok {
tmpPath = regFile.(string)
// Don't remove — caller manages lifecycle
} else {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
// Size limit: 50MB
if header.Size > 50*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
return
}
// Validate extension
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
strings.HasSuffix(header.Filename, ".surface") ||
strings.HasSuffix(header.Filename, ".zip")
if !validExt {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
return
}
// Read into temp file for zip processing
tmpFile, err := os.CreateTemp("", "package-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
// Size limit: 50MB
if header.Size > 50*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
return
}
if _, err := io.Copy(tmpFile, file); err != nil {
// Read into temp file for zip processing
tmpFile, err := os.CreateTemp("", "package-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpPath = tmpFile.Name()
cleanupTmp = true
if _, err := io.Copy(tmpFile, file); err != nil {
tmpFile.Close()
os.Remove(tmpPath)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
return
}
tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
return
}
tmpFile.Close()
if cleanupTmp {
defer os.Remove(tmpPath)
}
// Open as zip
zr, err := zip.OpenReader(tmpPath)
@@ -332,8 +357,14 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
userID := c.GetString("user_id")
// v0.30.0: use registry source if set by registry install handler
pkgSource := "extension"
if src, ok := c.Get("_registry_source"); ok {
pkgSource = src.(string)
}
// Register in database via Seed (upsert — handles re-install)
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, "extension", manifest); err != nil {
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
return
}
@@ -371,12 +402,41 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
}
}
// v0.30.0: Run schema migrations if declared.
newSchemaVersion := ParseSchemaVersion(manifest)
if newSchemaVersion > 0 && h.sandbox != nil {
oldSchemaVersion := 0
if existing != nil {
oldSchemaVersion = existing.SchemaVersion
}
if newSchemaVersion < oldSchemaVersion {
c.JSON(http.StatusConflict, gin.H{
"error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
oldSchemaVersion, newSchemaVersion),
})
return
}
if newSchemaVersion > oldSchemaVersion {
if err := RunSchemaMigrations(
c.Request.Context(), h.sandbox, h.stores,
database.DB, database.IsPostgres(),
pkgID, manifest, oldSchemaVersion, newSchemaVersion,
); err != nil {
log.Printf("[pkg-migrate] migration failed for %s: %v", pkgID, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "schema migration failed: " + err.Error(),
})
return
}
}
}
c.JSON(http.StatusOK, gin.H{
"id": pkgID,
"title": title,
"type": pkgType,
"version": version,
"source": "extension",
"source": pkgSource,
"enabled": preservedEnabled,
})
}
@@ -408,6 +468,100 @@ func extractableRelPath(name string) string {
return ""
}
// ── Package settings (v0.30.0 CS2) ──────────────
// GetPackageSettings returns the settings schema from the manifest merged
// with current admin-configured values.
// GET /api/v1/admin/packages/:id/settings
func (h *PackageHandler) GetPackageSettings(c *gin.Context) {
id := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
// Extract settings schema from manifest
schema, _ := pkg.Manifest["settings"].([]any)
// Get current values
current, err := h.stores.Packages.GetPackageSettings(c.Request.Context(), id)
if err != nil {
current = json.RawMessage("{}")
}
c.JSON(http.StatusOK, gin.H{
"schema": schema,
"values": json.RawMessage(current),
})
}
// UpdatePackageSettings validates and stores admin-configured package settings.
// PUT /api/v1/admin/packages/:id/settings
func (h *PackageHandler) UpdatePackageSettings(c *gin.Context) {
id := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
return
}
// Validate keys against declared schema
schemaArr, _ := pkg.Manifest["settings"].([]any)
validKeys := make(map[string]map[string]any, len(schemaArr))
for _, s := range schemaArr {
if entry, ok := s.(map[string]any); ok {
if key, ok := entry["key"].(string); ok {
validKeys[key] = entry
}
}
}
// Filter to only declared keys and type-check
filtered := make(map[string]any, len(body))
for k, v := range body {
schemaDef, ok := validKeys[k]
if !ok {
continue // skip undeclared keys
}
settingType, _ := schemaDef["type"].(string)
if validateSettingType(v, settingType) {
filtered[k] = v
}
}
settingsJSON, _ := json.Marshal(filtered)
if err := h.stores.Packages.SetPackageSettings(c.Request.Context(), id, json.RawMessage(settingsJSON)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save settings"})
return
}
c.JSON(http.StatusOK, gin.H{"values": json.RawMessage(settingsJSON)})
}
// validateSettingType checks if a value matches the expected setting type.
func validateSettingType(v any, settingType string) bool {
switch settingType {
case "string", "select":
_, ok := v.(string)
return ok
case "number":
_, ok := v.(float64)
return ok
case "boolean":
_, ok := v.(bool)
return ok
default:
return true // unknown types pass through
}
}
// ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
// GET /api/v1/surfaces
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {

View File

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

View File

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

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
}

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(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.

View File

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

View File

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