Skip to main content

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)

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)

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

Changelog

Delivery record

Not implemented yet.