Changeset 0.28.7 (#193)

This commit is contained in:
2026-03-15 15:45:00 +00:00
parent bffda043db
commit 3237d55e0c
82 changed files with 2481 additions and 2771 deletions

View File

@@ -29,6 +29,7 @@ const (
PermTaskCreate = "task.create" // create scheduled tasks (v0.27.2)
PermTaskAdmin = "task.admin" // manage all tasks, set global task config (v0.27.2)
PermTaskAction = "task.action" // create non-LLM action tasks (v0.28.0)
PermTaskStarlark = "task.starlark" // create Starlark tasks (pre-positioned for v0.29.0)
)
// AllPermissions is the complete set of valid permission strings.
@@ -49,6 +50,7 @@ var AllPermissions = []string{
PermTaskCreate,
PermTaskAdmin,
PermTaskAction,
PermTaskStarlark,
}
// ── Resolution ──────────────────────────────

View File

@@ -1,44 +0,0 @@
-- ==========================================
-- Chat Switchboard — 012 Extensions
-- ==========================================
-- Extension registry and per-user settings.
-- ICD §13 (Extensions)
-- ==========================================
-- =========================================
-- EXTENSIONS
-- =========================================
CREATE TABLE IF NOT EXISTS extensions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ext_id VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
tier VARCHAR(20) NOT NULL DEFAULT 'browser',
description TEXT NOT NULL DEFAULT '',
author VARCHAR(200) NOT NULL DEFAULT '',
manifest JSONB NOT NULL DEFAULT '{}',
is_system BOOLEAN NOT NULL DEFAULT false,
is_enabled BOOLEAN NOT NULL DEFAULT true,
scope VARCHAR(20) NOT NULL DEFAULT 'global',
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled) WHERE is_enabled = true;
-- =========================================
-- EXTENSION USER SETTINGS
-- =========================================
CREATE TABLE IF NOT EXISTS extension_user_settings (
extension_id UUID NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings JSONB NOT NULL DEFAULT '{}',
is_enabled BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (extension_id, user_id)
);

View File

@@ -0,0 +1,8 @@
-- ==========================================
-- Chat Switchboard — 012 (placeholder)
-- ==========================================
-- Previously: extensions + extension_user_settings tables.
-- v0.28.7: Both tables removed. Unified package registry in 016.
-- package_user_settings also in 016 (FK dependency on packages).
-- This file is intentionally empty to preserve migration numbering.
-- ==========================================

View File

@@ -0,0 +1,59 @@
-- ==========================================
-- Chat Switchboard — 016 Packages
-- ==========================================
-- Unified package registry. Replaces surface_registry (v0.25.0)
-- and extensions (v0.11.0) tables. A package is a surface, an
-- extension, or both.
--
-- v0.28.7: packages table + package_user_settings in 012.
-- ==========================================
CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'surface'
CHECK (type IN ('surface', 'extension', 'full')),
version TEXT NOT NULL DEFAULT '0.0.0',
description TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
tier TEXT NOT NULL DEFAULT 'browser'
CHECK (tier IN ('browser', 'starlark', 'sidecar')),
is_system BOOLEAN NOT NULL DEFAULT false,
scope TEXT NOT NULL DEFAULT 'global'
CHECK (scope IN ('global', 'team', 'personal')),
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
installed_by UUID REFERENCES users(id) ON DELETE SET NULL,
manifest JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT true,
source TEXT NOT NULL DEFAULT 'core'
CHECK (source IN ('core', 'builtin', 'extension')),
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type);
CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled) WHERE enabled = true;
CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id);
CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source);
COMMENT ON TABLE packages IS 'Unified package registry. Surfaces, extensions, and full packages. Replaces surface_registry + extensions tables.';
COMMENT ON COLUMN packages.id IS 'Slug identifier from manifest "id" field. Used in URLs: /s/:id';
COMMENT ON COLUMN packages.type IS 'surface = routable page, extension = hooks/tools/pipes, full = both';
COMMENT ON COLUMN packages.source IS 'core = page-engine seeded, builtin = extensions/builtin/ seeded, extension = admin-uploaded .pkg';
COMMENT ON COLUMN packages.enabled IS 'Admin toggle — disabled surfaces redirect to / and hide from nav';
-- =========================================
-- PACKAGE USER SETTINGS
-- =========================================
-- Per-user overrides for packages (enable/disable, custom config).
-- Replaces extension_user_settings from the former extensions schema.
-- Lives in 016 (not 012) because of FK dependency on packages table.
CREATE TABLE IF NOT EXISTS package_user_settings (
package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings JSONB NOT NULL DEFAULT '{}',
is_enabled BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (package_id, user_id)
);

View File

@@ -1,21 +0,0 @@
-- ==========================================
-- Chat Switchboard — 016 Surfaces
-- ==========================================
-- Surface registry for dynamic surface lifecycle.
-- Core surfaces seeded at startup. Extension surfaces uploaded via admin.
-- Consolidated v0.28.0: renumbered from 022.
-- ==========================================
CREATE TABLE IF NOT EXISTS surface_registry (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
manifest JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT true,
source TEXT NOT NULL DEFAULT 'core',
installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE surface_registry IS 'Registry of all surfaces (core + extension). Controls enable/disable and stores manifest metadata.';
COMMENT ON COLUMN surface_registry.source IS 'core = built-in, extension = uploaded via admin';
COMMENT ON COLUMN surface_registry.enabled IS 'Admin toggle — disabled surfaces redirect to / and hide from nav';

View File

@@ -1,30 +0,0 @@
-- Chat Switchboard — 012 Extensions (SQLite)
CREATE TABLE IF NOT EXISTS extensions (
id TEXT PRIMARY KEY,
ext_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '0.0.0',
tier TEXT NOT NULL DEFAULT 'browser',
description TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
manifest TEXT NOT NULL DEFAULT '{}',
is_system INTEGER NOT NULL DEFAULT 0,
is_enabled INTEGER NOT NULL DEFAULT 1,
scope TEXT NOT NULL DEFAULT 'global',
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_extensions_tier ON extensions(tier);
CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(is_enabled);
CREATE TABLE IF NOT EXISTS extension_user_settings (
extension_id TEXT NOT NULL REFERENCES extensions(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings TEXT NOT NULL DEFAULT '{}',
is_enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (extension_id, user_id)
);

View File

@@ -0,0 +1,3 @@
-- Chat Switchboard — 012 (placeholder, SQLite)
-- Previously: extensions + extension_user_settings tables.
-- v0.28.7: Both tables removed. Unified package registry in 016.

View File

@@ -0,0 +1,43 @@
-- Chat Switchboard — 016 Packages (SQLite)
-- Unified package registry. Replaces surface_registry + extensions.
-- v0.28.7
CREATE TABLE IF NOT EXISTS packages (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'surface'
CHECK (type IN ('surface', 'extension', 'full')),
version TEXT NOT NULL DEFAULT '0.0.0',
description TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
tier TEXT NOT NULL DEFAULT 'browser'
CHECK (tier IN ('browser', 'starlark', 'sidecar')),
is_system INTEGER NOT NULL DEFAULT 0,
scope TEXT NOT NULL DEFAULT 'global'
CHECK (scope IN ('global', 'team', 'personal')),
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
manifest TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'core'
CHECK (source IN ('core', 'builtin', 'extension')),
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type);
CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled);
CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id);
CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source);
-- Per-user overrides for packages (enable/disable, custom config).
-- Lives in 016 (not 012) because of FK dependency on packages table.
CREATE TABLE IF NOT EXISTS package_user_settings (
package_id TEXT NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
settings TEXT NOT NULL DEFAULT '{}',
is_enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (package_id, user_id)
);

View File

