> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Identidad de member y token exchange

> Cuando esto se entregue, un member podrá entrar a nuestro portal con un magic link — o seguir entrando al sitio del tenant y aun así alcanzar nuestra API, sin ver jamás una pantalla de login de Softcrum.

> Traducción. Autoritativo: [`fs-core-0009-member-identity.md`](/modules/core/features/fs-core-0009-member-identity).

## 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 de `core.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 `jti` en 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)*

1. 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.
2. Un token de member **nunca puede portar un permiso de consola**. Impuesto por construcción, y por
   un test que lo afirma.
3. 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 `jti` en Redis. Vida corta más uso único
   significa que una aserción capturada casi no vale nada.
4. La aserción lleva el `external_id` del 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.
5. Todo member autenticado mapea 1:1 a un contacto. Un member sin contacto es un estado inválido.
6. 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.
7. Los tokens de member tienen scopes. Un token `member:read` no puede canjear, y el scope se
   verifica en el endpoint, no en el cliente.
8. Los códigos OTP son de un solo uso, expiran en 10 minutos y tienen rate limit por contacto y por
   IP.
9. 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)*

| Tabla                  | Invariantes clave                                                                                                                             |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.member_sessions` | FK `contact_id`; `scopes`; `issued_at`, `expires_at`, `revoked_at` nullable; `origin` native\|exchange; índice para búsqueda de sesión activa |

Better Auth es dueño de las tablas propias del realm; esta registra lo que necesitamos para
revocación y auditoría.

## API *(normativo)*

| Endpoint                            | Clase   | Auth                        | Presupuesto  |
| ----------------------------------- | ------- | --------------------------- | ------------ |
| `POST /v1/core/auth/otp/request`    | Runtime | pública, con rate limit     | p95 \<300 ms |
| `POST /v1/core/auth/otp/verify`     | Runtime | pública, con rate limit     | p95 \<300 ms |
| `POST /v1/core/auth/token-exchange` | Runtime | aserción firmada por tenant | p95 \<300 ms |
| `POST /v1/core/auth/logout`         | Runtime | token de member             | p95 \<300 ms |

## Eventos *(normativo)*

Ninguno en el outbox. La autenticación se registra en `core.audit_log` con actor `member`.

## Criterios de aceptación *(normativo)*

1. Un token de member es rechazado por todo endpoint de consola — afirmado sobre el conjunto
   completo, no una muestra.
2. Una aserción reproducida (mismo `jti`) se rechaza en el segundo uso.
3. Una aserción con más de 5 minutos se rechaza.
4. Una aserción firmada con la llave del tenant A no puede emitir un token para un member del
   tenant B.
5. La rotación de llaves mantiene válidas las sesiones existentes durante toda la ventana de
   solapamiento.
6. Un `external_id` visto por primera vez crea el contacto y devuelve un token en una sola llamada.
7. Los códigos OTP son de un solo uso y expiran.
8. **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 en `backend/api`; la verificación de replay usa
las mismas primitivas de Upstash que el rate limiting.

## Preguntas abiertas

| # | Pregunta                                                                                             | Decide | Para             |
| - | ---------------------------------------------------------------------------------------------------- | ------ | ---------------- |
| 1 | Duración de sesión de member — ¿30 días deslizantes, o menos para un token que puede canjear puntos? | Daniel | antes de aprobar |
| 2 | ¿El token exchange soporta refresh, o el tenant vuelve a intercambiar cada vez?                      | Daniel | antes de aprobar |

## Changelog

| Versión | Fecha      | Cambio           | Por qué | Autor                  |
| ------- | ---------- | ---------------- | ------- | ---------------------- |
| 0.1.0   | 2026-08-17 | Borrador inicial | —       | daniel + claude-opus-5 |

## Registro de entrega

*Aún no implementado.*
