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