From 1a5eb707ba30f26438e55e618918af7721cf9bbd Mon Sep 17 00:00:00 2001 From: xcaliber Date: Thu, 12 Mar 2026 17:21:58 +0000 Subject: [PATCH] Changeset 0.28.0.6 (#178) --- docs/ICD/extensions.md | 66 +- docs/ROADMAP.md | 79 ++- server/handlers/extension_test.go | 1027 ++++++++++++++++++++++++++++ server/handlers/extensions.go | 40 +- server/store/postgres/extension.go | 6 + server/store/sqlite/extension.go | 6 + 6 files changed, 1208 insertions(+), 16 deletions(-) create mode 100644 server/handlers/extension_test.go diff --git a/docs/ICD/extensions.md b/docs/ICD/extensions.md index d8b35c4..0a16883 100644 --- a/docs/ICD/extensions.md +++ b/docs/ICD/extensions.md @@ -1,34 +1,76 @@ # Extensions Plugin system with three tiers: Browser JS (client-side), Starlark -sandbox (server-side, future), Sidecar containers (server-side, future). +sandbox (server-side, v0.29.0), Sidecar containers (server-side, future). Currently only Browser tier is implemented. ### User Extensions +**Auth:** Authenticated user + ``` -GET /extensions → { "extensions": [...] } -POST /extensions/:id/settings ← { "enabled": true, "config": {...} } -GET /extensions/:id/manifest → full manifest.json -GET /extensions/tools → { "tools": [tool schema objects] } +GET /extensions → {"data": [...UserExtension]} + ?tier=browser (optional filter by tier) +POST /extensions/:id/settings ← {"is_enabled": bool, "settings": {...}} + :id = extension UUID → {"ok": true} +GET /extensions/:id/manifest → {"data": } + :id = ext_id (manifest id) +GET /extensions/tools → {"data": [...tool schema objects]} ``` +**Notes:** +- `GET /extensions` returns `UserExtension` objects: the base extension + fields plus `user_enabled` and `user_settings` overrides. +- System extensions (`is_system: true`) cannot be disabled by users. + Attempting to set `is_enabled: false` on a system extension returns 403. +- `GET /extensions/tools` returns raw tool schema JSON from all enabled + browser extensions' `manifest.tools[]` arrays. + ### Admin Extension Management +**Auth:** Admin role + ``` -GET /admin/extensions → { "extensions": [...] } -POST /admin/extensions ← { manifest + script content } -PUT /admin/extensions/:id ← updated manifest/script -DELETE /admin/extensions/:id +GET /admin/extensions → {"data": [...Extension]} +POST /admin/extensions ← {ext_id*, name*, version?, tier?, + description?, author?, manifest?, + is_system?, is_enabled?} + → {"data": Extension} (201) +PUT /admin/extensions/:id ← {name?, version?, description?, + :id = extension UUID author?, is_system?, is_enabled?, + manifest?} + → {"data": Extension} +DELETE /admin/extensions/:id → {"ok": true} + :id = extension UUID ``` +**Install defaults:** `version` → `"0.0.0"`, `tier` → `"browser"`, +`manifest` → `{}`, `scope` → `"global"`. + +**Tier validation:** `tier` must be one of: `browser`, `starlark`, `sidecar`. + +**Duplicate rejection:** If `ext_id` is already installed, returns 409. + ### Asset Serving +**Auth:** None (public — script tags can't send Authorization headers) + ``` -GET /extensions/:id/assets/*path +GET /extensions/:id/assets/*path → application/javascript + :id = ext_id (manifest id) ``` -Public (no auth required). Serves static assets (icons, CSS, JS) -from extension bundles. +Returns the inline `_script` field from the extension's manifest. +The `*path` segment is accepted but currently ignored (all requests +return the same script). Disabled extensions return 404. + +### Builtin Seeding + +On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/` +for subdirectories containing `manifest.json` + `script.js`. Each +is upserted as a system extension: +- **New ext_id** → `Create` with `is_system: true` +- **Same version** → skip (idempotent) +- **Different version** → `Update` manifest, name, description, author --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2352108..1c39350 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -181,6 +181,11 @@ v0.27.5 Team Tasks ✅ v0.28.0 Platform Polish (virtual scroll, KB auto-inject, Helm chart, provider model prefs) + │ +v0.29.0 Starlark Extensions + (permissioned server-side plugins, + capability-gated module access, + admin grant/revoke per-extension) ``` --- @@ -1025,6 +1030,51 @@ TBD pull-forward: high-value items whose dependencies are met post-tasks. --- +## v0.29.0 — Starlark Extensions: Permissioned Server-Side Plugins + +Server-side extension runtime with capability-gated module access. +Depends on: v0.28.0 (platform polish), extension infrastructure (v0.11.0). + +Manifest declares requested permissions (flatpak/Android model): +```json +{ + "permissions": ["http", "store.read", "files.read", "notifications"] +} +``` + +Admin reviews and grants/revokes per-permission. Runtime enforces: +only approved modules are injected into the Starlark sandbox. + +- [ ] `go.starlark.net` integration (eval loop, timeout, memory ceiling) +- [ ] Permission model: manifest declarations, admin grant/revoke, DB schema + (`extension_permissions` table: extension_id, permission, granted, granted_by) +- [ ] Privileged module library: + - `http` — outbound HTTP requests (allowlist/blocklist per extension) + - `store` — key/value read/write (extension-namespaced, quota-limited) + - `files` — project-scoped file read/write + - `notifications` — emit notifications to users + - `secrets` — vault-backed per-extension secret storage +- [ ] Runtime enforcement: sandbox loader checks granted permissions, injects + only approved modules. Denied permissions → clean error, not silent no-op. +- [ ] Admin UI: permission review on install, per-extension grant/revoke toggle, + audit log of permission changes +- [ ] Server-side tool execution in completion handler (Starlark tools run + server-side, browser tools run client-side — unified tool schema) +- [ ] Extension lifecycle: `install → pending_review → approved → active` +- [ ] Migration: `extension_permissions` table +- [ ] ICD: update `extensions.md` with Starlark-specific endpoints +- [ ] Multi-file asset routing: `ServeExtensionAsset` currently ignores `*path` param + and returns inline `_script` for all requests. Starlark extensions need multi-file + bundles (source + manifest + static assets). Implement path-based lookup or + replace inline `_script` with filesystem/blob serving. +- [ ] Deduplicate tool schema extraction: `ListBrowserToolSchemas` (extensions.go) and + `ListTools` (completion.go) both independently iterate user extensions, parse + manifests, and extract tool schemas. Unify into a shared helper that handles + both browser and server-side tool types. The completion handler also has a + `hub.IsConnected` guard that the extension handler lacks — reconcile. + +--- + ## TBD (unscheduled — real features, no immediate need) Items that are real but don't yet have a version assignment. Pull left @@ -1036,9 +1086,9 @@ based on need. - Code execution sandbox (server-side, container isolation) **Extension System — Server Tiers** -- Starlark runtime integration (Tier 1 — server sandbox) +- ~~Starlark runtime integration (Tier 1 — server sandbox)~~ → scheduled v0.29.0 +- ~~Server-side tool execution in completion handler~~ → scheduled v0.29.0 - Sidecar HTTP tool protocol (Tier 2 — container isolation) -- Server-side tool execution in completion handler **Desktop + Mobile** - Desktop app (Tauri) @@ -1096,6 +1146,31 @@ based on need. - Surface IDE: built-in surface for building surfaces — Go template editor for server-rendered shells, JS/CSS editor for client behavior, live preview in sandboxed region - Surface marketplace: share custom surfaces across instances (presentation mode, kanban, form builder, dashboard, etc.) - Project-bound surface/pane defaults: project config specifies which panes are available and default layout + +--- + +## Pre-1.0 — Code Quality Sweeps + +Codebase-wide refactors required before 1.0. Not tied to a feature version — +pull left when touching the affected code, or schedule a dedicated pass. + +**`SELECT *` → Explicit Column Lists** +All store implementations use `SELECT * FROM ` with positional `rows.Scan()`. +If a migration adds a column, every scan breaks silently at runtime (wrong column +mapped to wrong field). Every `scanOne`/`scanMany` helper needs explicit column lists. +Scope: every store file in `store/postgres/` and `store/sqlite/`. + +**Raw SQL Hunt** +Several handlers execute raw SQL via `database.DB.Exec` / `database.TestDB.Exec` +instead of going through the store interface. These bypass the dialect abstraction +and are a source of PG/SQLite divergence bugs. Known instances: +- `handlers/channels.go` — inline channel queries with `pq.Array` +- `handlers/workflows.go` — inline unique constraint string matching +- `handlers/participants.go` — `isDuplicateErr` string matching (should use `database.IsUniqueViolation`) +- `knowledge/` handlers — direct `database.DB.ExecContext` for storage key updates +Full sweep: grep for `database.DB.Exec`, `database.TestDB.Exec`, `DB.QueryRow` outside +of `store/` packages. Move to store methods or use existing dialect-safe helpers. + --- ## v0.27.5 — Team Tasks ✅ diff --git a/server/handlers/extension_test.go b/server/handlers/extension_test.go new file mode 100644 index 0000000..b55c00e --- /dev/null +++ b/server/handlers/extension_test.go @@ -0,0 +1,1027 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + + authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" + "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" +) + +// ── Extension Test Harness ────────────────── + +type extensionHarness struct { + *testHarness + adminToken string + adminID string + userToken string + userID string + stores store.Stores +} + +func setupExtensionHarness(t *testing.T) *extensionHarness { + 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) + } + + r := gin.New() + api := r.Group("/api/v1") + + // Auth (for token generation) + auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) + api.POST("/auth/register", auth.Register) + api.POST("/auth/login", auth.Login) + + // Public extension assets (no auth) + extH := NewExtensionHandler(stores) + api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset) + + // Protected routes + protected := api.Group("") + protected.Use(middleware.Auth(cfg)) + + // User extension endpoints + protected.GET("/extensions", extH.ListUserExtensions) + protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings) + protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest) + protected.GET("/extensions/tools", extH.ListBrowserToolSchemas) + + // Admin extension endpoints + admin := api.Group("/admin") + admin.Use(middleware.Auth(cfg), middleware.RequireAdmin()) + admin.GET("/extensions", extH.AdminListExtensions) + admin.POST("/extensions", extH.AdminInstallExtension) + admin.PUT("/extensions/:id", extH.AdminUpdateExtension) + admin.DELETE("/extensions/:id", extH.AdminUninstallExtension) + + // Seed admin user + adminID := seedInsertReturningID(t, + `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + "ext-admin", "ext-admin@test.com", "$2a$10$test", "admin", "ext-admin", "builtin", + ) + adminToken := makeToken(adminID, "ext-admin@test.com", "admin") + + // Seed regular user + userID := seedInsertReturningID(t, + `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + "ext-user", "ext-user@test.com", "$2a$10$test", "user", "ext-user", "builtin", + ) + userToken := makeToken(userID, "ext-user@test.com", "user") + + return &extensionHarness{ + testHarness: &testHarness{router: r, t: t}, + adminToken: adminToken, + adminID: adminID, + userToken: userToken, + userID: userID, + stores: stores, + } +} + +// installExtension is a helper that installs an extension via the admin API. +func (h *extensionHarness) installExtension(extID, name string, extra map[string]interface{}) map[string]interface{} { + h.t.Helper() + body := map[string]interface{}{ + "ext_id": extID, + "name": name, + "is_enabled": true, + } + for k, v := range extra { + body[k] = v + } + w := h.request("POST", "/api/v1/admin/extensions", h.adminToken, body) + if w.Code != http.StatusCreated { + h.t.Fatalf("installExtension(%s): want 201, got %d: %s", extID, w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + data, ok := resp["data"].(map[string]interface{}) + if !ok { + h.t.Fatalf("installExtension: response missing data wrapper") + } + return data +} + +// ═══════════════════════════════════════════════ +// Admin CRUD Lifecycle +// ═══════════════════════════════════════════════ + +func TestExtension_AdminCRUDLifecycle(t *testing.T) { + h := setupExtensionHarness(t) + + // ── Install ───────────────────────── + ext := h.installExtension("test-ext", "Test Extension", map[string]interface{}{ + "version": "1.0.0", + "description": "A test extension", + "author": "tester", + "manifest": map[string]interface{}{"_script": "console.log('hello')"}, + }) + extID := ext["id"].(string) + 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"]) + } + if ext["tier"].(string) != "browser" { + t.Fatalf("tier should default to browser, got %q", ext["tier"]) + } + + // ── Admin List ────────────────────── + w := h.request("GET", "/api/v1/admin/extensions", h.adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("admin list: want 200, got %d", w.Code) + } + var listResp map[string]interface{} + decode(w, &listResp) + data := listResp["data"].([]interface{}) + if len(data) != 1 { + t.Fatalf("expected 1 extension, got %d", len(data)) + } + + // ── Update ────────────────────────── + w = h.request("PUT", "/api/v1/admin/extensions/"+extID, h.adminToken, map[string]interface{}{ + "name": "Updated Extension", + "version": "2.0.0", + }) + if w.Code != http.StatusOK { + t.Fatalf("update: want 200, got %d: %s", w.Code, w.Body.String()) + } + 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["version"].(string) != "2.0.0" { + t.Fatalf("version not updated: got %q", updatedData["version"]) + } + + // ── Delete ────────────────────────── + w = h.request("DELETE", "/api/v1/admin/extensions/"+extID, h.adminToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("delete: want 200, got %d", w.Code) + } + + // Verify gone + w = h.request("GET", "/api/v1/admin/extensions", h.adminToken, nil) + decode(w, &listResp) + data = listResp["data"].([]interface{}) + if len(data) != 0 { + t.Fatalf("expected 0 extensions after delete, got %d", len(data)) + } +} + +// ═══════════════════════════════════════════════ +// Admin Install Validation +// ═══════════════════════════════════════════════ + +func TestExtension_DuplicateExtID(t *testing.T) { + h := setupExtensionHarness(t) + + h.installExtension("dup-ext", "First", nil) + + // Second install with same ext_id → 409 + w := h.request("POST", "/api/v1/admin/extensions", h.adminToken, map[string]interface{}{ + "ext_id": "dup-ext", + "name": "Second", + }) + if w.Code != http.StatusConflict { + t.Fatalf("duplicate ext_id: want 409, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestExtension_InvalidTier(t *testing.T) { + h := setupExtensionHarness(t) + + w := h.request("POST", "/api/v1/admin/extensions", h.adminToken, map[string]interface{}{ + "ext_id": "bad-tier", + "name": "Bad Tier", + "tier": "invalid", + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("invalid tier: want 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestExtension_ValidTiers(t *testing.T) { + h := setupExtensionHarness(t) + + for _, tier := range []string{"browser", "starlark", "sidecar"} { + ext := h.installExtension(tier+"-ext", tier+" Extension", map[string]interface{}{ + "tier": tier, + }) + if ext["tier"].(string) != tier { + t.Fatalf("tier mismatch for %s: got %q", tier, ext["tier"]) + } + } +} + +func TestExtension_MissingRequiredFields(t *testing.T) { + h := setupExtensionHarness(t) + + // Missing ext_id + w := h.request("POST", "/api/v1/admin/extensions", h.adminToken, map[string]interface{}{ + "name": "No ExtID", + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing ext_id: want 400, got %d", w.Code) + } + + // Missing name + w = h.request("POST", "/api/v1/admin/extensions", h.adminToken, map[string]interface{}{ + "ext_id": "no-name", + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing name: want 400, got %d", w.Code) + } +} + +func TestExtension_UpdateNotFound(t *testing.T) { + h := setupExtensionHarness(t) + + w := h.request("PUT", "/api/v1/admin/extensions/00000000-0000-0000-0000-000000000000", h.adminToken, map[string]interface{}{ + "name": "Ghost", + }) + if w.Code != http.StatusNotFound { + t.Fatalf("update nonexistent: want 404, got %d", w.Code) + } +} + +func TestExtension_DeleteNotFound(t *testing.T) { + h := setupExtensionHarness(t) + + w := h.request("DELETE", "/api/v1/admin/extensions/00000000-0000-0000-0000-000000000000", h.adminToken, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("delete nonexistent: want 404, got %d", w.Code) + } +} + +// ═══════════════════════════════════════════════ +// User List + Tier Filter +// ═══════════════════════════════════════════════ + +func TestExtension_UserListWithTierFilter(t *testing.T) { + h := setupExtensionHarness(t) + + h.installExtension("browser-ext", "Browser One", map[string]interface{}{"tier": "browser"}) + h.installExtension("starlark-ext", "Starlark One", map[string]interface{}{"tier": "starlark"}) + + // List all + w := h.request("GET", "/api/v1/extensions", h.userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list all: want 200, got %d", w.Code) + } + var resp map[string]interface{} + decode(w, &resp) + all := resp["data"].([]interface{}) + if len(all) != 2 { + t.Fatalf("expected 2 extensions, got %d", len(all)) + } + + // Filter by tier=browser + w = h.request("GET", "/api/v1/extensions?tier=browser", h.userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list filtered: want 200, got %d", w.Code) + } + decode(w, &resp) + filtered := resp["data"].([]interface{}) + if len(filtered) != 1 { + t.Fatalf("expected 1 browser extension, got %d", len(filtered)) + } +} + +func TestExtension_UserListEmptyArray(t *testing.T) { + h := setupExtensionHarness(t) + + // No extensions installed — should return [] not null + w := h.request("GET", "/api/v1/extensions", h.userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("list empty: want 200, got %d", w.Code) + } + var resp map[string]interface{} + decode(w, &resp) + data := resp["data"].([]interface{}) + if data == nil { + t.Fatal("data should be [] not null") + } + if len(data) != 0 { + t.Fatalf("expected 0 extensions, got %d", len(data)) + } +} + +// ═══════════════════════════════════════════════ +// User Settings +// ═══════════════════════════════════════════════ + +func TestExtension_UserSettingsUpsert(t *testing.T) { + h := setupExtensionHarness(t) + + ext := h.installExtension("settings-ext", "Settings Test", nil) + extID := ext["id"].(string) + + // Save settings (insert) + w := h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ + "settings": map[string]interface{}{"theme": "dark"}, + "is_enabled": true, + }) + if w.Code != http.StatusOK { + t.Fatalf("save settings: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Update settings (upsert — same PK) + w = h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ + "settings": map[string]interface{}{"theme": "light", "compact": true}, + }) + if w.Code != http.StatusOK { + t.Fatalf("update settings: want 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify via user list — settings should be merged + w = h.request("GET", "/api/v1/extensions", h.userToken, nil) + var resp map[string]interface{} + decode(w, &resp) + data := resp["data"].([]interface{}) + if len(data) != 1 { + t.Fatalf("expected 1 extension, got %d", len(data)) + } + extData := data[0].(map[string]interface{}) + if extData["user_settings"] == nil { + t.Fatal("user_settings should be present after saving") + } +} + +func TestExtension_SystemExtensionCannotBeDisabled(t *testing.T) { + h := setupExtensionHarness(t) + + ext := h.installExtension("sys-ext", "System Extension", map[string]interface{}{ + "is_system": true, + }) + extID := ext["id"].(string) + + // Try to disable → 403 + w := h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ + "is_enabled": false, + }) + if w.Code != http.StatusForbidden { + t.Fatalf("disable system ext: want 403, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestExtension_SettingsNotFoundExtension(t *testing.T) { + h := setupExtensionHarness(t) + + w := h.request("POST", "/api/v1/extensions/00000000-0000-0000-0000-000000000000/settings", h.userToken, map[string]interface{}{ + "is_enabled": true, + }) + if w.Code != http.StatusNotFound { + t.Fatalf("settings on nonexistent ext: want 404, got %d", w.Code) + } +} + +// ═══════════════════════════════════════════════ +// User Disable/Re-enable +// ═══════════════════════════════════════════════ + +func TestExtension_UserDisableHidesFromList(t *testing.T) { + h := setupExtensionHarness(t) + + ext := h.installExtension("toggle-ext", "Toggle Test", nil) + extID := ext["id"].(string) + + // Disable via user settings + w := h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ + "is_enabled": false, + }) + if w.Code != http.StatusOK { + t.Fatalf("disable: want 200, got %d", w.Code) + } + + // User list should be empty (extension hidden) + w = h.request("GET", "/api/v1/extensions", h.userToken, nil) + var resp map[string]interface{} + decode(w, &resp) + data := resp["data"].([]interface{}) + if len(data) != 0 { + t.Fatalf("expected 0 after disable, got %d", len(data)) + } + + // Admin list should still show it + w = h.request("GET", "/api/v1/admin/extensions", h.adminToken, nil) + decode(w, &resp) + data = resp["data"].([]interface{}) + if len(data) != 1 { + t.Fatalf("admin should still see 1, got %d", len(data)) + } + + // Re-enable + w = h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ + "is_enabled": true, + }) + if w.Code != http.StatusOK { + t.Fatalf("re-enable: want 200, got %d", w.Code) + } + + // Should reappear + w = h.request("GET", "/api/v1/extensions", h.userToken, nil) + decode(w, &resp) + data = resp["data"].([]interface{}) + if len(data) != 1 { + t.Fatalf("expected 1 after re-enable, got %d", len(data)) + } +} + +// ═══════════════════════════════════════════════ +// Manifest Endpoint +// ═══════════════════════════════════════════════ + +func TestExtension_GetManifest(t *testing.T) { + h := setupExtensionHarness(t) + + h.installExtension("manifest-ext", "Manifest Test", map[string]interface{}{ + "manifest": map[string]interface{}{ + "tools": []map[string]interface{}{ + {"name": "my_tool", "description": "does stuff"}, + }, + }, + }) + + // Manifest endpoint uses ext_id, not UUID + w := h.request("GET", "/api/v1/extensions/manifest-ext/manifest", h.userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("get manifest: want 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + decode(w, &resp) + if resp["data"] == nil { + t.Fatal("manifest should be in data wrapper") + } +} + +func TestExtension_GetManifestNotFound(t *testing.T) { + h := setupExtensionHarness(t) + + w := h.request("GET", "/api/v1/extensions/nonexistent/manifest", h.userToken, nil) + if w.Code != http.StatusNotFound { + t.Fatalf("manifest not found: want 404, got %d", w.Code) + } +} + +// ═══════════════════════════════════════════════ +// Tool Schema Endpoint +// ═══════════════════════════════════════════════ + +func TestExtension_ToolSchemas(t *testing.T) { + h := setupExtensionHarness(t) + + // Install extension with tools in manifest + h.installExtension("tool-ext", "Tool Extension", map[string]interface{}{ + "manifest": map[string]interface{}{ + "tools": []map[string]interface{}{ + {"name": "tool_a", "description": "Tool A"}, + {"name": "tool_b", "description": "Tool B"}, + }, + }, + }) + + w := h.request("GET", "/api/v1/extensions/tools", h.userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("tools: want 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + decode(w, &resp) + tools := resp["data"].([]interface{}) + if len(tools) != 2 { + t.Fatalf("expected 2 tool schemas, got %d", len(tools)) + } +} + +func TestExtension_ToolSchemasEmpty(t *testing.T) { + h := setupExtensionHarness(t) + + // No extensions → empty array, not null + w := h.request("GET", "/api/v1/extensions/tools", h.userToken, nil) + if w.Code != http.StatusOK { + t.Fatalf("tools empty: want 200, got %d", w.Code) + } + var resp map[string]interface{} + decode(w, &resp) + tools := resp["data"].([]interface{}) + if tools == nil { + t.Fatal("tools should be [] not null") + } + if len(tools) != 0 { + t.Fatalf("expected 0 tools, got %d", len(tools)) + } +} + +func TestExtension_ToolSchemasSkipsNonBrowser(t *testing.T) { + h := setupExtensionHarness(t) + + // Starlark extension with tools should not appear in browser tool list + h.installExtension("starlark-tool-ext", "Starlark Tool", map[string]interface{}{ + "tier": "starlark", + "manifest": map[string]interface{}{ + "tools": []map[string]interface{}{ + {"name": "server_tool", "description": "Server-side"}, + }, + }, + }) + + w := h.request("GET", "/api/v1/extensions/tools", h.userToken, nil) + var resp map[string]interface{} + decode(w, &resp) + tools := resp["data"].([]interface{}) + if len(tools) != 0 { + t.Fatalf("starlark tools should not appear: got %d", len(tools)) + } +} + +// ═══════════════════════════════════════════════ +// Asset Serving +// ═══════════════════════════════════════════════ + +func TestExtension_AssetServing(t *testing.T) { + h := setupExtensionHarness(t) + + h.installExtension("asset-ext", "Asset Test", map[string]interface{}{ + "manifest": map[string]interface{}{ + "_script": "console.log('extension loaded');", + }, + }) + + // Asset endpoint is public (no token), uses ext_id + w := h.request("GET", "/api/v1/extensions/asset-ext/assets/main.js", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("asset: want 200, got %d: %s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); ct != "application/javascript; charset=utf-8" { + t.Fatalf("content-type: got %q", ct) + } + body := w.Body.String() + if body != "console.log('extension loaded');" { + t.Fatalf("script body mismatch: got %q", body) + } +} + +func TestExtension_AssetDisabledExtension(t *testing.T) { + h := setupExtensionHarness(t) + + ext := h.installExtension("disabled-ext", "Disabled", map[string]interface{}{ + "manifest": map[string]interface{}{"_script": "nope"}, + }) + extID := ext["id"].(string) + + // Disable the extension via admin + w := h.request("PUT", "/api/v1/admin/extensions/"+extID, h.adminToken, map[string]interface{}{ + "is_enabled": false, + }) + if w.Code != http.StatusOK { + t.Fatalf("disable: want 200, got %d", w.Code) + } + + // Asset should 404 + w = h.request("GET", "/api/v1/extensions/disabled-ext/assets/main.js", "", nil) + if w.Code != http.StatusNotFound { + t.Fatalf("disabled asset: want 404, got %d", w.Code) + } +} + +func TestExtension_AssetNotFound(t *testing.T) { + h := setupExtensionHarness(t) + + w := h.request("GET", "/api/v1/extensions/nonexistent/assets/main.js", "", nil) + if w.Code != http.StatusNotFound { + t.Fatalf("asset not found: want 404, got %d", w.Code) + } +} + +func TestExtension_AssetNoScript(t *testing.T) { + h := setupExtensionHarness(t) + + // Install extension without _script in manifest + h.installExtension("no-script-ext", "No Script", map[string]interface{}{ + "manifest": map[string]interface{}{"tools": []string{}}, + }) + + w := h.request("GET", "/api/v1/extensions/no-script-ext/assets/main.js", "", nil) + if w.Code != http.StatusNotFound { + t.Fatalf("no script: want 404, got %d", w.Code) + } +} + +// ═══════════════════════════════════════════════ +// Admin Auth Enforcement +// ═══════════════════════════════════════════════ + +func TestExtension_AdminEndpointsRequireAdmin(t *testing.T) { + h := setupExtensionHarness(t) + + // Regular user → 403 on admin endpoints + endpoints := []struct { + method string + path string + }{ + {"GET", "/api/v1/admin/extensions"}, + {"POST", "/api/v1/admin/extensions"}, + } + for _, ep := range endpoints { + w := h.request(ep.method, ep.path, h.userToken, map[string]interface{}{ + "ext_id": "nope", "name": "nope", + }) + if w.Code != http.StatusForbidden { + t.Fatalf("%s %s with user token: want 403, got %d", ep.method, ep.path, w.Code) + } + } +} + +// ═══════════════════════════════════════════════ +// Partial Update +// ═══════════════════════════════════════════════ + +func TestExtension_PartialUpdate(t *testing.T) { + h := setupExtensionHarness(t) + + ext := h.installExtension("partial-ext", "Original Name", map[string]interface{}{ + "version": "1.0.0", + "description": "Original desc", + "author": "Original author", + }) + extID := ext["id"].(string) + + // Update only name — other fields should stay + w := h.request("PUT", "/api/v1/admin/extensions/"+extID, h.adminToken, map[string]interface{}{ + "name": "New Name", + }) + if w.Code != http.StatusOK { + t.Fatalf("partial update: want 200, got %d", w.Code) + } + 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["version"].(string) != "1.0.0" { + t.Fatalf("version should be unchanged: got %q", data["version"]) + } + if data["description"].(string) != "Original desc" { + t.Fatalf("description should be unchanged: got %q", data["description"]) + } + if data["author"].(string) != "Original author" { + t.Fatalf("author should be unchanged: got %q", data["author"]) + } +} + +// ═══════════════════════════════════════════════ +// Store-Level Tests (forward-looking methods) +// ═══════════════════════════════════════════════ + +func TestExtension_StoreListEnabled(t *testing.T) { + h := setupExtensionHarness(t) + + h.installExtension("enabled-ext", "Enabled", map[string]interface{}{"is_enabled": true}) + ext2 := h.installExtension("will-disable", "Will Disable", nil) + + // Disable one via admin + w := h.request("PUT", "/api/v1/admin/extensions/"+ext2["id"].(string), h.adminToken, map[string]interface{}{ + "is_enabled": false, + }) + if w.Code != http.StatusOK { + t.Fatalf("disable: want 200, got %d", w.Code) + } + + // ListEnabled should return only the enabled one + ctx := context.Background() + exts, err := h.stores.Extensions.ListEnabled(ctx) + if err != nil { + t.Fatalf("ListEnabled: %v", err) + } + if len(exts) != 1 { + t.Fatalf("expected 1 enabled, got %d", len(exts)) + } + if exts[0].ExtID != "enabled-ext" { + t.Fatalf("wrong extension: got %q", exts[0].ExtID) + } +} + +func TestExtension_StoreGetUserSettings(t *testing.T) { + h := setupExtensionHarness(t) + + ext := h.installExtension("settings-store-ext", "Settings Store", nil) + extID := ext["id"].(string) + + ctx := context.Background() + + // No settings yet → sql.ErrNoRows + _, err := h.stores.Extensions.GetUserSettings(ctx, extID, h.userID) + if err != sql.ErrNoRows { + t.Fatalf("expected ErrNoRows, got %v", err) + } + + // Save settings via handler + w := h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ + "settings": map[string]interface{}{"key": "value"}, + "is_enabled": true, + }) + if w.Code != http.StatusOK { + t.Fatalf("save: %d", w.Code) + } + + // Now GetUserSettings should return them + eus, err := h.stores.Extensions.GetUserSettings(ctx, extID, h.userID) + if err != nil { + t.Fatalf("GetUserSettings: %v", err) + } + if !eus.IsEnabled { + t.Fatal("should be enabled") + } + var settings map[string]interface{} + json.Unmarshal(eus.Settings, &settings) + if settings["key"] != "value" { + t.Fatalf("settings mismatch: %v", settings) + } +} + +func TestExtension_StoreDeleteUserSettings(t *testing.T) { + h := setupExtensionHarness(t) + + ext := h.installExtension("del-settings-ext", "Del Settings", nil) + extID := ext["id"].(string) + ctx := context.Background() + + // Save settings + w := h.request("POST", "/api/v1/extensions/"+extID+"/settings", h.userToken, map[string]interface{}{ + "settings": map[string]interface{}{"remove": "me"}, + "is_enabled": true, + }) + if w.Code != http.StatusOK { + t.Fatalf("save: %d", w.Code) + } + + // Verify present + _, err := h.stores.Extensions.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) + if err != nil { + t.Fatalf("DeleteUserSettings: %v", err) + } + + // Verify gone + _, err = h.stores.Extensions.GetUserSettings(ctx, extID, h.userID) + if err != sql.ErrNoRows { + t.Fatalf("should be gone, got %v", err) + } +} + +// ═══════════════════════════════════════════════ +// Seed Logic (F26/F27) +// ═══════════════════════════════════════════════ + +// makeSeedDir creates a temp directory with extension subdirectories. +// Each entry in exts maps a dir name to {manifest, script}. +func makeSeedDir(t *testing.T, exts map[string]struct{ manifest, script string }) string { + t.Helper() + dir := t.TempDir() + for name, files := range exts { + extDir := filepath.Join(dir, name) + if err := os.MkdirAll(extDir, 0755); err != nil { + t.Fatalf("mkdir %s: %v", extDir, err) + } + if files.manifest != "" { + if err := os.WriteFile(filepath.Join(extDir, "manifest.json"), []byte(files.manifest), 0644); err != nil { + t.Fatalf("write manifest: %v", err) + } + } + if files.script != "" { + if err := os.WriteFile(filepath.Join(extDir, "script.js"), []byte(files.script), 0644); err != nil { + t.Fatalf("write script: %v", err) + } + } + } + return dir +} + +func TestSeed_NewExtension(t *testing.T) { + h := setupExtensionHarness(t) + ctx := context.Background() + + dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ + "test-seeded": { + manifest: `{"id":"test-seeded","name":"Seeded Extension","version":"1.0.0","tier":"browser","author":"test"}`, + script: `console.log("seeded");`, + }, + }) + + SeedBuiltinExtensions(h.stores, dir) + + // Should be created as system extension + ext, err := h.stores.Extensions.GetByExtID(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 ext.Version != "1.0.0" { + t.Fatalf("version: got %q", ext.Version) + } + if !ext.IsSystem { + t.Fatal("should be system extension") + } + if !ext.IsEnabled { + 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 { + t.Fatal("manifest should contain _script") + } +} + +func TestSeed_SameVersionSkips(t *testing.T) { + h := setupExtensionHarness(t) + ctx := context.Background() + + dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ + "skip-ext": { + manifest: `{"id":"skip-ext","name":"Skip Test","version":"1.0.0"}`, + script: `console.log("v1");`, + }, + }) + + // Seed twice + SeedBuiltinExtensions(h.stores, dir) + SeedBuiltinExtensions(h.stores, dir) + + // Should still have exactly one + exts, err := h.stores.Extensions.ListAll(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + count := 0 + for _, e := range exts { + if e.ExtID == "skip-ext" { + count++ + } + } + if count != 1 { + t.Fatalf("expected 1 skip-ext, got %d", count) + } +} + +func TestSeed_VersionChangeUpdates(t *testing.T) { + h := setupExtensionHarness(t) + ctx := context.Background() + + // Seed v1 + dir1 := makeSeedDir(t, map[string]struct{ manifest, script string }{ + "versioned-ext": { + manifest: `{"id":"versioned-ext","name":"V1 Name","version":"1.0.0","author":"original"}`, + script: `console.log("v1");`, + }, + }) + SeedBuiltinExtensions(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) + } + + // Seed v2 (different version triggers update) + dir2 := makeSeedDir(t, map[string]struct{ manifest, script string }{ + "versioned-ext": { + manifest: `{"id":"versioned-ext","name":"V2 Name","version":"2.0.0","author":"updated"}`, + script: `console.log("v2");`, + }, + }) + SeedBuiltinExtensions(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) + } + if ext.Name != "V2 Name" { + t.Fatalf("v2 name: got %q", ext.Name) + } +} + +func TestSeed_MissingManifestSkips(t *testing.T) { + h := setupExtensionHarness(t) + ctx := context.Background() + + // Directory with no manifest.json + dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ + "no-manifest": {script: `console.log("orphan");`}, + }) + + SeedBuiltinExtensions(h.stores, dir) + + exts, err := h.stores.Extensions.ListAll(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)) + } +} + +func TestSeed_InvalidManifestSkips(t *testing.T) { + h := setupExtensionHarness(t) + ctx := context.Background() + + dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ + "bad-manifest": {manifest: `{not valid json`, script: `console.log("bad");`}, + }) + + SeedBuiltinExtensions(h.stores, dir) + + exts, _ := h.stores.Extensions.ListAll(ctx) + if len(exts) != 0 { + t.Fatalf("should skip invalid manifest, got %d extensions", len(exts)) + } +} + +func TestSeed_MissingIDSkips(t *testing.T) { + h := setupExtensionHarness(t) + ctx := context.Background() + + // Valid JSON but no "id" field + dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ + "no-id": {manifest: `{"name":"No ID Extension","version":"1.0.0"}`, script: `console.log("no id");`}, + }) + + SeedBuiltinExtensions(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)) + } +} + +func TestSeed_NonexistentDirectorySkips(t *testing.T) { + h := setupExtensionHarness(t) + + // Should not panic on missing directory + SeedBuiltinExtensions(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)) + } +} + +func TestSeed_ScriptOptional(t *testing.T) { + h := setupExtensionHarness(t) + ctx := context.Background() + + // Manifest-only extension (no script.js) + dir := makeSeedDir(t, map[string]struct{ manifest, script string }{ + "manifest-only": {manifest: `{"id":"manifest-only","name":"Manifest Only","version":"1.0.0"}`}, + }) + + SeedBuiltinExtensions(h.stores, dir) + + ext, err := h.stores.Extensions.GetByExtID(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 { + t.Fatal("manifest should not contain _script when no script.js exists") + } +} diff --git a/server/handlers/extensions.go b/server/handlers/extensions.go index 1e13686..e39573c 100644 --- a/server/handlers/extensions.go +++ b/server/handlers/extensions.go @@ -3,9 +3,11 @@ package handlers import ( "database/sql" "encoding/json" + "log" "github.com/gin-gonic/gin" + "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -19,6 +21,13 @@ func NewExtensionHandler(stores store.Stores) *ExtensionHandler { return &ExtensionHandler{stores: stores} } +// validTiers is the set of accepted extension tier values. +var validTiers = map[string]bool{ + models.ExtTierBrowser: true, + models.ExtTierStarlark: true, + models.ExtTierSidecar: true, +} + // ── User endpoints ────────────────────────────── // ListUserExtensions returns all enabled extensions for the current user, @@ -147,6 +156,12 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) { body.Manifest = json.RawMessage("{}") } + // Validate tier + if !validTiers[body.Tier] { + c.JSON(400, gin.H{"error": "invalid tier: must be browser, starlark, or sidecar"}) + return + } + // Check for duplicate ext_id existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID) if err != nil && err != sql.ErrNoRows { @@ -173,6 +188,10 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) { } if err := h.stores.Extensions.Create(c.Request.Context(), ext); err != nil { + if database.IsUniqueViolation(err) { + c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"}) + return + } c.JSON(500, gin.H{"error": "failed to install extension"}) return } @@ -197,7 +216,9 @@ func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) { var body struct { Name *string `json:"name"` + Version *string `json:"version"` Description *string `json:"description"` + Author *string `json:"author"` IsSystem *bool `json:"is_system"` IsEnabled *bool `json:"is_enabled"` Manifest *json.RawMessage `json:"manifest"` @@ -210,9 +231,15 @@ func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) { if body.Name != nil { ext.Name = *body.Name } + if body.Version != nil { + ext.Version = *body.Version + } if body.Description != nil { ext.Description = *body.Description } + if body.Author != nil { + ext.Author = *body.Author + } if body.IsSystem != nil { ext.IsSystem = *body.IsSystem } @@ -241,10 +268,14 @@ func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) { // path := c.Param("path") // reserved for future multi-file support ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID) - if err != nil { + if err == sql.ErrNoRows { 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 { c.JSON(404, gin.H{"error": "extension not enabled"}) @@ -282,10 +313,14 @@ func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) { extID := c.Param("id") ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID) - if err != nil { + if err == sql.ErrNoRows { 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}) } @@ -311,6 +346,7 @@ func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) { 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) continue } toolSchemas = append(toolSchemas, manifest.Tools...) diff --git a/server/store/postgres/extension.go b/server/store/postgres/extension.go index 2867a9e..37bd5b6 100644 --- a/server/store/postgres/extension.go +++ b/server/store/postgres/extension.go @@ -54,6 +54,8 @@ func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`) } +// ListEnabled returns all globally enabled extensions regardless of user. +// TODO(v0.29.0): Used by Starlark runtime to load server-side extensions at startup. func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) { return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = true ORDER BY name`) } @@ -112,6 +114,8 @@ func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]mode return result, rows.Err() } +// GetUserSettings returns per-user settings for a specific extension. +// TODO(v0.29.0): Used by Starlark runtime to load per-user config for server-side extensions. func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) { var eus models.ExtensionUserSettings err := DB.QueryRowContext(ctx, ` @@ -137,6 +141,8 @@ func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.Extens return err } +// DeleteUserSettings removes per-user settings, reverting to defaults. +// TODO(v0.29.0): Exposed via user settings UI when Starlark extensions have per-user config. func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error { _, err := DB.ExecContext(ctx, ` DELETE FROM extension_user_settings WHERE extension_id = $1 AND user_id = $2`, diff --git a/server/store/sqlite/extension.go b/server/store/sqlite/extension.go index 76d6907..8af2155 100644 --- a/server/store/sqlite/extension.go +++ b/server/store/sqlite/extension.go @@ -61,6 +61,8 @@ func (s *ExtensionStore) ListAll(ctx context.Context) ([]models.Extension, error return s.scanMany(ctx, `SELECT * FROM extensions ORDER BY name`) } +// ListEnabled returns all globally enabled extensions regardless of user. +// TODO(v0.29.0): Used by Starlark runtime to load server-side extensions at startup. func (s *ExtensionStore) ListEnabled(ctx context.Context) ([]models.Extension, error) { return s.scanMany(ctx, `SELECT * FROM extensions WHERE is_enabled = 1 ORDER BY name`) } @@ -119,6 +121,8 @@ func (s *ExtensionStore) ListForUser(ctx context.Context, userID string) ([]mode return result, rows.Err() } +// GetUserSettings returns per-user settings for a specific extension. +// TODO(v0.29.0): Used by Starlark runtime to load per-user config for server-side extensions. func (s *ExtensionStore) GetUserSettings(ctx context.Context, extID, userID string) (*models.ExtensionUserSettings, error) { var eus models.ExtensionUserSettings err := DB.QueryRowContext(ctx, ` @@ -144,6 +148,8 @@ func (s *ExtensionStore) SetUserSettings(ctx context.Context, eus *models.Extens return err } +// DeleteUserSettings removes per-user settings, reverting to defaults. +// TODO(v0.29.0): Exposed via user settings UI when Starlark extensions have per-user config. func (s *ExtensionStore) DeleteUserSettings(ctx context.Context, extID, userID string) error { _, err := DB.ExecContext(ctx, ` DELETE FROM extension_user_settings WHERE extension_id = ? AND user_id = ?`,