This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/connections.go
Jeffrey Smith 11fd8c1e57 step 1: rename module chat-switchboard → switchboard-core
- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
2026-03-25 19:48:04 -04:00

313 lines
9.3 KiB
Go

package handlers
// v0.38.1: Extension connection handlers — personal scope + resolution.
// Pattern follows apiconfigs.go (ProviderConfigHandler).
import (
"encoding/base64"
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/crypto"
"switchboard-core/models"
"switchboard-core/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)
// v0.38.5: Show all accessible connections (personal + team + global)
// so users can see which connections are available to them.
conns, err := h.stores.Connections.ListAccessible(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 — base64 encoded by json.Marshal for []byte).
func toBytes(v interface{}) []byte {
switch t := v.(type) {
case []byte:
return t
case string:
if t == "" {
return nil
}
// JSON round-trip: json.Marshal encodes []byte as base64 strings.
// Decode back to the original bytes.
decoded, err := base64.StdEncoding.DecodeString(t)
if err != nil {
// Fallback: might be raw string (shouldn't happen, but be safe)
return []byte(t)
}
return decoded
}
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"),
}
}