Changeset 0.38.1 (#234)

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

View File

@@ -0,0 +1,37 @@
-- ==========================================
-- Chat Switchboard — 022 Extension Connections
-- ==========================================
-- v0.38.1: Scoped credential management for extensions.
-- Same scope/resolution pattern as provider_configs.
-- ==========================================
CREATE TABLE IF NOT EXISTS ext_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL,
package_id TEXT NOT NULL,
scope VARCHAR(10) NOT NULL
CHECK (scope IN ('global', 'team', 'personal')),
owner_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(type, scope, owner_id, name)
);
CREATE INDEX IF NOT EXISTS idx_ext_connections_type ON ext_connections(type);
CREATE INDEX IF NOT EXISTS idx_ext_connections_scope ON ext_connections(scope);
CREATE INDEX IF NOT EXISTS idx_ext_connections_owner ON ext_connections(owner_id) WHERE owner_id != '';
CREATE INDEX IF NOT EXISTS idx_ext_connections_active ON ext_connections(is_active) WHERE is_active = true;
DROP TRIGGER IF EXISTS ext_connections_updated_at ON ext_connections;
CREATE TRIGGER ext_connections_updated_at BEFORE UPDATE ON ext_connections
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON TABLE ext_connections IS 'v0.38.1: Extension connection credentials (scoped, encrypted secrets in config JSON)';
COMMENT ON COLUMN ext_connections.type IS 'Connection type identifier, shared across packages (e.g. "gitea", "github")';
COMMENT ON COLUMN ext_connections.package_id IS 'Declaring package ID (UI attribution only, not access control)';
COMMENT ON COLUMN ext_connections.scope IS 'global=admin-managed, team=team-scoped, personal=user-owned';
COMMENT ON COLUMN ext_connections.owner_id IS 'Empty for global; team_id for team scope; user_id for personal scope';
COMMENT ON COLUMN ext_connections.config IS 'JSON config blob; secret-type fields encrypted at rest by handler layer';

View File

@@ -0,0 +1,24 @@
-- ==========================================
-- Chat Switchboard — 022 Extension Connections
-- ==========================================
-- v0.38.1: Scoped credential management for extensions.
-- SQLite version (no partial indexes, TEXT types).
-- ==========================================
CREATE TABLE IF NOT EXISTS ext_connections (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
package_id TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('global', 'team', 'personal')),
owner_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
is_active INTEGER DEFAULT 1,
created_at TEXT,
updated_at TEXT,
UNIQUE(type, scope, owner_id, name)
);
CREATE INDEX IF NOT EXISTS idx_ext_connections_type ON ext_connections(type);
CREATE INDEX IF NOT EXISTS idx_ext_connections_scope ON ext_connections(scope);
CREATE INDEX IF NOT EXISTS idx_ext_connections_owner ON ext_connections(owner_id);

View File

@@ -0,0 +1,112 @@
package handlers
// v0.38.1: Extension connection handlers — admin/global scope.
// Methods on AdminHandler, mirrors admin provider config pattern.
import (
"net/http"
"github.com/gin-gonic/gin"
"chat-switchboard/models"
)
// ListGlobalConnections returns all global-scope connections.
func (h *AdminHandler) ListGlobalConnections(c *gin.Context) {
conns, err := h.stores.Connections.ListGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
return
}
c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)})
}
// CreateGlobalConnection creates a global-scope connection.
func (h *AdminHandler) CreateGlobalConnection(c *gin.Context) {
var req struct {
Type string `json:"type" binding:"required"`
PackageID string `json:"package_id" binding:"required"`
Name string `json:"name" binding:"required"`
Config models.JSONMap `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
secretFields := ch.lookupSecretFields(c, req.PackageID, req.Type)
encConfig := ch.encryptSecrets(req.Config, secretFields, models.ScopeGlobal, "")
conn := &models.ExtConnection{
Type: req.Type,
PackageID: req.PackageID,
Scope: models.ScopeGlobal,
OwnerID: "",
Name: req.Name,
Config: encConfig,
IsActive: true,
}
if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create connection"})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": conn.ID,
"type": conn.Type,
"name": conn.Name,
})
}
// UpdateGlobalConnection updates a global-scope connection.
func (h *AdminHandler) UpdateGlobalConnection(c *gin.Context) {
id := c.Param("id")
existing, err := h.stores.Connections.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopeGlobal {
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
return
}
var req struct {
Name *string `json:"name,omitempty"`
Config models.JSONMap `json:"config,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
patch := models.ExtConnectionPatch{Name: req.Name}
if req.Config != nil {
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
secretFields := ch.lookupSecretFields(c, existing.PackageID, existing.Type)
patch.Config = ch.encryptSecrets(req.Config, secretFields, models.ScopeGlobal, "")
}
if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "connection updated"})
}
// DeleteGlobalConnection deletes a global-scope connection.
func (h *AdminHandler) DeleteGlobalConnection(c *gin.Context) {
id := c.Param("id")
n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopeGlobal, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "connection deleted"})
}

