Skip to content

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:

ts
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+):

ts
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:

ts
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:

RoleRank
owner3
admin2
member1

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:

ts
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:

ts
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:

ts
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):

EndpointRequires
POST /team/invites { email, role? }admin
POST /team/invites/accept { token }login with a verified email
GET /team/invites · DELETE /team/invites/:idadmin
GET /team/membersmember (adds user with memberContacts: true)
PATCH /team/members/:userId { role }admin
DELETE /team/members/:userIdadmin

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:

ts
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.

ts
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 from roleRank (rank 0) is not rejected. Pass role: 'member' (or higher) to switch to rank semantics.
  • exempt is 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 over meta.central when the exemption is about who is calling — central disables 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 the team:joined / team:role_changed / team:member_removed hooks — same-process changes are always exact. ttlMs only bounds staleness for changes made on another replica: a member removed elsewhere may retain access for up to ttlMs. The map is size-bounded by maxEntries (default 10 000, oldest evicted).
ts
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:

bash
# 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):

ts
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) as acceptingEmail; a forwarded or leaked link redeemed by a different account fails with the same TEAM_INVITE_INVALID as 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 in ctx().user is refused (TEAM_INVITE_INVALID), never enrolled unbound.
  • The address must be verified. By default the accept route also requires ctx().user.emailVerified === true and otherwise answers 403 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 with teamRoutes({ requireVerifiedEmail: false }). The address binding still applies.
  • Single use, even under concurrency. Stores accept an invitation with a compare-and-set (markAccepted resolves false if the invitation is no longer pending), so one token enrolls at most one account. A custom InvitationStore should do the same. Returning void is 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:

ts
app.hooks.on('team:invited', ({ invitation, token }) =>
  mailer.send(InviteEmail, { url: `${APP_URL}/invite?token=${token}` }, { to: invitation.email }))

Listing members and invites ​

ts
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/:id

changeRole 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:

ts
const users = sqliteAuthStores('./data/auth.db').users

plugins: [
  authPlugin({ users, secret }),
  teamsPlugin({ users }),   // the same directory — nothing else to wire
]
ts
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 findByIds the whole team resolves in a single batched query; otherwise it falls back to one findById per 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 — findById returns 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 — user is required on TeamMemberWithUser, so the result is always safe to email. The membership record itself is untouched, and members() 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:

ts
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:

ts
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):

OptionTypeDefaultPurpose
membershipsMembershipStorein-memoryWhere memberships live — swap for teams-sqlite/teams-prisma in production
invitationsInvitationStorein-memoryWhere invitations (hashed tokens) live
usersMemberUserSource—Read-only user directory behind membersWithUsers / roleRecipients; an @basaltkit/auth UserSource fits as-is
accessRoleAssigner—Mirrors every membership change into a @basaltkit/permissions role grant in the tenant's scope
inviteTtlDurationInput'7d'Invitation link lifetime
roleRankRecord<string, number>{ owner: 3, admin: 2, member: 1 }Role hierarchy; roles outside the map have no rank (matched exactly)
grantableRolesTeamRole[][]Unranked roles an acting user may still grant; any other role outside roleRank is refused (TEAM_ROLE_NOT_GRANTABLE)
now() => numberDate.nowInjectable clock (tests)

tenantMembershipPlugin(options):

OptionTypeDefaultPurpose
roleTeamRole— (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 }offOpt-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):

OptionTypeDefaultPurpose
requireVerifiedEmailbooleantrueRequire ctx().user.emailVerified === true to accept an invitation
memberContactsbooleanfalseInclude each member's user: { id, email, emailVerified } in GET /team/members, resolved through the users directory

Failure modes & troubleshooting ​

ErrorCodeHTTPWhen
TeamInviteInvalidErrorTEAM_INVITE_INVALID400Token unknown, used, revoked, expired — or redeemed by an account whose email isn't the invited one
NotATeamMemberErrorTEAM_NOT_A_MEMBER403tenantMembershipPlugin found no membership; or a meta.teamRole route ran with no user or no tenant in context
InsufficientTeamRoleErrorTEAM_ROLE_REQUIRED403Role rank below the required one, including an actor trying to grant, demote or remove above their own rank
TeamRoleNotGrantableErrorTEAM_ROLE_NOT_GRANTABLE403An acting user tried to grant a role that is neither in roleRank nor in grantableRoles
TeamEmailNotVerifiedErrorTEAM_EMAIL_NOT_VERIFIED403POST /team/invites/accept by a user whose email isn't verified (see requireVerifiedEmail)
UnknownTeamRoleErrorTEAM_ROLE_UNKNOWN500meta.teamRole / tenantMembershipPlugin({ role }) names a role neither in roleRank nor in grantableRoles (typo, '', non-string)
TeamUserSourceMissingErrorTEAM_USER_SOURCE_MISSING500membersWithUsers / roleRecipients (or memberContacts: true) ran with no users directory configured
LastOwnerErrorTEAM_LAST_OWNER400The change would leave the team with no owner
TEAM_NO_TENANTTEAM_NO_TENANT400A teamRoutes() endpoint was called with no tenant in context — register tenancy and send the tenant identifier
TEAM_INVITE_NOT_FOUNDTEAM_INVITE_NOT_FOUND404DELETE /team/invites/:id for an id that doesn't exist or belongs to another tenant
UnguardedRouteMetaErrorHTTP_UNGUARDED_ROUTE_METAbootA route declares meta.teamRole and teamsPlugin isn't registered
  • TEAM_NOT_A_MEMBER right after adding a member on another replica — the membership cache's ttlMs bounds cross-replica staleness in both directions; the decision refreshes within ttlMs.
  • A custom role keeps getting TEAM_ROLE_REQUIRED — roles outside roleRank have 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 of role:.
  • Boot fails with InvalidRouteMetaError … meta.teamRole "Admin" is not a known team role — fix the value, or rank the role in roleRank / list it in grantableRoles (roles are case-sensitive).
  • 500 TEAM_ROLE_UNKNOWN on a route — its meta.teamRole isn't in roleRank or grantableRoles and the route escaped the boot check (mounted outside the adapter's route list); usually a typo.
  • 403 on a central route (login, sign-up, tenant creation) — mark it meta: { central: true }, or exempt the calling identity with exempt.

Events ​

HookPayload
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.

Released under the MIT License.