Team members & invitations
V1 is single-tenant in practice (the plan.ai team) but the schema is multi-tenant from day one. Adding a new team member is a small, deliberate flow — not self-service signup.
V1 ships four roles per tenant. Full role matrix lives in Agent roles — this page covers only membership lifecycle.
Membership management is owner-only in V1. This is enforced at the database layer in Supabase SQL plan (tenant_members insert/update/delete policies are owner-only, plus a constraint preventing non-owner callers from updating tenant_members.role). Loosening this to admin is V2 work — it requires a separate policy + a UI guard, neither of which V1 ships.
| Role | Can invite | Can change roles | Can create API keys |
|---|---|---|---|
owner | yes | yes (incl. other owners) | yes (MFA-gated) |
admin | no | no | yes (MFA-gated) |
member | no | no | no |
viewer | no | no | no |
Invitation flow
Section titled “Invitation flow”- An
owneropens Team settings in the workbench and submits an invitation: target email + role. - The workbench calls an Edge Function (
team-invitations.create) which:- Verifies the caller is
ownerwithaal2. - Inserts a row into
tenant_invitations(id, tenant_id, email, role, token_hash, invited_by, expires_at)withexpires_at = now() + interval '7 days'.token_hashisHMAC-SHA256(API_KEY_PEPPER, raw_token)— the raw token only leaves the Edge Function inside the email body.redeemed_at/redeemed_bystay null until step 3. - Sends an OTP-style email via Supabase Auth (
inviteUserByEmail) with the redemption link pointing at/workbench/accept-invite?token=<token>. The token is a one-time, server-issued opaque string stored hashed (HMAC-SHA256(API_KEY_PEPPER, token)) — never the raw row id.
- Verifies the caller is
- The invitee clicks the link, completes the OTP login if no Supabase Auth user exists yet, then the redemption endpoint:
- Verifies the token hash and
expires_at. - Creates the
tenant_members(tenant_id, user_id, role)row in a transaction with stampingredeemed_aton the invitation. - Triggers MFA enrollment immediately for
owner/admin(the API-keys screen will be locked untilaal2is set).
- Verifies the token hash and
- The new member lands in the workbench with the role they were invited at.
Failure states
Section titled “Failure states”| Condition | Response |
|---|---|
| Token expired | UI shows “this invite expired — ask your team to resend.” No tenant context leaked. The partial unique index on (tenant_id, email) where redeemed_at is null means a resend must first DELETE the expired-but-unredeemed row (or stamp it redeemed_at as a tombstone) before inserting the new invitation. |
| Token already redeemed | Same generic copy as expired (do not distinguish — avoids enumeration). |
| Email mismatch (signed-in user differs from invitee) | UI tells the user to sign out and complete the link in a private tab. |
Invitation revoked (an owner deletes the row) | Same generic copy as expired. |
| Tenant member already exists | Redirect to workbench, no-op on the invitation, mark redeemed_at. |
Role changes & removal
Section titled “Role changes & removal”- Role changes go through the same Edge Function (
team-invitations.update_role); onlyownercallers may use it, and the databasetenant_membersUPDATE policy enforces the same in case the Edge Function is bypassed. - Removing a member deletes the
tenant_membersrow. The last-owner guard prevents removing or demoting the lastownerof a tenant. API keys owned by the removed member are not auto-revoked — they belong to the agent, not the user — but the audit event (tenant_member.removed) is appended toframe_events. - A removed member’s existing Supabase Auth session is invalidated on next request because RLS will return no rows for that tenant.
What V1 does not do
Section titled “What V1 does not do”- No public signup form.
- No tenant-to-tenant member sharing.
- No SCIM / directory sync.
- No “request to join” flow — invitations only.
Those are V2+ work.