Multi-Tenancy

Isolated workspaces, members, and invites - resolved per request with withTenant.

A tenant is an isolated workspace. Membership links users to tenants with a role. Requests select the active tenant via the X-TENANT-ID header.

Resolving the Tenant - withTenant

import { withTenant } from "@plugins/Huruf-Tech/thunder-core/utils/withTenant.ts";

const { tenant, member, user, owner, getParentMember } = await withTenant(req);

const maybe = await withTenant(req, true); // silent mode

withTenant resolves the auth session, requires X-TENANT-ID, then loads the tenant plus the caller's membership. It throws Response.forbidden(...) with a specific message when something is missing:

MessageMeaning
You are not a member of this tenant!The user has no membership in that tenant
Tenant not found!The X-TENANT-ID doesn't resolve
Tenant owner not found!The tenant exists but its creator does not

In silent mode (withTenant(req, true)) the session lookup is silent too, so an unauthenticated request returns undefined instead of throwing a 401.

If you only need access to the user object, use withAuthSession() directly. Using withTenant() adds additional processing overhead and is unnecessary in this case. See the withAuthSession example.

For non-owner members it also returns:

  • owner - the tenant creator.
  • getParentMember() - a lazy lookup of the inviter's membership, used for RBAC role cascading.

Pair withTenant with createCRUD's isolationFields to scope a collection per tenant automatically. See the Posts CMS example for a complete multi-tenant CRUD walkthrough.

Tenancy Routes

Read each file for endpoints and exact behavior:

Route fileResponsibility
routes/tenants.tsTenants. Creating one atomically creates an owner member; deleting one removes its members.
routes/tenantMembers.tsMembers. The create handler consumes a tenantInvite. update may change a member's role and expiresAt.
routes/tenantInvites.tsInvites. Validates the invited role against the caller's subRoles and the optional membershipExpiresAt, renders the invite URL, and emails it.
routes/tenantMemberships.tsLists the current user's memberships with the joined tenant document.

The X-TENANT-ID header is allow-listed for CORS by the essentials hook, so browser clients can send it cross-origin.

Temporary Memberships

A membership can be time-limited - useful for contractors, trials, or temporary access.

The inviter opts in by passing membershipExpiresAt (an absolute date) when creating the invite. On acceptance it is copied onto the new member as expiresAt. Both fields are optional - omit them and the membership never expires.

// POST /tenantInvites/api
{
  "email": "temp@contractor.com",
  "role": "member",
  "url": "https://app.com/invite/{{_id}}",
  "membershipExpiresAt": "2026-12-31T23:59:59Z" // optional
}

Enforcement lives in one place

withTenant throws Response.forbidden("Your membership of this tenant has expired!") once expiresAt has passed. The predicate is isMembershipExpired(member, now?), exported from schemas/tenantMember.ts - reuse it rather than comparing dates yourself.

That single check reaches further than it looks. Like the "not a member" check, it throws even in silent mode - only a missing session or missing X-TENANT-ID returns undefined. Since hooks/guard.ts calls withAuthGuard → withTenant(req, true) on every request, a request carrying an expired X-TENANT-ID is rejected globally, not just on routes that call withTenant themselves.

An expired member hitting me.get with the header set gets the expiry 403; dropping the header gets them their non-tenant access back.

Gotchas

  • Expiry is absolute, not a duration from acceptance. An invite accepted after its membershipExpiresAt produces a membership that is already expired - so pair a short window with prompt acceptance. The date must be in the future at invite-creation time, or it is rejected.
  • Expired member documents are not deleted. They stay listed and keep their audit trail. There is no TTL cleanup.
  • To reinstate, PATCH expiresAt to a later date or clear it. To revoke immediately, PATCH it to a past date.
  • Only the member's inviter can patch or delete them (isolationFields pins createdBy).
  • Owner memberships created by routes/tenants.ts never carry an expiry.
  • A member whose membership expires still exists as a user - only tenant-scoped access is denied.

On this page