Invite Lifecycle
User-facing reference for the team-invite lifecycle: how an invite is created, delivered, accepted, resent, and expired across the surfaces that touch it.
The lifecycle rules are locked by ADR-023 (expiry, token rotation, self-service resend). This doc is the user-/operator- facing view; ADR-023 is the authoritative spec and the source of truth for any behaviour described here.
Surfacesโ
The same invite lifecycle is reached from three places:
- AM onboarding โ
/ops/clients/[id]/setup/step-4: an Account Manager invites the first client admin while standing up a new client. - Client self-service โ
/client/settings/team: a clientorg_admin/ owner invites and manages their own colleagues. - Ops โ
/ops/team: a SuperAdmin manages invites/members across any org.
All three drive the same API endpoints and the same invites row lifecycle.
State machineโ
An invite is one row in invites (scoped to org_id + email):
POST /orgs/:orgId/invites
(none) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโถ pending
โ
POST /auth/accept-invite-by-token โ accepted โ User row active
pending โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโถ โ (used_at set)
โ
7 days elapse (expires_at < now) โ expired
pending โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโถ โ (link dead; row remains for audit)
- Expiry: 7 days, hardcoded.
INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000(api/src/auth/invite-lifecycle.ts). Long enough for "I'll get to it tomorrow", short enough that a leaked link doesn't grant access forever. (ADR-023 D1.) - Acceptance is one-shot. Delivery of the invite UUID to the recipient's mailbox is the email-ownership proof, so accepting does not demand a second OTP.
Creating / re-sending an invite (admin & AM)โ
POST /orgs/:orgId/invites (org admin or SuperAdmin).
- Idempotent. If a pending invite already exists for that email in the org (
used_at IS NULL,expires_at > now()), a second call does not create a duplicate row. It instead rotates the token (new UUID), resetsexpires_atto now + 7 days, appends ametadata.history[]entry, incrementsmetadata.resendCount, and re-emails โ returning the same row id. So pressing "Invite" twice never produces two pending invites. (ADR-023 D2.) - Re-inviting an already-active user โ
400. If the email already maps to an activeUser, the call is rejected ("User is already a member. Use /ops/members to manage their role.") rather than minting a stray pending invite or accidentally resetting the user's role. (ADR-023 D5.) - Token rotation on every resend. Each resend issues a fresh token and invalidates the previous link โ so a forwarded/leaked email is cut the moment a new link is sent. The row id,
createdBy,org_id/email/platform_role/org_role, and themetadata.history[]audit trail are preserved. (ADR-023 D3.)
Self-service resend (the invited user)โ
POST /auth/invites/resend โ public, body { email }. Removes the "I lost
the invite email โ admin ping-pong" loop. Guarded three ways (ADR-023 D4):
- Enumeration-safe. Always returns
200 { ok: true }whether or not the email matches a pending invite โ it can't be used as an oracle to discover invited addresses. - Rate-limited.
3 / minuteper IP. - No new attack surface. It only resends an already-issued invite (same recipient, same role); it cannot create a new invite, change the recipient, or change the role. Same blast radius as an admin pressing "resend".
The expired/invalid accept page (/accept-invite?token=โฆ) surfaces a small
"request a new link" form on terminal errors (404 not found / 400 expired/used)
that posts to this endpoint, so a dead link is no longer a dead end. (ADR-023 D6.)
A user who genuinely changed email addresses still cannot self-resend to the new
address โ changing the recipient is admin-only by design.
Accepting an inviteโ
POST /auth/accept-invite-by-tokenโ the current one-shot accept (no second OTP); invite UUID delivery is the proof of email ownership (#1250).GET /invites/validate?token=โฆโ public; returns the org name + invitee email, and powers both the accept page and the resend form's messaging.POST /auth/accept-invite/:tokenandPOST /auth/send-invite-otp/POST /auth/verify-invite-otpโ legacy password-flow / OTP-flow endpoints kept live for in-flight emails during the deprecation window; do not build new flows on them.
Revoking / removingโ
There is no dedicated "revoke this pending invite" endpoint. A pending invite is neutralized by:
- Auto-expiry โ the link dies after 7 days with no action needed.
- Token rotation โ any resend cuts the previously-issued link.
- Member removal โ
DELETE /orgs/:orgId/members/:userId(orgadmin) removes the member; use this to pull access for someone invited by mistake.
Audit (invites.metadata)โ
Every resend appends to an append-only log (ADR-023 D7), capped at 50 entries (oldest evicted):
type InviteMetadata = {
resendCount?: number; // total resends (admin + user combined)
lastResentAt?: string; // ISO timestamp
history?: Array<{
at: string; // ISO timestamp
by: "admin" | "user"; // who triggered the resend
actorId?: string; // requester id if admin
tokenSuffix: string; // last 8 chars of token โ debug correlation
}>;
};
The audit story per invite is "invite N was sent at T1, T2, T3 to email@x".
Relatedโ
- ADR-023 โ Invite Lifecycle (authoritative spec)
- ADR-021 โ Throttler rate-limit strategy (the limiter the self-service resend depends on)
- User stories:
C-O1(accept invite),C-O7(self-service new link),C-T1(invite colleague),C-T7(resend),C-T8(revoke),SA-C10(ops resend/revoke) in user-stories.md - #1250 โ initial one-shot accept fix that motivated ADR-023