package handlers // Pattern follows apiconfigs.go (ProviderConfigHandler). import ( "encoding/base64" "encoding/json" "log" "net/http" "github.com/gin-gonic/gin" "armature/crypto" "armature/models" "armature/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) // so users can see which connections are available to them. conns, err := h.stores.Connections.ListAccessible(c.Request.Context(), userID) if err != nil { log.Printf("[connections] ListAccessible failed for user %s: %v", userID, err) 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"), } }