diff --git a/ROADMAP.md b/ROADMAP.md index 3e44aeb..a806dad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -200,16 +200,19 @@ See `docs/DEMO-WORKFLOWS.md` for full stage definitions and Starlark code. | Documentation | ✅ | `docs/DEMO-WORKFLOWS.md`: feature capability matrix, stage flows, API walkthrough, per-package READMEs. | | Review pass | | Docker E2E walkthrough: install all packages, adopt into team, activate, publish, run each workflow end-to-end. Fix remaining UI/UX issues. | -### v0.3.7 — Package Audit +### v0.3.7 — Package Audit (complete) -Verify all non-chat packages install and render correctly. -Tag chat-dependent packages with `requires` and `status: "dormant"`. +Verified all 16 packages install correctly. Chat-dependent packages auto-set to dormant. | Step | Status | Description | |------|--------|-------------| -| Browser package audit | | Install and verify all standalone packages (hello-dashboard, dashboard, schedules, editor, tasks, team-activity-log, test runners). | -| Chat-dependency tagging | | Add `"requires": ["chat"]` and `"status": "dormant"` to csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester, git-board, gitea-client. | -| Fix broken packages | | Repair any install, rendering, or API issues found during audit. | +| Manifest fixes | ✅ | Fixed 6 chat-extension manifests (`"name"` → `"title"`, was blocking install). Added explicit `"type": "surface"` to hello-dashboard, icd-test-runner, sdk-test-runner. | +| Dormant status | ✅ | Added `dormant` to `packages.status` CHECK constraint (both dialects). Installer auto-detects `requires: ["chat"]` and sets `status=dormant, enabled=false`. Enable endpoint returns 409 for dormant packages. | +| Browser package audit | ✅ | All 10 standalone packages install and activate: hello-dashboard, dashboard, editor, schedules, tasks, team-activity-log, gitea-client, git-board, icd-test-runner, sdk-test-runner. | +| Chat-dependency tagging | ✅ | 6 chat-dependent extensions (csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester) install as dormant. Excluded from nav, shown with badge in admin. git-board and gitea-client are standalone (not chat-dependent). | +| Dependency check fix | ✅ | Relaxed library dependency validation to allow `pending_review` libraries (gitea-client declares permissions). Only `suspended`/`dormant` libraries blocked. | +| Admin UI | ✅ | Dormant badge, disabled Enable button with tooltip, Dormant stat card in admin packages view. | +| Tests | ✅ | 4 handler tests (SetStatus dormant, enable blocked, surfaces exclude dormant, admin list includes dormant). All existing tests pass. | ### v0.3.8 — Distribution diff --git a/packages/csv-table/manifest.json b/packages/csv-table/manifest.json index 4b72abd..aa520fd 100644 --- a/packages/csv-table/manifest.json +++ b/packages/csv-table/manifest.json @@ -1,6 +1,6 @@ { "id": "csv-table", - "name": "CSV Table Viewer", + "title": "CSV Table Viewer", "version": "1.0.0", "type": "extension", "tier": "browser", diff --git a/packages/diff-viewer/manifest.json b/packages/diff-viewer/manifest.json index 18ffaec..e8fea94 100644 --- a/packages/diff-viewer/manifest.json +++ b/packages/diff-viewer/manifest.json @@ -1,6 +1,6 @@ { "id": "diff-viewer", - "name": "Diff Viewer", + "title": "Diff Viewer", "version": "1.0.0", "type": "extension", "tier": "browser", diff --git a/packages/hello-dashboard/manifest.json b/packages/hello-dashboard/manifest.json index a39ef64..e6521cf 100644 --- a/packages/hello-dashboard/manifest.json +++ b/packages/hello-dashboard/manifest.json @@ -1,6 +1,7 @@ { "id": "hello-dashboard", "icon": "👋", + "type": "surface", "title": "Hello Dashboard", "route": "/s/hello-dashboard", "auth": "authenticated", diff --git a/packages/icd-test-runner/manifest.json b/packages/icd-test-runner/manifest.json index 0c2db45..a067cc9 100644 --- a/packages/icd-test-runner/manifest.json +++ b/packages/icd-test-runner/manifest.json @@ -1,6 +1,7 @@ { "id": "icd-test-runner", "icon": "🧪", + "type": "surface", "title": "ICD Test Runner", "route": "/s/icd-test-runner", "auth": "authenticated", diff --git a/packages/js-sandbox/manifest.json b/packages/js-sandbox/manifest.json index c3227c6..fd81c5b 100644 --- a/packages/js-sandbox/manifest.json +++ b/packages/js-sandbox/manifest.json @@ -1,6 +1,6 @@ { "id": "js-sandbox", - "name": "JavaScript Sandbox", + "title": "JavaScript Sandbox", "version": "1.0.0", "type": "extension", "tier": "browser", diff --git a/packages/katex-renderer/manifest.json b/packages/katex-renderer/manifest.json index 0a0e184..fb1420c 100644 --- a/packages/katex-renderer/manifest.json +++ b/packages/katex-renderer/manifest.json @@ -1,6 +1,6 @@ { "id": "katex-renderer", - "name": "KaTeX Math", + "title": "KaTeX Math", "version": "1.0.0", "type": "extension", "tier": "browser", diff --git a/packages/mermaid-renderer/manifest.json b/packages/mermaid-renderer/manifest.json index 524c41b..7353dbc 100644 --- a/packages/mermaid-renderer/manifest.json +++ b/packages/mermaid-renderer/manifest.json @@ -1,6 +1,6 @@ { "id": "mermaid-renderer", - "name": "Mermaid Diagrams", + "title": "Mermaid Diagrams", "version": "2.0.0", "type": "extension", "tier": "browser", diff --git a/packages/regex-tester/manifest.json b/packages/regex-tester/manifest.json index b92a0d2..88834c2 100644 --- a/packages/regex-tester/manifest.json +++ b/packages/regex-tester/manifest.json @@ -1,6 +1,6 @@ { "id": "regex-tester", - "name": "Regex Tester", + "title": "Regex Tester", "version": "1.0.0", "type": "extension", "tier": "browser", diff --git a/packages/sdk-test-runner/manifest.json b/packages/sdk-test-runner/manifest.json index eae9218..83243c0 100644 --- a/packages/sdk-test-runner/manifest.json +++ b/packages/sdk-test-runner/manifest.json @@ -1,6 +1,7 @@ { "id": "sdk-test-runner", "icon": "🔬", + "type": "surface", "title": "SDK Test Runner", "route": "/s/sdk-test-runner", "auth": "authenticated", diff --git a/server/database/migrations/postgres/003_packages.sql b/server/database/migrations/postgres/003_packages.sql index ee0930f..dad0abc 100644 --- a/server/database/migrations/postgres/003_packages.sql +++ b/server/database/migrations/postgres/003_packages.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS packages ( manifest JSONB NOT NULL DEFAULT '{}', enabled BOOLEAN NOT NULL DEFAULT true, status TEXT NOT NULL DEFAULT 'active' - CHECK (status IN ('active', 'pending_review', 'suspended')), + CHECK (status IN ('active', 'pending_review', 'suspended', 'dormant')), schema_version INTEGER NOT NULL DEFAULT 0, package_settings JSONB NOT NULL DEFAULT '{}', source TEXT NOT NULL DEFAULT 'core' diff --git a/server/database/migrations/sqlite/003_packages.sql b/server/database/migrations/sqlite/003_packages.sql index 979e73a..16947db 100644 --- a/server/database/migrations/sqlite/003_packages.sql +++ b/server/database/migrations/sqlite/003_packages.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS packages ( manifest TEXT NOT NULL DEFAULT '{}', enabled INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'active' - CHECK (status IN ('active', 'pending_review', 'suspended')), + CHECK (status IN ('active', 'pending_review', 'suspended', 'dormant')), schema_version INTEGER NOT NULL DEFAULT 0, package_settings TEXT NOT NULL DEFAULT '{}', source TEXT NOT NULL DEFAULT 'core' diff --git a/server/handlers/package_dormant_test.go b/server/handlers/package_dormant_test.go new file mode 100644 index 0000000..577a317 --- /dev/null +++ b/server/handlers/package_dormant_test.go @@ -0,0 +1,232 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + authpkg "switchboard-core/auth" + "switchboard-core/config" + "switchboard-core/database" + "switchboard-core/middleware" + "switchboard-core/store" + postgres "switchboard-core/store/postgres" + sqlite "switchboard-core/store/sqlite" +) + +// ── Dormant Test Harness ────────────────────── + +type dormantHarness struct { + *testHarness + adminToken string + adminID string + stores store.Stores + pkgH *PackageHandler +} + +func setupDormantHarness(t *testing.T) *dormantHarness { + 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() + + r := gin.New() + api := r.Group("/api/v1") + + auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) + api.POST("/auth/register", auth.Register) + + pkgH := NewPackageHandler(stores) + + // Protected (authenticated) routes + protected := api.Group("") + protected.Use(middleware.Auth(cfg, stores.Users, userCache)) + protected.GET("/surfaces", pkgH.ListEnabledSurfaces) + + // Admin routes + admin := api.Group("/admin") + admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin(stores)) + admin.GET("/packages", pkgH.ListPackages) + admin.PUT("/packages/:id/enable", pkgH.EnablePackage) + admin.PUT("/packages/:id/disable", pkgH.DisablePackage) + + // Seed admin user + adminID := seedInsertReturningID(t, + `INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`, + "dorm-admin", "dorm-admin@test.com", "$2a$10$test", "dorm-admin", "builtin", + ) + database.SeedEveryoneGroupMember(t, adminID) + database.SeedAdminsGroupMember(t, adminID) + adminToken := makeToken(adminID, "dorm-admin@test.com", "admin") + + return &dormantHarness{ + testHarness: &testHarness{router: r, t: t}, + adminToken: adminToken, + adminID: adminID, + stores: stores, + pkgH: pkgH, + } +} + +// seedPackage inserts a package directly into the DB for testing. +func (h *dormantHarness) seedPackage(id, title, pkgType, status string, enabled bool, manifest map[string]any) { + h.t.Helper() + ctx := context.Background() + + manifestJSON, _ := json.Marshal(manifest) + + q := dialectSQL(`INSERT INTO packages (id, title, type, version, tier, manifest, enabled, status, source) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`) + + enabledInt := 0 + if enabled { + enabledInt = 1 + } + + _, err := database.TestDB.ExecContext(ctx, q, + id, title, pkgType, "1.0.0", "browser", string(manifestJSON), enabledInt, status, "extension", + ) + if err != nil { + h.t.Fatalf("seedPackage(%s): %v", id, err) + } +} + +// ═══════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════ + +// TestSetStatus_Dormant verifies the DB CHECK constraint accepts 'dormant'. +func TestSetStatus_Dormant(t *testing.T) { + h := setupDormantHarness(t) + + h.seedPackage("status-test", "Status Test", "surface", "active", true, map[string]any{ + "id": "status-test", "title": "Status Test", "type": "surface", + }) + + err := h.stores.Packages.SetStatus(context.Background(), "status-test", "dormant") + if err != nil { + t.Fatalf("SetStatus(dormant) failed: %v", err) + } + + pkg, err := h.stores.Packages.Get(context.Background(), "status-test") + if err != nil || pkg == nil { + t.Fatalf("Get after SetStatus failed: %v", err) + } + if pkg.Status != "dormant" { + t.Errorf("status = %q, want %q", pkg.Status, "dormant") + } +} + +// TestEnableDormant_Blocked verifies the enable endpoint rejects dormant packages. +func TestEnableDormant_Blocked(t *testing.T) { + h := setupDormantHarness(t) + + h.seedPackage("dormant-ext", "Dormant Extension", "extension", "dormant", false, map[string]any{ + "id": "dormant-ext", "title": "Dormant Extension", "type": "extension", "requires": []string{"chat"}, + }) + + w := h.request("PUT", "/api/v1/admin/packages/dormant-ext/enable", h.adminToken, nil) + if w.Code != http.StatusConflict { + t.Fatalf("enable dormant: want 409, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + decode(t, w, &resp) + errMsg, _ := resp["error"].(string) + if errMsg == "" { + t.Fatal("expected error message in response") + } +} + +// TestDormantNotInSurfaces verifies dormant packages are excluded from the surfaces list. +func TestDormantNotInSurfaces(t *testing.T) { + h := setupDormantHarness(t) + + // Active surface — should appear + h.seedPackage("active-surface", "Active Surface", "surface", "active", true, map[string]any{ + "id": "active-surface", "title": "Active Surface", "type": "surface", + "route": "/s/active-surface", + }) + // Dormant surface — should NOT appear + h.seedPackage("dormant-surface", "Dormant Surface", "full", "dormant", false, map[string]any{ + "id": "dormant-surface", "title": "Dormant Surface", "type": "full", + "route": "/s/dormant-surface", "requires": []string{"chat"}, + }) + + w := h.request("GET", "/api/v1/surfaces", h.adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("surfaces list: want 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + decode(t, w, &resp) + data, _ := resp["data"].([]interface{}) + + for _, item := range data { + m, _ := item.(map[string]interface{}) + if m["id"] == "dormant-surface" { + t.Fatal("dormant surface should not appear in surfaces list") + } + } + + found := false + for _, item := range data { + m, _ := item.(map[string]interface{}) + if m["id"] == "active-surface" { + found = true + } + } + if !found { + t.Fatal("active surface should appear in surfaces list") + } +} + +// TestDormantInAdminList verifies dormant packages still appear in admin package list. +func TestDormantInAdminList(t *testing.T) { + h := setupDormantHarness(t) + + h.seedPackage("vis-active", "Visible Active", "surface", "active", true, map[string]any{ + "id": "vis-active", "title": "Visible Active", "type": "surface", + }) + h.seedPackage("vis-dormant", "Visible Dormant", "extension", "dormant", false, map[string]any{ + "id": "vis-dormant", "title": "Visible Dormant", "type": "extension", "requires": []string{"chat"}, + }) + + w := h.request("GET", "/api/v1/admin/packages", h.adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("admin list: want 200, got %d", w.Code) + } + + var resp map[string]interface{} + decode(t, w, &resp) + data, _ := resp["data"].([]interface{}) + + ids := map[string]bool{} + for _, item := range data { + m, _ := item.(map[string]interface{}) + id, _ := m["id"].(string) + ids[id] = true + } + + if !ids["vis-active"] { + t.Error("active package missing from admin list") + } + if !ids["vis-dormant"] { + t.Error("dormant package missing from admin list") + } +} diff --git a/server/handlers/packages.go b/server/handlers/packages.go index c884bf7..30fb97f 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -94,6 +94,20 @@ func (h *PackageHandler) GetPackage(c *gin.Context) { // PUT /api/v1/admin/surfaces/:id/enable (alias) func (h *PackageHandler) EnablePackage(c *gin.Context) { id := c.Param("id") + + // v0.3.7: Block enabling dormant packages (unmet requires). + 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.Status == "dormant" { + c.JSON(http.StatusConflict, gin.H{ + "error": "package is dormant — it requires capabilities not yet available (e.g. chat)", + }) + return + } + if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return @@ -533,8 +547,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID}) return } - if lib.Status != "active" { - c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not active: " + libID}) + if lib.Status == "suspended" || lib.Status == "dormant" { + c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID}) return } versionSpec, _ := vSpec.(string) @@ -569,14 +583,36 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { } } - c.JSON(http.StatusOK, gin.H{ + // v0.3.7: Auto-set dormant for packages with unmet requires. + isDormant := false + if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 { + for _, r := range reqs { + req, _ := r.(string) + if req == "chat" { + isDormant = true + break + } + } + } + if isDormant { + h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant") + h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false) + preservedEnabled = false + log.Printf("[packages] %s: dormant (unmet requires: chat)", pkgID) + } + + resp := gin.H{ "id": pkgID, "title": title, "type": pkgType, "version": version, "source": pkgSource, "enabled": preservedEnabled, - }) + } + if isDormant { + resp["status"] = "dormant" + } + c.JSON(http.StatusOK, resp) } // extractableRelPath returns the relative path for a zip entry if it diff --git a/server/store/package_iface.go b/server/store/package_iface.go index a642272..f09cb14 100644 --- a/server/store/package_iface.go +++ b/server/store/package_iface.go @@ -25,7 +25,7 @@ type PackageStore interface { SetEnabled(ctx context.Context, id string, enabled bool) error // SetStatus transitions a package's lifecycle status. - // Valid statuses: active, pending_review, suspended. + // Valid statuses: active, pending_review, suspended, dormant. SetStatus(ctx context.Context, id string, status string) error // Delete removes a non-core package. Core packages cannot be deleted. diff --git a/src/js/sw/surfaces/admin/packages.js b/src/js/sw/surfaces/admin/packages.js index 0a7631a..6e82752 100644 --- a/src/js/sw/surfaces/admin/packages.js +++ b/src/js/sw/surfaces/admin/packages.js @@ -27,6 +27,11 @@ function sourceBadge(source) { return html`${source}`; } +function statusBadge(status) { + if (status === 'dormant') return html`dormant`; + return null; +} + export default function PackagesSection() { const [packages, setPackages] = useState([]); const [loading, setLoading] = useState(true); @@ -56,6 +61,10 @@ export default function PackagesSection() { // ── Actions ───────────────────────────────── async function toggleEnabled(pkg) { if (CORE_IDS.has(pkg.id)) return; + if (pkg.status === 'dormant') { + sw.toast('This package requires chat — it cannot be enabled yet', 'error'); + return; + } try { if (pkg.enabled) { await sw.api.admin.packages.disable(pkg.id); @@ -154,6 +163,7 @@ export default function PackagesSection() { if (loading) return html`