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, ownership only ever changes through an explicit transfer.

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.

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 and require the signed-in email to match the invited address, the mailed link is not a secret, so the matching account is what actually authorizes joining. Accept inserts the team_members row (skipping it if they somehow already joined) and flips the invite to accepted; decline just flips it to declined.

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.

Edit this page on GitHub →Last updated Jul 22, 2026