Team & Multi-Org
Team invites
Dashboard: /overview/settings/team. Internal API: /api/internal/team/*.
Roles
| Role | Can do |
|---|---|
| Viewer | Read-only. Changes nothing. |
| Member | Read-only, the same access as Viewer today. |
| Developer | Sends and configures: domains, templates, webhooks, audiences, suppressions, SMS numbers, brands and campaigns. Reads the audit log. No billing, team or API keys. |
| Owner | Full control, including billing, API keys, team, tenants, IP pools, SSO/SCIM and deleting the organization. |
The invite form starts at Developer and shows the picked role's line above
before the invite goes out (src/app/overview/settings/team/roles.ts, copy
only; the gates are requireDeveloperSession / requireOwnerSession). In the
members table a change to or from Owner asks for confirmation; other changes
apply on select. A grant of Owner (an invite or resend as Owner, or a promotion
to Owner) also asks for the granting owner's password, authenticator or emailed
code, the same proof as deleting the org; see
Security → Granting the owner role.
Invite flow
- Owner submits email + role at
POST /api/internal/team/invite. - Server:
- Rejects if target already a member (409).
- Runs the seat check (
402) unless the address already holds an outstanding invite: that is a resend, which rotates the token without taking a seat. - Throttles every send, first invite and resend alike, per UTC day: 5 to one
address and 50 in total, each counted both per org and per inviting account (so
owning several orgs does not multiply them).
429withRetry-After(next 00:00 UTC). One all-or-nothing charge (chargeDailyAllowance), so a refused invite spends no budget; a409for an existing member spends none either. - Upserts a
team_invitesrow with a 48-char nanoid token, 7-day expiry. - Emails the invitee via the app's own
sendEmail()usingSYSTEM_FROM_EMAIL(or fallback), awaited. The org name and inviter address are HTML-escaped in the body. - Returns
201 { ok, email_sent }. When the email failed the row still stands, the failure is logged (team_invite.email_failed), and the response addsinvite_linkso the owner can pass it on by hand. The page keeps that link on screen with a Copy button until dismissed. That link carries a token re-minted with theiv2.h.prefix (src/lib/auth/invite-token.ts), replacing the emailed one: it went to the owner rather than the invitee's inbox, so accepting it proves nothing about the address (see step 4). If the swap fails the original link is returned, as before, andteam_invite.handed_token_rotate_failedis logged. - Writes audit log entry (
member.invited, ormember.invite_resentfor a resend).
- Invitee lands on
/invite?token=.... POST /api/internal/team/accept { token, name?, password? }:- If the address already has an account — the caller must hold a session for
that address. No session →
401 SIGN_IN_REQUIRED; a session for anyone else →403 WRONG_ACCOUNT. The page turns both into a sign-in / switch-account button that returns to the invite via?callbackUrl=. Then add asorg_member. - An owner invite whose token lacks the
iv2.prefix (minted before owner grants needed a step-up) →410 INVITE_REISSUE_REQUIRED, checked before the identity gate and writing nothing. The page swaps the form for the message: ask an owner to resend. Unknown, spent and lapsed tokens still answer400. - Else create user (name + password required) and add as member.
email_verifiedisnow()for an emailed link, since opening it proves the inbox, and null for a handed-outiv2.h.link: whoever holds that link (the owner included) could otherwise claim an address they do not control as a verified account. Invited accounts are not behind the signup email gate, so it still signs in; it verifies from Settings like any other unverified account, and until then the sandbox sender does not mail it.
- If the address already has an account — the caller must hold a session for
that address. No session →
- Invite marked
accepted_at. Auditmember.joined. - The response carries
org_idand points the active-org cookie at the invite's org, so the invitee lands in the org they just joined instead of their oldest membership (the sign-in default). Set only when a membership really stands, and only for the account that holds it: the signed-in invitee, or a brand-new account accepted with no session. A browser signed in as somebody else keeps its own active org. The invite page then does a full load into/overview.
Pending invites
An invite is outstanding while accepted_at is null and expires_at is in the
future. That one set is what the team page lists under Pending, what
GET /api/internal/team/invite returns, and what holds a seat in the plan's
teamMembers count. Each pending row has:
- Resend: re-POSTs the same address and role (the resend case above).
- Revoke:
DELETE /api/internal/team/invite?email=…, owner-only. Deletes the outstanding row in the active org, so the emailed link stops working and the seat is freed.404when there is no outstanding invite for that address (an accepted one is never touched). Auditmember.invite_revoked.
The token proves invitation, not identity
The endpoint stays reachable without a session — a first-time invitee has no account to sign in to. But once the invited address exists, the token alone is not enough.
Without that split, whoever held the token could POST it and the membership was
written for the existing account: the real person was joined to a stranger's org
having never opened the email. That is not merely a nuisance membership —
organizations.owner_id can be re-pointed onto a surviving member when an account
is deleted, and ownership now also carries an uncapped billing entitlement
(Billing → Comped accounts). The identity check is
what keeps a non-consenting person out of that position.
Both refusals return before the claiming UPDATE, so a rejected attempt never
burns a single-use invite.
The token is never written to the audit log
member.invited records the invited address in resource_id, not the token.
The token is bearer-equivalent and GET /api/internal/audit is readable by every
member of the org down to viewer. Rows written before this carry the raw token;
they are not rewritten (resource_id is covered by the row signature) — the value
is withheld on read instead, in the audit list and in both org exports. See
Audit log → Invite tokens.
The address is also the better identifier: (org_id, email) is unique and the token
rotates on every re-invite.
Schema
team_invites (src/lib/db/schema/team-invites.ts):
| Column | Notes |
|---|---|
token |
PK — the link value |
org_id |
scope |
email |
invited address |
role |
member / owner |
invited_by |
user id |
expires_at |
7d default |
accepted_at |
nullable |
Unique (org_id, email) prevents duplicate invites; re-inviting rotates the token.
Multi-org membership
A user can belong to multiple orgs. The active org is tracked via a cookie (sendoka_active_org) rather than JWT — no re-login required to switch.
Resolution
getActiveOrgId(userId, fallbackOrgId) in src/lib/auth/active-org.ts:
- Read
sendoka_active_orgcookie. - Verify the user has a matching
org_membersrow. - Use it; else fall back to the session's default org (oldest membership at sign-in
time) while the user still belongs to it, else their oldest surviving membership.
""when they belong to no org at all (see No organization).
Endpoints
GET /api/internal/org— list the user's orgs + current active.POST /api/internal/org { name }— create an org owned by the caller and point the cookie at it.requireUserSession(), so it works with no org at all. Provisions exactly what signup does, through the same statements (src/lib/auth/provision-org.ts, shared with registration and OAuth auto-signup) in onesql.transaction: org, owner membership, default project, platform-managed sandbox domain. The slug is derived from the name (DNS-safe, since it becomes the sandbox host's first label), falling back to the address's local part. Limited to 5 per user per day (429), and to 10 owned orgs per account in total (MAX_OWNED_ORGS, counted onorganizations.owner_id): at 10 it refuses with403 ORG_OWNERSHIP_LIMITbefore the daily limiter runs, so the refusal does not spend one of the day's attempts. Returns201 { org: { id, name, slug }, sandbox_domain }. Auditorg.created, filed under the new org.POST /api/internal/org/switch { org_id }— verify membership, set cookie, return success. Client should refresh.POST /api/internal/team/accept— also points the cookie at the joined org (step 6 above).DELETE /api/internal/org— after the delete, resolves where the owner lands exactly as their next request would (the sign-in default if they are still in it, else the oldest surviving membership), returns it asnext_org: { id, name } | null, and points the cookie at it. The danger zone then reloads/overviewwith "Switching to name…", or signs out whennext_orgisnull.
The cookie's attributes live in one place, setActiveOrgCookie() in
src/lib/auth/active-org.ts, shared by all three writers.
UI
src/components/dashboard/org-switcher.tsx — an Organizations group at the top of
the sidebar's account menu, rendered only for a user with 2+ memberships. The account
button then shows the active org's name instead of "Account". Choosing an org calls
/api/internal/org/switch and does a full load of /overview (not the current path: a
resource URL from the old org would 404 in the new one). The command palette offers the
same switch as Switch organization: name for every other org. Both go through
src/components/dashboard/org-navigation.ts. The org list comes from the same
GET /api/internal/org read RoleProvider already makes (useOrgs()).
The switcher was unmounted in the April 2026 shell redesign. With the sign-in default being the oldest membership, a user who accepted an invite into a second org had no way to reach it.
No organization
A signed-in user can belong to no org: they deleted the only one they owned, their
last org was deleted by its owner, or they were removed from it. requireSession() is
null for them, and every page under /overview used to send them to /login, which
forwarded any session holder straight back to /overview: an
ERR_TOO_MANY_REDIRECTS loop.
/no-org(src/app/no-org/) is where they land now. The overview layout and page send an org-less session there, and/loginsends a session holder there instead of to its callback, unless the callback is/invite, since accepting an invite is how they get back into an org (signedInLanding()insrc/lib/auth/landing.ts). The page explains the situation and offers Start a new organization (POST /api/internal/org, then a session refresh and a full load of/overview), Sign out and Delete my account (theDELETE /api/internal/userconfirmation contract: password, plus an authenticator or backup code when 2FA is enrolled, or an emailed deletion code for a passwordless account without 2FA). It sits outside/overviewand outside the proxy matcher. Both its mutations still go through/api/internal/*, which the 2FA confinement covers, so a session that owes the OAuth sign-in code is asked for it on the page first.requireUserSession()(src/lib/auth/session.ts) is identity only ({ userId, email }, re-read fromusers) for the personal endpoints that must work without an org:DELETE /api/internal/user,POST /api/internal/user/deletion-code,POST /api/internal/user/data-export,/api/internal/sessions(list, revoke one, revoke others), and/api/internal/two-factor/*. Their audit rows are filed bylogPersonalAudit()under the user's active org, or, with no org to file them in, written to the operational log asaudit.personal_no_org. Removing a member (from the team page or by SCIM) revokes none of their sessions, so the data export keeps the audit rows they authored in an org they have left but withholds those rows'metadata(metadata: null,metadata_withheld: "no_longer_a_member"): that is the org's data, not theirs.- Deleting an org lands the owner in their next org (
next_orgabove), or signs them out when there is none. Erasing an account always signs out.
Caveats
- Dashboard pages and most internal APIs still use
session.user.orgIddirectly — they see the sign-in-time default, not the cookie-overridden value. Migrate them togetActiveOrgId()as a follow-up (tracked in the team's internal gap register). - Members can't be removed yet — only added via invite.
- Role privileges are documented in permissions — four roles (
owner/developer/member/viewer), gated byrequireOwnerSession/requireDeveloperSession/requireSession.