Skip to content

Package reference

Mirrors the package README (single source). Install @basaltkit/teams v4.0.0 — npm · source.

<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>

@basaltkit/teams ​

Teams for Basalt applications: makes each tenant multi-user, with hierarchical roles (owner/admin/member), email invitations with acceptance and revocation, member management, and a route guard by team role.

You need this module when several people share the same account/organization — "invite a colleague to the workspace" is exactly this.

What this module solves ​

In a SaaS, an organization (tenant) rarely has just one user: the founder invites colleagues, some are administrators and others just members. This module manages these memberships — who belongs to which team and with what role (the name of the person's "position" on the team, such as owner, admin, or member) — and invitations: the person receives an email with a link containing a token (single-use secret code), and upon accepting, joins the team with the role set in the invitation.

Roles are hierarchical by rank: by default owner (3) > admin (2) > member (1). Whoever has a higher-rank role can do everything a lower one can — a route that requires admin also accepts owner. There are built-in protections: a team can never end up without an owner (the last one can't be removed or demoted), invitation tokens expire (7 days by default), are single-use, and never appear in HTTP responses — only in the team:invited hook, for your application to send by email.

The module is deliberately decoupled: it receives tenantId and userId as strings, so it works with any authentication and tenancy setup. Optionally, it mirrors team roles into @basaltkit/permissions, so that "admin of team acme" automatically translates into permissions.

Installation ​

bash
pnpm add @basaltkit/teams

Get started in 5 minutes ​

  1. Just the logic (no HTTP) — create the team, invite, and accept:
ts
import { Teams } from '@basaltkit/teams'

const teams = new Teams() // in-memory stores by default

// The first owner is added directly (e.g. when creating the tenant)
await teams.addMember('acme', 'user-ada', 'owner')

// Invite Bob as a member — the token goes in the email link
const { invitation, token } = await teams.invite({
  tenantId: 'acme',
  email: 'bob@example.com',
  role: 'member',
  invitedBy: 'user-ada',
})

// Bob (already authenticated as user-bob) accepts with the token from the link
const membership = await teams.accept(token, 'user-bob')
console.log(membership) // { tenantId: 'acme', userId: 'user-bob', role: 'member', createdAt: ... }

console.log(await teams.roleOf('acme', 'user-bob')) // 'member'
console.log(await teams.can('acme', 'user-bob', 'admin')) // false — member < admin
  1. Complete HTTP application with auth + tenancy + ready-made team routes:
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()],
    }),
    authPlugin({ users: new MemoryUserSource(), secret: process.env.AUTH_SECRET! }),
    teamsPlugin(),
    fastifyPlugin({ routes: [...authRoutes(), ...teamRoutes()] }),
  ],
}).boot()

// Send the invitation email when it's created
app.hooks.on('team:invited', async ({ invitation, token }) => {
  await sendEmail(invitation.email, `https://app.example.com/invite?token=${token}`)
})

// Seed the first owner when creating the organization
const teams = app.container.get(TEAMS)
await teams.addMember('acme', 'ada-id', 'owner')
  1. From here, HTTP requests (with Authorization: Bearer <login token> and x-tenant-id: acme) use the ready-made routes: POST /team/invites, POST /team/invites/accept, GET /team/members, etc.

Usage guide ​

