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, andtests/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/acceptmoves to a CENTRAL route group (noInitializeTenancyByRequestData), mirroring the shape of Authentication's centraltenant-discovery/registergroup:correlationturnstile+throttle:10,1, no Sanctum/tenancy middleware. AnyX-Tenantheader a caller sends is simply never read by this route — harmless, not honored.
Team\Support\InvitationTenantResolverruns the lookup in the CENTRAL/BYPASSRLS context (tenancy()->central(...)) — the same central-read shape asDiscoverTenantsHandler(email → tenant) and the webhooksClientReferenceIdTenantResolver(provider reference → tenant): it hashes the token, finds the matching unacceptedclient-contextteam_invitationsrow, checksexpires_at(mirroringAcceptTeamInvitationHandler::isExpired()), and returns the owningTenantornull. 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)→ dispatchAcceptTeamInvitationCommandthrough the bus →tenancy()->end()in afinally— the same resolve/initialize/work/end shapeProcessInboundWebhookJobuses for inbound webhooks. Anullresolution throws the identicalDomainException(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". InvitationTenantResolveris sanctioned by exact class inCqrsBoundaryTest's out-of-bus allow-list (theClientReferenceIdTenantResolver/DiscoverTenantsHandlerprecedent): tenant resolution is a tenancy/infrastructure concern, not a domain read the bus needs to own.- The email drops the tenant identifier.
TeamInvitationNotificationno longer takes atenantIdconstructor 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_URLconfig. 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 existingFRONTEND_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-Tenantis already documented as attacker-controllable, which is whytenant.memberexists 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-Tenantheader 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 thetenant('uuid')argument — one fewer thing this handler exposes about the tenant to the invitee's inbox.TeamInvitationIsolationTest's RLS coverage forteam_invitationsis unaffected: the resolver reads centrally (BYPASSRLS, by design, same asDiscoverTenantsHandler), 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.accepttov1.team.invitations.accept(central routes drop thetenant.prefix, same asv1.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 owncommand.rejected@warning audit line.TeamInvitationController::accept()logsteam.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 durableaudit_eventsrow (mirroring the bus's own routine-4xx classification, D76).