Concepts

Members & invitations

Roles, the invite flow, and the guardrails that keep tenant membership safe.

A tenant is a small team, not a single login. Every tenant has at least one owner, optionally one or more admins, and any number of members. Adding people is an explicit, audited action — nobody joins by accident.

Roles

RoleRead dashboardManage API keysManage membersRename / delete tenant
owner
admin
member

Roles are enforced at three layers:

  1. UI — admin-only forms (InviteForm, RemoveButton, RevokeInviteButton) are not rendered for plain members.
  2. Server actions — every mutating action calls getTenantContext() and rejects callers whose activeMembership.role is not owner or admin.
  3. Database — Postgres row-level-security policies on tenant_members and tenant_invitations make admin-only writes fail even if the UI and server action were bypassed.

owner is intentionally not invitable. Promoting someone to owner goes through a separate (future) ownership-transfer flow so a stale invite link can never escalate privileges.

Invite flow

Owner / Admin                         Invitee
─────────────                         ───────
1. POST /app/settings  (inviteMember)
2. Mint 32-byte token
3. Store SHA-256(token)
4. Send email with /invite/{token}    ──►  click link
                                      5. /app/invite/{token}
                                      6. Hash + look up invitation
                                      7. Sign in if needed
                                      8. Upsert tenant_members row
                                      9. Set prom_tenant cookie
                                     10. Redirect /app/dashboard

The plaintext token leaves the system exactly once — in the email. The database only ever holds its SHA-256 hash, so a database read does not yield usable invite links.

Guardrails

  • Self-invite blocked. You can't invite the email address you are signed in with.
  • Re-invite is idempotent. Inviting the same email twice revokes the prior pending row and mints a new token. There is never more than one live invite per (tenant, email).
  • Tokens expire after 7 days. Expired tokens redirect to /sign-in?invite=expired and never enroll the user.
  • Token shape is validated before any database I/O — obvious garbage in the URL is rejected without burning a lookup round-trip.
  • Email must match. The signed-in email has to match the invited email (case-insensitive). Mismatches redirect to /sign-in?invite=email_mismatch without modifying state.
  • Self-removal blocked. Admins cannot remove themselves — the future "leave tenant" flow will handle that explicitly.
  • Last-owner protection. Removing an owner is allowed only if at least one other owner remains. A tenant is never left ownerless.

What gets logged

Every membership change writes a row to the tenant audit log (visible at /app/audit):

ActionWhen
member.inviteA new invitation is minted
member.invite.revokeA pending invitation is revoked
member.invite.acceptAn invitee clicks the link and joins
member.removeAn admin removes a member

Audit entries include the actor, the affected user / invitation id, and whether the invite email was actually dispatched — the same forensic surface you can filter and export from the audit page.

Acceptance behaviour

The /invite/{token} route is deliberately stateless until the final step:

  • Unknown / malformed token/sign-in?invite=invalid or /sign-in?invite=not_found.
  • Already accepted, revoked, or expired/sign-in?invite=already_used | revoked | expired.
  • Signed out → redirect to /sign-in?next=...&email=... so the magic-link form is pre-filled with the invited address.
  • Signed in, email matches → upsert membership (idempotent on (tenant_id, user_id)), set the prom_tenant cookie to the newly-joined tenant, audit, redirect to /app/dashboard.

Re-clicking a freshly accepted link is harmless: the upsert is a no-op and the user lands on the dashboard either way.