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
| Role | Read dashboard | Manage API keys | Manage members | Rename / delete tenant |
|---|---|---|---|---|
owner | ✅ | ✅ | ✅ | ✅ |
admin | ✅ | ✅ | ✅ | ❌ |
member | ✅ | ❌ | ❌ | ❌ |
Roles are enforced at three layers:
- UI — admin-only forms (
InviteForm,RemoveButton,RevokeInviteButton) are not rendered for plain members. - Server actions — every mutating action calls
getTenantContext()and rejects callers whoseactiveMembership.roleis notowneroradmin. - Database — Postgres row-level-security policies on
tenant_membersandtenant_invitationsmake 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=expiredand 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_mismatchwithout 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):
| Action | When |
|---|---|
member.invite | A new invitation is minted |
member.invite.revoke | A pending invitation is revoked |
member.invite.accept | An invitee clicks the link and joins |
member.remove | An 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=invalidor/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 theprom_tenantcookie 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.