Changeset 0.38.1 (#234)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
37
CHANGELOG.md
37
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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
117
packages/sdk-test-runner/js/domains/connections.js
Normal file
117
packages/sdk-test-runner/js/domains/connections.js
Normal 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;
|
||||
});
|
||||
|
||||
});
|
||||
})();
|
||||
@@ -75,6 +75,7 @@
|
||||
'domains/misc.js',
|
||||
'domains/admin.js',
|
||||
'domains/packages.js',
|
||||
'domains/connections.js',
|
||||
// UI (must come last)
|
||||
'ui.js'
|
||||
];
|
||||
|
||||
37
server/database/migrations/022_ext_connections.sql
Normal file
37
server/database/migrations/022_ext_connections.sql
Normal 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';
|
||||
24
server/database/migrations/sqlite/022_ext_connections.sql
Normal file
24
server/database/migrations/sqlite/022_ext_connections.sql
Normal 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);
|
||||
112
server/handlers/admin_connections.go
Normal file
112
server/handlers/admin_connections.go
Normal 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"})
|
||||
}
|
||||
101
server/handlers/connection_resolver.go
Normal file
101
server/handlers/connection_resolver.go
Normal 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
|
||||
}
|
||||
299
server/handlers/connections.go
Normal file
299
server/handlers/connections.go
Normal 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"),
|
||||
}
|
||||
}
|
||||
118
server/handlers/team_connections.go
Normal file
118
server/handlers/team_connections.go
Normal 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"})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
24
server/models/ext_connection.go
Normal file
24
server/models/ext_connection.go
Normal 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"`
|
||||
}
|
||||
@@ -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 ───────────────
|
||||
|
||||
107
server/sandbox/connections_module.go
Normal file
107
server/sandbox/connections_module.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
182
server/store/postgres/ext_connection.go
Normal file
182
server/store/postgres/ext_connection.go
Normal 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()
|
||||
}
|
||||
@@ -51,5 +51,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Tickets: NewTicketStore(),
|
||||
RateLimits: NewRateLimitStore(),
|
||||
Export: NewExportStore(),
|
||||
Connections: NewConnectionStore(),
|
||||
}
|
||||
}
|
||||
|
||||
186
server/store/sqlite/ext_connection.go
Normal file
186
server/store/sqlite/ext_connection.go
Normal 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()
|
||||
}
|
||||
@@ -51,5 +51,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Tickets: NewTicketStore(),
|
||||
RateLimits: NewRateLimitStore(),
|
||||
Export: NewExportStore(),
|
||||
Connections: NewConnectionStore(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
219
src/js/sw/surfaces/admin/connections.js
Normal file
219
src/js/sw/surfaces/admin/connections.js
Normal 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>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -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}`),
|
||||
|
||||
222
src/js/sw/surfaces/settings/connections.js
Normal file
222
src/js/sw/surfaces/settings/connections.js
Normal 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>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
210
src/js/sw/surfaces/team-admin/connections.js
Normal file
210
src/js/sw/surfaces/team-admin/connections.js
Normal 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>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user