@@ -1,12 +0,0 @@
-- Chat Switchboard — 016 Surfaces (SQLite)
-- Consolidated v0.28.0: was missing from SQLite entirely
CREATE TABLE IF NOT EXISTS surface_registry (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
manifest TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'core',
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -325,12 +325,11 @@ func TruncateAll(t *testing.T) {
"refresh_tokens",
"user_presence",
"users",
// Config & extensions
// Config & packages
"platform_policies",
"global_settings",
"extension_user_settings",
"extensions",
"surface_registry",
"package_user_settings",
"packages",
"oidc_auth_state",
}

View File

@@ -899,10 +899,10 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
// Browser extension tools
if h.hub != nil && h.hub.IsConnected(userID) {
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID)
if err == nil {
for _, ext := range exts {
if ext.Tier != "browser" {
for _, pkg := range pkgs {
if pkg.Tier != "browser" {
continue
}
var manifest struct {
@@ -912,7 +912,7 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
Description string `json:"description"`
} `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil {
continue
}
for _, t := range manifest.Tools {

View File

@@ -145,9 +145,6 @@ func TestExtension_AdminCRUDLifecycle(t *testing.T) {
if extID == "" {
t.Fatal("extension should have an id")
}
if ext["ext_id"].(string) != "test-ext" {
t.Fatalf("ext_id mismatch: got %q", ext["ext_id"])
}
if ext["version"].(string) != "1.0.0" {
t.Fatalf("version mismatch: got %q", ext["version"])
}
@@ -178,8 +175,8 @@ func TestExtension_AdminCRUDLifecycle(t *testing.T) {
var updated map[string]interface{}
decode(w, &updated)
updatedData := updated["data"].(map[string]interface{})
if updatedData["name"].(string) != "Updated Extension" {
t.Fatalf("name not updated: got %q", updatedData["name"])
if updatedData["title"].(string) != "Updated Extension" {
t.Fatalf("name not updated: got %q", updatedData["title"])
}
if updatedData["version"].(string) != "2.0.0" {
t.Fatalf("version not updated: got %q", updatedData["version"])
@@ -687,8 +684,8 @@ func TestExtension_PartialUpdate(t *testing.T) {
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].(map[string]interface{})
if data["name"].(string) != "New Name" {
t.Fatalf("name not updated: got %q", data["name"])
if data["title"].(string) != "New Name" {
t.Fatalf("name not updated: got %q", data["title"])
}
if data["version"].(string) != "1.0.0" {
t.Fatalf("version should be unchanged: got %q", data["version"])
@@ -721,15 +718,15 @@ func TestExtension_StoreListEnabled(t *testing.T) {
// ListEnabled should return only the enabled one
ctx := context.Background()
exts, err := h.stores.Extensions.ListEnabled(ctx)
pkgs, err := h.stores.Packages.ListEnabledByType(ctx, "extension")
if err != nil {
t.Fatalf("ListEnabled: %v", err)
}
if len(exts) != 1 {
t.Fatalf("expected 1 enabled, got %d", len(exts))
if len(pkgs) != 1 {
t.Fatalf("expected 1 enabled, got %d", len(pkgs))
}
if exts[0].ExtID != "enabled-ext" {
t.Fatalf("wrong extension: got %q", exts[0].ExtID)
if pkgs[0].ID != "enabled-ext" {
t.Fatalf("wrong extension: got %q", pkgs[0].ID)
}
}
@@ -742,7 +739,7 @@ func TestExtension_StoreGetUserSettings(t *testing.T) {
ctx := context.Background()
// No settings yet → sql.ErrNoRows
_, err := h.stores.Extensions.GetUserSettings(ctx, extID, h.userID)
_, err := h.stores.Packages.GetUserSettings(ctx, extID, h.userID)
if err != sql.ErrNoRows {
t.Fatalf("expected ErrNoRows, got %v", err)
}
@@ -757,15 +754,15 @@ func TestExtension_StoreGetUserSettings(t *testing.T) {
}
// Now GetUserSettings should return them
eus, err := h.stores.Extensions.GetUserSettings(ctx, extID, h.userID)
pus, err := h.stores.Packages.GetUserSettings(ctx, extID, h.userID)
if err != nil {
t.Fatalf("GetUserSettings: %v", err)
}
if !eus.IsEnabled {
if !pus.IsEnabled {
t.Fatal("should be enabled")
}
var settings map[string]interface{}
json.Unmarshal(eus.Settings, &settings)
json.Unmarshal(pus.Settings, &settings)
if settings["key"] != "value" {
t.Fatalf("settings mismatch: %v", settings)
}
@@ -788,19 +785,19 @@ func TestExtension_StoreDeleteUserSettings(t *testing.T) {
}
// Verify present
_, err := h.stores.Extensions.GetUserSettings(ctx, extID, h.userID)
_, err := h.stores.Packages.GetUserSettings(ctx, extID, h.userID)
if err != nil {
t.Fatalf("should exist: %v", err)
}
// Delete via store
err = h.stores.Extensions.DeleteUserSettings(ctx, extID, h.userID)
err = h.stores.Packages.DeleteUserSettings(ctx, extID, h.userID)
if err != nil {
t.Fatalf("DeleteUserSettings: %v", err)
}
// Verify gone
_, err = h.stores.Extensions.GetUserSettings(ctx, extID, h.userID)
_, err = h.stores.Packages.GetUserSettings(ctx, extID, h.userID)
if err != sql.ErrNoRows {
t.Fatalf("should be gone, got %v", err)
}
@@ -845,29 +842,27 @@ func TestSeed_NewExtension(t *testing.T) {
},
})
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
// Should be created as system extension
ext, err := h.stores.Extensions.GetByExtID(ctx, "test-seeded")
pkg, err := h.stores.Packages.Get(ctx, "test-seeded")
if err != nil {
t.Fatalf("seeded extension not found: %v", err)
}
if ext.Name != "Seeded Extension" {
t.Fatalf("name: got %q", ext.Name)
if pkg.Title != "Seeded Extension" {
t.Fatalf("name: got %q", pkg.Title)
}
if ext.Version != "1.0.0" {
t.Fatalf("version: got %q", ext.Version)
if pkg.Version != "1.0.0" {
t.Fatalf("version: got %q", pkg.Version)
}
if !ext.IsSystem {
if !pkg.IsSystem {
t.Fatal("should be system extension")
}
if !ext.IsEnabled {
if !pkg.Enabled {
t.Fatal("should be enabled")
}
// Verify _script is inlined in manifest
var manifest map[string]json.RawMessage
json.Unmarshal(ext.Manifest, &manifest)
if _, ok := manifest["_script"]; !ok {
if _, ok := pkg.Manifest["_script"]; !ok {
t.Fatal("manifest should contain _script")
}
}
@@ -884,17 +879,17 @@ func TestSeed_SameVersionSkips(t *testing.T) {
})
// Seed twice
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
// Should still have exactly one
exts, err := h.stores.Extensions.ListAll(ctx)
pkgs, err := h.stores.Packages.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
count := 0
for _, e := range exts {
if e.ExtID == "skip-ext" {
for _, p := range pkgs {
if p.ID == "skip-ext" {
count++
}
}
@@ -914,11 +909,11 @@ func TestSeed_VersionChangeUpdates(t *testing.T) {
script: `console.log("v1");`,
},
})
SeedBuiltinExtensions(h.stores, dir1)
SeedBuiltinPackages(h.stores, dir1)
ext, _ := h.stores.Extensions.GetByExtID(ctx, "versioned-ext")
if ext.Version != "1.0.0" {
t.Fatalf("v1 version: got %q", ext.Version)
pkg, _ := h.stores.Packages.Get(ctx, "versioned-ext")
if pkg.Version != "1.0.0" {
t.Fatalf("v1 version: got %q", pkg.Version)
}
// Seed v2 (different version triggers update)
@@ -928,14 +923,14 @@ func TestSeed_VersionChangeUpdates(t *testing.T) {
script: `console.log("v2");`,
},
})
SeedBuiltinExtensions(h.stores, dir2)
SeedBuiltinPackages(h.stores, dir2)
ext, _ = h.stores.Extensions.GetByExtID(ctx, "versioned-ext")
if ext.Version != "2.0.0" {
t.Fatalf("v2 version: got %q", ext.Version)
pkg, _ = h.stores.Packages.Get(ctx, "versioned-ext")
if pkg.Version != "2.0.0" {
t.Fatalf("v2 version: got %q", pkg.Version)
}
if ext.Name != "V2 Name" {
t.Fatalf("v2 name: got %q", ext.Name)
if pkg.Title != "V2 Name" {
t.Fatalf("v2 name: got %q", pkg.Title)
}
}
@@ -948,14 +943,14 @@ func TestSeed_MissingManifestSkips(t *testing.T) {
"no-manifest": {script: `console.log("orphan");`},
})
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
exts, err := h.stores.Extensions.ListAll(ctx)
pkgs, err := h.stores.Packages.List(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(exts) != 0 {
t.Fatalf("should skip dir without manifest, got %d extensions", len(exts))
if len(pkgs) != 0 {
t.Fatalf("should skip dir without manifest, got %d extensions", len(pkgs))
}
}
@@ -967,11 +962,11 @@ func TestSeed_InvalidManifestSkips(t *testing.T) {
"bad-manifest": {manifest: `{not valid json`, script: `console.log("bad");`},
})
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
exts, _ := h.stores.Extensions.ListAll(ctx)
if len(exts) != 0 {
t.Fatalf("should skip invalid manifest, got %d extensions", len(exts))
pkgs, _ := h.stores.Packages.List(ctx)
if len(pkgs) != 0 {
t.Fatalf("should skip invalid manifest, got %d extensions", len(pkgs))
}
}
@@ -984,11 +979,11 @@ func TestSeed_MissingIDSkips(t *testing.T) {
"no-id": {manifest: `{"name":"No ID Extension","version":"1.0.0"}`, script: `console.log("no id");`},
})
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
exts, _ := h.stores.Extensions.ListAll(ctx)
if len(exts) != 0 {
t.Fatalf("should skip manifest without id, got %d extensions", len(exts))
pkgs, _ := h.stores.Packages.List(ctx)
if len(pkgs) != 0 {
t.Fatalf("should skip manifest without id, got %d extensions", len(pkgs))
}
}
@@ -996,11 +991,11 @@ func TestSeed_NonexistentDirectorySkips(t *testing.T) {
h := setupExtensionHarness(t)
// Should not panic on missing directory
SeedBuiltinExtensions(h.stores, "/nonexistent/path/extensions")
SeedBuiltinPackages(h.stores, "/nonexistent/path/extensions")
exts, _ := h.stores.Extensions.ListAll(context.Background())
if len(exts) != 0 {
t.Fatalf("should seed nothing from missing dir, got %d", len(exts))
pkgs, _ := h.stores.Packages.List(context.Background())
if len(pkgs) != 0 {
t.Fatalf("should seed nothing from missing dir, got %d", len(pkgs))
}
}
@@ -1013,16 +1008,14 @@ func TestSeed_ScriptOptional(t *testing.T) {
"manifest-only": {manifest: `{"id":"manifest-only","name":"Manifest Only","version":"1.0.0"}`},
})
SeedBuiltinExtensions(h.stores, dir)
SeedBuiltinPackages(h.stores, dir)
ext, err := h.stores.Extensions.GetByExtID(ctx, "manifest-only")
pkg, err := h.stores.Packages.Get(ctx, "manifest-only")
if err != nil {
t.Fatalf("should create manifest-only ext: %v", err)
}
// Manifest should NOT contain _script
var manifest map[string]json.RawMessage
json.Unmarshal(ext.Manifest, &manifest)
if _, ok := manifest["_script"]; ok {
if _, ok := pkg.Manifest["_script"]; ok {
t.Fatal("manifest should not contain _script when no script.js exists")
}
}

View File

@@ -13,6 +13,7 @@ import (
)
// ExtensionHandler serves extension management endpoints.
// v0.28.7: Backed by PackageStore (packages table) instead of ExtensionStore.
type ExtensionHandler struct {
stores store.Stores
}
@@ -35,45 +36,41 @@ var validTiers = map[string]bool{
// GET /api/v1/extensions
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
userID := c.GetString("user_id")
tier := c.Query("tier") // optional filter
tier := c.Query("tier")
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
if tier != "" {
filtered := make([]models.UserExtension, 0)
for _, e := range exts {
if e.Tier == tier {
filtered = append(filtered, e)
filtered := make([]store.UserPackage, 0)
for _, p := range pkgs {
if p.Tier == tier {
filtered = append(filtered, p)
}
}
exts = filtered
pkgs = filtered
}
c.JSON(200, gin.H{"data": exts})
c.JSON(200, gin.H{"data": pkgs})
}
// UpdateUserExtensionSettings saves per-user settings for an extension.
// POST /api/v1/extensions/:id/settings
func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
userID := c.GetString("user_id")
extID := c.Param("id")
pkgID := c.Param("id")
// Verify extension exists
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), extID)
if err == sql.ErrNoRows {
// Verify package exists
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
// System extensions can't be disabled by users
// System packages can't be disabled by users
var body struct {
Settings json.RawMessage `json:"settings"`
IsEnabled *bool `json:"is_enabled"`
@@ -85,7 +82,7 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
enabled := true
if body.IsEnabled != nil {
if ext.IsSystem && !*body.IsEnabled {
if pkg.IsSystem && !*body.IsEnabled {
c.JSON(403, gin.H{"error": "system extensions cannot be disabled"})
return
}
@@ -97,13 +94,13 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
settings = body.Settings
}
eus := &models.ExtensionUserSettings{
ExtensionID: extID,
UserID: userID,
Settings: settings,
IsEnabled: enabled,
pus := &store.PackageUserSettings{
PackageID: pkgID,
UserID: userID,
Settings: settings,
IsEnabled: enabled,
}
if err := h.stores.Extensions.SetUserSettings(c.Request.Context(), eus); err != nil {
if err := h.stores.Packages.SetUserSettings(c.Request.Context(), pus); err != nil {
c.JSON(500, gin.H{"error": "failed to save settings"})
return
}
@@ -113,19 +110,28 @@ func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
// ── Admin endpoints ─────────────────────────────
// AdminListExtensions returns all extensions (enabled and disabled).
// AdminListExtensions returns all extension-type packages.
// GET /api/v1/admin/extensions
func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) {
exts, err := h.stores.Extensions.ListAll(c.Request.Context())
pkgs, err := h.stores.Packages.ListByType(c.Request.Context(), "extension")
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
c.JSON(200, gin.H{"data": exts})
// Also include 'full' type packages
fullPkgs, err := h.stores.Packages.ListByType(c.Request.Context(), "full")
if err == nil {
pkgs = append(pkgs, fullPkgs...)
}
if pkgs == nil {
pkgs = []store.PackageRegistration{}
}
c.JSON(200, gin.H{"data": pkgs})
}
// AdminInstallExtension installs an extension from a manifest.
// AdminInstallExtension installs an extension from a JSON body (legacy path).
// POST /api/v1/admin/extensions
// New installs should use POST /admin/packages/install with a .pkg archive.
func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
userID := c.GetString("user_id")
@@ -162,32 +168,36 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
return
}
// Check for duplicate ext_id
existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID)
if err != nil && err != sql.ErrNoRows {
c.JSON(500, gin.H{"error": "failed to check existing extension"})
return
}
// Check for duplicate (ext_id is now the package ID)
existing, _ := h.stores.Packages.Get(c.Request.Context(), body.ExtID)
if existing != nil {
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
return
}
ext := &models.Extension{
ExtID: body.ExtID,
Name: body.Name,
// Parse manifest into map
var manifestMap map[string]any
if err := json.Unmarshal(body.Manifest, &manifestMap); err != nil {
manifestMap = map[string]any{}
}
pkg := &store.PackageRegistration{
ID: body.ExtID,
Title: body.Name,
Type: "extension",
Version: body.Version,
Tier: body.Tier,
Description: body.Description,
Author: body.Author,
Manifest: body.Manifest,
Tier: body.Tier,
IsSystem: body.IsSystem,
IsEnabled: body.IsEnabled,
Scope: models.ScopeGlobal,
Scope: "global",
Manifest: manifestMap,
Enabled: body.IsEnabled,
Source: "extension",
InstalledBy: &userID,
}
if err := h.stores.Extensions.Create(c.Request.Context(), ext); err != nil {
if err := h.stores.Packages.Create(c.Request.Context(), pkg); err != nil {
if database.IsUniqueViolation(err) {
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
return
@@ -196,23 +206,19 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
return
}
c.JSON(201, gin.H{"data": ext})
c.JSON(201, gin.H{"data": pkg})
}
// AdminUpdateExtension updates an extension's config.
// AdminUpdateExtension updates an extension-type package.
// PUT /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
id := c.Param("id")
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
var body struct {
Name *string `json:"name"`
@@ -229,62 +235,58 @@ func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
}
if body.Name != nil {
ext.Name = *body.Name
pkg.Title = *body.Name
}
if body.Version != nil {
ext.Version = *body.Version
pkg.Version = *body.Version
}
if body.Description != nil {
ext.Description = *body.Description
pkg.Description = *body.Description
}
if body.Author != nil {
ext.Author = *body.Author
pkg.Author = *body.Author
}
if body.IsSystem != nil {
ext.IsSystem = *body.IsSystem
pkg.IsSystem = *body.IsSystem
}
if body.IsEnabled != nil {
ext.IsEnabled = *body.IsEnabled
pkg.Enabled = *body.IsEnabled
}
if body.Manifest != nil {
ext.Manifest = *body.Manifest
var manifestMap map[string]any
if err := json.Unmarshal(*body.Manifest, &manifestMap); err == nil {
pkg.Manifest = manifestMap
}
}
if err := h.stores.Extensions.Update(c.Request.Context(), id, ext); err != nil {
if err := h.stores.Packages.Update(c.Request.Context(), id, pkg); err != nil {
c.JSON(500, gin.H{"error": "failed to update extension"})
return
}
c.JSON(200, gin.H{"data": ext})
c.JSON(200, gin.H{"data": pkg})
}
// ServeExtensionAsset serves browser extension JS files.
// GET /api/v1/extensions/:id/assets/*path
//
// For the MVP, scripts are stored inline in manifest._script.
// The :id param is the ext_id (e.g. "mermaid-renderer"), not the UUID.
func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
extID := c.Param("id")
// path := c.Param("path") // reserved for future multi-file support
pkgID := c.Param("id")
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
if err == sql.ErrNoRows {
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
if !ext.IsEnabled {
if !pkg.Enabled {
c.JSON(404, gin.H{"error": "extension not enabled"})
return
}
// Extract inline script from manifest
manifestBytes := marshalManifest(pkg.Manifest)
var manifest map[string]json.RawMessage
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
c.JSON(500, gin.H{"error": "invalid manifest"})
return
}
@@ -295,7 +297,6 @@ func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
return
}
// _script is a JSON string — unquote it
var script string
if err := json.Unmarshal(scriptRaw, &script); err != nil {
c.JSON(500, gin.H{"error": "invalid script in manifest"})
@@ -310,43 +311,39 @@ func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
// GetExtensionManifest returns the manifest for a specific extension.
// GET /api/v1/extensions/:id/manifest
func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) {
extID := c.Param("id")
pkgID := c.Param("id")
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
if err == sql.ErrNoRows {
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
c.JSON(200, gin.H{"data": ext.Manifest})
c.JSON(200, gin.H{"data": pkg.Manifest})
}
// ListBrowserToolSchemas returns tool schemas from all enabled browser extensions.
// Used by the completion handler to include browser tools in LLM requests.
// GET /api/v1/extensions/tools
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
userID := c.GetString("user_id")
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
var toolSchemas []json.RawMessage
for _, ext := range exts {
if ext.Tier != "browser" {
for _, pkg := range pkgs {
if pkg.Tier != "browser" {
continue
}
manifestBytes := marshalManifest(pkg.Manifest)
var manifest struct {
Tools []json.RawMessage `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
log.Printf("[extensions] failed to parse manifest for %s: %v", ext.ExtID, err)
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
log.Printf("[extensions] failed to parse manifest for %s: %v", pkg.ID, err)
continue
}
toolSchemas = append(toolSchemas, manifest.Tools...)
@@ -358,25 +355,39 @@ func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
c.JSON(200, gin.H{"data": toolSchemas})
}
// AdminUninstallExtension removes an extension.
// AdminUninstallExtension removes an extension-type package.
// DELETE /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
id := c.Param("id")
_, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
if err := h.stores.Extensions.Delete(c.Request.Context(), id); err != nil {
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
if err == sql.ErrNoRows {
c.JSON(400, gin.H{"error": "core packages cannot be deleted"})
return
}
c.JSON(500, gin.H{"error": "failed to uninstall extension"})
return
}
c.JSON(200, gin.H{"ok": true})
}
// marshalManifest converts map[string]any → []byte for struct unmarshaling.
// PackageRegistration.Manifest is map[string]any (from JSONB scan), but
// several callsites need to unmarshal into typed structs.
func marshalManifest(m map[string]any) []byte {
if m == nil {
return []byte("{}")
}
b, err := json.Marshal(m)
if err != nil {
return []byte("{}")
}
return b
}

433
server/handlers/packages.go Normal file
View File

@@ -0,0 +1,433 @@
package handlers
import (
"archive/zip"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// validPackageID matches lowercase alphanumeric slugs with optional hyphens.
// No leading/trailing hyphens, 2-64 chars.
var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// PackageHandler manages unified package lifecycle (admin-only).
// Replaces SurfaceHandler (v0.28.7).
type PackageHandler struct {
stores store.Stores
packagesDir string // e.g. /data/packages — where archives are extracted
}
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
dir := ""
if len(packagesDir) > 0 {
dir = packagesDir[0]
}
return &PackageHandler{stores: s, packagesDir: dir}
}
// ListPackages returns all registered packages.
// GET /api/v1/admin/packages
// GET /api/v1/admin/surfaces (alias)
func (h *PackageHandler) ListPackages(c *gin.Context) {
typeFilter := c.Query("type")
var pkgs []store.PackageRegistration
var err error
if typeFilter != "" {
pkgs, err = h.stores.Packages.ListByType(c.Request.Context(), typeFilter)
} else {
pkgs, err = h.stores.Packages.List(c.Request.Context())
}
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})
}
// GetPackage returns a single package by ID.
// GET /api/v1/admin/packages/:id
// GET /api/v1/admin/surfaces/:id (alias)
func (h *PackageHandler) GetPackage(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
}
c.JSON(http.StatusOK, pkg)
}
// EnablePackage enables a package.
// PUT /api/v1/admin/packages/:id/enable
// PUT /api/v1/admin/surfaces/:id/enable (alias)
func (h *PackageHandler) EnablePackage(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
}
// DisablePackage disables a package. Chat and Admin cannot be disabled.
// PUT /api/v1/admin/packages/:id/disable
// PUT /api/v1/admin/surfaces/:id/disable (alias)
func (h *PackageHandler) DisablePackage(c *gin.Context) {
id := c.Param("id")
if id == "chat" || id == "admin" {
c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"})
return
}
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, false); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
}
// DeletePackage uninstalls a package. Core packages cannot be deleted.
// DELETE /api/v1/admin/packages/:id
// DELETE /api/v1/admin/surfaces/:id (alias)
func (h *PackageHandler) DeletePackage(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": "core packages cannot be uninstalled"})
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 extracted static assets
if h.packagesDir != "" {
assetDir := filepath.Join(h.packagesDir, id)
if err := os.RemoveAll(assetDir); err != nil {
log.Printf("⚠️ Failed to clean up package assets at %s: %v", assetDir, err)
}
}
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
}
// InstallPackage uploads and installs a .pkg/.surface archive.
// POST /api/v1/admin/packages/install
// POST /api/v1/admin/surfaces/install (alias)
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()
// 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
}
// Size limit: 50MB
if header.Size > 50*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
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)
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
zr, err := zip.OpenReader(tmpPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
return
}
defer zr.Close()
// Find and parse manifest.json
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, err := io.ReadAll(rc)
rc.Close()
if err != nil {
continue
}
if err := json.Unmarshal(data, &manifest); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
return
}
break
}
}
if manifest == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
return
}
// Validate required fields
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
}
// Validate package ID is a safe slug
if !validPackageID.MatchString(pkgID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"})
return
}
// Determine type (default: surface for backward compat)
pkgType, _ := manifest["type"].(string)
if pkgType == "" {
pkgType = "surface"
}
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', or 'full'"})
return
}
// Type-specific validation
hasRoute := manifest["route"] != nil || manifest["routes"] != nil
hasTools := manifest["tools"] != nil
hasPipes := manifest["pipes"] != nil
hasHooks := manifest["hooks"] != nil
hasExtBehavior := hasTools || hasPipes || hasHooks
switch pkgType {
case "surface":
// route is optional for surfaces (core surfaces don't always have it in manifest)
case "extension":
if !hasExtBehavior {
c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages require at least one of: tools, pipes, hooks"})
return
}
if hasRoute {
c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages cannot have a route — use type 'full' for both"})
return
}
case "full":
if !hasRoute {
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require a route"})
return
}
if !hasExtBehavior {
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require at least one of: tools, pipes, hooks"})
return
}
}
// Check for conflicts with core packages
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: " + pkgID})
return
}
// Extract static assets (js/, css/, assets/) to packagesDir/{id}/
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)
// Security: prevent path traversal
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()
}
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
}
// Extract manifest fields for DB columns
version, _ := manifest["version"].(string)
if version == "" {
version = "0.0.0"
}
description, _ := manifest["description"].(string)
author, _ := manifest["author"].(string)
tier, _ := manifest["tier"].(string)
if tier == "" {
tier = "browser"
}
userID := c.GetString("user_id")
// Register in database via Seed (upsert — handles re-install)
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, "extension", manifest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
return
}
// Read back to get the preserved enabled state (Seed doesn't override it)
existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID)
preservedEnabled := true
if existing != nil {
preservedEnabled = existing.Enabled
}
// Update fields that Seed doesn't set (type, version, author, etc.)
pkg := &store.PackageRegistration{
Title: title,
Type: pkgType,
Version: version,
Description: description,
Author: author,
Tier: tier,
IsSystem: false,
Enabled: preservedEnabled,
Manifest: manifest,
}
if userID != "" {
pkg.InstalledBy = &userID
}
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
}
c.JSON(http.StatusOK, gin.H{
"id": pkgID,
"title": title,
"type": pkgType,
"version": version,
"source": "extension",
"enabled": preservedEnabled,
})
}
// extractableRelPath returns the relative path for a zip entry if it
// belongs to an extractable static directory (js/, css/, assets/).
// Returns "" if the file should be skipped.
func extractableRelPath(name string) string {
staticPrefixes := []string{"js/", "css/", "assets/"}
for _, p := range staticPrefixes {
if strings.HasPrefix(name, p) {
return name
}
}
idx := strings.Index(name, "/")
if idx <= 0 {
return ""
}
rest := name[idx+1:]
for _, p := range staticPrefixes {
if strings.HasPrefix(rest, p) {
return rest
}
}
return ""
}
// ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
// GET /api/v1/surfaces
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
pkgs, err := h.stores.Packages.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
return
}
type navSurface struct {
ID string `json:"id"`
Title string `json:"title"`
Route string `json:"route"`
}
var enabled []navSurface
for _, p := range pkgs {
if !p.Enabled {
continue
}
// Only surface and full types have routes
if p.Type != "surface" && p.Type != "full" {
continue
}
route, _ := p.Manifest["route"].(string)
enabled = append(enabled, navSurface{
ID: p.ID,
Title: p.Title,
Route: route,
})
}
if enabled == nil {
enabled = []navSurface{}
}
c.JSON(http.StatusOK, gin.H{"data": enabled})
}

