Skip to main content

D115 — Invitation accept: central route, token resolves tenant

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. The mechanics live in app-modules/team/routes/team-routes.php, app-modules/team/src/Support/InvitationTenantResolver.php, app-modules/team/src/Http/Controllers/Api/TeamInvitationController.php, app-modules/team/src/Notifications/TeamInvitationNotification.php, and tests/Architecture/CqrsBoundaryTest.php.

Context

POST /api/v1/team/invitations/accept was mounted inside the tenant-identified route group (InitializeTenancyByRequestData), so redeeming an invitation required the caller to already send an X-Tenant header identifying the tenant. But a brand-new invitee — the only caller of this endpoint — has no way to learn the tenant: TeamInvitationNotification emailed only the raw single-use token (->line($this->token)), and tenant discovery (Authentication\Handlers\Queries\DiscoverTenantsHandler) resolves a tenant by matching an existing user's email — a not-yet-created invitee matches nothing. The route and command docblocks already asserted "the token is the credential" (team-routes.php, AcceptTeamInvitationCommand), but the mounting contradicted that: the token was necessary but not sufficient, and nothing in the system could supply the missing X-Tenant.

Decision

Make behaviour match the invariant: the token is the sole credential, and it resolves its own tenant server-side.

  • invitations/accept moves to a CENTRAL route group (no InitializeTenancyByRequestData), mirroring the shape of Authentication's central tenant-discovery/register group: correlation
    • turnstile + throttle:10,1, no Sanctum/tenancy middleware. Any X-Tenant header a caller sends is simply never read by this route — harmless, not honored.
  • Team\Support\InvitationTenantResolver runs the lookup in the CENTRAL/BYPASSRLS context (tenancy()->central(...)) — the same central-read shape as DiscoverTenantsHandler (email → tenant) and the webhooks ClientReferenceIdTenantResolver (provider reference → tenant): it hashes the token, finds the matching unaccepted client-context team_invitations row, checks expires_at (mirroring AcceptTeamInvitationHandler::isExpired()), and returns the owning Tenant or null. It is deliberately minimal — identifying the tenant only; the handler still owns all accept-time validation (unknown token, already-used, expired) once tenancy is initialized in the resolved tenant's RLS scope.
  • The controller drives the transition explicitly: resolve → tenancy()->initialize($tenant) → dispatch AcceptTeamInvitationCommand through the bus → tenancy()->end() in a finally — the same resolve/initialize/work/end shape ProcessInboundWebhookJob uses for inbound webhooks. A null resolution throws the identical DomainException(ErrorCode::InvitationInvalid, 422, …) the handler throws for an invalid token — no enumeration signal distinguishes "no such token" from "token valid but its tenant is gone".
  • InvitationTenantResolver is sanctioned by exact class in CqrsBoundaryTest's out-of-bus allow-list (the ClientReferenceIdTenantResolver / DiscoverTenantsHandler precedent): tenant resolution is a tenancy/infrastructure concern, not a domain read the bus needs to own.
  • The email drops the tenant identifier. TeamInvitationNotification no longer takes a tenantId constructor argument (nor emits one) — the token alone is sufficient, so there is nothing left for the tenant id to help the SPA build.

Alternatives considered

  • Email the SPA an accept link carrying the tenant, via a new FRONTEND_URL config. Rejected: off-convention. Every auth-adjacent notification in this app (password reset, MFA, other invitations) delivers a code/token in the message body for the SPA to collect and submit — none constructs a clickable frontend URL, and there is no existing FRONTEND_URL/frontend-base-URL config to build one from. Introducing that pattern here, for this one flow, would fork the notification convention rather than follow it.
  • Email the tenant's uuid alongside the token (restoring the removed constructor argument instead of resolving server-side). Rejected: it does not fix the underlying contradiction, it just makes the client supply what the server should derive — the docblocks already claim the token is the sole credential, and a client-supplied tenant id sitting next to it is redundant at best and a second attacker-controlled input at worst (D2's X-Tenant is already documented as attacker-controllable, which is why tenant.member exists as a defence-in-depth backstop on every authenticated tenant route — this endpoint would have no such backstop, since the invitee isn't authenticated yet).

Consequences

  • A supplied X-Tenant header is harmless on this one route: it is never read, so a stale or guessed value cannot steer which tenant an invitation redeems into. The token is the only signal that matters.
  • InviteTeamMemberHandler's notification call site drops the tenant('uuid') argument — one fewer thing this handler exposes about the tenant to the invitee's inbox.
  • TeamInvitationIsolationTest's RLS coverage for team_invitations is unaffected: the resolver reads centrally (BYPASSRLS, by design, same as DiscoverTenantsHandler), and the accept command itself still executes inside the resolved tenant's initialized RLS scope exactly as before.
  • The route rename (the accept route moving out of the tenant-identified group) changed its Scramble operation id from tenant.api.v1.team.invitations.accept to v1.team.invitations.accept (central routes drop the tenant. prefix, same as v1.tenant-discovery); the committed OpenAPI/abilities exports must be regenerated (make openapi-export) whenever this route changes shape — the freshness gate only diffs method+path, so an operation-id-only drift like this one needs a manual export.
  • A resolver-null rejection (unresolvable token) is thrown by the controller before CommandBus::dispatch, so it never reaches the bus's own command.rejected@warning audit line. TeamInvitationController::accept() logs team.invitation.accept_rejected@warning itself (reason + IP/UA/correlation id, never the token or its hash) so the anticipated abuse case for this public endpoint — token guessing — still leaves a trace; it is file-channel only, not a durable audit_events row (mirroring the bus's own routine-4xx classification, D76).

← Engineering decision log