Invitations ​

  • invite({ tenantId, email, role?, invitedBy?, actingUserId? }) creates (or replaces) the invitation — one pending invitation per email per team; a new one revokes the previous. The address is stored in canonical form (canonicalInviteEmail: trimmed and lower-cased, the same folding @basaltkit/auth applies), so Bob@x.test and bob@x.test are one invitee — a later member invite supersedes an earlier admin one whatever the case, including mixed-case rows written before 4.0. No Unicode-compatibility (NFKC) folding: it would merge distinct mailboxes. Default role: 'member'. Validity: inviteTtl (default '7d').
  • Returns { invitation, token } — invitation is PublicInvitation (without the token) and token is used to build the link. The team:invited hook receives the same pair.
  • Only the SHA-256 hash of the token is persisted; the raw value exists solely in the emailed link. A leak of the invitations table can't be replayed to accept an invite.
  • The team:invited hook carries the raw token — it is the delivery channel for the link. @basaltkit/audit's default redactor masks token keys; redact it yourself in any other catch-all hook listener.
  • accept(token, userId, acceptingEmail?) consumes the token (single use) and enrolls the user with the invitation's role. Unknown, used, revoked, or expired token → TeamInviteInvalidError (400).
  • Pass the caller's verified email as acceptingEmail and acceptance is bound to the invited address, so a forwarded or leaked link can't enroll a different account. A mismatch throws the same TEAM_INVITE_INVALID as a bad token, so a wrong recipient can't distinguish a real token from a fake one. teamRoutes() passes ctx().user.email for you; omit it only in trusted server-side flows.
  • POST /team/invites/accept also requires ctx().user.emailVerified === true (403 TEAM_EMAIL_NOT_VERIFIED) and refuses callers with no email. Opt out only with teamRoutes({ requireVerifiedEmail: false }). Acceptance is a compare-and-set in every bundled store, so a token enrolls at most one account even under concurrency.
  • An invitation only ever adds access: accepting it never lowers an existing membership of equal or higher rank (an owner clicking a member invite stays owner).
  • An invitation carries its inviter's authority: when the inviter (invitedBy) is removed, or re-roled below the invited role, their pending invitations for roles they can no longer grant are revoked.
  • pendingInvites(tenantId) lists non-expired pending invites; revokeInvite(id) cancels one; invitation(id) looks one up.

Privilege-escalation guard (actingUserId) ​

addMember, invite, changeRole and removeMember accept the acting user. When you pass it, the actor must be a member who ranks at least as high as the role being granted, and at least as high as the target's current role (for removeMember: self-removal, or a target who doesn't outrank the actor). The granted role must be in roleRank or listed in grantableRoles, otherwise TeamRoleNotGrantableError (403). The HTTP routes always pass it:

ts
// admin (rank 2) invites a member — fine
await teams.invite({ tenantId: 'acme', email: 'x@y.z', role: 'member', actingUserId: 'admin-1' })

// admin tries to mint an owner (rank 3) — InsufficientTeamRoleError
await teams.invite({ tenantId: 'acme', email: 'x@y.z', role: 'owner', actingUserId: 'admin-1' })

// admin tries to demote an owner — InsufficientTeamRoleError
await teams.changeRole('acme', 'owner-1', 'member', { actingUserId: 'admin-1' })

Omit actingUserId for trusted server-side seeding (creating a tenant's first owner). teamRoutes() always passes the caller's id, so the HTTP surface is guarded by default. A non-member actor gets NotATeamMemberError.

"At least as high" is deliberate: peers can manage peers — an admin can re-role or remove another admin, and an owner another owner (the last-owner rule still applies). If your product needs "only strictly higher ranks manage a role", give that tier its own rank.

Members and roles ​

ts
import { Teams } from '@basaltkit/teams'

const teams = new Teams()
await teams.addMember('acme', 'u1', 'owner')

await teams.members('acme')                 // list of Membership
await teams.roleOf('acme', 'u1')            // 'owner' (or null if not a member)
await teams.can('acme', 'u1', 'admin')      // true — owner (3) >= admin (2)
await teams.changeRole('acme', 'u2', 'admin')
await teams.removeMember('acme', 'u2')

Last-owner protection: changeRole and removeMember throw LastOwnerError (400) if they would leave the team without any owner.

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 both problems go away:

ts
import { authPlugin } from '@basaltkit/auth'
import { teamsPlugin } from '@basaltkit/teams'

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

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(user.email, 'A task is waiting for approval', { role })
}