View File

@@ -0,0 +1,101 @@
package handlers
// v0.38.1: ConnectionResolver bridges the sandbox connections module
// to the store + vault layers. Implements sandbox.ConnectionResolver.
import (
"context"
"encoding/json"
"chat-switchboard/crypto"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ConnectionResolverAdapter implements sandbox.ConnectionResolver.
type ConnectionResolverAdapter struct {
stores store.Stores
vault *crypto.KeyResolver
}
func NewConnectionResolverAdapter(s store.Stores, vault *crypto.KeyResolver) *ConnectionResolverAdapter {
return &ConnectionResolverAdapter{stores: s, vault: vault}
}
// Resolve returns a single connection with secrets decrypted.
func (r *ConnectionResolverAdapter) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) {
conn, err := r.stores.Connections.Resolve(ctx, userID, connType, name)
if err != nil {
return nil, err
}
r.decryptInPlace(ctx, conn)
return conn, nil
}
// ResolveAll returns all accessible connections with secrets decrypted.
func (r *ConnectionResolverAdapter) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) {
conns, err := r.stores.Connections.ResolveAll(ctx, userID, connType)
if err != nil {
return nil, err
}
for i := range conns {
r.decryptInPlace(ctx, &conns[i])
}
return conns, nil
}
// decryptInPlace decrypts secret fields in the connection's config.
func (r *ConnectionResolverAdapter) decryptInPlace(ctx context.Context, conn *models.ExtConnection) {
if conn == nil || conn.Config == nil || r.vault == nil {
return
}
secretFields := r.lookupSecretFields(ctx, conn.PackageID, conn.Type)
for k, v := range conn.Config {
if !secretFields[k] {
continue
}
m, ok := v.(map[string]interface{})
if !ok {
continue
}
enc := toBytes(m["enc"])
nonce := toBytes(m["nonce"])
if len(enc) == 0 {
continue
}
plaintext, err := r.vault.Decrypt(enc, nonce, conn.Scope, conn.OwnerID)
if err == nil {
conn.Config[k] = plaintext
}
}
}
// lookupSecretFields reads the package manifest to identify secret fields.
func (r *ConnectionResolverAdapter) lookupSecretFields(ctx context.Context, packageID, connType string) map[string]bool {
result := map[string]bool{}
pkg, err := r.stores.Packages.Get(ctx, packageID)
if err != nil || pkg == nil {
return result
}
connsRaw, ok := pkg.Manifest["connections"]
if !ok {
return result
}
connsJSON, _ := json.Marshal(connsRaw)
var conns []struct {
Type string `json:"type"`
Fields map[string]map[string]string `json:"fields"`
}
json.Unmarshal(connsJSON, &conns)
for _, cd := range conns {
if cd.Type == connType {
for fieldName, fieldDef := range cd.Fields {
if fieldDef["type"] == "secret" {
result[fieldName] = true
}
}
break
}
}
return result
}

View File

