> ## 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.

# Member Identity and Token Exchange

> After this ships, a member can sign in to our portal with a magic link — or keep signing in to the tenant's own site and still reach our API, without ever seeing a Softcrum login screen.

## Context

The suite serves two populations that must never mix: **users** who run a tenant, and **members**
who are that tenant's customers. They differ in scale, in trust and in blast radius — a member
token that could reach a console permission would be a full compromise.

ADR-010 keeps Softcrum as the identity authority while letting embedded integrations keep their own
login experience. Native entry is OTP or magic link; delegated entry is token exchange, where the
tenant's backend signs a short-lived assertion and receives a scoped member token. It is the pattern
Smile.io uses, and it is what makes headless mode a first-class citizen rather than a downgrade.

Every authenticated member maps 1:1 to a `core.contacts` row. There is no member without a contact,
because the contact is the thing the rest of the platform is about.

And the identity is **scoped to one tenant, always**. Doña María shopping at company A and
subscribing to company B holds two member accounts with the same email address and no relationship
between them: two credentials, two sessions, two point balances, two consent records. Neither
tenant can discover the other. That is not an inconvenience to be optimised away later — it is the
isolation the whole multi-tenant model rests on (`standards/data.md` §2b).

## Scope *(normative)*

* A **separate Better Auth realm** for members, with its own tables and configuration.
* Native entry: OTP and magic link over email, via Resend.
* Delegated entry: `POST /v1/core/auth/token-exchange`.
* Per-tenant signing keys with rotation.
* Scoped member tokens: `member:read`, `member:redeem`, `member:referral`.
* Single-use assertions with `jti` replay checking in Redis.
* The contact ↔ member mapping.

## Non-scope *(normative)*

* Console user authentication — the organizational realm, which predates this module.
* Social login for members. Deferred: it adds provider surface for a population that mostly arrives
  by link.
* Member password login. OTP and magic link only, deliberately: no member password means no member
  password to leak.

## Behaviour *(normative)*

1. Members live in a **separate realm** with no session, cookie or token overlap with the
   organizational realm. This is realm isolation, not a permission check — the separation is
   structural, so there is no privilege-escalation path to get wrong.
   1b. **Member identity is unique per tenant, never globally.** The same email address may
   authenticate as a different member for every tenant that has it as a contact. An OTP issued for
   tenant A never authenticates against tenant B, and a member session always carries its tenant.
   FORBIDDEN: any index, lookup or account-linking flow that spans tenants.
2. A member token **can never carry a console permission**. Enforced by construction, and by a test
   that asserts it.
3. A token-exchange assertion is a JWT signed with a **per-tenant key**, valid for ≤5 minutes,
   **single-use** via a `jti` replay check in Redis. Short life plus single use means a captured
   assertion is worth almost nothing.
4. The assertion carries the tenant's `external_id` for the member. Resolution finds or creates the
   matching contact — a member arriving for the first time is not an error.
5. Every authenticated member maps 1:1 to a contact. A member without a contact is an invalid state.
6. Signing keys rotate **without invalidating live sessions**: an overlap window accepts both keys.
   A rotation that logs everyone out is a rotation nobody performs.
7. Member tokens are scoped. A `member:read` token cannot redeem, and the scope is checked at the
   endpoint, not in the client.
8. OTP codes are single-use, expire in 10 minutes, and are rate-limited per contact and per IP.
9. FORBIDDEN: logging a token or an assertion · accepting an assertion signed with another tenant's
   key · issuing a member token whose contact belongs to a different tenant.

## Data *(normative)*

| Table                  | Key invariants                                                                                                                                        |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.member_sessions` | `tenant_id` + FK `contact_id`; `scopes`; `issued_at`, `expires_at`, `revoked_at` nullable; `origin` native\|exchange; index for active-session lookup |

Member credentials live in the member realm's own tables, and **every uniqueness constraint on them
is `(tenant_id, …)`** per `standards/data.md` §2b. There is no global member account.

Better Auth owns the realm's own tables; this one records what we need for revocation and audit.

## API *(normative)*

| Endpoint                            | Class   | Auth                        | Budget       |
| ----------------------------------- | ------- | --------------------------- | ------------ |
| `POST /v1/core/auth/otp/request`    | Runtime | public, rate-limited        | p95 \<300 ms |
| `POST /v1/core/auth/otp/verify`     | Runtime | public, rate-limited        | p95 \<300 ms |
| `POST /v1/core/auth/token-exchange` | Runtime | per-tenant signed assertion | p95 \<300 ms |
| `POST /v1/core/auth/logout`         | Runtime | member token                | p95 \<300 ms |

## Events *(normative)*

None on the outbox. Authentication is recorded in `core.audit_log` with a `member` actor; emitting
a domain event per login would flood every consumer for no benefit.

## Acceptance criteria *(normative)*

1. A member token is rejected by every console endpoint — asserted across the full endpoint set,
   not a sample.
   1b. The same email registered as a member of two tenants produces two independent accounts: an OTP
   issued for tenant A fails against tenant B, and neither account's existence is discoverable from
   the other.
2. A replayed assertion (same `jti`) is rejected on the second use.
3. An assertion older than 5 minutes is rejected.
4. An assertion signed with tenant A's key cannot mint a token for tenant B's member.
5. Key rotation keeps existing sessions valid throughout the overlap window.
6. A first-time `external_id` creates the contact and returns a token in one call.
7. OTP codes are single-use and expire; a second verification of the same code fails.
8. **Negative:** no log line contains a token, an assertion or an OTP code.

## Execution

Synchronous command. Realm configuration and endpoints in `backend/api`; the replay check uses the
same Upstash primitives as rate limiting.

## Open questions

| # | Question                                                                                  | Decides | By              |
| - | ----------------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Member session lifetime — 30 days sliding, or shorter for a token that can redeem points? | Daniel  | before approval |
| 2 | Does token exchange support a refresh, or does the tenant re-exchange each time?          | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