// The whole team, with contacts, in one lookup.
const everyone = await teams.membersWithUsers('acme')
// [{ tenantId: 'acme', userId: 'u1', role: 'owner', createdAt: 1_7…,
//    user: { id: 'u1', email: 'ada@acme.test', emailVerified: true } }, …]

What the pair guarantees:

  • One lookup, automatically. If the directory implements findByIds (MemoryUserSource and both shipped drivers do), the whole team resolves in a single batched query; otherwise Teams 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 on an @basaltkit/auth UserSource 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 arbitrary accounts, 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 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; 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). Narrow a ranked role the same way with { exact: true }:

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 (TEAM_USER_SOURCE_MISSING, 500) rather than silently returning contact-less rows.

Custom role hierarchy ​

Roles are free-form strings; the hierarchy is a name → rank map. Only ranked roles have a hierarchy: can() / meta.teamRole with a ranked role admits holders of that rank or higher (and never a holder of an unranked role), while a role outside the map (e.g. one from grantableRoles) is matched exactly — it never ranks 0 and admits everyone:

ts
import { Teams } from '@basaltkit/teams'

const teams = new Teams({
  roleRank: { owner: 4, admin: 3, editor: 2, viewer: 1 },
})

Protecting routes by role (meta.teamRole) ​

teamsPlugin registers a guard: routes with meta: { teamRole: 'admin' } require the current user (ctx().user, from auth) to have that role or higher in the current tenant (ctx().tenant, from tenancy):

ts
import { route } from '@basaltkit/fastify'

const myRoute = route({
  method: 'POST',
  url: '/projects',
  meta: { auth: true, teamRole: 'admin' }, // member → 403 TEAM_ROLE_REQUIRED
  async handler() { return { created: true } },
})

No tenant or no user in context → NotATeamMemberError (403).

