Multi-tenant data
The DMS is multi-tenant by default: one backend serves isolated tenants (workspaces/orgs), each with its own data. This is a deliberate design choice — building a multi-tenant SaaS is the case the DMS is meant to make easy, so tenancy is wired into auth, data, and the dashboard from the start instead of bolted on later. Auth & permissions covers the tenant auth decorators; here the subject is how data gets scoped to a tenant, how memberships change, and the invites interface that brings users into a tenant.
How the request's tenant is resolved
Every authenticated request carries its tenant in the session token: getRequestTenantId(ctx) decodes the bearer JWT (it does not re-verify it) and returns its tenantId claim. An anonymous request has no token, so it resolves to the built-in default tenant — the one bootstrapped at first run, where a single-tenant project keeps all of its data.
import { getRequestTenantId } from "@antelopejs-private/cms/interfaces/cms/request-tenant";
getRequestTenantId trusts an unverified claim. It is only safe behind an auth decorator (@AuthUser, @AuthTenantMember, a PageController) that has already verified the token's signature. Never authorize on the tenant id in a route that isn't auth-gated — the claim is forgeable otherwise.Scope a model to the tenant
The @TenantScopedModel decorator injects a model already bound to the request's tenant. It works in both places you bind a model: as a parameter decorator on a route handler, and as a property decorator on a DataController's model field.
In a route:
import { Controller, Get } from "@antelopejs/interface-api";
import { AuthTenantMember } from "@antelopejs-private/cms/interfaces/cms/guards";
import { TenantScopedModel } from "@antelopejs-private/cms/interfaces/cms/tenant-scoped-model";
import { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";
export class BillingController extends Controller("/api/billing") {
@Get("/status")
async status(
@AuthTenantMember() _user: User,
@TenantScopedModel(SubscriptionModel) subs: SubscriptionModel,
) {
return subs.findOne(); // reads only this tenant's data
}
}
In a DataController (Data controller), decorate the field marked with @ModelReference:
@ModelReference()
@TenantScopedModel(InvoiceModel)
declare model: InvoiceModel;
@TenantScopedModel(Model) is shorthand for the general binding @Model(Model, (ctx) => getRequestTenantId(ctx)) — both resolve to GetModel(Model, <tenantId>). Reach for @Model directly when you need an instance other than the request's tenant: a static instance id, or a callback that resolves it some other way. A plain @Model(Model) (no second argument) is not tenant-scoped — use it only for registry-global tables like TenantModel itself.
Query across all tenants — CROSS_INSTANCE
To read one table across every tenant (a platform-owner view), pass CROSS_INSTANCE instead of a tenant id:
import { CROSS_INSTANCE } from "@antelopejs/interface-database";
const members = GetModel(TenantMemberModel, CROSS_INSTANCE);
const rows = await members.listByUserWithTenantIds(user._id);
// each row already carries its tenant: { tenantId, member }
listByUserWithTenantIds returns { tenantId, member }.Grant or change tenant membership
applyTenantOwnership(userModel, userId, tenantId, patch) upserts a tenant membership. It fires Hook.MEMBER_BEING_ADDED then Hook.MEMBER_ADDED — but only when the member is new:
import { applyTenantOwnership } from "@antelopejs-private/cms/interfaces/cms/tenant-ownership";
await applyTenantOwnership(this.userModel, user._id, tenantId, {
roleIds: [],
isTenantOwner: false,
});
Invites
inviteUserToTenant handles both adding an existing user to a tenant and inviting a new one by email, through a single call.
import { inviteUserToTenant } from "@antelopejs-private/cms/interfaces/cms/invites";
const result = await inviteUserToTenant({
tenantId,
email,
roleIds: [],
asTenantOwner: false,
sendEmail: true, // opt-in — off by default
});
if (result.kind === "added") {
// existing user → added directly (member hooks fired), result.userId
} else {
// new email → invite row created, result.inviteId + result.token
}
The flow branches on whether a user with that email already exists:
- Existing user →
applyTenantOwnership(...)runs directly (no token); returns{ kind: "added", userId }. - New email → a 7-day invite is created; if
sendEmailis set, the admin-invite email dispatches fire-and-forget; returns{ kind: "invited", inviteId, token }.
Optional firstname / lastname are stored on the invite and prefill the name field of the signup page through the invite link (the Members invite form requires them when skipEmailValidation is set).
Creating an invite replaces any existing invite for the same email (firing INVITE_DELETED), then fires INVITE_BEING_CREATED and INVITE_CREATED. Subscribe to those hooks (Backend services) to react.
inviteUserToTenant does not throw on duplicates — it's deliberately side-effect-light. Do context-specific pre-checks in the caller (for example, return 409 when the user is already a member).The SaaS module (cms-saas)
Everything above — tenant isolation, scoped models, invites, roles, the platform owner — is the foundation a SaaS needs, not the commercial layer itself. The optional cms-saas module adds that layer on top: public registration, plans, subscriptions, and billing, run from the same admin you ship your customers. SaaS mode covers it.