Team & Multi-Org
Team invites
Dashboard: /overview/settings/team. Internal API: /api/internal/team/*.
Invite flow
- Owner submits email + role at
POST /api/internal/team/invite. - Server:
- Rejects if target already a member (409).
- 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). - Writes audit log entry (
member.invited).
- 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. - Else create user (name + password required) with
email_verified: now()and add as member.
- 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.
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/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 Sign out and Delete my account (theDELETE /api/internal/userconfirmation contract: password, or the account's email for a passwordless account). It sits outside/overviewand outside the proxy matcher. Its one mutation still goes 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/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.