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

  1. Owner submits email + role at POST /api/internal/team/invite.
  2. 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). 429 with Retry-After (next 00:00 UTC). One all-or-nothing charge (chargeDailyAllowance), so a refused invite spends no budget; a 409 for an existing member spends none either.
    • Upserts a team_invites row with a 48-char nanoid token, 7-day expiry.
    • Emails the invitee via the app's own sendEmail() using SYSTEM_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 adds invite_link so 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 the iv2.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, and team_invite.handed_token_rotate_failed is logged.
    • Writes audit log entry (member.invited, or member.invite_resent for a resend).
  3. Invitee lands on /invite?token=....
  4. 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 as org_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 answer 400.
    • Else create user (name + password required) and add as member. email_verified is now() for an emailed link, since opening it proves the inbox, and null for a handed-out iv2.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.
  5. Invite marked accepted_at. Audit member.joined.
  6. The response carries org_id and 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. 404 when there is no outstanding invite for that address (an accepted one is never touched). Audit member.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:

  1. Read sendoka_active_org cookie.
  2. Verify the user has a matching org_members row.
  3. 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 one sql.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 on organizations.owner_id): at 10 it refuses with 403 ORG_OWNERSHIP_LIMIT before the daily limiter runs, so the refusal does not spend one of the day's attempts. Returns 201 { org: { id, name, slug }, sandbox_domain }. Audit org.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 as next_org: { id, name } | null, and points the cookie at it. The danger zone then reloads /overview with "Switching to name…", or signs out when next_org is null.

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 /login sends 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() in src/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 (the DELETE /api/internal/user confirmation 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 /overview and 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 from users) 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 by logPersonalAudit() under the user's active org, or, with no org to file them in, written to the operational log as audit.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_org above), 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.orgId directly — they see the sign-in-time default, not the cookie-overridden value. Migrate them to getActiveOrgId() 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 by requireOwnerSession / requireDeveloperSession / requireSession.