Changeset 0.38.1 (#234)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-25 11:13:12 +00:00
committed by xcaliber
parent 10acadc9d0
commit 6943c91f40
30 changed files with 2410 additions and 10 deletions

View File

@@ -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

View File

@@ -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/`

View File

@@ -54,7 +54,7 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
│ v0.37.1718 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. |
---

View File

@@ -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;
});
});
})();

View File

@@ -75,6 +75,7 @@
'domains/misc.js',
'domains/admin.js',
'domains/packages.js',
'domains/connections.js',
// UI (must come last)
'ui.js'
];

View File

@@ -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';

View File

@@ -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);

View File

@@ -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"})
}

View File

@@ -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
}

View File

@@ -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"),
}
}

View File

@@ -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"})
}

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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 ───────────────

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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:

View File

@@ -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.

View File

@@ -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()
}

View File

@@ -51,5 +51,6 @@ func NewStores(db *sql.DB) store.Stores {
Tickets: NewTicketStore(),
RateLimits: NewRateLimitStore(),
Export: NewExportStore(),
Connections: NewConnectionStore(),
}
}

View File

@@ -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()
}

View File

@@ -51,5 +51,6 @@ func NewStores(db *sql.DB) store.Stores {
Tickets: NewTicketStore(),
RateLimits: NewRateLimitStore(),
Export: NewExportStore(),
Connections: NewConnectionStore(),
}
}

View File

@@ -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}`),

View File

@@ -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;

View File

@@ -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`<div class="admin-placeholder">Loading connections\u2026</div>`;
}
return html`
<div style="margin-bottom:12px;display:flex;align-items:center;gap:8px;">
<button class="btn-md btn-primary" onClick=${openAdd}
disabled=${!connTypes.length}>+ Add Connection</button>
${!connTypes.length && html`
<span style="color:var(--text-3);font-size:12px;">
No installed packages declare connection types.
</span>
`}
</div>
${showForm && html`
<div class="admin-card" style="margin-bottom:16px;padding:16px;">
<h3 style="margin-top:0;">${editId ? 'Edit Connection' : 'Add Connection'}</h3>
${!editId && html`
<div class="form-group">
<label>Connection Type</label>
<select value=${form.type}
onChange=${e => onTypeChange(e.target.value)}>
${connTypes.map(ct => html`
<option value=${ct.type}>${esc(ct.label)}</option>
`)}
</select>
</div>
`}
<div class="form-group">
<label>Name</label>
<input type="text" placeholder="e.g. Production Gitea"
value=${form.name}
onInput=${e => setField('name', e.target.value)} />
</div>
${selectedType && Object.entries(selectedType.fields || {}).map(([fk, fd]) => html`
<div class="form-group" key=${fk}>
<label>${esc(fd.label || fk)}${fd.required ? ' *' : ''}${editId && fd.type === 'secret' ? ' (leave blank to keep)' : ''}</label>
${fd.type === 'secret' ? html`
<input type="password" placeholder="\u2022\u2022\u2022\u2022"
value=${form.config[fk] || ''}
onInput=${e => setConfigField(fk, e.target.value)} />
` : fd.type === 'url' ? html`
<input type="url" placeholder="https://..."
value=${form.config[fk] || ''}
onInput=${e => setConfigField(fk, e.target.value)} />
` : fd.type === 'boolean' ? html`
<input type="checkbox" checked=${!!form.config[fk]}
onChange=${e => setConfigField(fk, e.target.checked)} />
` : html`
<input type="text"
value=${form.config[fk] || ''}
onInput=${e => setConfigField(fk, e.target.value)} />
`}
</div>
`)}
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="btn-md btn-primary" disabled=${saving}
onClick=${save}>
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
</button>
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
</div>
</div>
`}
${!connections.length && !showForm && html`
<div class="empty-hint">No global connections configured.</div>
`}
${connections.length > 0 && html`
<table class="admin-table">
<thead><tr><th>Name</th><th>Type</th><th>Package</th><th>Status</th><th></th></tr></thead>
<tbody>
${connections.map(c => html`
<tr>
<td style="font-weight:500;font-size:13px;">${esc(c.name)}</td>
<td style="font-size:12px;color:var(--text-2);">${esc(c.type)}</td>
<td style="font-size:12px;color:var(--text-3);">${esc(c.package_id)}</td>
<td style="font-size:12px;">
${c.is_active
? html`<span style="color:var(--green);">\u25CF Active</span>`
: html`<span style="color:var(--text-3);">\u25CB Inactive</span>`}
</td>
<td class="admin-actions-cell" style="white-space:nowrap;">
<button class="icon-btn" title="Edit" onClick=${() => openEdit(c)}>
\u{270F}\u{FE0F}
</button>
<button class="icon-btn icon-btn-danger" title="Delete"
onClick=${() => del(c)}>
\u{1F5D1}
</button>
</td>
</tr>
`)}
</tbody>
</table>
`}
`;
}

View File