View File

@@ -206,14 +206,14 @@ func BuildToolDefs(
}
// Append browser-defined tool schemas from extensions
if includeBrowser && stores.Extensions != nil {
exts, err := stores.Extensions.ListForUser(ctx, userID)
if includeBrowser && stores.Packages != nil {
pkgs, err := stores.Packages.ListForUser(ctx, userID)
if err != nil {
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
return defs
}
for _, ext := range exts {
if ext.Tier != "browser" {
for _, pkg := range pkgs {
if pkg.Tier != "browser" {
continue
}
var manifest struct {
@@ -224,7 +224,7 @@ func BuildToolDefs(
Tier string `json:"tier"`
} `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil {
continue
}
for _, t := range manifest.Tools {

View File

@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -25,9 +24,9 @@ type builtinManifest struct {
Settings json.RawMessage `json:"settings"`
}
// SeedBuiltinExtensions scans a directory for builtin extension bundles
// SeedBuiltinPackages scans a directory for builtin extension bundles
// (each subdirectory contains manifest.json + script.js) and upserts them
// into the extensions table as system extensions.
// into the packages table as system packages.
//
// Layout:
//
@@ -36,10 +35,10 @@ type builtinManifest struct {
// manifest.json
// script.js
//
// Idempotent: skips extensions whose version matches what's already installed,
// Idempotent: skips packages whose version matches what's already installed,
// updates those with a newer version, creates new ones.
func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) {
if stores.Extensions == nil {
func SeedBuiltinPackages(stores store.Stores, extensionsDir string) {
if stores.Packages == nil {
return
}
@@ -89,64 +88,92 @@ func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) {
script = string(scriptData)
}
// Build the full manifest JSONB with _script inlined
// Build the full manifest with _script inlined
fullManifest := buildFullManifest(manifestData, script)
// Parse into map for Seed/Update
var manifestMap map[string]any
json.Unmarshal(fullManifest, &manifestMap)
// Check if already installed
existing, err := stores.Extensions.GetByExtID(ctx, manifest.ID)
if err == nil {
// Already exists — check version
existing, err := stores.Packages.Get(ctx, manifest.ID)
if err == nil && existing != nil {
// Resolve tier for fixup + update paths
tier := manifest.Tier
if tier == "" {
tier = "browser"
}
// Same version — skip, but fixup type/tier if wrong
// (v0.28.7 initial seed created builtins with type='surface'
// because Seed() uses schema DEFAULT and Update() didn't set type)
if existing.Version == manifest.Version {
if existing.Type != "extension" || existing.Tier != tier {
pkg := &store.PackageRegistration{
Title: existing.Title, Type: "extension", Version: existing.Version,
Description: existing.Description, Author: existing.Author,
Tier: tier, Manifest: existing.Manifest,
IsSystem: true, Enabled: existing.Enabled,
}
if err := stores.Packages.Update(ctx, manifest.ID, pkg); err != nil {
log.Printf("⚠ Failed to fixup builtin package %s: %v", manifest.ID, err)
}
}
skipped++
continue
}
// Version changed — update
existing.Name = manifest.Name
existing.Version = manifest.Version
existing.Description = manifest.Description
existing.Author = manifest.Author
existing.Manifest = fullManifest
existing.IsSystem = true
existing.IsEnabled = true
if err := stores.Extensions.Update(ctx, existing.ID, existing); err != nil {
log.Printf("⚠ Failed to update builtin extension %s: %v", manifest.ID, err)
pkg := &store.PackageRegistration{
Title: manifest.Name,
Type: "extension",
Version: manifest.Version,
Description: manifest.Description,
Author: manifest.Author,
Tier: tier,
Manifest: manifestMap,
IsSystem: true,
Enabled: true,
}
if err := stores.Packages.Update(ctx, manifest.ID, pkg); err != nil {
log.Printf("⚠ Failed to update builtin package %s: %v", manifest.ID, err)
continue
}
updated++
continue
}
// New extension — create
// New package — seed with full metadata
if err := stores.Packages.Seed(ctx, manifest.ID, manifest.Name, "builtin", manifestMap); err != nil {
log.Printf("⚠ Failed to seed builtin package %s: %v", manifest.ID, err)
continue
}
// Set extension-specific fields that Seed doesn't cover
tier := manifest.Tier
if tier == "" {
tier = models.ExtTierBrowser
tier = "browser"
}
ext := &models.Extension{
ExtID: manifest.ID,
Name: manifest.Name,
pkg := &store.PackageRegistration{
Title: manifest.Name,
Type: "extension",
Version: manifest.Version,
Tier: tier,
Description: manifest.Description,
Author: manifest.Author,
Manifest: fullManifest,
Tier: tier,
Manifest: manifestMap,
IsSystem: true,
IsEnabled: true,
Scope: "global",
Enabled: true,
}
if err := stores.Extensions.Create(ctx, ext); err != nil {
log.Printf("⚠ Failed to seed builtin extension %s: %v", manifest.ID, err)
continue
if err := stores.Packages.Update(ctx, manifest.ID, pkg); err != nil {
log.Printf("⚠ Failed to update builtin package metadata %s: %v", manifest.ID, err)
}
seeded++
}
if seeded+updated > 0 {
log.Printf(" 📦 Builtin extensions: %d seeded, %d updated, %d unchanged", seeded, updated, skipped)
log.Printf(" 📦 Builtin packages: %d seeded, %d updated, %d unchanged", seeded, updated, skipped)
} else if skipped > 0 {
log.Printf(" 📦 Builtin extensions: %d up to date", skipped)
log.Printf(" 📦 Builtin packages: %d up to date", skipped)
}
}
@@ -156,10 +183,8 @@ func buildFullManifest(rawManifest []byte, script string) json.RawMessage {
return json.RawMessage(rawManifest)
}
// Parse manifest as generic map, add _script, re-serialize
var m map[string]json.RawMessage
if err := json.Unmarshal(rawManifest, &m); err != nil {
// Fallback: just return raw manifest
return json.RawMessage(rawManifest)
}

View File

@@ -1,386 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
)
// surfaceStore returns the appropriate SurfaceRegistryStore for the
// current test database backend.
func surfaceStore() store.SurfaceRegistryStore {
if database.IsSQLite() {
return sqlite.NewSurfaceRegistryStore()
}
return postgres.NewSurfaceRegistryStore()
}
// ── Store: Seed ─────────────────────────────
func TestSurfaceStore_Seed_Basic(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
manifest := map[string]any{"route": "/test", "scripts": []string{"app.js"}}
if err := s.Seed(ctx, "test-surface", "Test Surface", "core", manifest); err != nil {
t.Fatalf("Seed: %v", err)
}
sr, err := s.Get(ctx, "test-surface")
if err != nil || sr == nil {
t.Fatal("Get after Seed: surface should exist")
}
if sr.ID != "test-surface" {
t.Errorf("ID: got %q, want %q", sr.ID, "test-surface")
}
if sr.Title != "Test Surface" {
t.Errorf("Title: got %q, want %q", sr.Title, "Test Surface")
}
if sr.Source != "core" {
t.Errorf("Source: got %q, want %q", sr.Source, "core")
}
if !sr.Enabled {
t.Error("newly seeded surface should be enabled")
}
route, _ := sr.Manifest["route"].(string)
if route != "/test" {
t.Errorf("Manifest route: got %q, want %q", route, "/test")
}
}
func TestSurfaceStore_Seed_PreservesEnabledFlag(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed, then disable
s.Seed(ctx, "my-surface", "v1", "core", map[string]any{"route": "/v1"})
s.SetEnabled(ctx, "my-surface", false)
// Re-seed with updated title and manifest
s.Seed(ctx, "my-surface", "v2", "core", map[string]any{"route": "/v2"})
sr, _ := s.Get(ctx, "my-surface")
if sr == nil {
t.Fatal("surface should exist after re-seed")
}
if sr.Title != "v2" {
t.Errorf("Title should be updated: got %q, want %q", sr.Title, "v2")
}
if sr.Enabled {
t.Error("Enabled should be preserved as false across re-seed")
}
route, _ := sr.Manifest["route"].(string)
if route != "/v2" {
t.Errorf("Manifest should be updated: got %q, want %q", route, "/v2")
}
}
func TestSurfaceStore_Seed_NilManifest(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed with nil manifest — should not panic
err := s.Seed(ctx, "nil-manifest", "Nil Manifest", "core", nil)
if err != nil {
t.Fatalf("Seed with nil manifest: %v", err)
}
sr, _ := s.Get(ctx, "nil-manifest")
if sr == nil {
t.Fatal("surface should exist")
}
}
// ── Store: List ─────────────────────────────
func TestSurfaceStore_List_Empty(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
surfaces, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
// Should return nil or empty slice, not an error
if surfaces != nil && len(surfaces) != 0 {
t.Errorf("empty DB should return 0 surfaces, got %d", len(surfaces))
}
}
func TestSurfaceStore_List_Order(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed in reverse alphabetical order
s.Seed(ctx, "zebra", "Zebra", "extension", map[string]any{})
s.Seed(ctx, "alpha", "Alpha", "core", map[string]any{})
s.Seed(ctx, "beta", "Beta", "core", map[string]any{})
surfaces, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(surfaces) != 3 {
t.Fatalf("want 3 surfaces, got %d", len(surfaces))
}
// Ordered by source, then title: core first (Alpha, Beta), then extension (Zebra)
if surfaces[0].ID != "alpha" {
t.Errorf("first: got %q, want %q", surfaces[0].ID, "alpha")
}
if surfaces[1].ID != "beta" {
t.Errorf("second: got %q, want %q", surfaces[1].ID, "beta")
}
if surfaces[2].ID != "zebra" {
t.Errorf("third: got %q, want %q", surfaces[2].ID, "zebra")
}
}
func TestSurfaceStore_List_IncludesDisabled(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
s.Seed(ctx, "enabled-one", "Enabled", "core", map[string]any{})
s.Seed(ctx, "disabled-one", "Disabled", "extension", map[string]any{})
s.SetEnabled(ctx, "disabled-one", false)
surfaces, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(surfaces) != 2 {
t.Fatalf("List should return all surfaces including disabled, got %d", len(surfaces))
}
}
// ── Store: ListEnabled ──────────────────────
func TestSurfaceStore_ListEnabled(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
s.Seed(ctx, "chat", "Chat", "core", map[string]any{})
s.Seed(ctx, "editor", "Editor", "core", map[string]any{})
s.Seed(ctx, "my-ext", "My Ext", "extension", map[string]any{})
s.SetEnabled(ctx, "editor", false)
ids, err := s.ListEnabled(ctx)
if err != nil {
t.Fatalf("ListEnabled: %v", err)
}
if len(ids) != 2 {
t.Fatalf("want 2 enabled IDs, got %d: %v", len(ids), ids)
}
// Verify disabled one is excluded
for _, id := range ids {
if id == "editor" {
t.Error("disabled surface 'editor' should not appear in ListEnabled")
}
}
}
func TestSurfaceStore_ListEnabled_Empty(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
ids, err := s.ListEnabled(ctx)
if err != nil {
t.Fatalf("ListEnabled: %v", err)
}
if ids != nil && len(ids) != 0 {
t.Errorf("empty DB should return 0 enabled IDs, got %d", len(ids))
}
}
// ── Store: Get ──────────────────────────────
func TestSurfaceStore_Get_NotFound(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
sr, err := s.Get(ctx, "nonexistent")
if err != nil {
t.Errorf("Get nonexistent should return (nil, nil), got err: %v", err)
}
if sr != nil {
t.Error("Get nonexistent should return nil surface")
}
}
func TestSurfaceStore_Get_ManifestRoundTrip(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
manifest := map[string]any{
"route": "/s/complex",
"scripts": []any{"app.js", "vendor.js"},
"css": []any{"style.css"},
"nested": map[string]any{"key": "value"},
}
s.Seed(ctx, "complex", "Complex", "extension", manifest)
sr, err := s.Get(ctx, "complex")
if err != nil || sr == nil {
t.Fatal("Get should return the surface")
}
// Verify manifest round-trips correctly
got, _ := json.Marshal(sr.Manifest)
want, _ := json.Marshal(manifest)
if string(got) != string(want) {
t.Errorf("Manifest round-trip mismatch:\n got: %s\n want: %s", got, want)
}
}
// ── Store: SetEnabled ───────────────────────
func TestSurfaceStore_SetEnabled_NotFound(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
err := s.SetEnabled(ctx, "nonexistent", true)
if err == nil {
t.Error("SetEnabled on nonexistent surface should return error")
}
}
// ── Store: Delete ───────────────────────────
func TestSurfaceStore_Delete_ExtensionOnly(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
// Seed a core surface
s.Seed(ctx, "chat", "Chat", "core", map[string]any{})
// Try to delete it at the store level — should fail
err := s.Delete(ctx, "chat")
if err == nil {
t.Error("Delete of core surface should return error at store level")
}
// Verify still exists
sr, _ := s.Get(ctx, "chat")
if sr == nil {
t.Error("core surface should survive Delete attempt")
}
}
func TestSurfaceStore_Delete_Extension(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
s.Seed(ctx, "my-ext", "My Extension", "extension", map[string]any{})
err := s.Delete(ctx, "my-ext")
if err != nil {
t.Fatalf("Delete extension: %v", err)
}
sr, _ := s.Get(ctx, "my-ext")
if sr != nil {
t.Error("extension surface should be gone after Delete")
}
}
func TestSurfaceStore_Delete_NotFound(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := surfaceStore()
ctx := context.Background()
err := s.Delete(ctx, "nonexistent")
if err == nil {
t.Error("Delete nonexistent should return error")
}
}
// ── Handler + Store Integration: ListEnabled response shape ─────
// This test verifies the exact JSON contract that the frontend depends on.
func TestSurface_ListEnabled_ResponseContract(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d", w.Code)
}
// Parse as raw JSON to verify exact shape
var raw map[string]json.RawMessage
json.Unmarshal(w.Body.Bytes(), &raw)
surfacesJSON, ok := raw["surfaces"]
if !ok {
t.Fatal("response must have 'surfaces' key")
}
var surfaces []map[string]interface{}
json.Unmarshal(surfacesJSON, &surfaces)
if len(surfaces) != 1 {
t.Fatalf("want 1 surface, got %d", len(surfaces))
}
s := surfaces[0]
// Must have exactly id, title, route
expectedKeys := map[string]bool{"id": true, "title": true, "route": true}
for key := range s {
if !expectedKeys[key] {
t.Errorf("unexpected key in response: %q", key)
}
}
for key := range expectedKeys {
if _, ok := s[key]; !ok {
t.Errorf("missing expected key: %q", key)
}
}
}

View File

@@ -1,346 +0,0 @@
package handlers
import (
"archive/zip"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// validSurfaceID matches lowercase alphanumeric slugs with optional hyphens.
// No leading/trailing hyphens, 2-64 chars.
var validSurfaceID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
// SurfaceHandler manages surface lifecycle (admin-only).
type SurfaceHandler struct {
stores store.Stores
surfacesDir string // e.g. /data/surfaces — where archives are extracted
}
func NewSurfaceHandler(s store.Stores, surfacesDir ...string) *SurfaceHandler {
dir := ""
if len(surfacesDir) > 0 {
dir = surfacesDir[0]
}
return &SurfaceHandler{stores: s, surfacesDir: dir}
}
// ListSurfaces returns all registered surfaces with their enabled state.
// GET /api/v1/admin/surfaces
func (h *SurfaceHandler) ListSurfaces(c *gin.Context) {
surfaces, err := h.stores.Surfaces.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
return
}
if surfaces == nil {
surfaces = []store.SurfaceRegistration{}
}
c.JSON(http.StatusOK, gin.H{"surfaces": surfaces})
}
// GetSurface returns a single surface by ID.
// GET /api/v1/admin/surfaces/:id
func (h *SurfaceHandler) GetSurface(c *gin.Context) {
id := c.Param("id")
surface, err := h.stores.Surfaces.Get(c.Request.Context(), id)
if err != nil || surface == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, surface)
}
// EnableSurface enables a surface.
// PUT /api/v1/admin/surfaces/:id/enable
func (h *SurfaceHandler) EnableSurface(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, true); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
}
// DisableSurface disables a surface. Chat cannot be disabled.
// PUT /api/v1/admin/surfaces/:id/disable
func (h *SurfaceHandler) DisableSurface(c *gin.Context) {
id := c.Param("id")
// Chat and Admin are required — cannot be disabled
if id == "chat" || id == "admin" {
c.JSON(http.StatusBadRequest, gin.H{"error": id + " surface cannot be disabled"})
return
}
if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, false); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
}
// DeleteSurface uninstalls an extension surface. Core surfaces cannot be deleted.
// DELETE /api/v1/admin/surfaces/:id
func (h *SurfaceHandler) DeleteSurface(c *gin.Context) {
id := c.Param("id")
// Check it's not a core surface
surface, err := h.stores.Surfaces.Get(c.Request.Context(), id)
if err != nil || surface == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
if surface.Source == "core" {
c.JSON(http.StatusBadRequest, gin.H{"error": "core surfaces cannot be uninstalled"})
return
}
if err := h.stores.Surfaces.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete surface"})
return
}
// Clean up extracted static assets for this surface
if h.surfacesDir != "" {
assetDir := filepath.Join(h.surfacesDir, id)
if err := os.RemoveAll(assetDir); err != nil {
log.Printf("⚠️ Failed to clean up surface assets at %s: %v", assetDir, err)
}
}
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
}
// InstallSurface uploads and installs a .surface archive.
// POST /api/v1/admin/surfaces/install (multipart: file)
func (h *SurfaceHandler) InstallSurface(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()
// Validate extension
if !strings.HasSuffix(header.Filename, ".surface") && !strings.HasSuffix(header.Filename, ".zip") {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .surface or .zip archive"})
return
}
// Size limit: 50MB
if header.Size > 50*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
return
}
// Read into temp file for zip processing
tmpFile, err := os.CreateTemp("", "surface-*.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
zr, err := zip.OpenReader(tmpPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
return
}
defer zr.Close()
// Find and parse manifest.json
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, err := io.ReadAll(rc)
rc.Close()
if err != nil {
continue
}
if err := json.Unmarshal(data, &manifest); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
return
}
break
}
}
if manifest == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
return
}
// Validate required fields
surfaceID, _ := manifest["id"].(string)
title, _ := manifest["title"].(string)
if surfaceID == "" || title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
return
}
// Validate surface ID is a safe slug — prevents path traversal in
// filesystem operations (filepath.Join(surfacesDir, surfaceID)).
if !validSurfaceID.MatchString(surfaceID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "surface id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"})
return
}
// Check for conflicts with core surfaces
existing, _ := h.stores.Surfaces.Get(c.Request.Context(), surfaceID)
if existing != nil && existing.Source == "core" {
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core surface: " + surfaceID})
return
}
// Extract static assets (js/, css/, assets/) to surfacesDir/{id}/
if h.surfacesDir != "" {
destDir := filepath.Join(h.surfacesDir, surfaceID)
os.MkdirAll(destDir, 0755)
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
// Resolve the relative path, stripping an optional single
// top-level directory prefix from the archive.
relPath := extractableRelPath(f.Name)
if relPath == "" {
continue // Not an extractable static file
}
destPath := filepath.Join(destDir, relPath)
// Security: prevent path traversal
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()
}
log.Printf("[surfaces] Extracted %s to %s", surfaceID, destDir)
}
// Register in database
if err := h.stores.Surfaces.Seed(c.Request.Context(), surfaceID, title, "extension", manifest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register surface"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": surfaceID,
"title": title,
"source": "extension",
"enabled": true,
})
}
// extractableRelPath returns the relative path for a zip entry if it
// belongs to an extractable static directory (js/, css/, assets/).
// Returns "" if the file should be skipped.
//
// Handles two archive layouts:
//
// Prefixed: my-surface/js/app.js → js/app.js
// Flat: js/app.js → js/app.js
//
// Files not under js/, css/, or assets/ (e.g., manifest.json, script.js
// at root) are skipped.
func extractableRelPath(name string) string {
// Static directory prefixes we extract
staticPrefixes := []string{"js/", "css/", "assets/"}
// Check if already a relative static path (flat archive)
for _, p := range staticPrefixes {
if strings.HasPrefix(name, p) {
return name
}
}
// Check for a single directory prefix to strip
idx := strings.Index(name, "/")
if idx <= 0 {
return "" // No slash, or starts with slash — skip
}
rest := name[idx+1:]
for _, p := range staticPrefixes {
if strings.HasPrefix(rest, p) {
return rest
}
}
return "" // Not under a static directory
}
// ListEnabledSurfaces returns enabled surface IDs (non-admin, for nav).
// GET /api/v1/surfaces
func (h *SurfaceHandler) ListEnabledSurfaces(c *gin.Context) {
surfaces, err := h.stores.Surfaces.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
return
}
// Return only enabled surfaces with minimal info for nav
type navSurface struct {
ID string `json:"id"`
Title string `json:"title"`
Route string `json:"route"`
}
var enabled []navSurface
for _, s := range surfaces {
if !s.Enabled {
continue
}
route, _ := s.Manifest["route"].(string)
enabled = append(enabled, navSurface{
ID: s.ID,
Title: s.Title,
Route: route,
})
}
if enabled == nil {
enabled = []navSurface{}
}
c.JSON(http.StatusOK, gin.H{"surfaces": enabled})
}

View File

@@ -1,605 +0,0 @@
package handlers
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
)
// ── Surface Test Harness ────────────────────
type surfaceHarness struct {
router *gin.Engine
t *testing.T
stores store.Stores
adminToken string
userToken string
tmpDir string
}
func setupSurfaceHarness(t *testing.T) *surfaceHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
tmpDir, err := os.MkdirTemp("", "surface-test-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tmpDir) })
r := gin.New()
api := r.Group("/api/v1")
// User endpoint (authenticated)
surfaceH := NewSurfaceHandler(stores)
userGroup := api.Group("")
userGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
userGroup.GET("/surfaces", surfaceH.ListEnabledSurfaces)
// Admin endpoints
surfaceAdm := NewSurfaceHandler(stores, tmpDir)
adminGroup := api.Group("/admin")
adminGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
adminGroup.Use(middleware.RequireAdmin())
adminGroup.GET("/surfaces", surfaceAdm.ListSurfaces)
adminGroup.GET("/surfaces/:id", surfaceAdm.GetSurface)
adminGroup.POST("/surfaces/install", surfaceAdm.InstallSurface)
adminGroup.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface)
adminGroup.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface)
adminGroup.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface)
// Seed admin user
adminID := database.SeedTestUser(t, "surf-admin", "surf-admin@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID)
adminToken := makeToken(adminID, "surf-admin@test.com", "admin")
// Seed regular user
userID := database.SeedTestUser(t, "surf-user", "surf-user@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "surf-user@test.com", "user")
return &surfaceHarness{
router: r,
t: t,
stores: stores,
adminToken: adminToken,
userToken: userToken,
tmpDir: tmpDir,
}
}
func (h *surfaceHarness) jsonReq(method, path, token string, body interface{}) *httptest.ResponseRecorder {
h.t.Helper()
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
h.router.ServeHTTP(w, req)
return w
}
func (h *surfaceHarness) uploadSurface(token string, archive []byte, filename string) *httptest.ResponseRecorder {
h.t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
h.t.Fatalf("CreateFormFile: %v", err)
}
part.Write(archive)
writer.Close()
req := httptest.NewRequest("POST", "/api/v1/admin/surfaces/install", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
h.router.ServeHTTP(w, req)
return w
}
func (h *surfaceHarness) seedCoreSurface(id, title string) {
h.t.Helper()
manifest := map[string]any{"route": "/" + id}
if err := h.stores.Surfaces.Seed(context.Background(), id, title, "core", manifest); err != nil {
h.t.Fatalf("seedCoreSurface(%s): %v", id, err)
}
}
func (h *surfaceHarness) seedExtensionSurface(id, title string, enabled bool) {
h.t.Helper()
manifest := map[string]any{"route": "/s/" + id}
if err := h.stores.Surfaces.Seed(context.Background(), id, title, "extension", manifest); err != nil {
h.t.Fatalf("seedExtensionSurface(%s): %v", id, err)
}
if !enabled {
if err := h.stores.Surfaces.SetEnabled(context.Background(), id, false); err != nil {
h.t.Fatalf("disable extension %s: %v", id, err)
}
}
}
// ── Tests: User Endpoint ────────────────────
func TestSurface_ListEnabled_Empty(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct{ Surfaces []json.RawMessage `json:"surfaces"` }
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Surfaces) != 0 {
t.Errorf("want 0 surfaces, got %d", len(resp.Surfaces))
}
}
func TestSurface_ListEnabled_FiltersDisabled(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
h.seedExtensionSurface("my-ext", "My Extension", true)
h.seedExtensionSurface("disabled-ext", "Disabled Extension", false)
w := h.jsonReq("GET", "/api/v1/surfaces", h.userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Surfaces []struct {
ID string `json:"id"`
Title string `json:"title"`
Route string `json:"route"`
} `json:"surfaces"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Surfaces) != 2 {
t.Fatalf("want 2 enabled surfaces, got %d", len(resp.Surfaces))
}
// Response shape: id + title + route only — no source or enabled
raw := w.Body.Bytes()
if bytes.Contains(raw, []byte(`"source"`)) {
t.Error("user endpoint should not expose 'source' field")
}
if bytes.Contains(raw, []byte(`"enabled"`)) {
t.Error("user endpoint should not expose 'enabled' field")
}
}
// ── Tests: Admin List / Get ─────────────────
func TestSurface_AdminList_IncludesDisabled(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
h.seedExtensionSurface("my-ext", "My Extension", true)
h.seedExtensionSurface("disabled-ext", "Disabled Extension", false)
w := h.jsonReq("GET", "/api/v1/admin/surfaces", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Surfaces []struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
Source string `json:"source"`
} `json:"surfaces"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp.Surfaces) != 3 {
t.Fatalf("want 3 surfaces (including disabled), got %d", len(resp.Surfaces))
}
}
func TestSurface_AdminList_RequiresAdmin(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("GET", "/api/v1/admin/surfaces", h.userToken, nil)
if w.Code != http.StatusForbidden {
t.Errorf("non-admin should get 403, got %d", w.Code)
}
}
func TestSurface_Get(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("GET", "/api/v1/admin/surfaces/chat", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var sr store.SurfaceRegistration
json.Unmarshal(w.Body.Bytes(), &sr)
if sr.ID != "chat" {
t.Errorf("id: want %q, got %q", "chat", sr.ID)
}
if sr.Source != "core" {
t.Errorf("source: want %q, got %q", "core", sr.Source)
}
}
func TestSurface_Get_NotFound(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("GET", "/api/v1/admin/surfaces/nonexistent", h.adminToken, nil)
if w.Code != http.StatusNotFound {
t.Errorf("want 404, got %d", w.Code)
}
}
// ── Tests: Enable / Disable ─────────────────
func TestSurface_EnableDisable(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedExtensionSurface("my-ext", "My Extension", true)
// Disable
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/disable", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("disable: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr.Enabled {
t.Error("surface should be disabled")
}
// Re-enable
w = h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/enable", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("enable: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ = h.stores.Surfaces.Get(context.Background(), "my-ext")
if !sr.Enabled {
t.Error("surface should be enabled")
}
}
func TestSurface_DisableChat_Rejected(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/chat/disable", h.adminToken, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("disabling chat: want 400, got %d", w.Code)
}
}
func TestSurface_DisableAdmin_Rejected(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("admin", "Admin")
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/admin/disable", h.adminToken, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("disabling admin: want 400, got %d", w.Code)
}
}
func TestSurface_DisableNonexistent(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("PUT", "/api/v1/admin/surfaces/nonexistent/disable", h.adminToken, nil)
if w.Code != http.StatusNotFound {
t.Errorf("disable nonexistent: want 404, got %d", w.Code)
}
}
// ── Tests: Delete ───────────────────────────
func TestSurface_DeleteExtension(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedExtensionSurface("my-ext", "My Extension", true)
// Create a fake asset dir to verify cleanup
assetDir := filepath.Join(h.tmpDir, "my-ext")
os.MkdirAll(filepath.Join(assetDir, "js"), 0755)
os.WriteFile(filepath.Join(assetDir, "js", "app.js"), []byte("// test"), 0644)
w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/my-ext", h.adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr != nil {
t.Error("surface should be deleted from DB")
}
if _, err := os.Stat(assetDir); !os.IsNotExist(err) {
t.Error("surface asset directory should be removed")
}
}
func TestSurface_DeleteCore_Rejected(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/chat", h.adminToken, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("delete core: want 400, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "chat")
if sr == nil {
t.Error("core surface should still exist after rejected delete")
}
}
func TestSurface_DeleteNonexistent(t *testing.T) {
h := setupSurfaceHarness(t)
w := h.jsonReq("DELETE", "/api/v1/admin/surfaces/nonexistent", h.adminToken, nil)
if w.Code != http.StatusNotFound {
t.Errorf("delete nonexistent: want 404, got %d", w.Code)
}
}
// ── Tests: Install ──────────────────────────
func TestSurface_Install_HappyPath(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildSurfaceZip(t, "hello-dash", "Hello Dashboard", map[string]string{
"js/app.js": "console.log('hello');",
"css/style.css": "body { color: red; }",
})
w := h.uploadSurface(h.adminToken, archive, "hello-dash.surface")
if w.Code != http.StatusOK {
t.Fatalf("install: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
ID string `json:"id"`
Title string `json:"title"`
Source string `json:"source"`
}
json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ID != "hello-dash" {
t.Errorf("id: got %q, want %q", resp.ID, "hello-dash")
}
if resp.Source != "extension" {
t.Errorf("source: got %q, want %q", resp.Source, "extension")
}
// Verify in DB
sr, err := h.stores.Surfaces.Get(context.Background(), "hello-dash")
if err != nil || sr == nil {
t.Fatal("surface should exist in DB after install")
}
if sr.Title != "Hello Dashboard" {
t.Errorf("title: got %q, want %q", sr.Title, "Hello Dashboard")
}
// Verify assets extracted
jsPath := filepath.Join(h.tmpDir, "hello-dash", "js", "app.js")
if _, err := os.Stat(jsPath); os.IsNotExist(err) {
t.Error("js/app.js should be extracted")
}
cssPath := filepath.Join(h.tmpDir, "hello-dash", "css", "style.css")
if _, err := os.Stat(cssPath); os.IsNotExist(err) {
t.Error("css/style.css should be extracted")
}
}
func TestSurface_Install_PrefixedArchive(t *testing.T) {
h := setupSurfaceHarness(t)
manifest, _ := json.Marshal(map[string]string{
"id": "hello-dash", "title": "Hello Dashboard", "route": "/s/hello-dash",
})
archive := buildZipRaw(t, map[string]string{
"hello-dash/manifest.json": string(manifest),
"hello-dash/js/app.js": "console.log('hello');",
"hello-dash/css/style.css": "body {}",
})
w := h.uploadSurface(h.adminToken, archive, "hello-dash.surface")
if w.Code != http.StatusOK {
t.Fatalf("install prefixed: want 200, got %d: %s", w.Code, w.Body.String())
}
jsPath := filepath.Join(h.tmpDir, "hello-dash", "js", "app.js")
if _, err := os.Stat(jsPath); os.IsNotExist(err) {
t.Error("js/app.js should be extracted from prefixed archive")
}
}
func TestSurface_Install_MissingManifest(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildZipRaw(t, map[string]string{
"js/app.js": "console.log('hello');",
})
w := h.uploadSurface(h.adminToken, archive, "bad.surface")
if w.Code != http.StatusBadRequest {
t.Errorf("missing manifest: want 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSurface_Install_MissingID(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildZipRaw(t, map[string]string{
"manifest.json": `{"title": "No ID"}`,
})
w := h.uploadSurface(h.adminToken, archive, "noid.surface")
if w.Code != http.StatusBadRequest {
t.Errorf("missing id: want 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSurface_Install_CoreConflict(t *testing.T) {
h := setupSurfaceHarness(t)
h.seedCoreSurface("chat", "Chat")
archive := buildSurfaceZip(t, "chat", "Evil Chat", nil)
w := h.uploadSurface(h.adminToken, archive, "chat.surface")
if w.Code != http.StatusConflict {
t.Errorf("core conflict: want 409, got %d: %s", w.Code, w.Body.String())
}
}
func TestSurface_Install_NoFile(t *testing.T) {
h := setupSurfaceHarness(t)
req := httptest.NewRequest("POST", "/api/v1/admin/surfaces/install", nil)
req.Header.Set("Authorization", "Bearer "+h.adminToken)
w := httptest.NewRecorder()
h.router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("no file: want 400, got %d", w.Code)
}
}
func TestSurface_Install_InvalidSlugID(t *testing.T) {
h := setupSurfaceHarness(t)
cases := []struct {
id string
desc string
}{
{"../../../etc", "path traversal"},
{"hello world", "spaces"},
{"Hello-Dashboard", "uppercase"},
{"a", "too short"},
{"-leading", "leading hyphen"},
{"trailing-", "trailing hyphen"},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
archive := buildSurfaceZip(t, tc.id, "Bad Surface", nil)
w := h.uploadSurface(h.adminToken, archive, "bad.surface")
if w.Code != http.StatusBadRequest {
t.Errorf("bad id %q: want 400, got %d: %s", tc.id, w.Code, w.Body.String())
}
})
}
}
func TestSurface_Install_UpdateExistingExtension(t *testing.T) {
h := setupSurfaceHarness(t)
archive := buildSurfaceZip(t, "my-ext", "My Extension v1", nil)
w := h.uploadSurface(h.adminToken, archive, "my-ext.surface")
if w.Code != http.StatusOK {
t.Fatalf("install v1: want 200, got %d: %s", w.Code, w.Body.String())
}
archive = buildSurfaceZip(t, "my-ext", "My Extension v2", nil)
w = h.uploadSurface(h.adminToken, archive, "my-ext.surface")
if w.Code != http.StatusOK {
t.Fatalf("install v2: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr == nil {
t.Fatal("surface should exist")
}
if sr.Title != "My Extension v2" {
t.Errorf("title: got %q, want %q", sr.Title, "My Extension v2")
}
}
func TestSurface_Install_PreservesEnabledOnReseed(t *testing.T) {
h := setupSurfaceHarness(t)
// Install, then disable
archive := buildSurfaceZip(t, "my-ext", "My Extension", nil)
h.uploadSurface(h.adminToken, archive, "my-ext.surface")
h.jsonReq("PUT", "/api/v1/admin/surfaces/my-ext/disable", h.adminToken, nil)
// Re-install — Seed's ON CONFLICT skips enabled column
archive = buildSurfaceZip(t, "my-ext", "My Extension Updated", nil)
w := h.uploadSurface(h.adminToken, archive, "my-ext.surface")
if w.Code != http.StatusOK {
t.Fatalf("re-install: want 200, got %d: %s", w.Code, w.Body.String())
}
sr, _ := h.stores.Surfaces.Get(context.Background(), "my-ext")
if sr == nil {
t.Fatal("surface should exist")
}
if sr.Enabled {
t.Error("enabled state should be preserved as false after re-seed")
}
}
// ── Zip Building Helpers ────────────────────
func buildSurfaceZip(t *testing.T, id, title string, extraFiles map[string]string) []byte {
t.Helper()
manifest, _ := json.Marshal(map[string]string{
"id": id, "title": title, "route": "/s/" + id,
})
files := map[string]string{"manifest.json": string(manifest)}
for k, v := range extraFiles {
files[k] = v
}
return buildZipRaw(t, files)
}
func buildZipRaw(t *testing.T, files map[string]string) []byte {
t.Helper()
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for name, content := range files {
f, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
f.Write([]byte(content))
}
zw.Close()
return buf.Bytes()
}

View File

@@ -685,6 +685,84 @@ func TestTask_WorkflowTypeRejected(t *testing.T) {
}
}
// ═══════════════════════════════════════════════
// Task Type RBAC (v0.28.7)
// ═══════════════════════════════════════════════
func TestTask_StarlarkTypeRejected(t *testing.T) {
h := setupHarness(t)
_, token := h.createAdminUser("staruser", "staruser@test.com")
// Even admin cannot create starlark tasks — executor doesn't exist yet
w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{
"name": "Starlark Task",
"task_type": "starlark",
"schedule": "@daily",
"user_prompt": "x",
"model_id": "m",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("starlark task_type: want 400, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "v0.29.0") {
t.Fatalf("expected 'v0.29.0' in error, got: %s", body)
}
}
func TestTask_SystemTypeNonAdminDenied(t *testing.T) {
h := setupHarness(t)
// Seed Everyone group with task.create so the route middleware passes
database.TestDB.Exec(dialectSQL(
`INSERT INTO groups (id, name, source, permissions) VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET permissions = $4`),
"00000000-0000-0000-0000-000000000001", "Everyone", "system", `["task.create"]`,
)
// Enable registration so registerUser works
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("sysuser", "sysuser@test.com", "password123!")
w := h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{
"name": "System Task",
"task_type": "system",
"system_function": "session_cleanup",
"schedule": "@daily",
})
if w.Code != http.StatusForbidden {
t.Fatalf("system task as non-admin: want 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestTask_ActionTypeNonAdminDenied(t *testing.T) {
h := setupHarness(t)
// Seed Everyone group with task.create but NOT task.action
database.TestDB.Exec(dialectSQL(
`INSERT INTO groups (id, name, source, permissions) VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET permissions = $4`),
"00000000-0000-0000-0000-000000000001", "Everyone", "system", `["task.create"]`,
)
// Enable registration so registerUser works
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("actuser", "actuser@test.com", "password123!")
w := h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{
"name": "Action Task",
"task_type": "action",
"schedule": "webhook",
})
if w.Code != http.StatusForbidden {
t.Fatalf("action task without task.action: want 403, got %d: %s", w.Code, w.Body.String())
}
}
// ═══════════════════════════════════════════════
// F8: Team Task Routes
// ═══════════════════════════════════════════════

View File

@@ -133,6 +133,14 @@ func (h *TaskHandler) Create(c *gin.Context) {
}
}
// v0.28.7: Starlark tasks — pre-positioned gate for v0.29.0.
// The Starlark executor does not exist yet. Reject all creation with
// a clear message rather than a cryptic CHECK constraint failure.
if t.TaskType == "starlark" {
c.JSON(http.StatusBadRequest, gin.H{"error": "starlark task execution requires v0.29.0 — not yet available"})
return
}
// v0.28.0-audit: Workflow task execution is not yet implemented.
// Reject at the API boundary to prevent silent wrong behavior
// (workflow tasks would fall through to the prompt pipeline).

View File

@@ -228,7 +228,7 @@ func main() {
handlers.SeedProviders(cfg, stores, keyResolver)
// Seed builtin extensions from disk (idempotent, version-aware)
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
handlers.SeedBuiltinPackages(stores, "extensions/builtin")
// Load search provider config from DB (defaults to DuckDuckGo if not set)
loadSearchConfig(stores)
@@ -662,9 +662,9 @@ func main() {
protected.POST("/chat/completions", comp.Complete)
protected.GET("/tools", comp.ListTools)
// Surface discovery (v0.25.0)
surfaceH := handlers.NewSurfaceHandler(stores)
protected.GET("/surfaces", surfaceH.ListEnabledSurfaces)
// Surface discovery (v0.25.0, v0.28.7: unified packages)
pkgH := handlers.NewPackageHandler(stores)
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
// Summarize & Continue (backed by compaction service)
compactionSvc := compaction.NewService(stores, roleResolver)
@@ -1124,18 +1124,26 @@ func main() {
admin.DELETE("/routing/policies/:id", routingAdm.DeletePolicy)
admin.POST("/routing/test", routingAdm.TestRouting)
// Surface lifecycle management (v0.25.0)
surfacesDir := ""
// Package lifecycle management (v0.28.7 — replaces surfaces + extensions admin)
packagesDir := ""
if cfg.StoragePath != "" {
surfacesDir = cfg.StoragePath + "/surfaces"
packagesDir = cfg.StoragePath + "/packages"
}
surfaceAdm := handlers.NewSurfaceHandler(stores, surfacesDir)
admin.GET("/surfaces", surfaceAdm.ListSurfaces)
admin.GET("/surfaces/:id", surfaceAdm.GetSurface)
admin.POST("/surfaces/install", surfaceAdm.InstallSurface)
admin.PUT("/surfaces/:id/enable", surfaceAdm.EnableSurface)
admin.PUT("/surfaces/:id/disable", surfaceAdm.DisableSurface)
admin.DELETE("/surfaces/:id", surfaceAdm.DeleteSurface)
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
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)
// Surface aliases (backward compat — same handlers)
admin.GET("/surfaces", pkgAdm.ListPackages)
admin.GET("/surfaces/:id", pkgAdm.GetPackage)
admin.POST("/surfaces/install", pkgAdm.InstallPackage)
admin.PUT("/surfaces/:id/enable", pkgAdm.EnablePackage)
admin.PUT("/surfaces/:id/disable", pkgAdm.DisablePackage)
admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage)
// Task management — admin (v0.27.1, extended v0.27.2)
taskAdm := handlers.NewTaskHandler(stores)
@@ -1154,15 +1162,16 @@ func main() {
// v0.27.0: Extension surface static assets (JS, CSS, images).
// Served without auth — same rationale as extension assets (script tags can't send headers).
// In split deployment, nginx serves these from /data/surfaces/ directly.
// v0.28.7: on-disk path moved to /data/packages/, URL stays /surfaces/:id/ for stability.
// In split deployment, nginx serves these from /data/packages/ directly.
if cfg.StoragePath != "" {
surfacesStaticDir := cfg.StoragePath + "/surfaces"
packagesStaticDir := cfg.StoragePath + "/packages"
base.GET("/surfaces/:id/*path", func(c *gin.Context) {
id := c.Param("id")
filePath := c.Param("path")
// Security: prevent path traversal
clean := filepath.Clean(filepath.Join(surfacesStaticDir, id, filePath))
if !strings.HasPrefix(clean, filepath.Clean(surfacesStaticDir)) {
clean := filepath.Clean(filepath.Join(packagesStaticDir, id, filePath))
if !strings.HasPrefix(clean, filepath.Clean(packagesStaticDir)) {
c.Status(http.StatusForbidden)
return
}

View File

@@ -124,11 +124,11 @@ type ExtensionNavItem struct {
// extensionNavItems returns enabled extension surfaces for sidebar rendering.
// Queries the DB at render time so newly installed surfaces appear immediately.
func (e *Engine) extensionNavItems() []ExtensionNavItem {
if e.stores.Surfaces == nil {
if e.stores.Packages == nil {
return nil
}
ctx := context.Background()
surfaces, err := e.stores.Surfaces.List(ctx)
surfaces, err := e.stores.Packages.List(ctx)
if err != nil {
log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
return nil
@@ -139,6 +139,10 @@ func (e *Engine) extensionNavItems() []ExtensionNavItem {
if sr.Source == "core" || !sr.Enabled {
continue
}
// Only surface and full types have routes — extensions are headless
if sr.Type != "surface" && sr.Type != "full" {
continue
}
// Editor is Source="extension" but hardcoded as core — skip from dynamic nav
if sr.ID == "editor" {
continue
@@ -367,13 +371,13 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
return
}
if e.stores.Surfaces == nil {
if e.stores.Packages == nil {
c.String(http.StatusNotFound, "Surface registry not available")
return
}
// Look up by ID (slug == surface ID)
sr, err := e.stores.Surfaces.Get(c.Request.Context(), slug)
sr, err := e.stores.Packages.Get(c.Request.Context(), slug)
if err != nil || sr == nil {
c.String(http.StatusNotFound, "Surface not found: "+slug)
return

View File

@@ -12,7 +12,7 @@ import (
// Called once at startup. Does NOT overwrite the enabled flag — admin
// toggles survive restarts.
func (e *Engine) SeedSurfaces() {
if e.stores.Surfaces == nil {
if e.stores.Packages == nil {
log.Printf("[pages] Surface registry not available — skipping seed")
return
}
@@ -27,7 +27,7 @@ func (e *Engine) SeedSurfaces() {
"auth": s.Auth,
"layout": s.Layout,
}
if err := e.stores.Surfaces.Seed(ctx, s.ID, s.Title, s.Source, manifest); err != nil {
if err := e.stores.Packages.Seed(ctx, s.ID, s.Title, s.Source, manifest); err != nil {
log.Printf("[pages] Failed to seed surface %q: %v", s.ID, err)
}
}
@@ -42,11 +42,11 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
if surfaceID == "chat" || surfaceID == "admin" {
return true
}
if e.stores.Surfaces == nil {
if e.stores.Packages == nil {
return true // No registry = all surfaces enabled (backward compat)
}
ctx := context.Background()
sr, err := e.stores.Surfaces.Get(ctx, surfaceID)
sr, err := e.stores.Packages.Get(ctx, surfaceID)
if err != nil || sr == nil {
return true // Not in registry = assume enabled (backward compat)
}
@@ -55,7 +55,7 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
// EnabledSurfaceIDs returns a list of enabled surface IDs for nav rendering.
func (e *Engine) EnabledSurfaceIDs() []string {
if e.stores.Surfaces == nil {
if e.stores.Packages == nil {
// No registry — return all core surface IDs
var all []string
for _, s := range e.surfaces {
@@ -64,7 +64,7 @@ func (e *Engine) EnabledSurfaceIDs() []string {
return all
}
ctx := context.Background()
ids, err := e.stores.Surfaces.ListEnabled(ctx)
ids, err := e.stores.Packages.ListEnabled(ctx)
if err != nil {
log.Printf("[pages] Failed to load enabled surfaces: %v", err)
// Fallback: return all core surface IDs

View File

@@ -219,7 +219,7 @@
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-api.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/task-admin.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-packages.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/broadcast-admin.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}">
document.addEventListener('DOMContentLoaded', () => {

View File

@@ -39,7 +39,6 @@ type Stores struct {
GlobalConfig GlobalConfigStore
Usage UsageStore
Pricing PricingStore
Extensions ExtensionStore
Files FileStore
KnowledgeBases KnowledgeBaseStore
Groups GroupStore
@@ -53,7 +52,7 @@ type Stores struct {
CapOverrides CapabilityOverrideStore
RoutingPolicies RoutingPolicyStore
Sessions SessionStore
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions)
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
Tasks TaskStore // v0.27.1: Task scheduling + run history
}
@@ -402,29 +401,6 @@ type PricingStore interface {
Delete(ctx context.Context, providerConfigID, modelID string) error
}
// =========================================
// EXTENSION STORE
// =========================================
type ExtensionStore interface {
// Admin CRUD
Create(ctx context.Context, ext *models.Extension) error
GetByID(ctx context.Context, id string) (*models.Extension, error)
GetByExtID(ctx context.Context, extID string) (*models.Extension, error)
Update(ctx context.Context, id string, ext *models.Extension) error
Delete(ctx context.Context, id string) error
// Listing
ListAll(ctx context.Context) ([]models.Extension, error)
ListEnabled(ctx context.Context) ([]models.Extension, error)
ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error)
// User settings
GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error)
SetUserSettings(ctx context.Context, s *models.ExtensionUserSettings) error
DeleteUserSettings(ctx context.Context, extID, userID string) error
}
// =========================================
// FILE STORE
// =========================================

View File

@@ -0,0 +1,101 @@
package store
import (
"context"
"encoding/json"
)
// PackageStore manages the unified package registry (surfaces + extensions).
// Replaces SurfaceRegistryStore and ExtensionStore (v0.28.7).
type PackageStore interface {
// ── Lifecycle (from SurfaceRegistryStore) ────────────
// Seed upserts a package at startup. Does NOT overwrite the enabled
// flag if the row already exists (admin toggle survives restarts).
// Called by the page engine for core surfaces and by
// SeedBuiltinPackages() for builtin extensions.
Seed(ctx context.Context, id, title, source string, manifest map[string]any) error
// List returns all registered packages, ordered by source then title.
List(ctx context.Context) ([]PackageRegistration, error)
// Get returns a single package by ID (slug).
Get(ctx context.Context, id string) (*PackageRegistration, error)
// SetEnabled toggles a package's enabled state.
SetEnabled(ctx context.Context, id string, enabled bool) error
// Delete removes a non-core package. Core packages cannot be deleted.
Delete(ctx context.Context, id string) error
// ListEnabled returns IDs of all enabled packages.
// Used by the page engine for nav rendering, not exposed via HTTP.
ListEnabled(ctx context.Context) ([]string, error)
// ── Extended lifecycle ───────────────────────────────
// Create inserts a new package with all fields. Used by the install
// handler and by SeedBuiltinPackages() for first-time seeding.
Create(ctx context.Context, pkg *PackageRegistration) error
// Update modifies mutable fields on an existing package.
Update(ctx context.Context, id string, pkg *PackageRegistration) error
// ListByType returns packages filtered by type (surface, extension, full).
ListByType(ctx context.Context, pkgType string) ([]PackageRegistration, error)
// ListEnabledByType returns enabled packages of a given type.
ListEnabledByType(ctx context.Context, pkgType string) ([]PackageRegistration, error)
// ── Extension runtime (from ExtensionStore) ─────────
// ListForUser returns enabled extension/full packages with per-user
// settings overlay from package_user_settings. System packages are
// always included (users can't disable them).
ListForUser(ctx context.Context, userID string) ([]UserPackage, error)
// GetUserSettings returns per-user settings for a specific package.
GetUserSettings(ctx context.Context, pkgID, userID string) (*PackageUserSettings, error)
// SetUserSettings upserts per-user settings for a package.
SetUserSettings(ctx context.Context, s *PackageUserSettings) error
// DeleteUserSettings removes per-user settings, reverting to defaults.
DeleteUserSettings(ctx context.Context, pkgID, userID string) error
}
// PackageRegistration is a row from the packages table.
type PackageRegistration struct {
ID string `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Type string `json:"type" db:"type"`
Version string `json:"version" db:"version"`
Description string `json:"description" db:"description"`
Author string `json:"author" db:"author"`
Tier string `json:"tier" db:"tier"`
IsSystem bool `json:"is_system" db:"is_system"`
Scope string `json:"scope" db:"scope"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
InstalledBy *string `json:"installed_by,omitempty" db:"installed_by"`
Manifest map[string]any `json:"manifest" db:"manifest"`
Enabled bool `json:"enabled" db:"enabled"`
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.
// Returned by ListForUser for the /extensions endpoint.
type UserPackage struct {
PackageRegistration
UserEnabled *bool `json:"user_enabled,omitempty"`
UserSettings *json.RawMessage `json:"user_settings,omitempty"`
}
// PackageUserSettings stores per-user overrides for a package.
type PackageUserSettings struct {
PackageID string `json:"package_id" db:"package_id"`
UserID string `json:"user_id" db:"user_id"`
Settings json.RawMessage `json:"settings" db:"settings"`
IsEnabled bool `json:"is_enabled" db:"is_enabled"`
}

View File

@@ -1,200 +0,0 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type ExtensionStore struct{}
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
return DB.QueryRowContext(ctx, `
INSERT INTO extensions (ext_id, name, version, tier, description, author,
manifest, is_system, is_enabled, scope, team_id, installed_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING id, created_at, updated_at`,
ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
).Scan(&ext.ID, &ext.CreatedAt, &ext.UpdatedAt)
}
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = $1`, id)
}
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = $1`, extID)
}
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
_, err := DB.ExecContext(ctx, `
UPDATE extensions SET
name = $2, version = $3, description = $4, author = $5,
manifest = $6, is_system = $7, is_enabled = $8,
updated_at = now()
WHERE id = $1`,
id, ext.Name, ext.Version, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled,
)
return err
}
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = $1`, id)
return err
}
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
}
// ListEnabled returns all globally enabled extensions regardless of user.
// TODO(v0.29.0): Used by Starlark runtime to load server-side extensions at startup.
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = true ORDER BY name`)
}
// ListForUser returns all enabled extensions with user-specific overrides merged in.
// System extensions are always included (users can't disable them).
// Non-system extensions respect per-user enabled toggle.
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
rows, err := DB.QueryContext(ctx, `
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
e.created_at, e.updated_at,
eus.is_enabled AS user_enabled,
eus.settings AS user_settings
FROM extensions e
LEFT JOIN extension_user_settings eus
ON eus.extension_id = e.id AND eus.user_id = $1
WHERE e.is_enabled = true
AND (e.is_system = true OR COALESCE(eus.is_enabled, true) = true)
ORDER BY e.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserExtension
for rows.Next() {
var ue models.UserExtension
var teamID, installedBy sql.NullString
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
&ue.CreatedAt, &ue.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
ue.TeamID = NullableStringPtr(teamID)
ue.InstalledBy = NullableStringPtr(installedBy)
if userEnabled.Valid {
ue.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
ue.UserSettings = &raw
}
result = append(result, ue)
}
if result == nil {
result = make([]models.UserExtension, 0)
}
return result, rows.Err()
}
// GetUserSettings returns per-user settings for a specific extension.
// TODO(v0.29.0): Used by Starlark runtime to load per-user config for server-side extensions.
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
var eus models.ExtensionUserSettings
err := DB.QueryRowContext(ctx, `
SELECT extension_id, user_id, settings, is_enabled
FROM extension_user_settings
WHERE extension_id = $1 AND user_id = $2`,
extID, userID,
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
if err != nil {
return nil, err
}
return &eus, nil
}
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (extension_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
)
return err
}
// DeleteUserSettings removes per-user settings, reverting to defaults.
// TODO(v0.29.0): Exposed via user settings UI when Starlark extensions have per-user config.
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM extension_user_settings WHERE extension_id = $1 AND user_id = $2`,
extID, userID,
)
return err
}
// ── Internal helpers ────────────────────────────
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
var ext models.Extension
var teamID, installedBy sql.NullString
err := DB.QueryRowContext(ctx, query, args...).Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
&ext.CreatedAt, &ext.UpdatedAt,
)
if err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
return &ext, nil
}
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Extension
for rows.Next() {
var ext models.Extension
var teamID, installedBy sql.NullString
if err := rows.Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
&ext.CreatedAt, &ext.UpdatedAt,
); err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
result = append(result, ext)
}
if result == nil {
result = make([]models.Extension, 0)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,281 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type PackageStore struct{}
func NewPackageStore() *PackageStore { return &PackageStore{} }
// ── Lifecycle ────────────────────────────────────
// Seed upserts a package at startup. Does NOT overwrite the enabled flag.
func (s *PackageStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
manifestJSON := ToJSON(manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO packages (id, title, source, manifest, enabled, installed_at, updated_at)
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
title = $2,
manifest = $4,
source = $3,
updated_at = NOW()`,
id, title, source, manifestJSON)
return err
}
func (s *PackageStore) List(ctx context.Context) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p ORDER BY p.source, p.title`)
}
func (s *PackageStore) Get(ctx context.Context, id string) (*store.PackageRegistration, error) {
return s.scanOne(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.id = $1`, id)
}
func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
result, err := DB.ExecContext(ctx,
`UPDATE packages SET enabled = $2, updated_at = NOW() WHERE id = $1`,
id, enabled)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PackageStore) Delete(ctx context.Context, id string) error {
result, err := DB.ExecContext(ctx,
`DELETE FROM packages WHERE id = $1 AND source != 'core'`, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id FROM packages WHERE enabled = true ORDER BY title`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ── Extended lifecycle ───────────────────────────
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
manifestJSON := ToJSON(pkg.Manifest)
return DB.QueryRowContext(ctx, `
INSERT INTO packages (id, title, type, version, description, author, tier,
is_system, scope, team_id, installed_by, manifest, enabled, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
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.Source,
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
}
func (s *PackageStore) Update(ctx context.Context, id string, pkg *store.PackageRegistration) error {
manifestJSON := ToJSON(pkg.Manifest)
_, err := DB.ExecContext(ctx, `
UPDATE packages SET
title = $2, type = $3, version = $4, description = $5, author = $6,
tier = $7, manifest = $8, is_system = $9, enabled = $10,
updated_at = NOW()
WHERE id = $1`,
id, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
pkg.Tier, manifestJSON, pkg.IsSystem, pkg.Enabled,
)
return err
}
func (s *PackageStore) ListByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.type = $1 ORDER BY p.title`, pkgType)
}
func (s *PackageStore) ListEnabledByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
return s.scanMany(ctx,
`SELECT `+pkgCols+` FROM packages p WHERE p.type = $1 AND p.enabled = true ORDER BY p.title`,
pkgType)
}
// ── Extension runtime ────────────────────────────
// ListForUser returns enabled extension/full packages with user settings overlay.
func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.UserPackage, error) {
rows, err := DB.QueryContext(ctx, `
SELECT `+pkgCols+`,
pus.is_enabled AS user_enabled,
pus.settings AS user_settings
FROM packages p
LEFT JOIN package_user_settings pus
ON pus.package_id = p.id AND pus.user_id = $1
WHERE p.enabled = true
AND p.type IN ('extension', 'full')
AND (p.is_system = true OR COALESCE(pus.is_enabled, true) = true)
ORDER BY p.title`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.UserPackage
for rows.Next() {
var up store.UserPackage
var teamID, installedBy sql.NullString
var manifestJSON []byte
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
&up.Author, &up.Tier, &up.IsSystem, &up.Scope,
&teamID, &installedBy,
&manifestJSON, &up.Enabled, &up.Source,
&up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
up.TeamID = NullableStringPtr(teamID)
up.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal(manifestJSON, &up.Manifest)
if userEnabled.Valid {
up.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
up.UserSettings = &raw
}
result = append(result, up)
}
if result == nil {
result = make([]store.UserPackage, 0)
}
return result, rows.Err()
}
func (s *PackageStore) GetUserSettings(ctx context.Context, pkgID, userID string) (*store.PackageUserSettings, error) {
var pus store.PackageUserSettings
err := DB.QueryRowContext(ctx, `
SELECT package_id, user_id, settings, is_enabled
FROM package_user_settings
WHERE package_id = $1 AND user_id = $2`,
pkgID, userID,
).Scan(&pus.PackageID, &pus.UserID, &pus.Settings, &pus.IsEnabled)
if err != nil {
return nil, err
}
return &pus, nil
}
func (s *PackageStore) SetUserSettings(ctx context.Context, pus *store.PackageUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO package_user_settings (package_id, user_id, settings, is_enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (package_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
pus.PackageID, pus.UserID, pus.Settings, pus.IsEnabled,
)
return err
}
func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM package_user_settings WHERE package_id = $1 AND user_id = $2`,
pkgID, userID,
)
return err
}
// ── Helpers ──────────────────────────────────────
// pkgCols is the explicit column list for packages scans. Avoids SELECT *
// 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.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
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.Source,
&pkg.InstalledAt, &pkg.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal(manifestJSON, &pkg.Manifest)
return &pkg, nil
}
func (s *PackageStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]store.PackageRegistration, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.PackageRegistration
for rows.Next() {
var pkg store.PackageRegistration
var teamID, installedBy sql.NullString
var manifestJSON []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.Source,
&pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil {
return nil, err
}
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal(manifestJSON, &pkg.Manifest)
result = append(result, pkg)
}
return result, rows.Err()
}
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
func nullStrPtr(s *string) sql.NullString {
if s == nil {
return sql.NullString{}
}
return sql.NullString{String: *s, Valid: true}
}