@@ -0,0 +1,299 @@
package handlers
// v0.38.1: Extension connection handlers — personal scope + resolution.
// Pattern follows apiconfigs.go (ProviderConfigHandler).
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"chat-switchboard/crypto"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ConnectionHandler handles user-facing extension connection endpoints.
type ConnectionHandler struct {
stores store.Stores
vault *crypto.KeyResolver
}
func NewConnectionHandler(s store.Stores, vault *crypto.KeyResolver) *ConnectionHandler {
return &ConnectionHandler{stores: s, vault: vault}
}
// ListConnections returns the user's personal connections.
func (h *ConnectionHandler) ListConnections(c *gin.Context) {
userID := getUserID(c)
conns, err := h.stores.Connections.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
return
}
c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)})
}
// CreateConnection creates a personal-scope connection.
func (h *ConnectionHandler) CreateConnection(c *gin.Context) {
userID := getUserID(c)
var req struct {
Type string `json:"type" binding:"required"`
PackageID string `json:"package_id" binding:"required"`
Name string `json:"name" binding:"required"`
Config models.JSONMap `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
secretFields := h.lookupSecretFields(c, req.PackageID, req.Type)
encConfig := h.encryptSecrets(req.Config, secretFields, models.ScopePersonal, userID)
conn := &models.ExtConnection{
Type: req.Type,
PackageID: req.PackageID,
Scope: models.ScopePersonal,
OwnerID: userID,
Name: req.Name,
Config: encConfig,
IsActive: true,
}
if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create connection"})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": conn.ID,
"type": conn.Type,
"name": conn.Name,
})
}
// GetConnection returns a single connection by ID.
func (h *ConnectionHandler) GetConnection(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
conn, err := h.stores.Connections.GetByID(c.Request.Context(), id)
if err != nil || conn.Scope != models.ScopePersonal || conn.OwnerID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
return
}
c.JSON(http.StatusOK, maskSingleConnection(conn))
}
// UpdateConnection updates a personal connection.
func (h *ConnectionHandler) UpdateConnection(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Connections.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own connections"})
return
}
var req struct {
Name *string `json:"name,omitempty"`
Config models.JSONMap `json:"config,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
patch := models.ExtConnectionPatch{Name: req.Name}
if req.Config != nil {
secretFields := h.lookupSecretFields(c, existing.PackageID, existing.Type)
patch.Config = h.encryptSecrets(req.Config, secretFields, models.ScopePersonal, userID)
}
if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "connection updated"})
}
// DeleteConnection deletes a personal connection.
func (h *ConnectionHandler) DeleteConnection(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopePersonal, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "connection deleted"})
}
// ResolveConnection resolves a connection via the scope chain.
// GET /connections/resolve?type=gitea&name=Work+Gitea
func (h *ConnectionHandler) ResolveConnection(c *gin.Context) {
userID := getUserID(c)
connType := c.Query("type")
if connType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "type query parameter required"})
return
}
name := c.Query("name")
conn, err := h.stores.Connections.Resolve(c.Request.Context(), userID, connType, name)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no connection found"})
return
}
// Decrypt secrets for the resolver response (used by frontend to test connections).
secretFields := h.lookupSecretFields(c, conn.PackageID, conn.Type)
conn.Config = h.decryptSecrets(conn.Config, secretFields, conn.Scope, conn.OwnerID)
c.JSON(http.StatusOK, conn)
}
// ── Encryption helpers ─────────────────────────────
// lookupSecretFields reads the package manifest to identify which config
// fields are of type "secret" for a given connection type.
func (h *ConnectionHandler) lookupSecretFields(c *gin.Context, packageID, connType string) map[string]bool {
result := map[string]bool{}
pkg, err := h.stores.Packages.Get(c.Request.Context(), packageID)
if err != nil || pkg == nil {
return result
}
connsRaw, ok := pkg.Manifest["connections"]
if !ok {
return result
}
connsJSON, _ := json.Marshal(connsRaw)
var conns []struct {
Type string `json:"type"`
Fields map[string]map[string]string `json:"fields"`
}
json.Unmarshal(connsJSON, &conns)
for _, cd := range conns {
if cd.Type == connType {
for fieldName, fieldDef := range cd.Fields {
if fieldDef["type"] == "secret" {
result[fieldName] = true
}
}
break
}
}
return result
}
// encryptSecrets encrypts secret-type fields within a config map.
// Non-secret fields pass through unchanged.
func (h *ConnectionHandler) encryptSecrets(config models.JSONMap, secretFields map[string]bool, scope, ownerID string) models.JSONMap {
if config == nil || h.vault == nil {
return config
}
out := make(models.JSONMap, len(config))
for k, v := range config {
if secretFields[k] {
if s, ok := v.(string); ok && s != "" {
enc, nonce, err := h.vault.EncryptForScope(s, scope, ownerID)
if err == nil {
out[k] = map[string]interface{}{
"enc": enc,
"nonce": nonce,
}
continue
}
}
}
out[k] = v
}
return out
}
// decryptSecrets decrypts secret-type fields within a config map.
func (h *ConnectionHandler) decryptSecrets(config models.JSONMap, secretFields map[string]bool, scope, ownerID string) models.JSONMap {
if config == nil || h.vault == nil {
return config
}
out := make(models.JSONMap, len(config))
for k, v := range config {
if secretFields[k] {
if m, ok := v.(map[string]interface{}); ok {
encRaw, _ := m["enc"]
nonceRaw, _ := m["nonce"]
// Type-assert to []byte or base64-decode from string
enc := toBytes(encRaw)
nonce := toBytes(nonceRaw)
if len(enc) > 0 {
plaintext, err := h.vault.Decrypt(enc, nonce, scope, ownerID)
if err == nil {
out[k] = plaintext
continue
}
}
}
}
out[k] = v
}
return out
}
// toBytes coerces an interface{} to []byte. Handles both []byte (from Go)
// and string (from JSON unmarshal of base64).
func toBytes(v interface{}) []byte {
switch t := v.(type) {
case []byte:
return t
case string:
return []byte(t)
}
return nil
}
// ── Response masking ───────────────────────────────
// maskConnectionSecrets returns safe connection summaries (no secret values).
func maskConnectionSecrets(conns []models.ExtConnection) []map[string]interface{} {
out := make([]map[string]interface{}, len(conns))
for i, c := range conns {
out[i] = map[string]interface{}{
"id": c.ID,
"type": c.Type,
"package_id": c.PackageID,
"scope": c.Scope,
"name": c.Name,
"is_active": c.IsActive,
"has_config": len(c.Config) > 0,
"created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated_at": c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
return out
}
func maskSingleConnection(c *models.ExtConnection) map[string]interface{} {
return map[string]interface{}{
"id": c.ID,
"type": c.Type,
"package_id": c.PackageID,
"scope": c.Scope,
"name": c.Name,
"is_active": c.IsActive,
"has_config": len(c.Config) > 0,
"created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z"),
"updated_at": c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}

View File

@@ -0,0 +1,118 @@
package handlers
// v0.38.1: Extension connection handlers — team scope.
// Methods on TeamHandler, mirrors team_providers.go.
import (
"net/http"
"github.com/gin-gonic/gin"
"chat-switchboard/models"
)
// ListTeamConnections returns connections scoped to a team.
func (h *TeamHandler) ListTeamConnections(c *gin.Context) {
teamID := getTeamID(c)
conns, err := h.stores.Connections.ListForTeam(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team connections"})
return
}
c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)})
}
// CreateTeamConnection creates a connection scoped to a team.
func (h *TeamHandler) CreateTeamConnection(c *gin.Context) {
teamID := getTeamID(c)
var req struct {
Type string `json:"type" binding:"required"`
PackageID string `json:"package_id" binding:"required"`
Name string `json:"name" binding:"required"`
Config models.JSONMap `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Use ConnectionHandler helpers for encryption
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
secretFields := ch.lookupSecretFields(c, req.PackageID, req.Type)
encConfig := ch.encryptSecrets(req.Config, secretFields, models.ScopeTeam, teamID)
conn := &models.ExtConnection{
Type: req.Type,
PackageID: req.PackageID,
Scope: models.ScopeTeam,
OwnerID: teamID,
Name: req.Name,
Config: encConfig,
IsActive: true,
}
if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team connection"})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": conn.ID,
"type": conn.Type,
"name": conn.Name,
})
}
// UpdateTeamConnection updates a team-scoped connection.
func (h *TeamHandler) UpdateTeamConnection(c *gin.Context) {
teamID := getTeamID(c)
id := c.Param("id")
existing, err := h.stores.Connections.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopeTeam || existing.OwnerID != teamID {
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
return
}
var req struct {
Name *string `json:"name,omitempty"`
Config models.JSONMap `json:"config,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
patch := models.ExtConnectionPatch{Name: req.Name}
if req.Config != nil {
ch := &ConnectionHandler{stores: h.stores, vault: h.vault}
secretFields := ch.lookupSecretFields(c, existing.PackageID, existing.Type)
patch.Config = ch.encryptSecrets(req.Config, secretFields, models.ScopeTeam, teamID)
}
if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "connection updated"})
}
// DeleteTeamConnection deletes a team-scoped connection.
func (h *TeamHandler) DeleteTeamConnection(c *gin.Context) {
teamID := getTeamID(c)
id := c.Param("id")
n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopeTeam, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "connection deleted"})
}

