Changeset 0.28.7 (#193)
This commit is contained in:
@@ -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 ──────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
8
server/database/migrations/012_package_user_settings.sql
Normal file
8
server/database/migrations/012_package_user_settings.sql
Normal 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.
|
||||
-- ==========================================
|
||||
59
server/database/migrations/016_packages.sql
Normal file
59
server/database/migrations/016_packages.sql
Normal 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)
|
||||
);
|
||||
@@ -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';
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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.
|
||||
43
server/database/migrations/sqlite/016_packages.sql
Normal file
43
server/database/migrations/sqlite/016_packages.sql
Normal 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)
|
||||
);
|
||||
@@ -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'))
|
||||
);
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
433
server/handlers/packages.go
Normal 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})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
// =========================================
|
||||
|
||||
101
server/store/package_iface.go
Normal file
101
server/store/package_iface.go
Normal 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"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
281
server/store/postgres/packages.go
Normal file
281
server/store/postgres/packages.go
Normal 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}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
301
server/store/sqlite/packages.go
Normal file
301
server/store/sqlite/packages.go
Normal 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}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user