View File

@@ -26,7 +26,6 @@ func NewStores(db *sql.DB) store.Stores {
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
Extensions: NewExtensionStore(),
Files: NewFileStore(),
KnowledgeBases: NewKnowledgeBaseStore(),
Groups: NewGroupStore(),
@@ -40,7 +39,7 @@ func NewStores(db *sql.DB) store.Stores {
CapOverrides: NewCapOverrideStore(db),
RoutingPolicies: NewRoutingPolicyStore(db),
Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(),
Packages: NewPackageStore(),
Workflows: NewWorkflowStore(),
Tasks: NewTaskStore(),
}

View File

@@ -1,117 +0,0 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type SurfaceRegistryStore struct{}
func NewSurfaceRegistryStore() *SurfaceRegistryStore { return &SurfaceRegistryStore{} }
// Seed upserts a core surface. Called at startup by the page engine
// to ensure core surfaces exist in the registry. Does NOT overwrite
// the enabled state if the row already exists (admin toggle survives restart).
func (s *SurfaceRegistryStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
manifestJSON := ToJSON(manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO surface_registry (id, title, source, manifest, enabled, installed_at, updated_at)
VALUES ($1, $2, $3, $4, true, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
title = $2,
manifest = $4,
source = $3,
updated_at = NOW()`,
// Note: ON CONFLICT does NOT update 'enabled' — preserves admin toggle
id, title, source, manifestJSON)
return err
}
func (s *SurfaceRegistryStore) List(ctx context.Context) ([]store.SurfaceRegistration, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
FROM surface_registry ORDER BY source, title`)
if err != nil {
return nil, err
}
defer rows.Close()
var surfaces []store.SurfaceRegistration
for rows.Next() {
var sr store.SurfaceRegistration
var manifestJSON []byte
if err := rows.Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt); err != nil {
continue
}
json.Unmarshal(manifestJSON, &sr.Manifest)
surfaces = append(surfaces, sr)
}
return surfaces, rows.Err()
}
func (s *SurfaceRegistryStore) Get(ctx context.Context, id string) (*store.SurfaceRegistration, error) {
var sr store.SurfaceRegistration
var manifestJSON []byte
err := DB.QueryRowContext(ctx,
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
FROM surface_registry WHERE id = $1`, id).
Scan(&sr.ID, &sr.Title, &manifestJSON, &sr.Enabled, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal(manifestJSON, &sr.Manifest)
return &sr, nil
}
func (s *SurfaceRegistryStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
result, err := DB.ExecContext(ctx,
`UPDATE surface_registry SET enabled = $2, updated_at = NOW() WHERE id = $1`,
id, enabled)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SurfaceRegistryStore) Delete(ctx context.Context, id string) error {
// Only allow deleting extension surfaces, not core
result, err := DB.ExecContext(ctx,
`DELETE FROM surface_registry WHERE id = $1 AND source = 'extension'`, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SurfaceRegistryStore) ListEnabled(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id FROM surface_registry WHERE enabled = true ORDER BY title`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, rows.Err()
}

View File

@@ -1,207 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type ExtensionStore struct{}
func NewExtensionStore() *ExtensionStore { return &ExtensionStore{} }
func (s *ExtensionStore) Create(ctx context.Context, ext *models.Extension) error {
ext.ID = store.NewID()
now := time.Now().UTC()
ext.CreatedAt = now
ext.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO extensions (id, ext_id, name, version, tier, description, author,
manifest, is_system, is_enabled, scope, team_id, installed_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
ext.ID, ext.ExtID, ext.Name, ext.Version, ext.Tier, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled, ext.Scope,
models.NullString(ext.TeamID), models.NullString(ext.InstalledBy),
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *ExtensionStore) GetByID(ctx context.Context, id string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE id = ?`, id)
}
func (s *ExtensionStore) GetByExtID(ctx context.Context, extID string) (*models.Extension, error) {
return s.scanOne(ctx, `SELECT * FROM extensions WHERE ext_id = ?`, extID)
}
func (s *ExtensionStore) Update(ctx context.Context, id string, ext *models.Extension) error {
_, err := DB.ExecContext(ctx, `
UPDATE extensions SET
name = ?, version = ?, description = ?, author = ?,
manifest = ?, is_system = ?, is_enabled = ?,
updated_at = datetime('now')
WHERE id = ?`,
ext.Name, ext.Version, ext.Description, ext.Author,
ext.Manifest, ext.IsSystem, ext.IsEnabled, id,
)
return err
}
func (s *ExtensionStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `DELETE FROM extensions WHERE id = ?`, id)
return err
}
func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`)
}
// ListEnabled returns all globally enabled extensions regardless of user.
// TODO(v0.29.0): Used by Starlark runtime to load server-side extensions at startup.
func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) {
return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = 1 ORDER BY name`)
}
// ListForUser returns all enabled extensions with user-specific overrides merged in.
// System extensions are always included (users can't disable them).
// Non-system extensions respect per-user enabled toggle.
func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]models.UserExtension, error) {
rows, err := DB.QueryContext(ctx, `
SELECT e.id, e.ext_id, e.name, e.version, e.tier, e.description, e.author,
e.manifest, e.is_system, e.is_enabled, e.scope, e.team_id, e.installed_by,
e.created_at, e.updated_at,
eus.is_enabled AS user_enabled,
eus.settings AS user_settings
FROM extensions e
LEFT JOIN extension_user_settings eus
ON eus.extension_id = e.id AND eus.user_id = ?
WHERE e.is_enabled = 1
AND (e.is_system = 1 OR COALESCE(eus.is_enabled, 1) = 1)
ORDER BY e.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserExtension
for rows.Next() {
var ue models.UserExtension
var teamID, installedBy sql.NullString
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&ue.ID, &ue.ExtID, &ue.Name, &ue.Version, &ue.Tier,
&ue.Description, &ue.Author, &ue.Manifest, &ue.IsSystem,
&ue.IsEnabled, &ue.Scope, &teamID, &installedBy,
st(&ue.CreatedAt), st(&ue.UpdatedAt),
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
ue.TeamID = NullableStringPtr(teamID)
ue.InstalledBy = NullableStringPtr(installedBy)
if userEnabled.Valid {
ue.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
ue.UserSettings = &raw
}
result = append(result, ue)
}
if result == nil {
result = make([]models.UserExtension, 0)
}
return result, rows.Err()
}
// GetUserSettings returns per-user settings for a specific extension.
// TODO(v0.29.0): Used by Starlark runtime to load per-user config for server-side extensions.
func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) {
var eus models.ExtensionUserSettings
err := DB.QueryRowContext(ctx, `
SELECT extension_id, user_id, settings, is_enabled
FROM extension_user_settings
WHERE extension_id = ? AND user_id = ?`,
extID, userID,
).Scan(&eus.ExtensionID, &eus.UserID, &eus.Settings, &eus.IsEnabled)
if err != nil {
return nil, err
}
return &eus, nil
}
func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.ExtensionUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO extension_user_settings (extension_id, user_id, settings, is_enabled)
VALUES (?, ?, ?, ?)
ON CONFLICT (extension_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
eus.ExtensionID, eus.UserID, eus.Settings, eus.IsEnabled,
)
return err
}
// DeleteUserSettings removes per-user settings, reverting to defaults.
// TODO(v0.29.0): Exposed via user settings UI when Starlark extensions have per-user config.
func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM extension_user_settings WHERE extension_id = ? AND user_id = ?`,
extID, userID,
)
return err
}
// ── Internal helpers ────────────────────────────
func (s *ExtensionStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.Extension, error) {
var ext models.Extension
var teamID, installedBy sql.NullString
err := DB.QueryRowContext(ctx, query, args...).Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
st(&ext.CreatedAt), st(&ext.UpdatedAt),
)
if err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
return &ext, nil
}
func (s *ExtensionStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]models.Extension, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Extension
for rows.Next() {
var ext models.Extension
var teamID, installedBy sql.NullString
if err := rows.Scan(
&ext.ID, &ext.ExtID, &ext.Name, &ext.Version, &ext.Tier,
&ext.Description, &ext.Author, &ext.Manifest, &ext.IsSystem,
&ext.IsEnabled, &ext.Scope, &teamID, &installedBy,
st(&ext.CreatedAt), st(&ext.UpdatedAt),
); err != nil {
return nil, err
}
ext.TeamID = NullableStringPtr(teamID)
ext.InstalledBy = NullableStringPtr(installedBy)
result = append(result, ext)
}
if result == nil {
result = make([]models.Extension, 0)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,301 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type PackageStore struct{}
func NewPackageStore() *PackageStore { return &PackageStore{} }
// ── Lifecycle ────────────────────────────────────
func (s *PackageStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
manifestJSON := ToJSON(manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO packages (id, title, source, manifest, enabled, installed_at, updated_at)
VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'))
ON CONFLICT (id) DO UPDATE SET
title = excluded.title,
manifest = excluded.manifest,
source = excluded.source,
updated_at = datetime('now')`,
id, title, source, manifestJSON)
return err
}
func (s *PackageStore) List(ctx context.Context) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p ORDER BY p.source, p.title`)
}
func (s *PackageStore) Get(ctx context.Context, id string) (*store.PackageRegistration, error) {
return s.scanOne(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.id = ?`, id)
}
func (s *PackageStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
enabledInt := 0
if enabled {
enabledInt = 1
}
result, err := DB.ExecContext(ctx,
`UPDATE packages SET enabled = ?, updated_at = datetime('now') WHERE id = ?`,
enabledInt, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PackageStore) Delete(ctx context.Context, id string) error {
result, err := DB.ExecContext(ctx,
`DELETE FROM packages WHERE id = ? AND source != 'core'`, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *PackageStore) ListEnabled(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id FROM packages WHERE enabled = 1 ORDER BY title`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ── Extended lifecycle ───────────────────────────
func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistration) error {
now := time.Now().UTC()
manifestJSON := ToJSON(pkg.Manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO packages (id, title, type, version, description, author, tier,
is_system, scope, team_id, installed_by, manifest, enabled, source,
installed_at, updated_at)
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.Source,
now.Format(timeFmt), now.Format(timeFmt),
)
if err != nil {
return err
}
pkg.InstalledAt = now.Format(timeFmt)
pkg.UpdatedAt = now.Format(timeFmt)
return nil
}
func (s *PackageStore) Update(ctx context.Context, id string, pkg *store.PackageRegistration) error {
manifestJSON := ToJSON(pkg.Manifest)
_, err := DB.ExecContext(ctx, `
UPDATE packages SET
title = ?, type = ?, version = ?, description = ?, author = ?,
tier = ?, manifest = ?, is_system = ?, enabled = ?,
updated_at = datetime('now')
WHERE id = ?`,
pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
pkg.Tier, manifestJSON, pkg.IsSystem, pkg.Enabled, id,
)
return err
}
func (s *PackageStore) ListByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `SELECT `+pkgCols+` FROM packages p WHERE p.type = ? ORDER BY p.title`, pkgType)
}
func (s *PackageStore) ListEnabledByType(ctx context.Context, pkgType string) ([]store.PackageRegistration, error) {
return s.scanMany(ctx,
`SELECT `+pkgCols+` FROM packages p WHERE p.type = ? AND p.enabled = 1 ORDER BY p.title`,
pkgType)
}
// ── Extension runtime ────────────────────────────
func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.UserPackage, error) {
rows, err := DB.QueryContext(ctx, `
SELECT `+pkgCols+`,
pus.is_enabled AS user_enabled,
pus.settings AS user_settings
FROM packages p
LEFT JOIN package_user_settings pus
ON pus.package_id = p.id AND pus.user_id = ?
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)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.UserPackage
for rows.Next() {
var up store.UserPackage
var teamID, installedBy sql.NullString
var manifestJSON string
var enabledInt int
var isSystemInt int
var userEnabled sql.NullBool
var userSettings []byte
if err := rows.Scan(
&up.ID, &up.Title, &up.Type, &up.Version, &up.Description,
&up.Author, &up.Tier, &isSystemInt, &up.Scope,
&teamID, &installedBy,
&manifestJSON, &enabledInt, &up.Source,
&up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
up.IsSystem = isSystemInt != 0
up.Enabled = enabledInt != 0
up.TeamID = NullableStringPtr(teamID)
up.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
if userEnabled.Valid {
up.UserEnabled = &userEnabled.Bool
}
if userSettings != nil {
raw := json.RawMessage(userSettings)
up.UserSettings = &raw
}
result = append(result, up)
}
if result == nil {
result = make([]store.UserPackage, 0)
}
return result, rows.Err()
}
func (s *PackageStore) GetUserSettings(ctx context.Context, pkgID, userID string) (*store.PackageUserSettings, error) {
var pus store.PackageUserSettings
err := DB.QueryRowContext(ctx, `
SELECT package_id, user_id, settings, is_enabled
FROM package_user_settings
WHERE package_id = ? AND user_id = ?`,
pkgID, userID,
).Scan(&pus.PackageID, &pus.UserID, &pus.Settings, &pus.IsEnabled)
if err != nil {
return nil, err
}
return &pus, nil
}
func (s *PackageStore) SetUserSettings(ctx context.Context, pus *store.PackageUserSettings) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO package_user_settings (package_id, user_id, settings, is_enabled)
VALUES (?, ?, ?, ?)
ON CONFLICT (package_id, user_id)
DO UPDATE SET settings = EXCLUDED.settings, is_enabled = EXCLUDED.is_enabled`,
pus.PackageID, pus.UserID, pus.Settings, pus.IsEnabled,
)
return err
}
func (s *PackageStore) DeleteUserSettings(ctx context.Context, pkgID, userID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM package_user_settings WHERE package_id = ? AND user_id = ?`,
pkgID, userID,
)
return err
}
// ── Helpers ──────────────────────────────────────
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.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 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.Source,
&pkg.InstalledAt, &pkg.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
pkg.IsSystem = isSystemInt != 0
pkg.Enabled = enabledInt != 0
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
return &pkg, nil
}
func (s *PackageStore) scanMany(ctx context.Context, query string, args ...interface{}) ([]store.PackageRegistration, error) {
rows, err := DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.PackageRegistration
for rows.Next() {
var pkg store.PackageRegistration
var teamID, installedBy sql.NullString
var manifestJSON 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.Source,
&pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil {
return nil, err
}
pkg.IsSystem = isSystemInt != 0
pkg.Enabled = enabledInt != 0
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
result = append(result, pkg)
}
return result, rows.Err()
}
func nullStrPtr(s *string) sql.NullString {
if s == nil {
return sql.NullString{}
}
return sql.NullString{String: *s, Valid: true}
}

