Teams
@basaltkit/teams turns a tenant into a multi-user team: members with ranked roles, and email invitations to join. It's decoupled from auth and tenancy — identifiers are read from the request context — and can mirror role changes into @basaltkit/permissions.
Setup
Teams reads the current tenant from ctx().tenant (set by tenancy) and the acting user from ctx().user (set by auth), so register all three. This is the full wiring, including seeding the first owner and turning the invite hook into an email:
import { createApp } from '@basaltkit/core'
import { fastifyPlugin } from '@basaltkit/fastify'
import { authPlugin, authRoutes, MemoryUserSource } from '@basaltkit/auth'
import { tenancyPlugin, headerResolver, MemoryTenantSource } from '@basaltkit/tenancy'
import { teamsPlugin, teamRoutes, TEAMS } from '@basaltkit/teams'
const app = await createApp({
plugins: [
tenancyPlugin({
source: new MemoryTenantSource().add({ id: 'acme' }),
resolvers: [headerResolver()], // reads x-tenant-id in dev
}),
authPlugin({ users: new MemoryUserSource(), secret: process.env.AUTH_SECRET! }),
teamsPlugin(),
fastifyPlugin({ routes: [...authRoutes(), ...teamRoutes()] }),
],
}).boot()
// Send the invitation email when one is created (see Invitations below)
app.hooks.on('team:invited', ({ invitation, token }) =>
mailer.send(invitation.email, `https://app.example.com/invite?token=${token}`))
// Seed the first owner when the tenant is created — invitations are for the rest
await app.container.get(TEAMS).addMember('acme', 'ada-id', 'owner')Requests then carry Authorization: Bearer <login token> and a tenant identifier (x-tenant-id: acme with headerResolver, or a subdomain in production).
Tenant-scoped
Everything is isolated per tenant: memberships, invitations, and the teamRole guard all key off ctx().tenant.id. A user can be an owner of one team and a member of another.
Durable stores (production)
The default Memory* stores forget everything on restart. Swap in a durable backend and rosters and pending invitations survive a redeploy.
SQLite — @basaltkit/teams-sqlite
Zero external dependencies, built on node:sqlite (Node 22.5+):
import { teamsPlugin } from '@basaltkit/teams'
import { sqliteTeamsStores } from '@basaltkit/teams-sqlite'
const t = sqliteTeamsStores('./data/teams.db') // ':memory:' by default; opens + migrates
teamsPlugin({ memberships: t.memberships, invitations: t.invitations })Prisma — @basaltkit/teams-prisma
For PostgreSQL/MySQL. Copy the TeamMembership / TeamInvitation models from @basaltkit/teams-prisma/schema.prisma, run prisma migrate dev && prisma generate, then:
import { teamsPlugin } from '@basaltkit/teams'
import { prismaTeamsStores } from '@basaltkit/teams-prisma'
import { PrismaClient } from '@prisma/client'
const t = prismaTeamsStores(new PrismaClient())
teamsPlugin({ memberships: t.memberships, invitations: t.invitations })Individual stores (SqliteMembershipStore, PrismaMembershipStore, …) are exported too, and take a DatabaseSync / PrismaClient in their constructor.
Roles
Roles are a ranked hierarchy — higher outranks lower, so a route that requires admin also accepts owner:
| Role | Rank |
|---|---|
owner | 3 |
admin | 2 |
member | 1 |
Roles are free-form strings; override the hierarchy with a name → rank map. Only ranked roles have a hierarchy: a ranked requirement admits that rank or higher (never a holder of an unranked role), and a requirement outside the map (e.g. a grantableRoles entry) is matched exactly — it never ranks 0 and admits every member:
teamsPlugin({ roleRank: { owner: 4, admin: 3, editor: 2, viewer: 1 } })A team member acting through the routes can only grant roles that are in the map. An unranked role (say a billing-admin permission role mirrored through access) is refused with TeamRoleNotGrantableError (403 TEAM_ROLE_NOT_GRANTABLE), so a free-form role string can't be used to grant it. To let members grant an extra unranked role, list it explicitly:
teamsPlugin({ grantableRoles: ['viewer'] })A team always keeps at least one owner — the service refuses to demote or remove the last one (LastOwnerError, TEAM_LAST_OWNER). Promote someone else first.
No privilege escalation through invites or role changes
The HTTP routes pass the acting user to the service (actingUserId), which then enforces two rules: the actor can never grant a role above their own rank (an admin can't invite or promote anyone — including themselves — to owner), and can never re-role or demote a member who currently outranks them. Violations throw InsufficientTeamRoleError (403 TEAM_ROLE_REQUIRED). The same applies to removal: DELETE /team/members/:userId can only remove yourself or a member who doesn't outrank you, so an admin can't remove an owner. Roles missing from roleRank can't be granted (see above). Service calls without actingUserId (trusted server-side seeding) skip the check. "Doesn't outrank" is deliberate — peers can manage peers (an admin can re-role or remove another admin); give a tier its own rank if it must only be managed from above.
Seeding the first owner
Invitations enroll members, but the first owner is seeded directly — typically when the tenant is created:
import { TEAMS } from '@basaltkit/teams'
await app.container.get(TEAMS).addMember(tenant.id, creator.id, 'owner')Routes
teamRoutes() registers, all scoped to the current tenant (no tenant in context → 400 TEAM_NO_TENANT):
| Endpoint | Requires |
|---|---|
POST /team/invites { email, role? } | admin |
POST /team/invites/accept { token } | login with a verified email |
GET /team/invites · DELETE /team/invites/:id | admin |
GET /team/members | member (adds user with memberContacts: true) |
PATCH /team/members/:userId { role } | admin |
DELETE /team/members/:userId | admin |
teamRoutes(options) accepts requireVerifiedEmail (default true), described in the Invitations section below.
Role guard
teamsPlugin registers the teamRole guard: the current user must hold the required role — or a higher-ranked one — in the current tenant. Missing user or tenant in context → 403 TEAM_NOT_A_MEMBER; insufficient role → 403 TEAM_ROLE_REQUIRED:
import { route } from '@basaltkit/fastify'
route({
method: 'POST',
url: '/projects',
meta: { auth: true, teamRole: 'admin' }, // member → 403 TEAM_ROLE_REQUIRED
async handler() { return { created: true } },
})The required role must be known — in roleRank or grantableRoles. A typo ('Admin', 'adimn'), an empty string or a non-string fails the boot: teamsPlugin registers a route-meta validator that every adapter runs over its routes before serving, so the app refuses to start with InvalidRouteMetaError (HTTP_INVALID_ROUTE_META) naming the route and the value — allowUnguardedMeta does not waive it. A route that escapes the boot check (mounted outside the adapter's list, or run through runRoute() directly) still fails closed with 500 TEAM_ROLE_UNKNOWN on every request (before @basaltkit/teams 4.0 it ranked 0 and admitted every member). Only undefined and false mean "no requirement". The same applies to tenantMembershipPlugin({ role }), which throws UnknownTeamRoleError at boot.
teamsPlugin also registers a pure visibility check (http:route-visibility), so listings such as @basaltkit/mcp's tools/list hide a meta.teamRole route from callers who do not hold the role in the current tenant — see MCP.
teamsPlugin claims the teamRole key in the adapters' boot-time guarded-meta check — declaring meta.teamRole on a route without registering the plugin refuses to boot with UnguardedRouteMetaError (HTTP_UNGUARDED_ROUTE_META) instead of silently serving the route unguarded. The same mechanism covers meta.auth and meta.can — see the guard/meta table in the authorization guide and the adapters guide.
Tenant isolation guard (tenantMembershipPlugin)
meta.teamRole protects the routes you remember to annotate. tenantMembershipPlugin closes the remaining gap app-wide: on every request that has both an authenticated user and a resolved tenant, it asserts the user actually holds a membership in that tenant — so a valid user of tenant A can never operate on tenant B just by sending x-tenant-id: b or the right Host header. Tenant resolution is identification, never authorization.
import { teamsPlugin, tenantMembershipPlugin } from '@basaltkit/teams'
createApp({
plugins: [
authPlugin(/* … */),
tenancyPlugin(/* … */),
teamsPlugin(/* … */),
tenantMembershipPlugin(), // membership enforced everywhere, by default
],
})A non-member gets 403 TEAM_NOT_A_MEMBER. The guard is skipped when the request has no resolved tenant or no user (central/anonymous traffic), for account routes (meta: { account: true }) and for routes that opt out explicitly with meta: { central: true } — tenant creation, platform admin: routes that legitimately act across or outside a single tenant.
Account routes are about the caller's own identity, not the tenant's data: authRoutes(), mfaRoutes() and oauthRoutes() (@basaltkit/auth) and POST /team/invites/accept declare account: true, so a signed-in user who is not (yet) a member can still log in, read /auth/me and accept an invitation on the company's subdomain, while every other tenant route stays members-only. apiKeyRoutes() is not an account route — keys are bound to a tenant. Mark your own profile routes with account: true the same way.
Three behaviours to know:
- Existence, not rank, by default. The guard asks "does a membership record exist?", not "does the role outrank
member?" — so a genuine member holding a custom role that's absent fromroleRank(rank 0) is not rejected. Passrole: 'member'(or higher) to switch to rank semantics. exemptis the WHO-based escape hatch. For identities that legitimately cross tenants (platform admins, support impersonation), give a predicate over the request context:exempt: ({ user }) => user?.platformAdmin === true. Prefer it overmeta.centralwhen the exemption is about who is calling —centraldisables the guard for everyone on that route. Exemption results are never cached.- The decision cache is opt-in. Without it, every guarded request costs one membership lookup (a single indexed PK read — usually fine). With
cache: { ttlMs, maxEntries }, decisions are cached in-process and dropped immediately by theteam:joined/team:role_changed/team:member_removedhooks — same-process changes are always exact.ttlMsonly bounds staleness for changes made on another replica: a member removed elsewhere may retain access for up tottlMs. The map is size-bounded bymaxEntries(default 10 000, oldest evicted).
tenantMembershipPlugin({
role: 'member', // optional: rank semantics instead of existence
exempt: ({ user }) => (user as { platformAdmin?: boolean })?.platformAdmin === true,
cache: { ttlMs: 30_000, maxEntries: 10_000 },
})Pair it with billing
billingRoutes() / invoiceRoutes() authenticate the user but resolve the billable from the tenant — with this guard registered, a user of tenant A calling checkout/portal/invoices with tenant B's identifier is stopped with 403 TEAM_NOT_A_MEMBER before any billing code runs. See Billing and the security guide.
Invitations (invite → accept)
POST /team/invites mints a one-time, expiring token (default 7 days) and emits team:invited carrying it. The token is emailed — never returned over HTTP. A fresh invite for the same address supersedes any pending one (one pending invite per email per team). Addresses are compared and stored in canonical form (trimmed, lower-cased — the folding @basaltkit/auth uses), so Bob@x.test and bob@x.test are one invitee, including mixed-case rows from before 4.0. Over HTTP:
# 1. An admin invites Bob (201; response never contains the token)
curl -X POST http://localhost:3000/team/invites \
-H 'authorization: Bearer <admin token>' -H 'x-tenant-id: acme' \
-H 'content-type: application/json' \
-d '{"email":"bob@example.com","role":"member"}'
# 2. Bob follows the emailed link, logs in, then accepts with the token
curl -X POST http://localhost:3000/team/invites/accept \
-H 'authorization: Bearer <bob token>' -H 'x-tenant-id: acme' \
-H 'content-type: application/json' -d '{"token":"<token-from-email>"}'The same flow with the Teams service (reached via the TEAMS token):
import { TEAMS } from '@basaltkit/teams'
const teams = app.container.get(TEAMS)
const { invitation, token } = await teams.invite({
tenantId: 'acme', email: 'bob@example.com', role: 'member', invitedBy: 'ada-id',
})
// invitation is PublicInvitation (no token); token goes in the email link
const membership = await teams.accept(token, 'bob-id')
// → { tenantId: 'acme', userId: 'bob-id', role: 'member', createdAt }These safety properties are built in:
- Tokens are stored hashed. Only the SHA-256 of the token is persisted — a leak of the invitations table can't be replayed to join a team; the raw token lives only in the emailed link.
- Acceptance is bound to the invited address. The accept route passes the caller's email (
ctx().user.email) asacceptingEmail; a forwarded or leaked link redeemed by a different account fails with the sameTEAM_INVITE_INVALIDas a bogus token — a wrong recipient can't distinguish a real token from a fake one. In code, pass the caller's verified email; omit it only for trusted server-side flows. A caller with no email inctx().useris refused (TEAM_INVITE_INVALID), never enrolled unbound. - The address must be verified. By default the accept route also requires
ctx().user.emailVerified === trueand otherwise answers403 TEAM_EMAIL_NOT_VERIFIED. Without it, anyone who registers the invitee's address could redeem a leaked link. Only apps that prove address ownership some other way should opt out withteamRoutes({ requireVerifiedEmail: false }). The address binding still applies. - Single use, even under concurrency. Stores accept an invitation with a compare-and-set (
markAcceptedresolvesfalseif the invitation is no longer pending), so one token enrolls at most one account. A customInvitationStoreshould do the same. Returningvoidis still accepted, but you lose that guarantee.
An unknown, used, revoked, or expired token throws TeamInviteInvalidError (400 TEAM_INVITE_INVALID). Wire the email hook once at startup:
app.hooks.on('team:invited', ({ invitation, token }) =>
mailer.send(InviteEmail, { url: `${APP_URL}/invite?token=${token}` }, { to: invitation.email }))Listing members and invites
const teams = app.container.get(TEAMS)
await teams.members('acme') // Membership[] — GET /team/members
await teams.pendingInvites('acme') // PublicInvitation[] — GET /team/invites
await teams.roleOf('acme', 'bob-id') // 'member' | null
await teams.can('acme', 'bob-id', 'admin') // false — member (1) < admin (2)
await teams.changeRole('acme', 'bob-id', 'admin') // PATCH /team/members/:userId
await teams.removeMember('acme', 'bob-id') // DELETE /team/members/:userId
await teams.revokeInvite(invitationId) // DELETE /team/invites/:idchangeRole and removeMember throw LastOwnerError (400 TEAM_LAST_OWNER) if they would leave the team without an owner. The rule is re-checked after the write, and the write is rolled back if it lost a race. This stops two concurrent demotions/removals from leaving the team with zero owners. Pass { actingUserId } to changeRole/removeMember/addMember to apply the rank rules to a user-initiated call, as the routes do.
Notifying everyone with a role
members() returns identifiers, not people: { tenantId, userId, role }. To email the admins of a tenant you also need their addresses — and looking them up in the auth tables from application code couples your product to the auth schema, while calling findById once per member is N round trips.
Give Teams a user directory and neither is necessary. An @basaltkit/authUserSource satisfies the contract as it stands, so it is the same object you already pass to authPlugin:
const users = sqliteAuthStores('./data/auth.db').users
plugins: [
authPlugin({ users, secret }),
teamsPlugin({ users }), // the same directory — nothing else to wire
]const teams = app.container.get(TEAMS)
// Everyone who can act as an admin — owners included (rank 3 >= admin 2).
for (const { user, role } of await teams.roleRecipients('acme', 'admin')) {
await mailer.send(ApprovalPending, { role }, { to: user.email })
}
// The whole team, with contacts, in one lookup.
await teams.membersWithUsers('acme')
// [{ tenantId: 'acme', userId: 'u1', role: 'owner', createdAt: 1_7…,
// user: { id: 'u1', email: 'ada@acme.test', emailVerified: true } }, …]membersWithUsers is the primitive: it is the one place the user lookup happens, and roleRecipients is a filter over it that costs no extra query. What the pair guarantees:
- One lookup, automatically. When the directory implements
findByIdsthe whole team resolves in a single batched query; otherwise it falls back to onefindByIdper member. The decision lives in one place, so an app gets the fast path the day its driver grows one, with no code change. - No credential ever leaks. Whatever the directory hands back —
findByIdreturns the full stored record — is projected down to{ id, email, emailVerified }. - Tenant scoped. The ids come from that tenant's membership records and nowhere else, so no caller can point the lookup at accounts of its choosing, and a user the directory returns that wasn't asked for is discarded.
- Missing accounts don't break the list. A membership whose account no longer exists (deleted, or an invite that never became a real user) is skipped —
useris required onTeamMemberWithUser, so the result is always safe to email. The membership record itself is untouched, andmembers()still shows it.
roleRecipients honours the hierarchy for ranked roles and matches unranked ones exactly — every role outside roleRank ranks 0, so ranking them would notify all of them at once:
await teams.roleRecipients('acme', 'admin') // owners + admins
await teams.roleRecipients('acme', 'admin', { exact: true }) // admins only
await teams.roleRecipients('acme', 'billing-contact') // exact (unranked)Without a users directory both methods throw TeamUserSourceMissingError (500 TEAM_USER_SOURCE_MISSING) instead of quietly returning contact-less rows.
Over HTTP the same data is opt-in: teamRoutes({ memberContacts: true }) adds user to each entry of GET /team/members (still member-only). It is off by default so a team's email addresses go over the wire only when you say so, and the ids resolved are always the tenant's own memberships — never anything the request supplied.
Mirroring roles into permissions
Pass an access store (a @basaltkit/permissions AccessStore satisfies the structural RoleAssigner) and every membership change becomes a role grant in that tenant's scope:
import { MemoryAccessStore } from '@basaltkit/permissions'
const access = new MemoryAccessStore()
teamsPlugin({ access })
// teams.addMember('acme', 'u1', 'admin') → access.assignRole('u1', 'admin', 'acme')The role is held in the tenant, so its permissions must resolve there too. Define what owner/admin/member may do once with permissionsPlugin({ roleCatalog }) (or inheritGlobalRolePermissions) instead of granting the catalogue into every tenant — see One role catalogue for every tenant.
Options reference
teamsPlugin(options):
| Option | Type | Default | Purpose |
|---|---|---|---|
memberships | MembershipStore | in-memory | Where memberships live — swap for teams-sqlite/teams-prisma in production |
invitations | InvitationStore | in-memory | Where invitations (hashed tokens) live |
users | MemberUserSource | — | Read-only user directory behind membersWithUsers / roleRecipients; an @basaltkit/auth UserSource fits as-is |
access | RoleAssigner | — | Mirrors every membership change into a @basaltkit/permissions role grant in the tenant's scope |
inviteTtl | DurationInput | '7d' | Invitation link lifetime |
roleRank | Record<string, number> | { owner: 3, admin: 2, member: 1 } | Role hierarchy; roles outside the map have no rank (matched exactly) |
grantableRoles | TeamRole[] | [] | Unranked roles an acting user may still grant; any other role outside roleRank is refused (TEAM_ROLE_NOT_GRANTABLE) |
now | () => number | Date.now | Injectable clock (tests) |
tenantMembershipPlugin(options):
| Option | Type | Default | Purpose |
|---|---|---|---|
role | TeamRole | — (existence check) | Require a minimum ranked role instead of any membership record |
exempt | (context) => boolean | — | WHO-based escape for cross-tenant identities (platform admin, support); never cached |
cache | { ttlMs: number; maxEntries?: number } | off | Opt-in in-process decision cache; hook-invalidated same-process (a lookup that overlaps an invalidation is not cached), ttlMs bounds cross-replica staleness, maxEntries default 10 000 |
teamRoutes(options):
| Option | Type | Default | Purpose |
|---|---|---|---|
requireVerifiedEmail | boolean | true | Require ctx().user.emailVerified === true to accept an invitation |
memberContacts | boolean | false | Include each member's user: { id, email, emailVerified } in GET /team/members, resolved through the users directory |
Failure modes & troubleshooting
| Error | Code | HTTP | When |
|---|---|---|---|
TeamInviteInvalidError | TEAM_INVITE_INVALID | 400 | Token unknown, used, revoked, expired — or redeemed by an account whose email isn't the invited one |
NotATeamMemberError | TEAM_NOT_A_MEMBER | 403 | tenantMembershipPlugin found no membership; or a meta.teamRole route ran with no user or no tenant in context |
InsufficientTeamRoleError | TEAM_ROLE_REQUIRED | 403 | Role rank below the required one, including an actor trying to grant, demote or remove above their own rank |
TeamRoleNotGrantableError | TEAM_ROLE_NOT_GRANTABLE | 403 | An acting user tried to grant a role that is neither in roleRank nor in grantableRoles |
TeamEmailNotVerifiedError | TEAM_EMAIL_NOT_VERIFIED | 403 | POST /team/invites/accept by a user whose email isn't verified (see requireVerifiedEmail) |
UnknownTeamRoleError | TEAM_ROLE_UNKNOWN | 500 | meta.teamRole / tenantMembershipPlugin({ role }) names a role neither in roleRank nor in grantableRoles (typo, '', non-string) |
TeamUserSourceMissingError | TEAM_USER_SOURCE_MISSING | 500 | membersWithUsers / roleRecipients (or memberContacts: true) ran with no users directory configured |
LastOwnerError | TEAM_LAST_OWNER | 400 | The change would leave the team with no owner |
TEAM_NO_TENANT | TEAM_NO_TENANT | 400 | A teamRoutes() endpoint was called with no tenant in context — register tenancy and send the tenant identifier |
TEAM_INVITE_NOT_FOUND | TEAM_INVITE_NOT_FOUND | 404 | DELETE /team/invites/:id for an id that doesn't exist or belongs to another tenant |
UnguardedRouteMetaError | HTTP_UNGUARDED_ROUTE_META | boot | A route declares meta.teamRole and teamsPlugin isn't registered |
TEAM_NOT_A_MEMBERright after adding a member on another replica — the membership cache'sttlMsbounds cross-replica staleness in both directions; the decision refreshes withinttlMs.- A custom role keeps getting
TEAM_ROLE_REQUIRED— roles outsideroleRankhave no rank: they never satisfy a ranked requirement. Add the role to the map, or (for the membership guard) rely on the default existence semantics instead ofrole:. - Boot fails with
InvalidRouteMetaError…meta.teamRole "Admin" is not a known team role— fix the value, or rank the role inroleRank/ list it ingrantableRoles(roles are case-sensitive). 500 TEAM_ROLE_UNKNOWNon a route — itsmeta.teamRoleisn't inroleRankorgrantableRolesand the route escaped the boot check (mounted outside the adapter's route list); usually a typo.403on a central route (login, sign-up, tenant creation) — mark itmeta: { central: true }, or exempt the calling identity withexempt.
Events
| Hook | Payload |
|---|---|
team:invited | { invitation, token } — send the email here |
team:joined | { membership } |
team:role_changed | { membership } |
team:member_removed | { tenantId, userId } |
The full flow — including email plumbing — is in the account lifecycle cookbook.