ADR-023: Invite Lifecycle โ Expiry, Rotation, Self-Service Resend
Date: 2026-06-01 Status: Accepted Issue: #1250 (initial 404 fix); UAT findings 2026-05-31 โ Percy Chan invite flow Authors: Corgi (agent), reviewed by Eusden
Contextโ
The h.work invite flow surfaced three lifecycle gaps during UAT on 2026-05-31:
- Backend emitted the wrong link shape (
/auth/accept-invite/<token>โ a NestJS POST handler โ instead of the Next.js page at/accept-invite?token=โฆ). Fixed in #1250. - The accept page silently demanded a second OTP even though the invite UUID had already been delivered to the recipient's mailbox. Resolved in #1250 by adding a no-OTP
POST /auth/accept-invite-by-tokenendpoint โ invite UUID delivery is the email-ownership proof. - No spec existed for the rest of the invite lifecycle. Today, calling
inviteMember(same@email)twice creates two pending invites. Re-inviting an already-accepted member creates a stray pending row. The accept page dead-ends on an expired link with no "request new link" affordance. Users cannot self-serve a resend, forcing every "I lost the email" case through admin ping-pong.
This ADR locks the rest of the lifecycle.
Industry surveyโ
Surveyed 2026-06-01:
| System | Expiry | Token rotates on resend | User-initiated resend |
|---|---|---|---|
| Stripe Dashboard | 7d | Yes | Yes (built-in form) |
| Linear | 7d | Yes | No (admin only) |
| Vercel | 7d | No (same token) | No (admin only) |
| GitHub Orgs | 7d | No (same token) | No (admin only) |
| Notion | 14d | Yes | No (admin only) |
| Slack | 30d (configurable) | No (same token) | No (admin only) |
| Figma | 30d | Yes | Yes (request-access flow) |
| Google Workspace | 7d | Yes | No (mailto admin) |
| Auth0/Clerk guidance | 24h โ 7d | "Both valid" | Configurable |
Median expiry: 7 days. Modal token behaviour: rotate. User-resend: split.
Decisionโ
D1 โ Expiry: 7 days, hardcoded constantโ
Match industry median. Long enough for "I'll get to it tomorrow", short enough that a leaked invite link doesn't grant access in perpetuity.
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
Exposed as a single named constant so any future tuning is a one-line change.
D2 โ Admin invite is idempotentโ
Calling POST /admin/members/invite for an email with an existing pending invite (same org_id scope, used_at IS NULL, expires_at > now()) does NOT create a duplicate row. Instead it:
- Rotates the token (new UUID).
- Resets
expires_atto now + 7 days. - Increments
metadata.resendCountand pushes ametadata.history[]entry. - Re-emails the invite.
- Returns the same row id.
This means an admin pressing "Invite Percy" twice never produces two pending invites. The UX matches Stripe / Linear / Notion.
D3 โ Admin resend rotates the tokenโ
POST /admin/invites/:id/resend already exists but reuses the same token. Change: rotate the token on each resend.
Why rotate: if the first email leaked (forwarded to wrong address, malware in mailbox, etc.), the resend cuts that link. Aligns with Stripe / Linear / Notion / Figma.
What is preserved: the invite row id, the createdBy, the org_id / email / platform_role / org_role, and the metadata.history[] trail. The audit story is "invite N was sent at T1, T2, T3 to email@x".
D4 โ User-initiated resend: yes, with three guardsโ
New endpoint: POST /auth/invites/resend โ public, body { email }.
Reasoning: today, the "I lost the invite email" case forces Percy โ Emanuele โ Percy ping-pong. This is the lesson from today's UAT thread. Adding self-service eliminates the admin-in-the-loop without weakening security, as long as three guards hold:
- Email-enumeration safety. Endpoint always returns
200 { ok: true }regardless of whether the email matches a pending invite. The attacker cannot use it as an oracle to discover invited emails. (This is how Stripe / Auth0 / every modern password-reset endpoint behaves.) - Rate limit.
@Throttle({ default: { limit: 3, ttl: 60_000 } })โ 3/min per IP. Tight enough to stop scripted enumeration even though (1) already makes enumeration valueless. - No new attack surface. The endpoint only triggers a resend of an already-issued invite โ it cannot create a new invite, change the recipient, or change the role. The blast radius is exactly the same as if an admin had pressed "resend" themselves.
The resend uses the same rotation logic as D3.
If no pending invite matches the email, no email is sent โ but the API still returns 200. The user sees "if your email is invited, a new link is on the way" on the frontend.
D5 โ Re-inviting an already-accepted user โ 400 with hintโ
If inviteMember() is called for an email that already has an active User row (status='active'), throw:
400 BadRequestException
{ error: "User is already a member. Use /ops/members to manage their role." }
This prevents:
- Stray pending invites that will never be used (because the user already has a working account).
- Confused admins who think "did the invite go out?" when in reality the user has been logged in for months.
- Accidental role-resets via the invite flow (re-accepting could downgrade
superadminโorg_member).
D6 โ Accept page surfaces a resend form on terminal errorsโ
/accept-invite?token=โฆ page today shows a generic "Invalid or expired" message and dead-ends. Change: when the validation call returns 404 Not found or 400 Invite expired/used, show a small form:
This invite link is no longer valid.
If you remember the email address it was sent to, we can send a new link:
[__________________________] [ Request new link ]
Submitting the form hits POST /auth/invites/resend from D4. Result message: "If that email has a pending invite, a new link is on its way."
D7 โ Metadata schema for invites.metadataโ
The existing metadata JSONB column gets a documented contract:
type InviteMetadata = {
resendCount?: number; // total resends (admin + user combined)
lastResentAt?: string; // ISO timestamp
history?: Array<{ // append-only resend log
at: string; // ISO timestamp
by: "admin" | "user"; // who triggered the resend
actorId?: string; // requesterId if admin
tokenSuffix: string; // last 8 chars of token โ for debug correlation
}>;
};
Capped at 50 history entries (defence against pathological abuse loops). New entries push, oldest evict.
Consequencesโ
Positiveโ
- One-shot invite flow with no friction once the new code is deployed.
- Admin "duplicate invite" mistake is now a no-op rather than producing two pending rows.
- "I lost my invite email" is self-service.
- Leaked links self-cut on every resend (token rotation).
- Audit trail per invite is visible in
metadata.history[]. - Accept page is no longer a dead-end on expiry.
Negativeโ
- One added DB write per resend (history append). Negligible.
- Throttler must be running for D4 to be safe (it is, per ADR-021).
- A user who genuinely changed email addresses cannot self-resend to the new address โ only an admin can edit the recipient. Acceptable: changing the recipient is a security-relevant action and should require admin intent.
Out of scope (deliberately)โ
- Multi-recipient invites (bulk CSV upload). Existing single-email flow stays. Bulk is a separate feature.
- Invite-link expiry tuning per org. Hardcoded 7d is fine until a customer asks.
- Magic-link-only login (no password, no OTP, every session is an invite-link). Different feature.
- Cleanup of the legacy OTP endpoints (
/auth/send-invite-otp+/auth/verify-invite-otp). They remain exported for in-flight emails. Drop after 14 days of new-flow soak.
Migrationโ
Pure additive on the API side โ no schema changes needed (uses existing metadata JSONB).
The legacy auth.service.acceptInvite() (password-flow) and verifyInviteOtp() (OTP-flow) endpoints stay live for backward compatibility during the deprecation window.
Relatedโ
- ADR-021: Throttler rate-limit strategy (the rate limiter D4 depends on)
- #1250: Initial 404 + one-shot accept fix that motivated this ADR