Changeset 0.28.7 (#193)

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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