View File

@@ -408,6 +408,8 @@ func main() {
)
// v0.29.1: provider module adapter — bridges sandbox.ProviderResolver to handlers.ResolveProviderConfig
starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver))
// v0.38.1: connections module — extension connection resolution
starlarkRunner.SetConnectionResolver(handlers.NewConnectionResolverAdapter(stores, keyResolver))
// v0.29.2: db module — extension namespaced table access
starlarkRunner.SetDB(database.DB, database.IsPostgres())
// v0.38.0: disk-based script loading + load() support
@@ -812,6 +814,15 @@ func main() {
protected.GET("/api-configs/:id/models", provCfg.ListModels)
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
// Extension Connections (personal scope — v0.38.1)
connH := handlers.NewConnectionHandler(stores, keyResolver)
protected.GET("/connections", connH.ListConnections)
protected.POST("/connections", connH.CreateConnection)
protected.GET("/connections/resolve", connH.ResolveConnection)
protected.GET("/connections/:id", connH.GetConnection)
protected.PUT("/connections/:id", connH.UpdateConnection)
protected.DELETE("/connections/:id", connH.DeleteConnection)
// Models (unified resolver — replaces scattered endpoints)
modelH := handlers.NewModelHandler(stores)
if healthStore != nil {
@@ -1044,6 +1055,12 @@ func main() {
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
// Team connections (v0.38.1)
teamScoped.GET("/connections", teams.ListTeamConnections)
teamScoped.POST("/connections", teams.CreateTeamConnection)
teamScoped.PUT("/connections/:id", teams.UpdateTeamConnection)
teamScoped.DELETE("/connections/:id", teams.DeleteTeamConnection)
// Team package management (v0.30.0)
teamPkgH := handlers.NewUserPackageHandler(stores, userPkgDir)
teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
@@ -1164,6 +1181,12 @@ func main() {
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
// Global Connections (v0.38.1)
admin.GET("/connections", adm.ListGlobalConnections)
admin.POST("/connections", adm.CreateGlobalConnection)
admin.PUT("/connections/:id", adm.UpdateGlobalConnection)
admin.DELETE("/connections/:id", adm.DeleteGlobalConnection)
// Model Catalog
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)

View File

@@ -0,0 +1,24 @@
package models
// ── Extension Connections (v0.38.1) ──────────────────
// ExtConnection represents a scoped credential/endpoint configuration
// for extensions that integrate with external services. Same scope
// hierarchy as ProviderConfig: global → team → personal.
type ExtConnection struct {
BaseModel
Type string `json:"type" db:"type"`
PackageID string `json:"package_id" db:"package_id"`
Scope string `json:"scope" db:"scope"`
OwnerID string `json:"owner_id" db:"owner_id"`
Name string `json:"name" db:"name"`
Config JSONMap `json:"config" db:"config"`
IsActive bool `json:"is_active" db:"is_active"`
}
// ExtConnectionPatch carries partial updates. Nil fields are skipped.
type ExtConnectionPatch struct {
Name *string `json:"name,omitempty"`
Config JSONMap `json:"config,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}

View File

@@ -35,6 +35,7 @@ const (
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks
ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access
ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution
)
// ValidExtensionPermissions is the set of recognized permission keys.
@@ -48,6 +49,7 @@ var ValidExtensionPermissions = map[string]bool{
ExtPermProviderComplete: true,
ExtPermFormValidate: true,
ExtPermWorkflowAccess: true,
ExtPermConnectionsRead: true,
}
// ── Extension Permission Model ───────────────

View File

@@ -0,0 +1,107 @@
// Package sandbox — connections_module.go
//
// v0.38.1: Connections module for Starlark extensions.
// Requires permission: connections.read
//
// Starlark API:
// conn = connections.get("gitea") → dict or None
// conn = connections.get("gitea", name="Work Gitea") → dict or None
// all = connections.list("gitea") → list of dicts
//
// Each returned dict contains the connection's config fields with
// secrets decrypted. The module delegates to a ConnectionResolver
// interface so the runner stays agnostic to the vault.
package sandbox
import (
"context"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
"chat-switchboard/models"
)
// ConnectionResolver resolves extension connections with secret decryption.
// Implemented by the handler layer which owns the vault.
type ConnectionResolver interface {
// Resolve returns a single connection's decrypted config via the
// scope chain (personal → team → global). Empty name = first match.
Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error)
// ResolveAll returns all accessible connections of a type with
// decrypted configs.
ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error)
}
// BuildConnectionsModule creates the "connections" module.
func BuildConnectionsModule(ctx context.Context, resolver ConnectionResolver, userID string) *starlarkstruct.Module {
return MakeModule("connections", starlark.StringDict{
"get": starlark.NewBuiltin("connections.get", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var connType string
var name string
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"type", &connType,
"name?", &name,
); err != nil {
return nil, err
}
conn, err := resolver.Resolve(ctx, userID, connType, name)
if err != nil || conn == nil {
return starlark.None, nil
}
return connectionToDict(conn), nil
}),
"list": starlark.NewBuiltin("connections.list", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var connType string
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &connType); err != nil {
return nil, err
}
conns, err := resolver.ResolveAll(ctx, userID, connType)
if err != nil {
return starlark.NewList(nil), nil
}
elems := make([]starlark.Value, len(conns))
for i := range conns {
elems[i] = connectionToDict(&conns[i])
}
return starlark.NewList(elems), nil
}),
})
}
// connectionToDict converts an ExtConnection to a Starlark dict.
// Config fields are flattened into the top-level dict alongside id/name/type.
func connectionToDict(conn *models.ExtConnection) *starlark.Dict {
d := starlark.NewDict(4 + len(conn.Config))
d.SetKey(starlark.String("id"), starlark.String(conn.ID))
d.SetKey(starlark.String("type"), starlark.String(conn.Type))
d.SetKey(starlark.String("name"), starlark.String(conn.Name))
d.SetKey(starlark.String("scope"), starlark.String(conn.Scope))
for k, v := range conn.Config {
switch val := v.(type) {
case string:
d.SetKey(starlark.String(k), starlark.String(val))
case float64:
d.SetKey(starlark.String(k), starlark.Float(val))
case bool:
d.SetKey(starlark.String(k), starlark.Bool(val))
default:
// Fallback: coerce to string
d.SetKey(starlark.String(k), starlark.String(""))
}
}
return d
}

View File

@@ -50,9 +50,10 @@ type Runner struct {
stores store.Stores
packagesDir string // v0.38.0: disk path for load() support
notifier NotificationSender // nil = notifications module unavailable
resolver ProviderResolver // nil = provider module unavailable
db *sql.DB // nil = db module unavailable
dbPostgres bool // true = use $N placeholders; false = use ?
resolver ProviderResolver // nil = provider module unavailable
connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1)
db *sql.DB // nil = db module unavailable
dbPostgres bool // true = use $N placeholders; false = use ?
}
// NewRunner creates a runner with the given sandbox and dependencies.
@@ -73,6 +74,11 @@ func (r *Runner) SetProviderResolver(pr ProviderResolver) {
r.resolver = pr
}
// SetConnectionResolver attaches the connection resolver for the connections module (v0.38.1).
func (r *Runner) SetConnectionResolver(cr ConnectionResolver) {
r.connResolver = cr
}
// SetDB attaches the raw database connection for the db module.
// isPostgres controls whether $N or ? placeholders are used.
// Call this at startup after database.Connect().
@@ -300,6 +306,11 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma
case models.ExtPermWorkflowAccess:
modules["workflow"] = BuildWorkflowModule(ctx, r.stores)
case models.ExtPermConnectionsRead:
if r.connResolver != nil && rc != nil && rc.UserID != "" {
modules["connections"] = BuildConnectionsModule(ctx, r.connResolver, rc.UserID)
}
}
}

View File

@@ -8820,6 +8820,213 @@ paths:
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
# ── Extension Connections (v0.38.1) ─────────────────────────────────
/api/v1/connections:
get:
tags: [Extensions]
summary: List personal connections
security:
- bearerAuth: []
responses:
'200':
description: Personal connections (secrets masked)
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/ExtConnection'
post:
tags: [Extensions]
summary: Create personal connection
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [type, package_id, name]
properties:
type: { type: string }
package_id: { type: string }
name: { type: string }
config: { type: object }
responses:
'201':
description: Created
/api/v1/connections/{id}:
get:
tags: [Extensions]
summary: Get personal connection
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
responses:
'200':
description: Connection (secrets masked)
put:
tags: [Extensions]
summary: Update personal connection
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
config: { type: object }
responses:
'200':
description: Updated
delete:
tags: [Extensions]
summary: Delete personal connection
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
responses:
'200':
description: Deleted
/api/v1/connections/resolve:
get:
tags: [Extensions]
summary: Resolve connection via scope chain (personal > team > global)
description: |
Returns a single connection with decrypted config, resolved via
the scope chain. Requires `type` query parameter. Optional `name`
for named resolution.
security:
- bearerAuth: []
parameters:
- name: type
in: query
required: true
schema: { type: string }
- name: name
in: query
schema: { type: string }
responses:
'200':
description: Resolved connection with decrypted config
'404':
description: No matching connection found
/api/v1/teams/{teamId}/connections:
get:
tags: [Teams, Extensions]
summary: List team connections
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/TeamID'
responses:
'200':
description: Team connections
post:
tags: [Teams, Extensions]
summary: Create team connection
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/TeamID'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [type, package_id, name]
properties:
type: { type: string }
package_id: { type: string }
name: { type: string }
config: { type: object }
responses:
'201':
description: Created
/api/v1/teams/{teamId}/connections/{id}:
put:
tags: [Teams, Extensions]
summary: Update team connection
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/TeamID'
- $ref: '#/components/parameters/ResourceID'
responses:
'200':
description: Updated
delete:
tags: [Teams, Extensions]
summary: Delete team connection
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/TeamID'
- $ref: '#/components/parameters/ResourceID'
responses:
'200':
description: Deleted
/api/v1/admin/connections:
get:
tags: [Admin, Extensions]
summary: List global connections
security:
- bearerAuth: []
responses:
'200':
description: Global connections
post:
tags: [Admin, Extensions]
summary: Create global connection
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [type, package_id, name]
properties:
type: { type: string }
package_id: { type: string }
name: { type: string }
config: { type: object }
responses:
'201':
description: Created
/api/v1/admin/connections/{id}:
put:
tags: [Admin, Extensions]
summary: Update global connection
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
responses:
'200':
description: Updated
delete:
tags: [Admin, Extensions]
summary: Delete global connection
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
responses:
'200':
description: Deleted
/api/v1/admin/workflows/{id}/export:
get:
tags:
@@ -12059,6 +12266,36 @@ components:
updated_at:
type: string
format: date-time
ExtConnection:
type: object
description: "v0.38.1: Extension connection credential (secrets masked in list responses)"
properties:
id:
type: string
format: uuid
type:
type: string
description: Connection type identifier (e.g. "gitea", "github")
package_id:
type: string
description: Declaring package ID (UI attribution)
scope:
type: string
enum: [global, team, personal]
name:
type: string
description: User label (e.g. "Work Gitea")
is_active:
type: boolean
has_config:
type: boolean
description: Whether config fields are set (secrets never exposed in list)
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
Extension:
type: object
properties:

View File

@@ -65,6 +65,7 @@ type Stores struct {
Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod)
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops
Connections ConnectionStore // v0.38.1: Extension connection credentials
}
// =========================================
@@ -1366,6 +1367,35 @@ type ExportStore interface {
// =========================================
// SHARED TYPES
// =========================================
// CONNECTION STORE (v0.38.1)
// =========================================
type ConnectionStore interface {
Create(ctx context.Context, conn *models.ExtConnection) error
GetByID(ctx context.Context, id string) (*models.ExtConnection, error)
Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error
Delete(ctx context.Context, id string) error
// Scoped queries
ListGlobal(ctx context.Context) ([]models.ExtConnection, error)
ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error)
ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error)
// Resolution chain: personal → team → global.
// Returns first active connection matching type (and optional name).
// Empty name means "first match". Returns sql.ErrNoRows if not found.
Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error)
// ResolveAll returns all active connections of the given type accessible
// to the user (personal + team + global).
ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error)
// DeleteByIDAndScope deletes a connection only if it matches the given
// scope and owner. Returns rows affected (0 or 1).
DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error)
}
// =========================================
// ListOptions provides standard pagination/sort for list queries.

View File

@@ -0,0 +1,182 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"chat-switchboard/models"
)
// ── ConnectionStore (v0.38.1) ────────────────────────
type ConnectionStore struct{}
func NewConnectionStore() *ConnectionStore { return &ConnectionStore{} }
const connCols = `id, type, package_id, scope, owner_id, name, config, is_active, created_at, updated_at`
func (s *ConnectionStore) Create(ctx context.Context, conn *models.ExtConnection) error {
return DB.QueryRowContext(ctx, `
INSERT INTO ext_connections (type, package_id, scope, owner_id, name, config, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at`,
conn.Type, conn.PackageID, conn.Scope, conn.OwnerID,
conn.Name, ToJSON(conn.Config), conn.IsActive,
).Scan(&conn.ID, &conn.CreatedAt, &conn.UpdatedAt)
}
func (s *ConnectionStore) GetByID(ctx context.Context, id string) (*models.ExtConnection, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM ext_connections WHERE id = $1", connCols), id)
return scanConnection(row)
}
func (s *ConnectionStore) Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error {
b := NewUpdate("ext_connections")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Config != nil {
b.SetJSON("config", patch.Config)
}
if patch.IsActive != nil {
b.Set("is_active", *patch.IsActive)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *ConnectionStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM ext_connections WHERE id = $1", id)
return err
}
// ── Scoped Queries ─────────────────────────────────
func (s *ConnectionStore) ListGlobal(ctx context.Context) ([]models.ExtConnection, error) {
return s.listByScope(ctx, models.ScopeGlobal, "")
}
func (s *ConnectionStore) ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error) {
return s.listByScope(ctx, models.ScopeTeam, teamID)
}
func (s *ConnectionStore) ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error) {
return s.listByScope(ctx, models.ScopePersonal, userID)
}
// ── Resolution ─────────────────────────────────────
// Resolve returns the first active connection matching type (and optional name)
// via the scope chain: personal → team → global.
func (s *ConnectionStore) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) {
q := fmt.Sprintf(`
SELECT %s FROM ext_connections
WHERE type = $1 AND is_active = true
AND (
(scope = 'personal' AND owner_id = $2)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $2
))
OR (scope = 'global')
)`, connCols)
args := []any{connType, userID}
if name != "" {
q += " AND name = $3"
args = append(args, name)
}
// Priority: personal(0) → team(1) → global(2).
q += " ORDER BY CASE scope WHEN 'personal' THEN 0 WHEN 'team' THEN 1 ELSE 2 END, created_at ASC LIMIT 1"
row := DB.QueryRowContext(ctx, q, args...)
return scanConnection(row)
}
// ResolveAll returns all active connections of the given type accessible to the user.
func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) {
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
SELECT %s FROM ext_connections
WHERE type = $1 AND is_active = true
AND (
(scope = 'personal' AND owner_id = $2)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $2
))
OR (scope = 'global')
)
ORDER BY scope ASC, name ASC`, connCols), connType, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanConnections(rows)
}
// DeleteByIDAndScope deletes a connection only if it matches the scope/owner.
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
res, err := DB.ExecContext(ctx,
`DELETE FROM ext_connections WHERE id = $1 AND scope = $2 AND owner_id = $3`,
id, scope, ownerID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ── Internal ───────────────────────────────────────
func (s *ConnectionStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ExtConnection, error) {
var rows *sql.Rows
var err error
if scope == models.ScopeGlobal {
rows, err = DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = $1 ORDER BY type, name", connCols),
scope)
} else {
rows, err = DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = $1 AND owner_id = $2 ORDER BY type, name", connCols),
scope, ownerID)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanConnections(rows)
}
func scanConnection(row *sql.Row) (*models.ExtConnection, error) {
var c models.ExtConnection
var configJSON []byte
err := row.Scan(
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
&c.Name, &configJSON, &c.IsActive, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
json.Unmarshal(configJSON, &c.Config)
return &c, nil
}
func scanConnections(rows *sql.Rows) ([]models.ExtConnection, error) {
var result []models.ExtConnection
for rows.Next() {
var c models.ExtConnection
var configJSON []byte
err := rows.Scan(
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
&c.Name, &configJSON, &c.IsActive, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
json.Unmarshal(configJSON, &c.Config)
result = append(result, c)
}
return result, rows.Err()
}

View File

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

View File

@@ -0,0 +1,186 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── ConnectionStore (v0.38.1) ────────────────────────
type ConnectionStore struct{}
func NewConnectionStore() *ConnectionStore { return &ConnectionStore{} }
const connCols = `id, type, package_id, scope, owner_id, name, config, is_active, created_at, updated_at`
func (s *ConnectionStore) Create(ctx context.Context, conn *models.ExtConnection) error {
conn.ID = store.NewID()
now := time.Now().UTC()
conn.CreatedAt = now
conn.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO ext_connections (id, type, package_id, scope, owner_id, name, config, is_active,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
conn.ID, conn.Type, conn.PackageID, conn.Scope, conn.OwnerID,
conn.Name, ToJSON(conn.Config), conn.IsActive,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
}
func (s *ConnectionStore) GetByID(ctx context.Context, id string) (*models.ExtConnection, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM ext_connections WHERE id = ?", connCols), id)
return scanConnection(row)
}
func (s *ConnectionStore) Update(ctx context.Context, id string, patch models.ExtConnectionPatch) error {
b := NewUpdate("ext_connections")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Config != nil {
b.SetJSON("config", patch.Config)
}
if patch.IsActive != nil {
b.Set("is_active", *patch.IsActive)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *ConnectionStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM ext_connections WHERE id = ?", id)
return err
}
// ── Scoped Queries ─────────────────────────────────
func (s *ConnectionStore) ListGlobal(ctx context.Context) ([]models.ExtConnection, error) {
return s.listByScope(ctx, models.ScopeGlobal, "")
}
func (s *ConnectionStore) ListForTeam(ctx context.Context, teamID string) ([]models.ExtConnection, error) {
return s.listByScope(ctx, models.ScopeTeam, teamID)
}
func (s *ConnectionStore) ListForUser(ctx context.Context, userID string) ([]models.ExtConnection, error) {
return s.listByScope(ctx, models.ScopePersonal, userID)
}
// ── Resolution ─────────────────────────────────────
func (s *ConnectionStore) Resolve(ctx context.Context, userID, connType, name string) (*models.ExtConnection, error) {
q := fmt.Sprintf(`
SELECT %s FROM ext_connections
WHERE type = ? AND is_active = 1
AND (
(scope = 'personal' AND owner_id = ?)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = ?
))
OR (scope = 'global')
)`, connCols)
args := []any{connType, userID, userID}
if name != "" {
q += " AND name = ?"
args = append(args, name)
}
// Priority: personal(0) → team(1) → global(2).
q += " ORDER BY CASE scope WHEN 'personal' THEN 0 WHEN 'team' THEN 1 ELSE 2 END, created_at ASC LIMIT 1"
row := DB.QueryRowContext(ctx, q, args...)
return scanConnection(row)
}
func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) {
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
SELECT %s FROM ext_connections
WHERE type = ? AND is_active = 1
AND (
(scope = 'personal' AND owner_id = ?)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = ?
))
OR (scope = 'global')
)
ORDER BY scope ASC, name ASC`, connCols), connType, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanConnections(rows)
}
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
res, err := DB.ExecContext(ctx,
`DELETE FROM ext_connections WHERE id = ? AND scope = ? AND owner_id = ?`,
id, scope, ownerID)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ── Internal ───────────────────────────────────────
func (s *ConnectionStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ExtConnection, error) {
var rows *sql.Rows
var err error
if scope == models.ScopeGlobal {
rows, err = DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = ? ORDER BY type, name", connCols),
scope)
} else {
rows, err = DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM ext_connections WHERE scope = ? AND owner_id = ? ORDER BY type, name", connCols),
scope, ownerID)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanConnections(rows)
}
func scanConnection(row *sql.Row) (*models.ExtConnection, error) {
var c models.ExtConnection
var configJSON []byte
err := row.Scan(
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
&c.Name, &configJSON, &c.IsActive, st(&c.CreatedAt), st(&c.UpdatedAt),
)
if err != nil {
return nil, err
}
json.Unmarshal(configJSON, &c.Config)
return &c, nil
}
func scanConnections(rows *sql.Rows) ([]models.ExtConnection, error) {
var result []models.ExtConnection
for rows.Next() {
var c models.ExtConnection
var configJSON []byte
err := rows.Scan(
&c.ID, &c.Type, &c.PackageID, &c.Scope, &c.OwnerID,
&c.Name, &configJSON, &c.IsActive, st(&c.CreatedAt), st(&c.UpdatedAt),
)
if err != nil {
return nil, err
}
json.Unmarshal(configJSON, &c.Config)
result = append(result, c)
}
return result, rows.Err()
}

View File

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