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`
+
+ `}
+
+ ${!connections.length && !showForm && html`
+ No global connections configured.
+ `}
+
+ ${connections.length > 0 && html`
+
+ | Name | Type | Package | Status | |
+
+ ${connections.map(c => html`
+
+ | ${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`
+
+ `}
+
+ ${!connections.length && !showForm && html`
+ No connections configured.
+ `}
+
+ ${connections.length > 0 && html`
+
+ | Name | Type | Status | |
+
+ ${connections.map(c => html`
+
+ | ${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`
+
+ `}
+
+ ${!connections.length && !showForm && html`
+ No team connections configured.
+ `}
+
+ ${connections.length > 0 && html`
+
+ | Name | Type | Status | |
+
+ ${connections.map(c => html`
+
+ | ${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'),