Changeset 0.28.7 (#193)
This commit is contained in:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user