Keel

Docs / Teams & RBAC / Invites

Invites

Invites bring an existing or brand-new user onto a team by email. The whole flow lives in three places: POST /api/teams/[teamId]/invites to send one, the token routes under /api/invites/[token]/* to act on one, and the /invites/[token] page the recipient lands on.

Sending an invite

POST /api/teams/[teamId]/invites gates on team:invite, so only an owner or admin can reach it. The body takes an email and a role of admin or member, owner is excluded on purpose, Keel has no ownership transfer, so there's no way to hand out ownership through an invite.

The sender must have a verified email address (403 otherwise), invite emails carry a team name the sender chose, so an unverified throwaway account could otherwise send phishing-style invites from your domain. When Upstash is configured (see lib/rate-limit.ts) sends are also rate-limited per sender and per team, resends included, returning 429 past the limit.

A rank guard blocks inviting above your own level: the route calls outranks(access.role, role), so an admin can only invite members, and only an owner can invite an admin. Without it an admin could mint another admin through the invite flow and sidestep the promotion rule enforced everywhere else. See Permissions for how outranks() compares roles.

On success it writes an invites row with a random token and a 7-day expiresAt, then sends the email (TeamInviteEmail) linking to /invites/{token}. Re-sending to someone who already has a pending invite refreshes that row (new token and expiry) rather than piling up duplicates. If the email provider rejects the send, the route returns 502 (the row is kept, resending retries it) instead of reporting success.

Neither this route nor GET ever returns the token, it's the credential that accepts the invite. GET reports a lapsed invite as expired even though the row still says pending (see lazy expiry below).

Accepting or declining

The recipient opens /invites/[token], a server component that reads the invite straight from the DAL and renders InviteFlow. Not signed in, it leads with sign-in; signed in as the wrong account, it offers to switch. The Accept and Decline buttons POST to /api/invites/[token]/accept and /decline.

Both mutations require a session, require the signed-in email to match the invited address, and require that address to be verified (checkInviteRecipient in lib/team-rules.ts). The mailed link is not a secret and sign-up doesn't force verification, so a verified matching account is what actually authorizes joining. The invite page asks an unverified user to verify first, with a button that resends the link back to the invite.

Accept runs in one transaction: it claims the invite (UPDATE ... WHERE status = 'pending' AND expires_at > now()), then inserts the team_members row, so an invite revoked or declined a moment earlier can't still let someone in. Someone already on the team gets a 409 and the invite stays pending. Decline just flips the invite to declined.

Invites don't outlive their sender's authority

When a member is removed (or leaves), their pending invites for that team are deleted. When they're demoted, pending invites at a role they can no longer grant are deleted (an admin demoted to member can't invite anyone). Accept re-checks the same rule as a backstop, inviterStillVouches in lib/dal/invites.ts. The one exception is an inviter who deleted their account: invitedBy is set to null and those invites stay valid on purpose, see the comment on invites.invitedBy.

Expiry is lazy

There is no cron sweeping stale invites. getValidInvite() in lib/dal/invites.ts is the shared read every token route calls, and it expires on read: if a still-pending invite is past expiresAt, it writes status: "expired" and returns a 410. An already-resolved invite (accepted, declined, or expired) also returns 410 with a message naming its state.

The duplicate guard

Two concurrent POSTs can both pass the app-level "already invited?" check and both reach the insert. A partial unique index, invites_team_email_pending_idx on (team_id, email) WHERE status = 'pending', catches the loser at the DB level. The route inspects the Postgres error code (23505, read off error.cause since drizzle wraps it) and returns a clean 409 instead of writing a duplicate.

Re-inviting later works fine

The index only covers pending rows, so accepted, declined, and expired invites persist without blocking anything. Someone who declined or let an invite lapse can be invited again, the old row stays as history and a fresh pending one is created.

Revoking

DELETE /api/teams/[teamId]/invites/[inviteId] (also gated on team:invite) hard-deletes the invite. There is no revoked status, the reference build has no need to keep a revoked invite around for audit, so removing the row is enough.