diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d2c5e..026e83b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## [0.38.1.0] — 2026-03-25 + +### Summary + +Extension Connections. Scoped credential management for extensions that +integrate with external services. Same scope/resolution pattern as LLM +providers (personal → team → global), sharable across packages. + +### Added + +- **Schema:** `ext_connections` table with `(type, scope, owner_id, name)` + unique constraint. Secret fields encrypted at rest using vault pattern. + (`server/database/migrations/022_ext_connections.sql`) +- **Store:** `ConnectionStore` interface with CRUD, scoped queries, and + resolution chain (personal → team → global). + (`server/store/interfaces.go`, `postgres/ext_connection.go`, `sqlite/ext_connection.go`) +- **Handlers:** Three scope tiers of REST endpoints: + - Personal: `GET/POST /connections`, `GET/PUT/DELETE /connections/:id`, `GET /connections/resolve` + - Team: `GET/POST /teams/:teamId/connections`, `PUT/DELETE /teams/:teamId/connections/:id` + - Admin: `GET/POST /admin/connections`, `PUT/DELETE /admin/connections/:id` + (`server/handlers/connections.go`, `team_connections.go`, `admin_connections.go`) +- **Starlark module:** `connections.get(type)`, `connections.get(type, name=)`, + `connections.list(type)` — gated by `connections.read` permission. + (`server/sandbox/connections_module.go`) +- **UI:** Connection management sections in Admin, Team Admin, and Settings + surfaces with dynamic form rendering from manifest field schemas. + (`src/js/sw/surfaces/admin/connections.js`, `team-admin/connections.js`, `settings/connections.js`) +- **SDK:** `sw.api.connections.*`, `sw.api.teams.{create,update,delete}Connection()`, + `sw.api.admin.connections.*` domain methods. + (`src/js/sw/sdk/api-domains.js`) +- **ICD:** Extension connections section with all endpoint specs. + (`docs/ICD/extensions.md`) +- **OpenAPI:** Connection endpoint schemas (personal, team, admin, resolve). + (`server/static/openapi.yaml`) +- **SDK test runner:** `connections` domain — CRUD + resolve chain tests. + (`packages/sdk-test-runner/js/domains/connections.js`) + ## [0.38.0.0] — 2026-03-25 ### Summary diff --git a/docs/ICD/extensions.md b/docs/ICD/extensions.md index b44cc66..22b1355 100644 --- a/docs/ICD/extensions.md +++ b/docs/ICD/extensions.md @@ -337,6 +337,83 @@ to disk. - `400` — `starlark package missing entry point "script.star"` (or custom `entry_point` value) +## 5. Extension Connections (v0.38.1) + +Scoped credential management for extensions that integrate with +external services. Same scope/resolution pattern as provider configs +(personal → team → global). + +### Personal Connections + +**Auth:** Authenticated user + +``` +GET /connections → {"data": [...connection summaries]} +POST /connections ← {"type","package_id","name","config"} → {"id","type","name"} +GET /connections/:id → connection summary (owned only) +PUT /connections/:id ← {"name?","config?"} → {"message":"connection updated"} +DELETE /connections/:id → {"message":"connection deleted"} +GET /connections/resolve → resolved connection (decrypted config) + ?type=gitea&name=Work+Gitea +``` + +### Team Connections + +**Auth:** Team admin (RequireTeamAdmin middleware) + +``` +GET /teams/:teamId/connections → {"data": [...connection summaries]} +POST /teams/:teamId/connections ← {"type","package_id","name","config"} → {"id","type","name"} +PUT /teams/:teamId/connections/:id ← {"name?","config?"} → {"message":"connection updated"} +DELETE /teams/:teamId/connections/:id → {"message":"connection deleted"} +``` + +### Admin (Global) Connections + +**Auth:** Admin + +``` +GET /admin/connections → {"data": [...connection summaries]} +POST /admin/connections ← {"type","package_id","name","config"} → {"id","type","name"} +PUT /admin/connections/:id ← {"name?","config?"} → {"message":"connection updated"} +DELETE /admin/connections/:id → {"message":"connection deleted"} +``` + +**Connection summary** (list/get responses — secrets masked): +```json +{ + "id": "uuid", "type": "gitea", "package_id": "git-board", + "scope": "personal", "name": "Work Gitea", + "is_active": true, "has_config": true, + "created_at": "2026-03-25T...", "updated_at": "2026-03-25T..." +} +``` + +**Scope resolution:** `GET /connections/resolve?type=gitea` resolves +via personal → team → global chain. Returns full connection with +decrypted config. Optional `name` parameter for named resolution. + +**Config encryption:** Fields with `type: "secret"` in the package +manifest's `connections[].fields` are encrypted at rest using the +same vault pattern as provider API keys. The handler layer encrypts +on write and decrypts on read. The store is encryption-agnostic. + +**Unique constraint:** `(type, scope, owner_id, name)` — a user cannot +have two connections of the same type with the same name. + +**Starlark module:** Extensions with `connections.read` permission +get a `connections` module: +```python +conn = connections.get("gitea") # scope chain +conn = connections.get("gitea", name="Work Gitea") # named +all = connections.list("gitea") # all accessible +``` + +Each returned dict contains `id`, `type`, `name`, `scope` plus all +config fields with secrets decrypted. + +--- + ### Builtin Seeding On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fac8634..7537cb5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,7 +54,7 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ │ v0.37.17–18 Surfaces ✅ │ │ v0.37.19 Tag ✅ │ │ │ │ - v0.38.0–.4 │ │ + v0.38.0–.5 │ │ Extension │ │ Continuation │ │ │ │ │ @@ -280,10 +280,11 @@ See design docs for full specifications. | Version | Summary | Design Doc | |---------|---------|------------| | v0.38.0 ✅ | Multi-file Starlark | [DESIGN-MULTI-FILE-STARLARK.md](DESIGN-MULTI-FILE-STARLARK.md) — `load()` support, disk-based scripts, package-scoped loader. Prerequisite for libraries. 5 backend changesets. | -| v0.38.1 | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. | +| v0.38.1 ✅ | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. | | v0.38.2 | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.load()` with per-library permission context, dependency resolution, uninstall protection. | -| v0.38.3 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library. | -| v0.38.4 | Git-board Rewrite | Capstone validation — rewrite git-board as multi-package, multi-platform extension (Gitea/GitLab/GitHub). Proves the entire extension architecture works end-to-end. Reference implementation for future extension authors. | +| v0.38.3 | Extension Config Sections | Manifest-driven `config_section` — packages declare a Preact component that the Settings/Admin surfaces discover and lazy-load as a nav section. Enables headless extensions (no surface) and libraries to own their configuration UX. Libraries can provide rich config for their connection types (OAuth flows, test buttons, pickers). | +| v0.38.4 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library. | +| v0.38.5 | Git-board Rewrite | Capstone validation — rewrite git-board as multi-package, multi-platform extension (Gitea/GitLab/GitHub). Proves the entire extension architecture works end-to-end. Reference implementation for future extension authors. | --- diff --git a/packages/sdk-test-runner/js/domains/connections.js b/packages/sdk-test-runner/js/domains/connections.js new file mode 100644 index 0000000..f6c29f7 --- /dev/null +++ b/packages/sdk-test-runner/js/domains/connections.js @@ -0,0 +1,117 @@ +/** + * SDK Test Runner — Domain: connections (v0.38.1) + * + * Tests extension connection CRUD and scope resolution. + * Requires admin role (global connection CRUD). + */ +(function () { + 'use strict'; + var T = window.SDKR; + if (!T) return; + + T.registerDomain('connections', async function () { + + if (!window.sw || !sw.isAdmin) { + await T.test('connections', 'guard', 'skip — not admin', async function () { + T.assert(true, 'skipped (user is not admin)'); + }); + return; + } + + var globalId = null; + var personalId = null; + + // ── Global CRUD ────────────────────────── + + await T.test('connections', 'admin-create', 'create global connection', async function () { + var r = await sw.api.admin.connections.create({ + type: 'sdk-test-conn', + package_id: 'sdk-test-pkg', + name: 'Global Test', + config: { base_url: 'https://test.example.com', org: 'test-org' } + }); + T.assert(r && r.id, 'should return id'); + T.assert(r.type === 'sdk-test-conn', 'type matches'); + globalId = r.id; + }); + + T.cleanup.push(async function () { + if (globalId) try { await sw.api.admin.connections.del(globalId); } catch (_) {} + }); + + await T.test('connections', 'admin-list', 'list global connections includes created', async function () { + var r = await sw.api.admin.connections.list(); + T.assert(Array.isArray(r), 'should be array'); + var found = r.find(function (c) { return c.id === globalId; }); + T.assert(found, 'created connection should be in list'); + T.assert(found.type === 'sdk-test-conn', 'type matches'); + T.assert(found.name === 'Global Test', 'name matches'); + }); + + await T.test('connections', 'admin-update', 'update global connection', async function () { + await sw.api.admin.connections.update(globalId, { name: 'Global Test Updated' }); + var list = await sw.api.admin.connections.list(); + var found = list.find(function (c) { return c.id === globalId; }); + T.assert(found && found.name === 'Global Test Updated', 'name should be updated'); + }); + + // ── Personal CRUD ──────────────────────── + + await T.test('connections', 'personal-create', 'create personal connection', async function () { + var r = await sw.api.connections.create({ + type: 'sdk-test-conn', + package_id: 'sdk-test-pkg', + name: 'Personal Test', + config: { base_url: 'https://personal.example.com' } + }); + T.assert(r && r.id, 'should return id'); + personalId = r.id; + }); + + T.cleanup.push(async function () { + if (personalId) try { await sw.api.connections.del(personalId); } catch (_) {} + }); + + await T.test('connections', 'personal-list', 'list personal connections', async function () { + var r = await sw.api.connections.list(); + T.assert(Array.isArray(r), 'should be array'); + var found = r.find(function (c) { return c.id === personalId; }); + T.assert(found, 'personal connection in list'); + }); + + // ── Scope Resolution ───────────────────── + + await T.test('connections', 'resolve-prefers-personal', 'resolve prefers personal over global', async function () { + var r = await sw.api.connections.resolve('sdk-test-conn'); + T.assert(r && r.id, 'should resolve'); + // Personal scope should win over global + T.assert(r.scope === 'personal', 'should resolve personal scope first'); + T.assert(r.id === personalId, 'should be the personal connection'); + }); + + await T.test('connections', 'resolve-by-name', 'resolve by name returns specific connection', async function () { + var r = await sw.api.connections.resolve('sdk-test-conn', 'Global Test Updated'); + T.assert(r && r.id, 'should resolve'); + T.assert(r.id === globalId, 'should match global connection by name'); + }); + + // ── Cleanup ────────────────────────────── + + await T.test('connections', 'personal-delete', 'delete personal connection', async function () { + await sw.api.connections.del(personalId); + var list = await sw.api.connections.list(); + var found = list.find(function (c) { return c.id === personalId; }); + T.assert(!found, 'should be deleted'); + personalId = null; + }); + + await T.test('connections', 'admin-delete', 'delete global connection', async function () { + await sw.api.admin.connections.del(globalId); + var list = await sw.api.admin.connections.list(); + var found = list.find(function (c) { return c.id === globalId; }); + T.assert(!found, 'should be deleted'); + globalId = null; + }); + + }); +})(); diff --git a/packages/sdk-test-runner/js/main.js b/packages/sdk-test-runner/js/main.js index 3c1a800..3fa1542 100644 --- a/packages/sdk-test-runner/js/main.js +++ b/packages/sdk-test-runner/js/main.js @@ -75,6 +75,7 @@ 'domains/misc.js', 'domains/admin.js', 'domains/packages.js', + 'domains/connections.js', // UI (must come last) 'ui.js' ]; diff --git a/server/database/migrations/022_ext_connections.sql b/server/database/migrations/022_ext_connections.sql new file mode 100644 index 0000000..82dce43 --- /dev/null +++ b/server/database/migrations/022_ext_connections.sql @@ -0,0 +1,37 @@ +-- ========================================== +-- Chat Switchboard — 022 Extension Connections +-- ========================================== +-- v0.38.1: Scoped credential management for extensions. +-- Same scope/resolution pattern as provider_configs. +-- ========================================== + +CREATE TABLE IF NOT EXISTS ext_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type TEXT NOT NULL, + package_id TEXT NOT NULL, + scope VARCHAR(10) NOT NULL + CHECK (scope IN ('global', 'team', 'personal')), + owner_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(type, scope, owner_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_ext_connections_type ON ext_connections(type); +CREATE INDEX IF NOT EXISTS idx_ext_connections_scope ON ext_connections(scope); +CREATE INDEX IF NOT EXISTS idx_ext_connections_owner ON ext_connections(owner_id) WHERE owner_id != ''; +CREATE INDEX IF NOT EXISTS idx_ext_connections_active ON ext_connections(is_active) WHERE is_active = true; + +DROP TRIGGER IF EXISTS ext_connections_updated_at ON ext_connections; +CREATE TRIGGER ext_connections_updated_at BEFORE UPDATE ON ext_connections + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +COMMENT ON TABLE ext_connections IS 'v0.38.1: Extension connection credentials (scoped, encrypted secrets in config JSON)'; +COMMENT ON COLUMN ext_connections.type IS 'Connection type identifier, shared across packages (e.g. "gitea", "github")'; +COMMENT ON COLUMN ext_connections.package_id IS 'Declaring package ID (UI attribution only, not access control)'; +COMMENT ON COLUMN ext_connections.scope IS 'global=admin-managed, team=team-scoped, personal=user-owned'; +COMMENT ON COLUMN ext_connections.owner_id IS 'Empty for global; team_id for team scope; user_id for personal scope'; +COMMENT ON COLUMN ext_connections.config IS 'JSON config blob; secret-type fields encrypted at rest by handler layer'; diff --git a/server/database/migrations/sqlite/022_ext_connections.sql b/server/database/migrations/sqlite/022_ext_connections.sql new file mode 100644 index 0000000..ed9f2f6 --- /dev/null +++ b/server/database/migrations/sqlite/022_ext_connections.sql @@ -0,0 +1,24 @@ +-- ========================================== +-- Chat Switchboard — 022 Extension Connections +-- ========================================== +-- v0.38.1: Scoped credential management for extensions. +-- SQLite version (no partial indexes, TEXT types). +-- ========================================== + +CREATE TABLE IF NOT EXISTS ext_connections ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + package_id TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('global', 'team', 'personal')), + owner_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + config TEXT NOT NULL DEFAULT '{}', + is_active INTEGER DEFAULT 1, + created_at TEXT, + updated_at TEXT, + UNIQUE(type, scope, owner_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_ext_connections_type ON ext_connections(type); +CREATE INDEX IF NOT EXISTS idx_ext_connections_scope ON ext_connections(scope); +CREATE INDEX IF NOT EXISTS idx_ext_connections_owner ON ext_connections(owner_id); diff --git a/server/handlers/admin_connections.go b/server/handlers/admin_connections.go new file mode 100644 index 0000000..458841d --- /dev/null +++ b/server/handlers/admin_connections.go @@ -0,0 +1,112 @@ +package handlers + +// v0.38.1: Extension connection handlers — admin/global scope. +// Methods on AdminHandler, mirrors admin provider config pattern. + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "chat-switchboard/models" +) + +// ListGlobalConnections returns all global-scope connections. +func (h *AdminHandler) ListGlobalConnections(c *gin.Context) { + conns, err := h.stores.Connections.ListGlobal(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)}) +} + +// CreateGlobalConnection creates a global-scope connection. +func (h *AdminHandler) CreateGlobalConnection(c *gin.Context) { + var req struct { + Type string `json:"type" binding:"required"` + PackageID string `json:"package_id" binding:"required"` + Name string `json:"name" binding:"required"` + Config models.JSONMap `json:"config"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ch := &ConnectionHandler{stores: h.stores, vault: h.vault} + secretFields := ch.lookupSecretFields(c, req.PackageID, req.Type) + encConfig := ch.encryptSecrets(req.Config, secretFields, models.ScopeGlobal, "") + + conn := &models.ExtConnection{ + Type: req.Type, + PackageID: req.PackageID, + Scope: models.ScopeGlobal, + OwnerID: "", + Name: req.Name, + Config: encConfig, + IsActive: true, + } + + if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create connection"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": conn.ID, + "type": conn.Type, + "name": conn.Name, + }) +} + +// UpdateGlobalConnection updates a global-scope connection. +func (h *AdminHandler) UpdateGlobalConnection(c *gin.Context) { + id := c.Param("id") + + existing, err := h.stores.Connections.GetByID(c.Request.Context(), id) + if err != nil || existing.Scope != models.ScopeGlobal { + c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) + return + } + + var req struct { + Name *string `json:"name,omitempty"` + Config models.JSONMap `json:"config,omitempty"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + patch := models.ExtConnectionPatch{Name: req.Name} + if req.Config != nil { + ch := &ConnectionHandler{stores: h.stores, vault: h.vault} + secretFields := ch.lookupSecretFields(c, existing.PackageID, existing.Type) + patch.Config = ch.encryptSecrets(req.Config, secretFields, models.ScopeGlobal, "") + } + + if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "connection updated"}) +} + +// DeleteGlobalConnection deletes a global-scope connection. +func (h *AdminHandler) DeleteGlobalConnection(c *gin.Context) { + id := c.Param("id") + + n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopeGlobal, "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"}) + return + } + if n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "connection deleted"}) +} diff --git a/server/handlers/connection_resolver.go b/server/handlers/connection_resolver.go new file mode 100644 index 0000000..151913e --- /dev/null +++ b/server/handlers/connection_resolver.go @@ -0,0 +1,101 @@ +package handlers + +// v0.38.1: ConnectionResolver bridges the sandbox connections module +// to the store + vault layers. Implements sandbox.ConnectionResolver. + +import ( + "context" + "encoding/json" + + "chat-switchboard/crypto" + "chat-switchboard/models" + "chat-switchboard/store" +) + +// ConnectionResolverAdapter implements sandbox.ConnectionResolver. +type ConnectionResolverAdapter struct { + stores store.Stores + vault *crypto.KeyResolver +} + +func NewConnectionResolverAdapter(s store.Stores, vault *crypto.KeyResolver) *ConnectionResolverAdapter { + return &ConnectionResolverAdapter{stores: s, vault: vault} +} + +// Resolve returns a single connection with secrets decrypted. +func (r *ConnectionResolverAdapter) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) { + conn, err := r.stores.Connections.Resolve(ctx, userID, connType, name) + if err != nil { + return nil, err + } + r.decryptInPlace(ctx, conn) + return conn, nil +} + +// ResolveAll returns all accessible connections with secrets decrypted. +func (r *ConnectionResolverAdapter) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) { + conns, err := r.stores.Connections.ResolveAll(ctx, userID, connType) + if err != nil { + return nil, err + } + for i := range conns { + r.decryptInPlace(ctx, &conns[i]) + } + return conns, nil +} + +// decryptInPlace decrypts secret fields in the connection's config. +func (r *ConnectionResolverAdapter) decryptInPlace(ctx context.Context, conn *models.ExtConnection) { + if conn == nil || conn.Config == nil || r.vault == nil { + return + } + secretFields := r.lookupSecretFields(ctx, conn.PackageID, conn.Type) + for k, v := range conn.Config { + if !secretFields[k] { + continue + } + m, ok := v.(map[string]interface{}) + if !ok { + continue + } + enc := toBytes(m["enc"]) + nonce := toBytes(m["nonce"]) + if len(enc) == 0 { + continue + } + plaintext, err := r.vault.Decrypt(enc, nonce, conn.Scope, conn.OwnerID) + if err == nil { + conn.Config[k] = plaintext + } + } +} + +// lookupSecretFields reads the package manifest to identify secret fields. +func (r *ConnectionResolverAdapter) lookupSecretFields(ctx context.Context, packageID, connType string) map[string]bool { + result := map[string]bool{} + pkg, err := r.stores.Packages.Get(ctx, packageID) + if err != nil || pkg == nil { + return result + } + connsRaw, ok := pkg.Manifest["connections"] + if !ok { + return result + } + connsJSON, _ := json.Marshal(connsRaw) + var conns []struct { + Type string `json:"type"` + Fields map[string]map[string]string `json:"fields"` + } + json.Unmarshal(connsJSON, &conns) + for _, cd := range conns { + if cd.Type == connType { + for fieldName, fieldDef := range cd.Fields { + if fieldDef["type"] == "secret" { + result[fieldName] = true + } + } + break + } + } + return result +} diff --git a/server/handlers/connections.go b/server/handlers/connections.go new file mode 100644 index 0000000..0017462 --- /dev/null +++ b/server/handlers/connections.go @@ -0,0 +1,299 @@ +package handlers + +// v0.38.1: Extension connection handlers — personal scope + resolution. +// Pattern follows apiconfigs.go (ProviderConfigHandler). + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + + "chat-switchboard/crypto" + "chat-switchboard/models" + "chat-switchboard/store" +) + +// ConnectionHandler handles user-facing extension connection endpoints. +type ConnectionHandler struct { + stores store.Stores + vault *crypto.KeyResolver +} + +func NewConnectionHandler(s store.Stores, vault *crypto.KeyResolver) *ConnectionHandler { + return &ConnectionHandler{stores: s, vault: vault} +} + +// ListConnections returns the user's personal connections. +func (h *ConnectionHandler) ListConnections(c *gin.Context) { + userID := getUserID(c) + conns, err := h.stores.Connections.ListForUser(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)}) +} + +// CreateConnection creates a personal-scope connection. +func (h *ConnectionHandler) CreateConnection(c *gin.Context) { + userID := getUserID(c) + + var req struct { + Type string `json:"type" binding:"required"` + PackageID string `json:"package_id" binding:"required"` + Name string `json:"name" binding:"required"` + Config models.JSONMap `json:"config"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + secretFields := h.lookupSecretFields(c, req.PackageID, req.Type) + encConfig := h.encryptSecrets(req.Config, secretFields, models.ScopePersonal, userID) + + conn := &models.ExtConnection{ + Type: req.Type, + PackageID: req.PackageID, + Scope: models.ScopePersonal, + OwnerID: userID, + Name: req.Name, + Config: encConfig, + IsActive: true, + } + + if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create connection"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": conn.ID, + "type": conn.Type, + "name": conn.Name, + }) +} + +// GetConnection returns a single connection by ID. +func (h *ConnectionHandler) GetConnection(c *gin.Context) { + userID := getUserID(c) + id := c.Param("id") + + conn, err := h.stores.Connections.GetByID(c.Request.Context(), id) + if err != nil || conn.Scope != models.ScopePersonal || conn.OwnerID != userID { + c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) + return + } + + c.JSON(http.StatusOK, maskSingleConnection(conn)) +} + +// UpdateConnection updates a personal connection. +func (h *ConnectionHandler) UpdateConnection(c *gin.Context) { + userID := getUserID(c) + id := c.Param("id") + + existing, err := h.stores.Connections.GetByID(c.Request.Context(), id) + if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own connections"}) + return + } + + var req struct { + Name *string `json:"name,omitempty"` + Config models.JSONMap `json:"config,omitempty"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + patch := models.ExtConnectionPatch{Name: req.Name} + if req.Config != nil { + secretFields := h.lookupSecretFields(c, existing.PackageID, existing.Type) + patch.Config = h.encryptSecrets(req.Config, secretFields, models.ScopePersonal, userID) + } + + if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "connection updated"}) +} + +// DeleteConnection deletes a personal connection. +func (h *ConnectionHandler) DeleteConnection(c *gin.Context) { + userID := getUserID(c) + id := c.Param("id") + + n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopePersonal, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"}) + return + } + if n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "connection deleted"}) +} + +// ResolveConnection resolves a connection via the scope chain. +// GET /connections/resolve?type=gitea&name=Work+Gitea +func (h *ConnectionHandler) ResolveConnection(c *gin.Context) { + userID := getUserID(c) + connType := c.Query("type") + if connType == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "type query parameter required"}) + return + } + name := c.Query("name") + + conn, err := h.stores.Connections.Resolve(c.Request.Context(), userID, connType, name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no connection found"}) + return + } + + // Decrypt secrets for the resolver response (used by frontend to test connections). + secretFields := h.lookupSecretFields(c, conn.PackageID, conn.Type) + conn.Config = h.decryptSecrets(conn.Config, secretFields, conn.Scope, conn.OwnerID) + + c.JSON(http.StatusOK, conn) +} + +// ── Encryption helpers ───────────────────────────── + +// lookupSecretFields reads the package manifest to identify which config +// fields are of type "secret" for a given connection type. +func (h *ConnectionHandler) lookupSecretFields(c *gin.Context, packageID, connType string) map[string]bool { + result := map[string]bool{} + pkg, err := h.stores.Packages.Get(c.Request.Context(), packageID) + if err != nil || pkg == nil { + return result + } + connsRaw, ok := pkg.Manifest["connections"] + if !ok { + return result + } + connsJSON, _ := json.Marshal(connsRaw) + var conns []struct { + Type string `json:"type"` + Fields map[string]map[string]string `json:"fields"` + } + json.Unmarshal(connsJSON, &conns) + for _, cd := range conns { + if cd.Type == connType { + for fieldName, fieldDef := range cd.Fields { + if fieldDef["type"] == "secret" { + result[fieldName] = true + } + } + break + } + } + return result +} + +// encryptSecrets encrypts secret-type fields within a config map. +// Non-secret fields pass through unchanged. +func (h *ConnectionHandler) encryptSecrets(config models.JSONMap, secretFields map[string]bool, scope, ownerID string) models.JSONMap { + if config == nil || h.vault == nil { + return config + } + out := make(models.JSONMap, len(config)) + for k, v := range config { + if secretFields[k] { + if s, ok := v.(string); ok && s != "" { + enc, nonce, err := h.vault.EncryptForScope(s, scope, ownerID) + if err == nil { + out[k] = map[string]interface{}{ + "enc": enc, + "nonce": nonce, + } + continue + } + } + } + out[k] = v + } + return out +} + +// decryptSecrets decrypts secret-type fields within a config map. +func (h *ConnectionHandler) decryptSecrets(config models.JSONMap, secretFields map[string]bool, scope, ownerID string) models.JSONMap { + if config == nil || h.vault == nil { + return config + } + out := make(models.JSONMap, len(config)) + for k, v := range config { + if secretFields[k] { + if m, ok := v.(map[string]interface{}); ok { + encRaw, _ := m["enc"] + nonceRaw, _ := m["nonce"] + // Type-assert to []byte or base64-decode from string + enc := toBytes(encRaw) + nonce := toBytes(nonceRaw) + if len(enc) > 0 { + plaintext, err := h.vault.Decrypt(enc, nonce, scope, ownerID) + if err == nil { + out[k] = plaintext + continue + } + } + } + } + out[k] = v + } + return out +} + +// toBytes coerces an interface{} to []byte. Handles both []byte (from Go) +// and string (from JSON unmarshal of base64). +func toBytes(v interface{}) []byte { + switch t := v.(type) { + case []byte: + return t + case string: + return []byte(t) + } + return nil +} + +// ── Response masking ─────────────────────────────── + +// maskConnectionSecrets returns safe connection summaries (no secret values). +func maskConnectionSecrets(conns []models.ExtConnection) []map[string]interface{} { + out := make([]map[string]interface{}, len(conns)) + for i, c := range conns { + out[i] = map[string]interface{}{ + "id": c.ID, + "type": c.Type, + "package_id": c.PackageID, + "scope": c.Scope, + "name": c.Name, + "is_active": c.IsActive, + "has_config": len(c.Config) > 0, + "created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z"), + "updated_at": c.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + } + return out +} + +func maskSingleConnection(c *models.ExtConnection) map[string]interface{} { + return map[string]interface{}{ + "id": c.ID, + "type": c.Type, + "package_id": c.PackageID, + "scope": c.Scope, + "name": c.Name, + "is_active": c.IsActive, + "has_config": len(c.Config) > 0, + "created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z"), + "updated_at": c.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} diff --git a/server/handlers/team_connections.go b/server/handlers/team_connections.go new file mode 100644 index 0000000..56972f4 --- /dev/null +++ b/server/handlers/team_connections.go @@ -0,0 +1,118 @@ +package handlers + +// v0.38.1: Extension connection handlers — team scope. +// Methods on TeamHandler, mirrors team_providers.go. + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "chat-switchboard/models" +) + +// ListTeamConnections returns connections scoped to a team. +func (h *TeamHandler) ListTeamConnections(c *gin.Context) { + teamID := getTeamID(c) + conns, err := h.stores.Connections.ListForTeam(c.Request.Context(), teamID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team connections"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)}) +} + +// CreateTeamConnection creates a connection scoped to a team. +func (h *TeamHandler) CreateTeamConnection(c *gin.Context) { + teamID := getTeamID(c) + + var req struct { + Type string `json:"type" binding:"required"` + PackageID string `json:"package_id" binding:"required"` + Name string `json:"name" binding:"required"` + Config models.JSONMap `json:"config"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Use ConnectionHandler helpers for encryption + ch := &ConnectionHandler{stores: h.stores, vault: h.vault} + secretFields := ch.lookupSecretFields(c, req.PackageID, req.Type) + encConfig := ch.encryptSecrets(req.Config, secretFields, models.ScopeTeam, teamID) + + conn := &models.ExtConnection{ + Type: req.Type, + PackageID: req.PackageID, + Scope: models.ScopeTeam, + OwnerID: teamID, + Name: req.Name, + Config: encConfig, + IsActive: true, + } + + if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team connection"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": conn.ID, + "type": conn.Type, + "name": conn.Name, + }) +} + +// UpdateTeamConnection updates a team-scoped connection. +func (h *TeamHandler) UpdateTeamConnection(c *gin.Context) { + teamID := getTeamID(c) + id := c.Param("id") + + existing, err := h.stores.Connections.GetByID(c.Request.Context(), id) + if err != nil || existing.Scope != models.ScopeTeam || existing.OwnerID != teamID { + c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) + return + } + + var req struct { + Name *string `json:"name,omitempty"` + Config models.JSONMap `json:"config,omitempty"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + patch := models.ExtConnectionPatch{Name: req.Name} + if req.Config != nil { + ch := &ConnectionHandler{stores: h.stores, vault: h.vault} + secretFields := ch.lookupSecretFields(c, existing.PackageID, existing.Type) + patch.Config = ch.encryptSecrets(req.Config, secretFields, models.ScopeTeam, teamID) + } + + if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "connection updated"}) +} + +// DeleteTeamConnection deletes a team-scoped connection. +func (h *TeamHandler) DeleteTeamConnection(c *gin.Context) { + teamID := getTeamID(c) + id := c.Param("id") + + n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopeTeam, teamID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"}) + return + } + if n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "connection deleted"}) +} diff --git a/server/main.go b/server/main.go index 8087a4f..4b496f1 100644 --- a/server/main.go +++ b/server/main.go @@ -408,6 +408,8 @@ func main() { ) // v0.29.1: provider module adapter — bridges sandbox.ProviderResolver to handlers.ResolveProviderConfig starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver)) + // v0.38.1: connections module — extension connection resolution + starlarkRunner.SetConnectionResolver(handlers.NewConnectionResolverAdapter(stores, keyResolver)) // v0.29.2: db module — extension namespaced table access starlarkRunner.SetDB(database.DB, database.IsPostgres()) // v0.38.0: disk-based script loading + load() support @@ -812,6 +814,15 @@ func main() { protected.GET("/api-configs/:id/models", provCfg.ListModels) protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) + // Extension Connections (personal scope — v0.38.1) + connH := handlers.NewConnectionHandler(stores, keyResolver) + protected.GET("/connections", connH.ListConnections) + protected.POST("/connections", connH.CreateConnection) + protected.GET("/connections/resolve", connH.ResolveConnection) + protected.GET("/connections/:id", connH.GetConnection) + protected.PUT("/connections/:id", connH.UpdateConnection) + protected.DELETE("/connections/:id", connH.DeleteConnection) + // Models (unified resolver — replaces scattered endpoints) modelH := handlers.NewModelHandler(stores) if healthStore != nil { @@ -1044,6 +1055,12 @@ func main() { teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels) + // Team connections (v0.38.1) + teamScoped.GET("/connections", teams.ListTeamConnections) + teamScoped.POST("/connections", teams.CreateTeamConnection) + teamScoped.PUT("/connections/:id", teams.UpdateTeamConnection) + teamScoped.DELETE("/connections/:id", teams.DeleteTeamConnection) + // Team package management (v0.30.0) teamPkgH := handlers.NewUserPackageHandler(stores, userPkgDir) teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage) @@ -1164,6 +1181,12 @@ func main() { admin.PUT("/configs/:id", adm.UpdateGlobalConfig) admin.DELETE("/configs/:id", adm.DeleteGlobalConfig) + // Global Connections (v0.38.1) + admin.GET("/connections", adm.ListGlobalConnections) + admin.POST("/connections", adm.CreateGlobalConnection) + admin.PUT("/connections/:id", adm.UpdateGlobalConnection) + admin.DELETE("/connections/:id", adm.DeleteGlobalConnection) + // Model Catalog admin.GET("/models", adm.ListModelConfigs) admin.POST("/models/fetch", adm.FetchModels) diff --git a/server/models/ext_connection.go b/server/models/ext_connection.go new file mode 100644 index 0000000..a63e3eb --- /dev/null +++ b/server/models/ext_connection.go @@ -0,0 +1,24 @@ +package models + +// ── Extension Connections (v0.38.1) ────────────────── + +// ExtConnection represents a scoped credential/endpoint configuration +// for extensions that integrate with external services. Same scope +// hierarchy as ProviderConfig: global → team → personal. +type ExtConnection struct { + BaseModel + Type string `json:"type" db:"type"` + PackageID string `json:"package_id" db:"package_id"` + Scope string `json:"scope" db:"scope"` + OwnerID string `json:"owner_id" db:"owner_id"` + Name string `json:"name" db:"name"` + Config JSONMap `json:"config" db:"config"` + IsActive bool `json:"is_active" db:"is_active"` +} + +// ExtConnectionPatch carries partial updates. Nil fields are skipped. +type ExtConnectionPatch struct { + Name *string `json:"name,omitempty"` + Config JSONMap `json:"config,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} diff --git a/server/models/models_extension_perm.go b/server/models/models_extension_perm.go index 978d34b..1b4078d 100644 --- a/server/models/models_extension_perm.go +++ b/server/models/models_extension_perm.go @@ -35,6 +35,7 @@ const ( ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access + ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution ) // ValidExtensionPermissions is the set of recognized permission keys. @@ -48,6 +49,7 @@ var ValidExtensionPermissions = map[string]bool{ ExtPermProviderComplete: true, ExtPermFormValidate: true, ExtPermWorkflowAccess: true, + ExtPermConnectionsRead: true, } // ── Extension Permission Model ─────────────── diff --git a/server/sandbox/connections_module.go b/server/sandbox/connections_module.go new file mode 100644 index 0000000..9ebbc74 --- /dev/null +++ b/server/sandbox/connections_module.go @@ -0,0 +1,107 @@ +// Package sandbox — connections_module.go +// +// v0.38.1: Connections module for Starlark extensions. +// Requires permission: connections.read +// +// Starlark API: +// conn = connections.get("gitea") → dict or None +// conn = connections.get("gitea", name="Work Gitea") → dict or None +// all = connections.list("gitea") → list of dicts +// +// Each returned dict contains the connection's config fields with +// secrets decrypted. The module delegates to a ConnectionResolver +// interface so the runner stays agnostic to the vault. +package sandbox + +import ( + "context" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" + + "chat-switchboard/models" +) + +// ConnectionResolver resolves extension connections with secret decryption. +// Implemented by the handler layer which owns the vault. +type ConnectionResolver interface { + // Resolve returns a single connection's decrypted config via the + // scope chain (personal → team → global). Empty name = first match. + Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) + + // ResolveAll returns all accessible connections of a type with + // decrypted configs. + ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) +} + +// BuildConnectionsModule creates the "connections" module. +func BuildConnectionsModule(ctx context.Context, resolver ConnectionResolver, userID string) *starlarkstruct.Module { + return MakeModule("connections", starlark.StringDict{ + "get": starlark.NewBuiltin("connections.get", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var connType string + var name string + if err := starlark.UnpackArgs(b.Name(), args, kwargs, + "type", &connType, + "name?", &name, + ); err != nil { + return nil, err + } + + conn, err := resolver.Resolve(ctx, userID, connType, name) + if err != nil || conn == nil { + return starlark.None, nil + } + return connectionToDict(conn), nil + }), + + "list": starlark.NewBuiltin("connections.list", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var connType string + if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &connType); err != nil { + return nil, err + } + + conns, err := resolver.ResolveAll(ctx, userID, connType) + if err != nil { + return starlark.NewList(nil), nil + } + + elems := make([]starlark.Value, len(conns)) + for i := range conns { + elems[i] = connectionToDict(&conns[i]) + } + return starlark.NewList(elems), nil + }), + }) +} + +// connectionToDict converts an ExtConnection to a Starlark dict. +// Config fields are flattened into the top-level dict alongside id/name/type. +func connectionToDict(conn *models.ExtConnection) *starlark.Dict { + d := starlark.NewDict(4 + len(conn.Config)) + d.SetKey(starlark.String("id"), starlark.String(conn.ID)) + d.SetKey(starlark.String("type"), starlark.String(conn.Type)) + d.SetKey(starlark.String("name"), starlark.String(conn.Name)) + d.SetKey(starlark.String("scope"), starlark.String(conn.Scope)) + + for k, v := range conn.Config { + switch val := v.(type) { + case string: + d.SetKey(starlark.String(k), starlark.String(val)) + case float64: + d.SetKey(starlark.String(k), starlark.Float(val)) + case bool: + d.SetKey(starlark.String(k), starlark.Bool(val)) + default: + // Fallback: coerce to string + d.SetKey(starlark.String(k), starlark.String("")) + } + } + + return d +} diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index c751da9..2e6567a 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -50,9 +50,10 @@ type Runner struct { stores store.Stores packagesDir string // v0.38.0: disk path for load() support notifier NotificationSender // nil = notifications module unavailable - resolver ProviderResolver // nil = provider module unavailable - db *sql.DB // nil = db module unavailable - dbPostgres bool // true = use $N placeholders; false = use ? + resolver ProviderResolver // nil = provider module unavailable + connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1) + db *sql.DB // nil = db module unavailable + dbPostgres bool // true = use $N placeholders; false = use ? } // NewRunner creates a runner with the given sandbox and dependencies. @@ -73,6 +74,11 @@ func (r *Runner) SetProviderResolver(pr ProviderResolver) { r.resolver = pr } +// SetConnectionResolver attaches the connection resolver for the connections module (v0.38.1). +func (r *Runner) SetConnectionResolver(cr ConnectionResolver) { + r.connResolver = cr +} + // SetDB attaches the raw database connection for the db module. // isPostgres controls whether $N or ? placeholders are used. // Call this at startup after database.Connect(). @@ -300,6 +306,11 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma case models.ExtPermWorkflowAccess: modules["workflow"] = BuildWorkflowModule(ctx, r.stores) + + case models.ExtPermConnectionsRead: + if r.connResolver != nil && rc != nil && rc.UserID != "" { + modules["connections"] = BuildConnectionsModule(ctx, r.connResolver, rc.UserID) + } } } diff --git a/server/static/openapi.yaml b/server/static/openapi.yaml index 5d38caf..6f5f762 100644 --- a/server/static/openapi.yaml +++ b/server/static/openapi.yaml @@ -8820,6 +8820,213 @@ paths: $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' + # ── Extension Connections (v0.38.1) ───────────────────────────────── + /api/v1/connections: + get: + tags: [Extensions] + summary: List personal connections + security: + - bearerAuth: [] + responses: + '200': + description: Personal connections (secrets masked) + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ExtConnection' + post: + tags: [Extensions] + summary: Create personal connection + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [type, package_id, name] + properties: + type: { type: string } + package_id: { type: string } + name: { type: string } + config: { type: object } + responses: + '201': + description: Created + /api/v1/connections/{id}: + get: + tags: [Extensions] + summary: Get personal connection + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + responses: + '200': + description: Connection (secrets masked) + put: + tags: [Extensions] + summary: Update personal connection + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + requestBody: + content: + application/json: + schema: + type: object + properties: + name: { type: string } + config: { type: object } + responses: + '200': + description: Updated + delete: + tags: [Extensions] + summary: Delete personal connection + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + responses: + '200': + description: Deleted + /api/v1/connections/resolve: + get: + tags: [Extensions] + summary: Resolve connection via scope chain (personal > team > global) + description: | + Returns a single connection with decrypted config, resolved via + the scope chain. Requires `type` query parameter. Optional `name` + for named resolution. + security: + - bearerAuth: [] + parameters: + - name: type + in: query + required: true + schema: { type: string } + - name: name + in: query + schema: { type: string } + responses: + '200': + description: Resolved connection with decrypted config + '404': + description: No matching connection found + /api/v1/teams/{teamId}/connections: + get: + tags: [Teams, Extensions] + summary: List team connections + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/TeamID' + responses: + '200': + description: Team connections + post: + tags: [Teams, Extensions] + summary: Create team connection + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/TeamID' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [type, package_id, name] + properties: + type: { type: string } + package_id: { type: string } + name: { type: string } + config: { type: object } + responses: + '201': + description: Created + /api/v1/teams/{teamId}/connections/{id}: + put: + tags: [Teams, Extensions] + summary: Update team connection + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/TeamID' + - $ref: '#/components/parameters/ResourceID' + responses: + '200': + description: Updated + delete: + tags: [Teams, Extensions] + summary: Delete team connection + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/TeamID' + - $ref: '#/components/parameters/ResourceID' + responses: + '200': + description: Deleted + /api/v1/admin/connections: + get: + tags: [Admin, Extensions] + summary: List global connections + security: + - bearerAuth: [] + responses: + '200': + description: Global connections + post: + tags: [Admin, Extensions] + summary: Create global connection + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [type, package_id, name] + properties: + type: { type: string } + package_id: { type: string } + name: { type: string } + config: { type: object } + responses: + '201': + description: Created + /api/v1/admin/connections/{id}: + put: + tags: [Admin, Extensions] + summary: Update global connection + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + responses: + '200': + description: Updated + delete: + tags: [Admin, Extensions] + summary: Delete global connection + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ResourceID' + responses: + '200': + description: Deleted /api/v1/admin/workflows/{id}/export: get: tags: @@ -12059,6 +12266,36 @@ components: updated_at: type: string format: date-time + ExtConnection: + type: object + description: "v0.38.1: Extension connection credential (secrets masked in list responses)" + properties: + id: + type: string + format: uuid + type: + type: string + description: Connection type identifier (e.g. "gitea", "github") + package_id: + type: string + description: Declaring package ID (UI attribution) + scope: + type: string + enum: [global, team, personal] + name: + type: string + description: User label (e.g. "Work Gitea") + is_active: + type: boolean + has_config: + type: boolean + description: Whether config fields are set (secrets never exposed in list) + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time Extension: type: object properties: diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 0116af0..de92d8a 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -65,6 +65,7 @@ type Stores struct { Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod) RateLimits RateLimitStore // v0.32.0: Distributed rate limiting Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops + Connections ConnectionStore // v0.38.1: Extension connection credentials } // ========================================= @@ -1366,6 +1367,35 @@ type ExportStore interface { // ========================================= // SHARED TYPES +// ========================================= +// CONNECTION STORE (v0.38.1) +// ========================================= + +type ConnectionStore interface { + Create(ctx context.Context, conn *models.ExtConnection) error + GetByID(ctx context.Context, id string) (*models.ExtConnection, error) + Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error + Delete(ctx context.Context, id string) error + + // Scoped queries + ListGlobal(ctx context.Context) ([]models.ExtConnection, error) + ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error) + ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error) + + // Resolution chain: personal → team → global. + // Returns first active connection matching type (and optional name). + // Empty name means "first match". Returns sql.ErrNoRows if not found. + Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) + + // ResolveAll returns all active connections of the given type accessible + // to the user (personal + team + global). + ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) + + // DeleteByIDAndScope deletes a connection only if it matches the given + // scope and owner. Returns rows affected (0 or 1). + DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) +} + // ========================================= // ListOptions provides standard pagination/sort for list queries. diff --git a/server/store/postgres/ext_connection.go b/server/store/postgres/ext_connection.go new file mode 100644 index 0000000..bcd4608 --- /dev/null +++ b/server/store/postgres/ext_connection.go @@ -0,0 +1,182 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "chat-switchboard/models" +) + +// ── ConnectionStore (v0.38.1) ──────────────────────── + +type ConnectionStore struct{} + +func NewConnectionStore() *ConnectionStore { return &ConnectionStore{} } + +const connCols = `id, type, package_id, scope, owner_id, name, config, is_active, created_at, updated_at` + +func (s *ConnectionStore) Create(ctx context.Context, conn *models.ExtConnection) error { + return DB.QueryRowContext(ctx, ` + INSERT INTO ext_connections (type, package_id, scope, owner_id, name, config, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, created_at, updated_at`, + conn.Type, conn.PackageID, conn.Scope, conn.OwnerID, + conn.Name, ToJSON(conn.Config), conn.IsActive, + ).Scan(&conn.ID, &conn.CreatedAt, &conn.UpdatedAt) +} + +func (s *ConnectionStore) GetByID(ctx context.Context, id string) (*models.ExtConnection, error) { + row := DB.QueryRowContext(ctx, + fmt.Sprintf("SELECT %s FROM ext_connections WHERE id = $1", connCols), id) + return scanConnection(row) +} + +func (s *ConnectionStore) Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error { + b := NewUpdate("ext_connections") + if patch.Name != nil { + b.Set("name", *patch.Name) + } + if patch.Config != nil { + b.SetJSON("config", patch.Config) + } + if patch.IsActive != nil { + b.Set("is_active", *patch.IsActive) + } + if !b.HasSets() { + return nil + } + b.Where("id", id) + _, err := b.Exec(DB) + return err +} + +func (s *ConnectionStore) Delete(ctx context.Context, id string) error { + _, err := DB.ExecContext(ctx, "DELETE FROM ext_connections WHERE id = $1", id) + return err +} + +// ── Scoped Queries ───────────────────────────────── + +func (s *ConnectionStore) ListGlobal(ctx context.Context) ([]models.ExtConnection, error) { + return s.listByScope(ctx, models.ScopeGlobal, "") +} + +func (s *ConnectionStore) ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error) { + return s.listByScope(ctx, models.ScopeTeam, teamID) +} + +func (s *ConnectionStore) ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error) { + return s.listByScope(ctx, models.ScopePersonal, userID) +} + +// ── Resolution ───────────────────────────────────── + +// Resolve returns the first active connection matching type (and optional name) +// via the scope chain: personal → team → global. +func (s *ConnectionStore) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) { + q := fmt.Sprintf(` + SELECT %s FROM ext_connections + WHERE type = $1 AND is_active = true + AND ( + (scope = 'personal' AND owner_id = $2) + OR (scope = 'team' AND owner_id IN ( + SELECT team_id FROM team_members WHERE user_id = $2 + )) + OR (scope = 'global') + )`, connCols) + args := []any{connType, userID} + if name != "" { + q += " AND name = $3" + args = append(args, name) + } + // Priority: personal(0) → team(1) → global(2). + q += " ORDER BY CASE scope WHEN 'personal' THEN 0 WHEN 'team' THEN 1 ELSE 2 END, created_at ASC LIMIT 1" + row := DB.QueryRowContext(ctx, q, args...) + return scanConnection(row) +} + +// ResolveAll returns all active connections of the given type accessible to the user. +func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) { + rows, err := DB.QueryContext(ctx, fmt.Sprintf(` + SELECT %s FROM ext_connections + WHERE type = $1 AND is_active = true + AND ( + (scope = 'personal' AND owner_id = $2) + OR (scope = 'team' AND owner_id IN ( + SELECT team_id FROM team_members WHERE user_id = $2 + )) + OR (scope = 'global') + ) + ORDER BY scope ASC, name ASC`, connCols), connType, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanConnections(rows) +} + +// DeleteByIDAndScope deletes a connection only if it matches the scope/owner. +func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM ext_connections WHERE id = $1 AND scope = $2 AND owner_id = $3`, + id, scope, ownerID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// ── Internal ─────────────────────────────────────── + +func (s *ConnectionStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ExtConnection, error) { + var rows *sql.Rows + var err error + if scope == models.ScopeGlobal { + rows, err = DB.QueryContext(ctx, + fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = $1 ORDER BY type, name", connCols), + scope) + } else { + rows, err = DB.QueryContext(ctx, + fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = $1 AND owner_id = $2 ORDER BY type, name", connCols), + scope, ownerID) + } + if err != nil { + return nil, err + } + defer rows.Close() + return scanConnections(rows) +} + +func scanConnection(row *sql.Row) (*models.ExtConnection, error) { + var c models.ExtConnection + var configJSON []byte + err := row.Scan( + &c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID, + &c.Name, &configJSON, &c.IsActive, &c.CreatedAt, &c.UpdatedAt, + ) + if err != nil { + return nil, err + } + json.Unmarshal(configJSON, &c.Config) + return &c, nil +} + +func scanConnections(rows *sql.Rows) ([]models.ExtConnection, error) { + var result []models.ExtConnection + for rows.Next() { + var c models.ExtConnection + var configJSON []byte + err := rows.Scan( + &c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID, + &c.Name, &configJSON, &c.IsActive, &c.CreatedAt, &c.UpdatedAt, + ) + if err != nil { + return nil, err + } + json.Unmarshal(configJSON, &c.Config) + result = append(result, c) + } + return result, rows.Err() +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index b373d59..4cd5f4e 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -51,5 +51,6 @@ func NewStores(db *sql.DB) store.Stores { Tickets: NewTicketStore(), RateLimits: NewRateLimitStore(), Export: NewExportStore(), + Connections: NewConnectionStore(), } } diff --git a/server/store/sqlite/ext_connection.go b/server/store/sqlite/ext_connection.go new file mode 100644 index 0000000..da89ce4 --- /dev/null +++ b/server/store/sqlite/ext_connection.go @@ -0,0 +1,186 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "chat-switchboard/models" + "chat-switchboard/store" +) + +// ── ConnectionStore (v0.38.1) ──────────────────────── + +type ConnectionStore struct{} + +func NewConnectionStore() *ConnectionStore { return &ConnectionStore{} } + +const connCols = `id, type, package_id, scope, owner_id, name, config, is_active, created_at, updated_at` + +func (s *ConnectionStore) Create(ctx context.Context, conn *models.ExtConnection) error { + conn.ID = store.NewID() + now := time.Now().UTC() + conn.CreatedAt = now + conn.UpdatedAt = now + _, err := DB.ExecContext(ctx, ` + INSERT INTO ext_connections (id, type, package_id, scope, owner_id, name, config, is_active, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + conn.ID, conn.Type, conn.PackageID, conn.Scope, conn.OwnerID, + conn.Name, ToJSON(conn.Config), conn.IsActive, + now.Format(timeFmt), now.Format(timeFmt), + ) + return err +} + +func (s *ConnectionStore) GetByID(ctx context.Context, id string) (*models.ExtConnection, error) { + row := DB.QueryRowContext(ctx, + fmt.Sprintf("SELECT %s FROM ext_connections WHERE id = ?", connCols), id) + return scanConnection(row) +} + +func (s *ConnectionStore) Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error { + b := NewUpdate("ext_connections") + if patch.Name != nil { + b.Set("name", *patch.Name) + } + if patch.Config != nil { + b.SetJSON("config", patch.Config) + } + if patch.IsActive != nil { + b.Set("is_active", *patch.IsActive) + } + if !b.HasSets() { + return nil + } + b.Where("id", id) + _, err := b.Exec(DB) + return err +} + +func (s *ConnectionStore) Delete(ctx context.Context, id string) error { + _, err := DB.ExecContext(ctx, "DELETE FROM ext_connections WHERE id = ?", id) + return err +} + +// ── Scoped Queries ───────────────────────────────── + +func (s *ConnectionStore) ListGlobal(ctx context.Context) ([]models.ExtConnection, error) { + return s.listByScope(ctx, models.ScopeGlobal, "") +} + +func (s *ConnectionStore) ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error) { + return s.listByScope(ctx, models.ScopeTeam, teamID) +} + +func (s *ConnectionStore) ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error) { + return s.listByScope(ctx, models.ScopePersonal, userID) +} + +// ── Resolution ───────────────────────────────────── + +func (s *ConnectionStore) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) { + q := fmt.Sprintf(` + SELECT %s FROM ext_connections + WHERE type = ? AND is_active = 1 + AND ( + (scope = 'personal' AND owner_id = ?) + OR (scope = 'team' AND owner_id IN ( + SELECT team_id FROM team_members WHERE user_id = ? + )) + OR (scope = 'global') + )`, connCols) + args := []any{connType, userID, userID} + if name != "" { + q += " AND name = ?" + args = append(args, name) + } + // Priority: personal(0) → team(1) → global(2). + q += " ORDER BY CASE scope WHEN 'personal' THEN 0 WHEN 'team' THEN 1 ELSE 2 END, created_at ASC LIMIT 1" + row := DB.QueryRowContext(ctx, q, args...) + return scanConnection(row) +} + +func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) { + rows, err := DB.QueryContext(ctx, fmt.Sprintf(` + SELECT %s FROM ext_connections + WHERE type = ? AND is_active = 1 + AND ( + (scope = 'personal' AND owner_id = ?) + OR (scope = 'team' AND owner_id IN ( + SELECT team_id FROM team_members WHERE user_id = ? + )) + OR (scope = 'global') + ) + ORDER BY scope ASC, name ASC`, connCols), connType, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanConnections(rows) +} + +func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM ext_connections WHERE id = ? AND scope = ? AND owner_id = ?`, + id, scope, ownerID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// ── Internal ─────────────────────────────────────── + +func (s *ConnectionStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ExtConnection, error) { + var rows *sql.Rows + var err error + if scope == models.ScopeGlobal { + rows, err = DB.QueryContext(ctx, + fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = ? ORDER BY type, name", connCols), + scope) + } else { + rows, err = DB.QueryContext(ctx, + fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = ? AND owner_id = ? ORDER BY type, name", connCols), + scope, ownerID) + } + if err != nil { + return nil, err + } + defer rows.Close() + return scanConnections(rows) +} + +func scanConnection(row *sql.Row) (*models.ExtConnection, error) { + var c models.ExtConnection + var configJSON []byte + err := row.Scan( + &c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID, + &c.Name, &configJSON, &c.IsActive, st(&c.CreatedAt), st(&c.UpdatedAt), + ) + if err != nil { + return nil, err + } + json.Unmarshal(configJSON, &c.Config) + return &c, nil +} + +func scanConnections(rows *sql.Rows) ([]models.ExtConnection, error) { + var result []models.ExtConnection + for rows.Next() { + var c models.ExtConnection + var configJSON []byte + err := rows.Scan( + &c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID, + &c.Name, &configJSON, &c.IsActive, st(&c.CreatedAt), st(&c.UpdatedAt), + ) + if err != nil { + return nil, err + } + json.Unmarshal(configJSON, &c.Config) + result = append(result, c) + } + return result, rows.Err() +} diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index c075304..9f7d611 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -51,5 +51,6 @@ func NewStores(db *sql.DB) store.Stores { Tickets: NewTicketStore(), RateLimits: NewRateLimitStore(), Export: NewExportStore(), + Connections: NewConnectionStore(), } } diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index 537c3be..0cea59f 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -188,6 +188,12 @@ export function createDomains(restClient) { fetchModels: (id) => rc.post(`/api/v1/api-configs/${id}/models/fetch`), }, + // ── 10b. Connections (v0.38.1) ──────── + connections: { + ...crud(rc, '/api/v1/connections'), + resolve: (type, name) => rc.get('/api/v1/connections/resolve' + _qs({ type, name })), + }, + // ── 11. Notifications ────────────────── notifications: { list: (opts) => rc.get('/api/v1/notifications' + _qs(opts)), @@ -241,6 +247,11 @@ export function createDomains(restClient) { updateProvider:(id, pid, data) => rc.put(`/api/v1/teams/${id}/providers/${pid}`, data), deleteProvider:(id, pid) => rc.del(`/api/v1/teams/${id}/providers/${pid}`), providerModels:(id, pid) => rc.get(`/api/v1/teams/${id}/providers/${pid}/models`), + // Team connections (v0.38.1) + connections: (id) => rc.get(`/api/v1/teams/${id}/connections`), + createConnection: (id, data) => rc.post(`/api/v1/teams/${id}/connections`, data), + updateConnection: (id, cid, data) => rc.put(`/api/v1/teams/${id}/connections/${cid}`, data), + deleteConnection: (id, cid) => rc.del(`/api/v1/teams/${id}/connections/${cid}`), roles: (id) => rc.get(`/api/v1/teams/${id}/roles`), updateRole: (id, role, config) => rc.put(`/api/v1/teams/${id}/roles/${role}`, config), deleteRole: (id, role) => rc.del(`/api/v1/teams/${id}/roles/${role}`), @@ -477,6 +488,9 @@ export function createDomains(restClient) { del: (id) => rc.del(`/api/v1/admin/extensions/${id}`), }, + // v0.38.1: Global connections + connections: crud(rc, '/api/v1/admin/connections'), + packages: { list: (opts) => rc.get('/api/v1/admin/packages' + _qs(opts)), get: (id) => rc.get(`/api/v1/admin/packages/${id}`), diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js index 052a5c0..4c56a47 100644 --- a/src/js/sw/sdk/index.js +++ b/src/js/sw/sdk/index.js @@ -152,7 +152,7 @@ export async function boot() { }; // Marker for idempotency - sw._sdk = '0.38.0'; + sw._sdk = '0.38.1'; // 8. Expose globally window.sw = sw; diff --git a/src/js/sw/surfaces/admin/connections.js b/src/js/sw/surfaces/admin/connections.js new file mode 100644 index 0000000..96bf5c6 --- /dev/null +++ b/src/js/sw/surfaces/admin/connections.js @@ -0,0 +1,219 @@ +/** + * Admin > Connections — global extension connection CRUD + * v0.38.1: Scoped credential management for extensions + */ +const { html } = window; +const { useState, useEffect, useCallback } = hooks; + +function esc(s) { return s == null ? '' : String(s); } + +export default function ConnectionsSection() { + const [connections, setConnections] = useState(null); + const [connTypes, setConnTypes] = useState([]); + const [showForm, setShowForm] = useState(false); + const [editId, setEditId] = useState(null); + const [form, setForm] = useState({ type: '', package_id: '', name: '', config: {} }); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + try { + const resp = await sw.api.admin.connections.list(); + setConnections(resp || []); + } catch (e) { + setConnections([]); + } + }, []); + + const loadTypes = useCallback(async () => { + try { + const pkgs = await sw.api.admin.packages.list(); + const types = []; + const seen = {}; + for (const pkg of (pkgs || [])) { + const conns = pkg.manifest?.connections || []; + for (const cd of conns) { + if (!seen[cd.type]) { + seen[cd.type] = true; + types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} }); + } + } + } + setConnTypes(types); + } catch (_) {} + }, []); + + useEffect(() => { load(); loadTypes(); }, [load, loadTypes]); + + const selectedType = connTypes.find(t => t.type === form.type); + + const openAdd = useCallback(() => { + setEditId(null); + setForm({ type: connTypes[0]?.type || '', package_id: connTypes[0]?.packageId || '', name: '', config: {} }); + setShowForm(true); + }, [connTypes]); + + const openEdit = useCallback((conn) => { + setEditId(conn.id); + setForm({ type: conn.type, package_id: conn.package_id, name: conn.name, config: {} }); + setShowForm(true); + }, []); + + const cancel = useCallback(() => { + setShowForm(false); + setEditId(null); + }, []); + + const save = useCallback(async () => { + if (!form.type || !form.name) { + sw.emit('toast', { message: 'Type and name are required', variant: 'error' }); + return; + } + setSaving(true); + try { + if (editId) { + const patch = { name: form.name }; + if (Object.keys(form.config).length > 0) patch.config = form.config; + await sw.api.admin.connections.update(editId, patch); + sw.emit('toast', { message: 'Connection updated', variant: 'success' }); + } else { + await sw.api.admin.connections.create({ + type: form.type, package_id: form.package_id, + name: form.name, config: form.config, + }); + sw.emit('toast', { message: 'Connection created', variant: 'success' }); + } + setShowForm(false); + setEditId(null); + load(); + } catch (e) { + sw.emit('toast', { message: e.message, variant: 'error' }); + } finally { + setSaving(false); + } + }, [form, editId, load]); + + const del = useCallback(async (conn) => { + const ok = await sw.confirm(`Remove connection "${conn.name}"?`); + if (!ok) return; + try { + await sw.api.admin.connections.del(conn.id); + sw.emit('toast', { message: 'Connection removed', variant: 'success' }); + load(); + } catch (e) { + sw.emit('toast', { message: e.message, variant: 'error' }); + } + }, [load]); + + const setField = useCallback((key, val) => { + setForm(f => ({ ...f, [key]: val })); + }, []); + + const setConfigField = useCallback((key, val) => { + setForm(f => ({ ...f, config: { ...f.config, [key]: val } })); + }, []); + + const onTypeChange = useCallback((typeVal) => { + const ct = connTypes.find(t => t.type === typeVal); + setForm(f => ({ ...f, type: typeVal, package_id: ct?.packageId || f.package_id, config: {} })); + }, [connTypes]); + + if (connections === null) { + return html`
Loading connections\u2026
`; + } + + return html` +
+ + ${!connTypes.length && html` + + No installed packages declare connection types. + + `} +
+ + ${showForm && html` +
+

${editId ? 'Edit Connection' : 'Add Connection'}

+ ${!editId && html` +
+ + +
+ `} +
+ + setField('name', e.target.value)} /> +
+ ${selectedType && Object.entries(selectedType.fields || {}).map(([fk, fd]) => html` +
+ + ${fd.type === 'secret' ? html` + setConfigField(fk, e.target.value)} /> + ` : fd.type === 'url' ? html` + setConfigField(fk, e.target.value)} /> + ` : fd.type === 'boolean' ? html` + setConfigField(fk, e.target.checked)} /> + ` : html` + setConfigField(fk, e.target.value)} /> + `} +
+ `)} +
+ + +
+
+ `} + + ${!connections.length && !showForm && html` +
No global connections configured.
+ `} + + ${connections.length > 0 && html` + + + + ${connections.map(c => html` + + + + + + + + `)} + +
NameTypePackageStatus
${esc(c.name)}${esc(c.type)}${esc(c.package_id)} + ${c.is_active + ? html`\u25CF Active` + : html`\u25CB Inactive`} + + + +
+ `} + `; +} diff --git a/src/js/sw/surfaces/admin/index.js b/src/js/sw/surfaces/admin/index.js index 6023fbf..3fd8683 100644 --- a/src/js/sw/surfaces/admin/index.js +++ b/src/js/sw/surfaces/admin/index.js @@ -21,7 +21,7 @@ const ADMIN_SECTIONS = { ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'], workflows: ['workflows', 'tasks'], routing: ['health', 'routing', 'capabilities'], - system: ['settings', 'storage', 'packages', 'channels', 'broadcast'], + system: ['settings', 'storage', 'packages', 'connections', 'channels', 'broadcast'], monitoring: ['dashboard', 'usage', 'audit', 'stats'], }; @@ -32,6 +32,7 @@ const ADMIN_LABELS = { health: 'Health', routing: 'Routing', capabilities: 'Capabilities', workflows: 'Workflows', tasks: 'Tasks', settings: 'Settings', storage: 'Storage', packages: 'Packages', + connections: 'Connections', channels: 'Channels', broadcast: 'Broadcast', dashboard: 'Dashboard', usage: 'Usage', audit: 'Audit', stats: 'Stats', }; @@ -66,6 +67,7 @@ const sectionModules = { settings: () => import(`./settings.js${_v}`), storage: () => import(`./storage.js${_v}`), packages: () => import(`./packages.js${_v}`), + connections: () => import(`./connections.js${_v}`), channels: () => import(`./channels.js${_v}`), broadcast: () => import(`./broadcast.js${_v}`), dashboard: () => import(`./dashboard.js${_v}`), diff --git a/src/js/sw/surfaces/settings/connections.js b/src/js/sw/surfaces/settings/connections.js new file mode 100644 index 0000000..8ea3319 --- /dev/null +++ b/src/js/sw/surfaces/settings/connections.js @@ -0,0 +1,222 @@ +/** + * ConnectionsSection — personal extension connection CRUD + * v0.38.1: Scoped credential management for extensions + */ +const { html } = window; +const { useState, useEffect, useCallback } = hooks; + +function esc(s) { return s == null ? '' : String(s); } + +export function ConnectionsSection() { + const [connections, setConnections] = useState(null); + const [connTypes, setConnTypes] = useState([]); + const [showForm, setShowForm] = useState(false); + const [editId, setEditId] = useState(null); + const [form, setForm] = useState({ type: '', package_id: '', name: '', config: {} }); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + try { + const resp = await sw.api.connections.list(); + setConnections(resp || []); + } catch (e) { + setConnections([]); + } + }, []); + + const loadTypes = useCallback(async () => { + try { + const pkgs = await sw.api.admin.packages.list(); + const types = []; + const seen = {}; + for (const pkg of (pkgs || [])) { + const conns = pkg.manifest?.connections || []; + for (const cd of conns) { + if (!seen[cd.type]) { + seen[cd.type] = true; + types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} }); + } + } + } + setConnTypes(types); + } catch (_) {} + }, []); + + useEffect(() => { load(); loadTypes(); }, [load, loadTypes]); + + const selectedType = connTypes.find(t => t.type === form.type); + + const openAdd = useCallback(() => { + setEditId(null); + setForm({ type: connTypes[0]?.type || '', package_id: connTypes[0]?.packageId || '', name: '', config: {} }); + setShowForm(true); + }, [connTypes]); + + const openEdit = useCallback((conn) => { + setEditId(conn.id); + setForm({ type: conn.type, package_id: conn.package_id, name: conn.name, config: {} }); + setShowForm(true); + }, []); + + const cancel = useCallback(() => { + setShowForm(false); + setEditId(null); + }, []); + + const save = useCallback(async () => { + if (!form.type || !form.name) { + sw.emit('toast', { message: 'Type and name are required', variant: 'error' }); + return; + } + setSaving(true); + try { + if (editId) { + const patch = { name: form.name }; + if (Object.keys(form.config).length > 0) patch.config = form.config; + await sw.api.connections.update(editId, patch); + sw.emit('toast', { message: 'Connection updated', variant: 'success' }); + } else { + await sw.api.connections.create({ + type: form.type, package_id: form.package_id, + name: form.name, config: form.config, + }); + sw.emit('toast', { message: 'Connection created', variant: 'success' }); + } + setShowForm(false); + setEditId(null); + load(); + } catch (e) { + sw.emit('toast', { message: e.message, variant: 'error' }); + } finally { + setSaving(false); + } + }, [form, editId, load]); + + const del = useCallback(async (conn) => { + const ok = await sw.confirm(`Remove connection "${conn.name}"?`); + if (!ok) return; + try { + await sw.api.connections.del(conn.id); + sw.emit('toast', { message: 'Connection removed', variant: 'success' }); + load(); + } catch (e) { + sw.emit('toast', { message: e.message, variant: 'error' }); + } + }, [load]); + + const setField = useCallback((key, val) => { + setForm(f => ({ ...f, [key]: val })); + }, []); + + const setConfigField = useCallback((key, val) => { + setForm(f => ({ ...f, config: { ...f.config, [key]: val } })); + }, []); + + const onTypeChange = useCallback((typeVal) => { + const ct = connTypes.find(t => t.type === typeVal); + setForm(f => ({ ...f, type: typeVal, package_id: ct?.packageId || f.package_id, config: {} })); + }, [connTypes]); + + if (connections === null) { + return html`
Loading connections\u2026
`; + } + + return html` +
+ + ${!connTypes.length && html` + + No installed packages declare connection types. + + `} +
+ + ${showForm && html` +
+

${editId ? 'Edit Connection' : 'Add Connection'}

+ ${!editId && html` +
+ + +
+ `} +
+ + setField('name', e.target.value)} /> +
+ ${selectedType && Object.entries(selectedType.fields || {}).map(([fk, fd]) => html` +
+ + ${fd.type === 'secret' ? html` + setConfigField(fk, e.target.value)} /> + ` : fd.type === 'url' ? html` + setConfigField(fk, e.target.value)} /> + ` : fd.type === 'boolean' ? html` + setConfigField(fk, e.target.checked)} /> + ` : fd.type === 'number' ? html` + setConfigField(fk, e.target.value)} /> + ` : html` + setConfigField(fk, e.target.value)} /> + `} +
+ `)} +
+ + +
+
+ `} + + ${!connections.length && !showForm && html` +
No connections configured.
+ `} + + ${connections.length > 0 && html` + + + + ${connections.map(c => html` + + + + + + + `)} + +
NameTypeStatus
${esc(c.name)}${esc(c.type)} + ${c.is_active + ? html`\u25CF Active` + : html`\u25CB Inactive`} + + + +
+ `} + `; +} diff --git a/src/js/sw/surfaces/settings/index.js b/src/js/sw/surfaces/settings/index.js index 683ea65..73be224 100644 --- a/src/js/sw/surfaces/settings/index.js +++ b/src/js/sw/surfaces/settings/index.js @@ -27,6 +27,7 @@ const sectionModules = { teams: () => import('./teams.js'), models: () => import('./models.js'), providers: () => import('./providers.js'), + connections: () => import('./connections.js'), personas: () => import('./personas.js'), roles: () => import('./roles.js'), usage: () => import('./usage.js'), @@ -49,6 +50,7 @@ const NAV_ITEMS = [ { key: 'teams', label: 'Teams' }, { key: 'workflows', label: 'Assignments' }, { key: 'tasks', label: 'Tasks' }, + { key: 'connections', label: 'Connections' }, { key: 'gitkeys', label: 'Git Keys' }, { key: 'knowledge', label: 'Knowledge Bases' }, { key: 'memory', label: 'Memory' }, @@ -67,6 +69,7 @@ const SECTION_TITLES = { general: 'General', appearance: 'Appearance', models: 'Models', personas: 'Personas', profile: 'Profile', teams: 'Teams', workflows: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys', + connections: 'Connections', data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles', usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory', notifications: 'Notifications', diff --git a/src/js/sw/surfaces/team-admin/connections.js b/src/js/sw/surfaces/team-admin/connections.js new file mode 100644 index 0000000..1eb1dff --- /dev/null +++ b/src/js/sw/surfaces/team-admin/connections.js @@ -0,0 +1,210 @@ +/** + * Team Admin > Connections — team-scoped extension connection CRUD + * v0.38.1: Scoped credential management for extensions + */ +const { html } = window; +const { useState, useEffect, useCallback } = hooks; + +function esc(s) { return s == null ? '' : String(s); } + +export default function ConnectionsSection({ teamId }) { + const [connections, setConnections] = useState(null); + const [connTypes, setConnTypes] = useState([]); + const [showForm, setShowForm] = useState(false); + const [editId, setEditId] = useState(null); + const [form, setForm] = useState({ type: '', package_id: '', name: '', config: {} }); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + try { + const resp = await sw.api.teams.connections(teamId); + setConnections(resp || []); + } catch (e) { + setConnections([]); + } + }, [teamId]); + + const loadTypes = useCallback(async () => { + try { + const pkgs = await sw.api.admin.packages.list(); + const types = []; + const seen = {}; + for (const pkg of (pkgs || [])) { + const conns = pkg.manifest?.connections || []; + for (const cd of conns) { + if (!seen[cd.type]) { + seen[cd.type] = true; + types.push({ type: cd.type, label: cd.label || cd.type, packageId: pkg.id, fields: cd.fields || {} }); + } + } + } + setConnTypes(types); + } catch (_) {} + }, []); + + useEffect(() => { load(); loadTypes(); }, [load, loadTypes]); + + const selectedType = connTypes.find(t => t.type === form.type); + + const openAdd = useCallback(() => { + setEditId(null); + setForm({ type: connTypes[0]?.type || '', package_id: connTypes[0]?.packageId || '', name: '', config: {} }); + setShowForm(true); + }, [connTypes]); + + const openEdit = useCallback((conn) => { + setEditId(conn.id); + setForm({ type: conn.type, package_id: conn.package_id, name: conn.name, config: {} }); + setShowForm(true); + }, []); + + const cancel = useCallback(() => { + setShowForm(false); + setEditId(null); + }, []); + + const save = useCallback(async () => { + if (!form.type || !form.name) { + sw.emit('toast', { message: 'Type and name are required', variant: 'error' }); + return; + } + setSaving(true); + try { + if (editId) { + const patch = { name: form.name }; + if (Object.keys(form.config).length > 0) patch.config = form.config; + await sw.api.teams.updateConnection(teamId, editId, patch); + sw.emit('toast', { message: 'Connection updated', variant: 'success' }); + } else { + await sw.api.teams.createConnection(teamId, { + type: form.type, package_id: form.package_id, + name: form.name, config: form.config, + }); + sw.emit('toast', { message: 'Connection created', variant: 'success' }); + } + setShowForm(false); + setEditId(null); + load(); + } catch (e) { + sw.emit('toast', { message: e.message, variant: 'error' }); + } finally { + setSaving(false); + } + }, [form, editId, teamId, load]); + + const del = useCallback(async (conn) => { + const ok = await sw.confirm(`Remove connection "${conn.name}"?`); + if (!ok) return; + try { + await sw.api.teams.deleteConnection(teamId, conn.id); + sw.emit('toast', { message: 'Connection removed', variant: 'success' }); + load(); + } catch (e) { + sw.emit('toast', { message: e.message, variant: 'error' }); + } + }, [teamId, load]); + + const setField = useCallback((key, val) => { + setForm(f => ({ ...f, [key]: val })); + }, []); + + const setConfigField = useCallback((key, val) => { + setForm(f => ({ ...f, config: { ...f.config, [key]: val } })); + }, []); + + const onTypeChange = useCallback((typeVal) => { + const ct = connTypes.find(t => t.type === typeVal); + setForm(f => ({ ...f, type: typeVal, package_id: ct?.packageId || f.package_id, config: {} })); + }, [connTypes]); + + if (connections === null) { + return html`
Loading connections\u2026
`; + } + + return html` +
+ +
+ + ${showForm && html` +
+

${editId ? 'Edit Connection' : 'Add Connection'}

+ ${!editId && html` +
+ + +
+ `} +
+ + setField('name', e.target.value)} /> +
+ ${selectedType && Object.entries(selectedType.fields || {}).map(([fk, fd]) => html` +
+ + ${fd.type === 'secret' ? html` + setConfigField(fk, e.target.value)} /> + ` : fd.type === 'url' ? html` + setConfigField(fk, e.target.value)} /> + ` : html` + setConfigField(fk, e.target.value)} /> + `} +
+ `)} +
+ + +
+
+ `} + + ${!connections.length && !showForm && html` +
No team connections configured.
+ `} + + ${connections.length > 0 && html` + + + + ${connections.map(c => html` + + + + + + + `)} + +
NameTypeStatus
${esc(c.name)}${esc(c.type)} + ${c.is_active + ? html`\u25CF Active` + : html`\u25CB Inactive`} + + + +
+ `} + `; +} diff --git a/src/js/sw/surfaces/team-admin/index.js b/src/js/sw/surfaces/team-admin/index.js index 08e793f..ce07db9 100644 --- a/src/js/sw/surfaces/team-admin/index.js +++ b/src/js/sw/surfaces/team-admin/index.js @@ -19,6 +19,7 @@ import { DialogStack } from '../../shell/dialog-stack.js'; const SECTIONS = [ { key: 'members', label: 'Members' }, { key: 'providers', label: 'Providers' }, + { key: 'connections', label: 'Connections' }, { key: 'personas', label: 'Personas' }, { key: 'knowledge', label: 'Knowledge' }, { key: 'groups', label: 'Groups' }, @@ -32,7 +33,8 @@ const SECTIONS = [ // ── Lazy section imports ──────────────────── const sectionModules = { members: () => import('./members.js'), - providers: () => import('./providers.js'), + providers: () => import('./providers.js'), + connections: () => import('./connections.js'), personas: () => import('./personas.js'), knowledge: () => import('./knowledge.js'), groups: () => import('./groups.js'),