Traducción. Autoritativo: fs-core-0009-member-identity.md.
Contexto
La suite sirve a dos poblaciones que nunca deben mezclarse: los usuarios que operan un tenant y los members que son sus clientes. Difieren en escala, en confianza y en radio de daño — un token de member que pudiera alcanzar un permiso de consola sería un compromiso total. ADR-010 mantiene a Softcrum como autoridad de identidad mientras deja que las integraciones embebidas conserven su propia experiencia de login. La entrada nativa es OTP o magic link; la delegada es token exchange, donde el backend del tenant firma una aserción de vida corta y recibe un token de member con scopes. Es el patrón que usa Smile.io, y es lo que hace del modo headless un ciudadano de primera clase en vez de una versión degradada. Todo member autenticado mapea 1:1 a una fila decore.contacts. No hay member sin contacto, porque el
contacto es aquello de lo que trata el resto de la plataforma.
Alcance (normativo)
- Un realm de Better Auth separado para members, con sus propias tablas y configuración.
- Entrada nativa: OTP y magic link por correo, vía Resend.
- Entrada delegada:
POST /v1/core/auth/token-exchange. - Llaves de firma por tenant, con rotación.
- Tokens de member con scopes:
member:read,member:redeem,member:referral. - Aserciones de un solo uso con verificación de replay por
jtien Redis. - El mapeo contacto ↔ member.
Fuera de alcance (normativo)
- La autenticación de usuarios de consola — el realm organizacional, anterior a este módulo.
- Login social para members. Diferido: agrega superficie de proveedores para una población que llega mayormente por enlace.
- Login con contraseña para members. Solo OTP y magic link, deliberadamente: sin contraseña de member no hay contraseña de member que se filtre.
Comportamiento (normativo)
- Los members viven en un realm separado sin superposición de sesión, cookie ni token con el organizacional. Esto es aislamiento de realm, no una verificación de permisos: la separación es estructural, así que no hay camino de escalamiento de privilegios que equivocar.
- Un token de member nunca puede portar un permiso de consola. Impuesto por construcción, y por un test que lo afirma.
- Una aserción de token exchange es un JWT firmado con una llave por tenant, válido ≤5 minutos,
de un solo uso vía verificación de replay por
jtien Redis. Vida corta más uso único significa que una aserción capturada casi no vale nada. - La aserción lleva el
external_iddel member según el tenant. La resolución encuentra o crea el contacto correspondiente — un member que llega por primera vez no es un error. - Todo member autenticado mapea 1:1 a un contacto. Un member sin contacto es un estado inválido.
- Las llaves de firma rotan sin invalidar sesiones vivas: una ventana de solapamiento acepta ambas. Una rotación que desconecta a todos es una rotación que nadie ejecuta.
- Los tokens de member tienen scopes. Un token
member:readno puede canjear, y el scope se verifica en el endpoint, no en el cliente. - Los códigos OTP son de un solo uso, expiran en 10 minutos y tienen rate limit por contacto y por IP.
- PROHIBIDO: registrar en logs un token o una aserción · aceptar una aserción firmada con la llave de otro tenant · emitir un token de member cuyo contacto pertenece a otro tenant.
Datos (normativo)
Better Auth es dueño de las tablas propias del realm; esta registra lo que necesitamos para
revocación y auditoría.
API (normativo)
Eventos (normativo)
Ninguno en el outbox. La autenticación se registra encore.audit_log con actor member.
Criterios de aceptación (normativo)
- Un token de member es rechazado por todo endpoint de consola — afirmado sobre el conjunto completo, no una muestra.
- Una aserción reproducida (mismo
jti) se rechaza en el segundo uso. - Una aserción con más de 5 minutos se rechaza.
- Una aserción firmada con la llave del tenant A no puede emitir un token para un member del tenant B.
- La rotación de llaves mantiene válidas las sesiones existentes durante toda la ventana de solapamiento.
- Un
external_idvisto por primera vez crea el contacto y devuelve un token en una sola llamada. - Los códigos OTP son de un solo uso y expiran.
- Negativo: ninguna línea de log contiene un token, una aserción ni un código OTP.
Ejecución
Comando síncrono. Configuración del realm y endpoints enbackend/api; la verificación de replay usa
las mismas primitivas de Upstash que el rate limiting.