@@ -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}`),

View File

@@ -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`<div class="settings-placeholder">Loading connections\u2026</div>`;
}
return html`
<div style="margin-bottom:12px;">
<button class="btn-md btn-primary" onClick=${openAdd}
disabled=${!connTypes.length}>+ Add Connection</button>
${!connTypes.length && html`
<span style="margin-left:8px;color:var(--text-3);font-size:12px;">
No installed packages declare connection types.
</span>
`}
</div>
${showForm && html`
<div class="settings-section" style="margin-bottom:16px;">
<h3>${editId ? 'Edit Connection' : 'Add Connection'}</h3>
${!editId && html`
<div class="form-group">
<label>Connection Type</label>
<select value=${form.type}
onChange=${e => onTypeChange(e.target.value)}>
${connTypes.map(ct => html`
<option value=${ct.type}>${esc(ct.label)}</option>
`)}
</select>
</div>
`}
<div class="form-group">
<label>Name</label>
<input type="text" placeholder="e.g. Work Gitea"
value=${form.name}
onInput=${e => setField('name', e.target.value)} />
</div>
${selectedType && Object.entries(selectedType.fields || {}).map(([fk, fd]) => html`
<div class="form-group" key=${fk}>
<label>${esc(fd.label || fk)}${fd.required ? ' *' : ''}${editId && fd.type === 'secret' ? ' (leave blank to keep current)' : ''}</label>
${fd.type === 'secret' ? html`
<input type="password" placeholder="\u2022\u2022\u2022\u2022"
value=${form.config[fk] || ''}
onInput=${e => setConfigField(fk, e.target.value)} />
` : fd.type === 'url' ? html`
<input type="url" placeholder="https://..."
value=${form.config[fk] || ''}
onInput=${e => setConfigField(fk, e.target.value)} />
` : fd.type === 'boolean' ? html`
<input type="checkbox" checked=${!!form.config[fk]}
onChange=${e => setConfigField(fk, e.target.checked)} />
` : fd.type === 'number' ? html`
<input type="number"
value=${form.config[fk] || ''}
onInput=${e => setConfigField(fk, e.target.value)} />
` : html`
<input type="text"
value=${form.config[fk] || ''}
onInput=${e => setConfigField(fk, e.target.value)} />
`}
</div>
`)}
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="btn-md btn-primary" disabled=${saving}
onClick=${save}>
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
</button>
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
</div>
</div>
`}
${!connections.length && !showForm && html`
<div class="empty-hint">No connections configured.</div>
`}
${connections.length > 0 && html`
<table class="admin-table">
<thead><tr><th>Name</th><th>Type</th><th>Status</th><th></th></tr></thead>
<tbody>
${connections.map(c => html`
<tr>
<td style="font-weight:500;font-size:13px;">${esc(c.name)}</td>
<td style="font-size:12px;color:var(--text-2);">${esc(c.type)}</td>
<td style="font-size:12px;">
${c.is_active
? html`<span style="color:var(--green);">\u25CF Active</span>`
: html`<span style="color:var(--text-3);">\u25CB Inactive</span>`}
</td>
<td class="admin-actions-cell" style="white-space:nowrap;">
<button class="icon-btn" title="Edit" onClick=${() => openEdit(c)}>
\u{270F}\u{FE0F}
</button>
<button class="icon-btn icon-btn-danger" title="Delete"
onClick=${() => del(c)}>
\u{1F5D1}
</button>
</td>
</tr>
`)}
</tbody>
</table>
`}
`;
}

View File

@@ -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',

View File

@@ -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`<div class="admin-placeholder">Loading connections\u2026</div>`;
}
return html`
<div style="margin-bottom:12px;">
<button class="btn-md btn-primary" onClick=${openAdd}
disabled=${!connTypes.length}>+ Add Connection</button>
</div>
${showForm && html`
<div class="admin-card" style="margin-bottom:16px;padding:16px;">
<h3 style="margin-top:0;">${editId ? 'Edit Connection' : 'Add Connection'}</h3>
${!editId && html`
<div class="form-group">
<label>Connection Type</label>
<select value=${form.type}
onChange=${e => onTypeChange(e.target.value)}>
${connTypes.map(ct => html`
<option value=${ct.type}>${esc(ct.label)}</option>
`)}
</select>
</div>
`}
<div class="form-group">
<label>Name</label>
<input type="text" placeholder="e.g. Team Gitea"
value=${form.name}
onInput=${e => setField('name', e.target.value)} />
</div>
${selectedType && Object.entries(selectedType.fields || {}).map(([fk, fd]) => html`
<div class="form-group" key=${fk}>
<label>${esc(fd.label || fk)}${fd.required ? ' *' : ''}${editId && fd.type === 'secret' ? ' (leave blank to keep)' : ''}</label>
${fd.type === 'secret' ? html`
<input type="password" placeholder="\u2022\u2022\u2022\u2022"
value=${form.config[fk] || ''}
onInput=${e => setConfigField(fk, e.target.value)} />
` : fd.type === 'url' ? html`
<input type="url" placeholder="https://..."
value=${form.config[fk] || ''}
onInput=${e => setConfigField(fk, e.target.value)} />
` : html`
<input type="text"
value=${form.config[fk] || ''}
onInput=${e => setConfigField(fk, e.target.value)} />
`}
</div>
`)}
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="btn-md btn-primary" disabled=${saving}
onClick=${save}>
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
</button>
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
</div>
</div>
`}
${!connections.length && !showForm && html`
<div class="empty-hint">No team connections configured.</div>
`}
${connections.length > 0 && html`
<table class="admin-table">
<thead><tr><th>Name</th><th>Type</th><th>Status</th><th></th></tr></thead>
<tbody>
${connections.map(c => html`
<tr>
<td style="font-weight:500;font-size:13px;">${esc(c.name)}</td>
<td style="font-size:12px;color:var(--text-2);">${esc(c.type)}</td>
<td style="font-size:12px;">
${c.is_active
? html`<span style="color:var(--green);">\u25CF Active</span>`
: html`<span style="color:var(--text-3);">\u25CB Inactive</span>`}
</td>
<td class="admin-actions-cell" style="white-space:nowrap;">
<button class="icon-btn" title="Edit" onClick=${() => openEdit(c)}>
\u{270F}\u{FE0F}
</button>
<button class="icon-btn icon-btn-danger" title="Delete"
onClick=${() => del(c)}>
\u{1F5D1}
</button>
</td>
</tr>
`)}
</tbody>
</table>
`}
`;
}

View File

@@ -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'),