The required role must be a known role — ranked in roleRank or listed in grantableRoles. A typo ('Admin', 'adimn'), an empty string or a non-string value fails the boot: teamsPlugin registers a route-meta validator (http:meta-validators) that every adapter runs 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 driven through runRoute()) still fails closed with UnknownTeamRoleError (TEAM_ROLE_UNKNOWN, 500) on every request; before 4.0 such a role ranked 0 and admitted every member. Only undefined and false mean "no requirement" (the same rule the adapters' boot check uses).

teamsPlugin also registers a pure visibility check (http:route-visibility): 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 (one membership read — no hooks, no writes).

teamsPlugin also claims 'teamRole' in the http:guarded-meta bucket, so a route declaring meta.teamRole in an app that never registered teamsPlugin fails loud at boot with UnguardedRouteMetaError (HTTP_UNGUARDED_ROUTE_META) instead of serving unguarded.

Mirroring roles into @basaltkit/permissions ​

Pass a RoleAssigner (any object with assignRole/removeRole — an AccessStore from permissions works) and every team join/change/leave is mirrored as a role in the tenant's scope:

ts
import { Teams } from '@basaltkit/teams'
import { MemoryAccessStore } from '@basaltkit/permissions'

const access = new MemoryAccessStore()
const teams = new Teams({ access })

await teams.addMember('acme', 'u1', 'admin')
// → access.assignRole('u1', 'admin', 'acme') was called automatically

Hooks (events) ​

HookPayloadWhen
team:invited{ invitation, token }Invitation created — send the email here.
team:joined{ membership }Someone joined (accept or addMember).
team:role_changed{ membership }Role changed.
team:member_removed{ tenantId, userId }Member removed.

API reference ​

teamsPlugin(options) and the Teams class ​

Options (TeamsOptions; TeamsPluginOptions is the same minus hooks) — all optional:

NameTypeDefaultDescription
membershipsMembershipStoreMemoryMembershipStoreWhere memberships live.
invitationsInvitationStoreMemoryInvitationStoreWhere invitations live.
usersMemberUserSource—Read-only user directory for membersWithUsers / roleRecipients. An @basaltkit/auth UserSource fits as-is.
accessRoleAssigner—Mirrors roles (e.g. permissions' AccessStore).
inviteTtlDurationInput'7d'Invitation link validity.
roleRankRecord<string, number>{ owner: 3, admin: 2, member: 1 }Role hierarchy.
grantableRolesreadonly TeamRole[][]Unranked roles an acting user may still grant.
now() => numberDate.nowInjectable clock (tests).
hooksHookBus—Class only; the plugin injects it.

tenantMembershipPlugin(options) ​

The tenant-isolation guard (see below). All options are optional:

OptionTypeDefaultPurpose
roleTeamRole— (existence check)Require a minimum ranked role instead of any membership record. Leave unset unless you mean rank semantics — see below.
exempt(context: Record<string, unknown>) => boolean—WHO-based escape hatch for identities that legitimately cross tenants (platform admin, support impersonation): ({ user }) => user?.platformAdmin === true. Evaluated per request and never cached. Prefer it over meta.central, which unguards the route for everyone.
cache{ ttlMs: number; maxEntries?: number }— (off)Opt-in in-process decision cache. ttlMs is required when you pass cache; maxEntries defaults to 10 000, oldest evicted. See the staleness note below.

Existence vs rank ​

By default the guard asks "does a membership record exist?", not "does this role outrank member?". That matters because rankOf() returns 0 for any role absent from roleRank — with rank semantics, a genuine member holding a custom role like billing-contact would be rejected. Set role: 'member' only when you deliberately want rank enforcement and every role you use is in roleRank. An unknown role (not ranked, not in grantableRoles) throws UnknownTeamRoleError at boot (when teamsPlugin is registered) and fails closed with TEAM_ROLE_UNKNOWN (500) at request time otherwise.

Cache staleness ​

Without a cache, every authenticated tenant-scoped request costs one membership lookup — a single indexed primary-key read, usually fine. With a cache, decisions are memoized per (tenantId, userId) and invalidated immediately by the team:joined / team:role_changed / team:member_removed hooks, so changes made in the same process are always exact. ttlMs therefore only bounds staleness for changes made on another replica — a member removed elsewhere may retain access for up to ttlMs. Both outcomes are cached, so a newly added member can also be denied for up to ttlMs.

Teams methods:

MethodReturnsDescription
addMember(tenantId, userId, role, opts?)Promise<Membership>Adds/updates directly (seed the first owner). opts.actingUserId enforces the escalation guard.
invite(input)Promise<{ invitation, token }>Creates/replaces the invitation; emits team:invited. Only the token hash is stored.
accept(token, userId, acceptingEmail?)Promise<Membership>Consumes the token and enrolls the user. Pass the caller's verified email to bind acceptance to the invited address.
members(tenantId)Promise<Membership[]>Lists the members.
membersWithUsers(tenantId)Promise<TeamMemberWithUser[]>Memberships + each member's { id, email, emailVerified }, batched through users. Memberships with no account are skipped. Needs users.
roleRecipients(tenantId, role, opts?)Promise<TeamMemberWithUser[]>Who to notify for a role — a filter over membersWithUsers (no extra lookup). Ranked roles include higher ranks; unranked roles (and opts.exact) match exactly.
pendingInvites(tenantId)Promise<PublicInvitation[]>Non-expired pending invitations.
invitation(id)Promise<PublicInvitation | null>One invitation (without the token).
revokeInvite(id)Promise<void>Cancels a pending invitation.
roleOf(tenantId, userId)Promise<TeamRole | null>User's role (or null).
can(tenantId, userId, required)Promise<boolean>Has the required ranked role or higher? An unranked required is matched exactly; '' / non-string → false.
isKnownRole(role)booleanRanked in roleRank or listed in grantableRoles.
changeRole(tenantId, userId, role, opts?)Promise<Membership>Changes the role; protects the last owner. opts.actingUserId enforces the escalation guard.
removeMember(tenantId, userId)Promise<void>Removes; protects the last owner.
rankOf(role)numberRank of the role (0 if unknown — never use it alone as an authorization check; use can()).

Ready-made routes — teamRoutes() ​

All require login (meta.auth); the marked ones also require a team role. The tenant comes from ctx().tenant (without it → 400 TEAM_NO_TENANT).

RouteMinimum roleDescription
POST /team/invites { email, role? }adminCreates the invitation (201; the token never appears in the response).
POST /team/invites/accept { token }(login only)Accepts the invitation.
GET /team/invitesadminLists pending invitations.
DELETE /team/invites/:idadminRevokes it (404 if from another tenant).
GET /team/membersmemberLists members. With teamRoutes({ memberContacts: true }) each entry also carries user: { id, email, emailVerified }.
PATCH /team/members/:userId { role }adminChanges the role.
DELETE /team/members/:userIdadminRemoves the member.

TeamRoutesOptions:

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. Off by default: a team's addresses go over HTTP only when you say so. The ids looked up come from the tenant's own memberships, never from the request.

Types, stores, and constants ​

ExportDescription
TeamRolestring — free-form role name.
Membership{ tenantId, userId, role, createdAt }.
Invitation / PublicInvitationInvitation with/without the token field.
MembershipStore / InvitationStoreInterfaces for you to implement over your DB.
MemoryMembershipStore / MemoryInvitationStoreIn-memory implementations (dev/testing). Records are copied in and out — mutating a returned object never rewrites the store.
canonicalInviteEmail(email)The canonical invitation address (trimmed, lower-cased). Custom InvitationStores should compare this form on both sides in findPending.
MemberUser{ id, email, emailVerified? } — the safe user shape a team listing exposes.
MemberUserSource{ findById(id), findByIds?(ids) } — the user directory users expects; @basaltkit/auth's UserSource satisfies it structurally, so neither package imports the other.
TeamMemberWithUserMembership & { user: MemberUser }.
RoleAssigner{ assignRole(userId, role, scope), removeRole(...) }.
DEFAULT_ROLE_RANK{ owner: 3, admin: 2, member: 1 }.
OWNERThe string 'owner'.
TEAMSInjection token: container.get(TEAMS) → Teams.

Failure modes & troubleshooting ​

ErrorCodeHTTPWhen
TeamInviteInvalidErrorTEAM_INVITE_INVALID400Token unknown, already accepted, revoked or expired — or redeemed by an account whose email isn't the invited one. Deliberately indistinguishable.
NotATeamMemberErrorTEAM_NOT_A_MEMBER403tenantMembershipPlugin found no membership; a meta.teamRole route ran with no user or no tenant in context; or an actingUserId isn't a member of the team.
InsufficientTeamRoleErrorTEAM_ROLE_REQUIRED403The role's rank is below what's required — including an actor trying to grant, or re-role someone, above their own rank.
LastOwnerErrorTEAM_LAST_OWNER400The change would leave the team with no owner.
UnknownTeamRoleErrorTEAM_ROLE_UNKNOWN500meta.teamRole (or tenantMembershipPlugin({ role })) names a role that is neither ranked nor in grantableRoles — a typo, '' or a non-string. Server misconfiguration; fails closed.
TeamUserSourceMissingErrorTEAM_USER_SOURCE_MISSING500membersWithUsers / roleRecipients (or teamRoutes({ memberContacts: true })) ran without a users directory on the service.
NoTenantErrorTEAM_NO_TENANT400A teamRoutes() endpoint ran with no ctx().tenant (or, on accept, no ctx().user). Not exported — matched by code.
InviteNotFoundErrorTEAM_INVITE_NOT_FOUND404DELETE /team/invites/:id for an id that doesn't exist or belongs to another tenant. Not exported — matched by code.
UnguardedRouteMetaErrorHTTP_UNGUARDED_ROUTE_METAbootA route declares meta.teamRole and teamsPlugin isn't registered. Raised by the adapter, from @basaltkit/http.

Every runtime error declares a status, so adapters return the code above with the real error code in the body.

  • 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, and an unranked requirement is matched exactly. 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 / 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 was mounted outside the adapter's route list (so the boot check never saw it). Usually a typo ('Admin').
  • 403 on a central route (tenant creation, platform admin) — mark it meta: { central: true }, or exempt the calling identity with exempt. Your own profile/account routes take meta: { account: true } instead (the @basaltkit/auth routes and the invite-accept route already declare it).
  • TEAM_INVITE_INVALID on a link the user swears is fresh — they may be signed in as a different account than the one invited, and accept binds to the invited address.

Common issues and solutions (FAQ) ​

"The invitation is created but no one gets an email." The module doesn't send emails — it emits the team:invited hook with { invitation, token }; your application listens to it and sends the link.

"400 TEAM_INVITE_INVALID when accepting." The token has already been used (it's single-use), has expired (inviteTtl, 7 days), was revoked, or was replaced by a newer invitation for the same email.

"403 TEAM_NOT_A_MEMBER on a route with teamRole." The guard needs both ctx().user and ctx().tenant. Confirm that auth and tenancy are registered and that the request carries credentials and a tenant identifier (e.g. the x-tenant-id header in dev).

"400 TEAM_LAST_OWNER when removing/demoting someone." This is the last-owner protection. Promote someone else to owner first.

"How do I create the first team?" When creating the tenant, call teams.addMember(tenantId, userId, 'owner') directly — invitations are for the ones that follow.

"Members disappear on restart." In-memory stores. Implement MembershipStore and InvitationStore over your database (you can store just the hash of the invitation token, as the comment on the Invitation type suggests).

Tenant isolation guard — tenantMembershipPlugin ​

Binds the authenticated user to the resolved tenant on every request: a valid user of tenant A forging x-tenant-id: B gets a 403 instead of tenant B's data. Tenant resolution is identification, never authorization — this plugin is what closes that gap.

ts
import { teamsPlugin, tenantMembershipPlugin } from '@basaltkit/teams'

createApp({
  plugins: [
    tenancyPlugin({ source, resolvers: [headerResolver()] }),
    authPlugin({ users, secret: process.env.AUTH_SECRET! }),
    teamsPlugin({ memberships, invitations }),
    tenantMembershipPlugin({
      exempt: ({ user }) => (user as { platformAdmin?: boolean })?.platformAdmin === true,
      cache: { ttlMs: 30_000 },
    }),
  ],
})

The guard runs only when both a tenant and a user are present, and is skipped for:

  • routes where no tenant resolved (central/platform routes),
  • account routes, meta: { account: true } — about the caller's own identity, not the tenant's data. authRoutes(), mfaRoutes(), oauthRoutes() (@basaltkit/auth) and POST /team/invites/accept declare it, so a non-member can sign in and accept an invitation on the company's subdomain. apiKeyRoutes() does not (keys are tenant-bound), and
  • routes that opt out explicitly with meta: { central: true } — tenant creation, platform admin.

Its options table, the existence-vs-rank semantics and the cache staleness rules are under API reference → tenantMembershipPlugin(options) above.

How it connects to other modules ​

  • @basaltkit/tenancy — the team IS the set of users of a tenant; routes and the guard read ctx().tenant.id.
  • @basaltkit/auth — identifies who's making the request (ctx().user.id), used by the teamRole guard and by accept.
  • @basaltkit/permissions — via the access option (RoleAssigner), team roles become roles in the tenant's scope, gaining whatever permissions you define for them in the Gate.
  • @basaltkit/core / @basaltkit/fastify — container, context, hooks, and execution of guards and routes.

Guides: Teams · Tenancy · Authorization · Auth.

Released under the MIT License.