Files
Lizandro GuarnizoandClaude Sonnet 5 a25329c7b1 fix(umind): libera las columnas huérfanas que dejó el refactor multi-agente
Error en producción al crear un tenant:

    null value in column "site_key" of relation "umind_tenants"
    violates not-null constraint (SQLSTATE 23502)

Es un bug que introduje yo. Al pasar uMind a multi-agente saqué SiteKey de
UmindTenant y renombré TenantID→AgenteID en seis tablas. GORM agrega
columnas pero nunca las borra ni les cambia las restricciones, así que las
viejas quedaron en la base CON su NOT NULL original — y el INSERT nuevo ya
no las incluye.

Al mirarlo, el alcance era mayor que el error reportado: no es solo
site_key. Las seis tablas renombradas tienen su tenant_id huérfano también
NOT NULL, así que fallaba insertar documentos, chunks, mensajes, tools,
canales y conexiones. En la práctica uMind quedaba inutilizable después de
desplegar el refactor: ni crear un tenant, ni ingestar conocimiento, ni
guardar un mensaje del chat.

La migración corre en cada arranque, antes de MigrarUmindAgentes, y
consulta information_schema para no intentar el ALTER a ciegas en una
instalación nueva donde la columna no existe.

No se hace DROP COLUMN a propósito: los datos viejos quedan por si hay que
reconciliar algo. Solo se libera la restricción.

El test compara los nombres de tabla contra el TableName() real de cada
modelo. Un typo ahí haría que la migración no encuentre la columna y siga
de largo: el bug seguiría vivo y el arranque se vería sano.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 07:45:17 -05:00

150 lines
4.3 KiB
Go

package services
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Cliente de Rapyd (wallets).
//
// Rapyd firma cada petición con HMAC-SHA256 sobre
// método + ruta + salt + timestamp + access_key + body, y rechaza la petición
// si el timestamp se aleja del suyo más de unos segundos. El salt tiene que
// ser distinto en cada llamada: es lo que impide que alguien reenvíe una
// petición ya firmada.
//
// Las credenciales salen del entorno. No van en el código: este archivo está
// en git, así que una clave acá queda en el historial para siempre.
const rapydURLPorDefecto = "https://sandboxapi.rapyd.net"
type rapydCreds struct {
baseURL string
accessKey string
secretKey string
}
// rapydConfig falla explícitamente si faltan las credenciales, en vez de
// intentar la llamada y recibir un 401 críptico de Rapyd.
func rapydConfig() (*rapydCreds, error) {
access := strings.TrimSpace(os.Getenv("RAPYD_ACCESS_KEY"))
secret := strings.TrimSpace(os.Getenv("RAPYD_SECRET_KEY"))
if access == "" || secret == "" {
return nil, fmt.Errorf("Rapyd no está configurado: definí RAPYD_ACCESS_KEY y RAPYD_SECRET_KEY")
}
base := strings.TrimSpace(os.Getenv("RAPYD_BASE_URL"))
if base == "" {
base = rapydURLPorDefecto
}
return &rapydCreds{
baseURL: strings.TrimRight(base, "/"),
accessKey: access,
secretKey: secret,
}, nil
}
// generarSalt usa crypto/rand y no math/rand: el salt es parte del esquema de
// firma, así que tiene que ser impredecible, no solo variado.
func generarSalt() (string, error) {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return "", err
}
const alfabeto = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
for i := range b {
b[i] = alfabeto[int(b[i])%len(alfabeto)]
}
return string(b), nil
}
// firmaRapyd arma la firma en el orden exacto que espera Rapyd. El resultado
// va en base64 URL-safe sin padding.
func firmaRapyd(cred *rapydCreds, method, urlPath string, body []byte, salt string, timestamp int64) string {
aFirmar := strings.ToLower(method) +
urlPath +
salt +
strconv.FormatInt(timestamp, 10) +
cred.accessKey +
string(body)
h := hmac.New(sha256.New, []byte(cred.secretKey))
h.Write([]byte(aFirmar))
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(h.Sum(nil))
}
// MakeRequest llama a la API de Rapyd. path es la ruta (ej. "/v1/ewallets").
func MakeRequest(method, path string, body interface{}) (map[string]interface{}, error) {
cred, err := rapydConfig()
if err != nil {
return nil, err
}
var bodyBytes []byte
if body != nil {
bodyBytes, err = json.Marshal(body)
if err != nil {
return nil, err
}
}
// La firma se calcula sobre la ruta, no sobre la URL completa.
u, err := url.Parse(cred.baseURL + path)
if err != nil {
return nil, err
}
urlPath := u.Path
salt, err := generarSalt()
if err != nil {
return nil, err
}
timestamp := time.Now().Unix()
req, err := http.NewRequest(strings.ToUpper(method), u.String(), bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("access_key", cred.accessKey)
req.Header.Set("salt", salt)
req.Header.Set("timestamp", strconv.FormatInt(timestamp, 10))
req.Header.Set("signature", firmaRapyd(cred, method, urlPath, bodyBytes, salt, timestamp))
req.Header.Set("idempotency", strconv.FormatInt(time.Now().UnixNano(), 10))
req.Header.Set("Content-Type", "application/json")
// Sin logging de la petición: el cuerpo de un wallet lleva nombre, correo,
// teléfono, documento y fecha de nacimiento, y las cabeceras llevan la
// access_key y la firma.
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("no se pudo contactar Rapyd: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("Rapyd respondió %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("respuesta inesperada de Rapyd: %s", string(respBody))
}
return result, nil
}