View File

@@ -26,7 +26,6 @@ func NewStores(db *sql.DB) store.Stores {
GlobalConfig: NewGlobalConfigStore(),
Usage: NewUsageStore(),
Pricing: NewPricingStore(),
Extensions: NewExtensionStore(),
Files: NewFileStore(),
KnowledgeBases: NewKnowledgeBaseStore(),
Groups: NewGroupStore(),
@@ -40,7 +39,7 @@ func NewStores(db *sql.DB) store.Stores {
CapOverrides: NewCapOverrideStore(),
RoutingPolicies: NewRoutingPolicyStore(),
Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(),
Packages: NewPackageStore(),
Workflows: NewWorkflowStore(),
Tasks: NewTaskStore(),
}

View File

@@ -1,125 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type SurfaceRegistryStore struct{}
func NewSurfaceRegistryStore() *SurfaceRegistryStore { return &SurfaceRegistryStore{} }
// Seed upserts a core surface. Called at startup by the page engine
// to ensure core surfaces exist in the registry. Does NOT overwrite
// the enabled state if the row already exists (admin toggle survives restart).
func (s *SurfaceRegistryStore) Seed(ctx context.Context, id, title, source string, manifest map[string]any) error {
manifestJSON := ToJSON(manifest)
_, err := DB.ExecContext(ctx, `
INSERT INTO surface_registry (id, title, source, manifest, enabled, installed_at, updated_at)
VALUES (?, ?, ?, ?, 1, datetime('now'), datetime('now'))
ON CONFLICT (id) DO UPDATE SET
title = excluded.title,
manifest = excluded.manifest,
source = excluded.source,
updated_at = datetime('now')`,
// Note: ON CONFLICT does NOT update 'enabled' — preserves admin toggle
id, title, source, manifestJSON)
return err
}
func (s *SurfaceRegistryStore) List(ctx context.Context) ([]store.SurfaceRegistration, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
FROM surface_registry ORDER BY source, title`)
if err != nil {
return nil, err
}
defer rows.Close()
var surfaces []store.SurfaceRegistration
for rows.Next() {
var sr store.SurfaceRegistration
var manifestJSON string
var enabledInt int
if err := rows.Scan(&sr.ID, &sr.Title, &manifestJSON, &enabledInt, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt); err != nil {
continue
}
sr.Enabled = enabledInt != 0
json.Unmarshal([]byte(manifestJSON), &sr.Manifest)
surfaces = append(surfaces, sr)
}
return surfaces, rows.Err()
}
func (s *SurfaceRegistryStore) Get(ctx context.Context, id string) (*store.SurfaceRegistration, error) {
var sr store.SurfaceRegistration
var manifestJSON string
var enabledInt int
err := DB.QueryRowContext(ctx,
`SELECT id, title, manifest, enabled, source, installed_at, updated_at
FROM surface_registry WHERE id = ?`, id).
Scan(&sr.ID, &sr.Title, &manifestJSON, &enabledInt, &sr.Source, &sr.InstalledAt, &sr.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
sr.Enabled = enabledInt != 0
json.Unmarshal([]byte(manifestJSON), &sr.Manifest)
return &sr, nil
}
func (s *SurfaceRegistryStore) SetEnabled(ctx context.Context, id string, enabled bool) error {
enabledInt := 0
if enabled {
enabledInt = 1
}
result, err := DB.ExecContext(ctx,
`UPDATE surface_registry SET enabled = ?, updated_at = datetime('now') WHERE id = ?`,
enabledInt, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SurfaceRegistryStore) Delete(ctx context.Context, id string) error {
// Only allow deleting extension surfaces, not core
result, err := DB.ExecContext(ctx,
`DELETE FROM surface_registry WHERE id = ? AND source = 'extension'`, id)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
return nil
}
func (s *SurfaceRegistryStore) ListEnabled(ctx context.Context) ([]string, error) {
rows, err := DB.QueryContext(ctx,
`SELECT id FROM surface_registry WHERE enabled = 1 ORDER BY title`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
continue
}
ids = append(ids, id)
}
return ids, rows.Err()
}

View File

@@ -1,36 +0,0 @@
package store
import "context"
// SurfaceRegistryStore — CRUD for the surface_registry table.
// Core surfaces are seeded at startup. Extension surfaces are managed via admin API.
type SurfaceRegistryStore interface {
// Seed upserts a core surface (called at startup by page engine).
Seed(ctx context.Context, id, title, source string, manifest map[string]any) error
// List returns all registered surfaces, ordered by title.
List(ctx context.Context) ([]SurfaceRegistration, error)
// Get returns a single surface by ID.
Get(ctx context.Context, id string) (*SurfaceRegistration, error)
// SetEnabled toggles a surface's enabled state.
SetEnabled(ctx context.Context, id string, enabled bool) error
// Delete removes an extension surface. Core surfaces cannot be deleted.
Delete(ctx context.Context, id string) error
// ListEnabled returns IDs of all enabled surfaces.
ListEnabled(ctx context.Context) ([]string, error)
}
// SurfaceRegistration is a row from surface_registry.
type SurfaceRegistration struct {
ID string `json:"id" db:"id"`
Title string `json:"title" db:"title"`
Manifest map[string]any `json:"manifest" db:"manifest"`
Enabled bool `json:"enabled" db:"enabled"`
Source string `json:"source" db:"source"`
InstalledAt string `json:"installed_at" db:"installed_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}