[data-reveal]{opacity:1!important;transform:none!important}
CMS Interfaces

Tenancy

Overview

The CMS serves several tenants from one backend. Tenant-owned tables live in a per-tenant schema, and a model handle is bound to one tenant when you obtain it: GetModel(TenantMemberModel, tenantId) reads and writes that tenant's rows only. No query filters rows by tenant, and no table carries a tenantId column — the isolation is in the handle.

The tenancy surface spans several subpaths:

SubpathContains
@antelopejs-private/cms/interfaces/cms/constantsSchema names and the default tenant
@antelopejs-private/cms/interfaces/cms/dbThe Tenant, TenantMember and Role tables and their models
@antelopejs-private/cms/interfaces/cms/request-tenantResolving the request's tenant
@antelopejs-private/cms/interfaces/cms/tenant-scoped-modelInjecting a tenant-bound model
@antelopejs-private/cms/interfaces/cms/tenant-accessTenant access gates
@antelopejs-private/cms/interfaces/cms/tenant-ownershipMembership and ownership writes
@antelopejs-private/cms/interfaces/cms/data-controllersThe built-in members and roles data controllers

Constants

import {
  CORE_SCHEMA_NAME,
  DEFAULT_TENANT_ID,
  DEFAULT_TENANT_NAME,
  TENANT_SCHEMA_NAME,
} from "@antelopejs-private/cms/interfaces/cms/constants";
ConstantValueMeaning
CORE_SCHEMA_NAME"cms-core"Schema for tables with a single instance across the whole backend.
TENANT_SCHEMA_NAME"cms-tenant"Schema instantiated once per tenant.
DEFAULT_TENANT_ID"default"The tenant an unauthenticated request resolves to, and the only tenant a single-tenant project uses.
DEFAULT_TENANT_NAME"Default"Display name of that tenant.

Register your own table in TENANT_SCHEMA_NAME to make it per-tenant, or in CORE_SCHEMA_NAME to keep one instance of it:

import {
  Field,
  Index,
  RegisterTable,
  Table,
} from "@antelopejs/interface-database-decorators";
import { TENANT_SCHEMA_NAME } from "@antelopejs-private/cms/interfaces/cms/constants";

@RegisterTable("invoices", TENANT_SCHEMA_NAME)
export class Invoice extends Table {
  @Field("string")
  declare _id: string;

  @Index()
  @Field("string")
  declare reference: string;
}

The Tenancy Tables

All three tables and their models are exported from @antelopejs-private/cms/interfaces/cms/db, along with their table-name constants tenantsTableName, tenantMembersTableName and ROLES_TABLE_NAME.

Tenant

Table tenants in cms-core — one row per tenant, shared across the backend.

FieldTypeNotes
_idstringTenant id. This is the instance id every per-tenant model handle takes.
namestringDisplay name.
createdAtDateCreation time, indexed.
updatedAtDateUpdate time, indexed.

TenantModel extends BasicDataModel(Tenant, tenantsTableName) and adds getMany(ids: string[]): Promise<Tenant[]>, which returns [] for an empty id list.

Being a core-schema table, TenantModel is obtained without an instance id: GetModel(TenantModel).

TenantMember

Table tenant_members in cms-tenant — one row per member of the tenant the handle is bound to.

FieldTypeNotes
_idstringMembership id.
userIdstringIndexed, related to the cms-auth User table.
roleIdsstring[]Related to Role, many. The roles whose grants make up the member's base permission set.
isTenantOwnerbooleanWhether the member owns the tenant.
joinedAtDateCreation time, indexed.
invitedBystring | nullIndexed, related to User.

TenantMemberModel extends BasicDataModel(TenantMember, tenantMembersTableName):

MethodReturns
getByUser(userId)Promise<TenantMember | undefined>
existsByUser(userId)Promise<boolean>
listAll()Promise<TenantMember[]>
listOwners()Promise<TenantMember[]> — members with isTenantOwner set
listByUserWithTenantIds(userId)Promise<Array<{ tenantId: string; member: TenantMember }>>
getOldestByUser(userId)Promise<{ tenantId: string; member: TenantMember } | undefined> — earliest joinedAt
countOwnersExcluding(excludedUserIds)Promise<number>

The two methods that return a tenantId alongside the row report which tenant each row came from, which is what a lookup that spans instances needs.

Role

Table roles in cms-tenant — the tenant's roles.

FieldTypeNotes
_idstringRole id, as referenced by TenantMember.roleIds.
createdAtDateCreation time, indexed.
updatedAtDateUpdate time, indexed.
namestringIndexed.
permissionsstring[]Granted permission ids (Permissions).

RoleModel extends BasicDataModel(Role, ROLES_TABLE_NAME) and adds getByName(name): Promise<Role | undefined>.

Resolve the Request's Tenant

getRequestTenantId(ctx) reads the tenantId claim of the request's bearer token and returns DEFAULT_TENANT_ID when there is no token or no claim.

import {
  Context,
  Controller,
  Get,
  type RequestContext,
} from "@antelopejs/interface-api";
import { AuthUser } from "@antelopejs-private/cms/interfaces/cms-auth";
import { getRequestTenantId } from "@antelopejs-private/cms/interfaces/cms/request-tenant";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

export class WorkspaceController extends Controller("/api/workspace") {
  @Get("/current")
  async current(@AuthUser() _user: User, @Context() ctx: RequestContext) {
    return { tenantId: getRequestTenantId(ctx) };
  }
}

The returned id is decoded, not verified: the function does not check the token's signature. It is safe behind a decorator that already authenticated the request — the guards from Permissions, AuthUser from the cms-auth interface, or the auth a PageController installs. Do not authorize on the id in a route that no such decorator gates.

Scope a Model to the Request's Tenant

TenantScopedModel(ModelClass) is a parameter and property decorator that injects GetModel(ModelClass, getRequestTenantId(ctx)).

import { Controller, Get } from "@antelopejs/interface-api";
import { AuthTenantMember } from "@antelopejs-private/cms/interfaces/cms/guards";
import { RoleModel } from "@antelopejs-private/cms/interfaces/cms/db";
import { TenantScopedModel } from "@antelopejs-private/cms/interfaces/cms/tenant-scoped-model";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";

export class RoleController extends Controller("/api/roles") {
  @Get("/list")
  async list(
    @AuthTenantMember() _user: User,
    @TenantScopedModel(RoleModel) roles: RoleModel,
  ) {
    return roles.getAll();
  }
}

As a property decorator it fills a data controller's @ModelReference() field:

import { ModelReference } from "@antelopejs/interface-data-api/metadata";
import { RoleModel } from "@antelopejs-private/cms/interfaces/cms/db";
import { TenantScopedModel } from "@antelopejs-private/cms/interfaces/cms/tenant-scoped-model";

// inside a DataController subclass
@ModelReference()
@TenantScopedModel(RoleModel)
declare model: RoleModel;

Only use it for a model whose table lives in TENANT_SCHEMA_NAME or another schema with one instance per tenant. For a core-schema table, take the model without an instance id.

Tenant Access Gates

A gate is a binary, tenant-level check, evaluated at every tenant authorization surface. It answers whether the tenant may use the product at all, independently of any permission set.

import { RegisterTenantAccessGate } from "@antelopejs-private/cms/interfaces/cms/tenant-access";

export async function construct(): Promise<void> {
  RegisterTenantAccessGate({
    id: "my_app.subscription-status",
    order: 100,
    gate: async (userId, tenantId) => {
      if (await tenantIsSuspended(tenantId)) {
        return { allowed: false, code: "my_app.errors.tenant_suspended" };
      }
      return { allowed: true };
    },
  });
}
type TenantAccessResult = { allowed: true } | { allowed: false; code: string };

type TenantAccessGateFn = (
  userId: string | undefined,
  tenantId: string,
) => Promise<TenantAccessResult> | TenantAccessResult;

TenantAccessGateInfo carries id, gate and order.

Semantics the interface guarantees:

  • Gates run in ascending order, ties in registration order; the first denial wins and its code is what reaches the client.
  • Registering an id twice replaces the earlier gate rather than adding a second one.
  • A gate that throws fails the request.
  • The registration is removed when the registering module unloads.
  • Register in construct() so the gate is active before the API serves requests.
  • userId is undefined for an unauthenticated request.

Two functions evaluate the gates:

FunctionBehavior
CheckTenantAccess(userId, tenantId)Resolves to the first denial, or { allowed: true }.
AssertTenantAccess(userId, tenantId)Calls CheckTenantAccess and throws a 403 whose body is the denying gate's code.
import {
  AssertTenantAccess,
  CheckTenantAccess,
} from "@antelopejs-private/cms/interfaces/cms/tenant-access";

const access = await CheckTenantAccess(user._id, tenantId);
if (!access.allowed) {
  // access.code is a machine-readable key
}

await AssertTenantAccess(user._id, tenantId); // or let it throw

AuthTenantOwner and AuthTenantMember call AssertTenantAccess unless the route opts out with bypassTenantAccessGate (Permissions).

Use a gate to deny a tenant outright; use a permissions resolver to shape what its users may do.

Memberships and Ownership

applyTenantOwnership(userModel, userId, tenantId, patch) upserts a membership row for the user in the tenant.

import { GetModel } from "@antelopejs/interface-database-decorators";
import { applyTenantOwnership } from "@antelopejs-private/cms/interfaces/cms/tenant-ownership";
import { UserModel } from "@antelopejs-private/cms/interfaces/cms-auth/db";

await applyTenantOwnership(GetModel(UserModel), userId, tenantId, {
  roleIds: ["role-editor"],
  isTenantOwner: false,
});

TenantMembershipPatch is the fourth argument:

FieldTypeDescription
roleIdsstring[]Roles the member holds after the write. Replaces the previous list.
isTenantOwnerbooleanOwnership flag after the write.
invitedBystring | nullRecorded on insert. Ignored when the membership row already exists.

When the user is not yet a member, applyTenantOwnership fires Hook.MEMBER_BEING_ADDED before the write and Hook.MEMBER_ADDED after it. For an existing member it updates roleIds and isTenantOwner and fires neither (Hooks).

Two helpers keep the legacy platform-owner flag on the user row aligned with tenant ownership:

FunctionSignature
syncPlatformOwnerOnTenantOwnerChange(userModel, userId, tenantId, nextIsTenantOwner: boolean) => Promise<void>
clearPlatformOwnerOnMemberRemoval(userModel, userId, tenantId, removedMemberWasTenantOwner: boolean) => Promise<void>

Both mirror the value onto User.owner, and both are no-ops outside the default tenant and when the backend runs in SaaS mode — where User.owner means platform ownership rather than tenant ownership. clearPlatformOwnerOnMemberRemoval additionally does nothing when the removed member was not a tenant owner. Call them after a write that changes ownership by a path other than applyTenantOwnership, which already mirrors on its own.

Built-in Data Controllers

The interface ships the two data controllers the built-in settings screens are built on. The CMS module registers both itself, so their routes exist as soon as it is loaded: reference them, do not register them again.

ExportImport fromTableBase path
memberSettingDataAPI@antelopejs-private/cms/interfaces/cms/data-controllersTenantMember/api/tables/members
roleSettingDataAPI@antelopejs-private/cms/interfaces/cms/data-controllers/rolesRole/api/tables/roles

The /data-controllers barrel re-exports the members controller only; the roles controller is imported from its own path.

import { roleSettingDataAPI } from "@antelopejs-private/cms/interfaces/cms/data-controllers/roles";
import { DefaultDataTypes } from "@antelopejs-private/cms/interfaces/cms-base/data-types/default-types";

// a role picker on one of your own form fields
const reviewerRolesField = {
  id: "reviewerRoleIds",
  label: "Reviewer roles",
  type: new DefaultDataTypes.RelationType({
    multiple: true,
    dataApiController: roleSettingDataAPI,
    keyMapping: { label: "name", value: "_id" },
  }),
};

Both bind their model with TenantScopedModel, so they read and write the request's tenant. memberSettingDataAPI exposes the member's name and email joined from the User table, the member's roles as a relation onto roleSettingDataAPI, isTenantOwner, joinedAt, isValidated and a computed connected field; roleSettingDataAPI exposes name, permissions, createdAt and updatedAt. Reference either one as the relation target of your own columns when you need a member or role picker, or feed one to a TableView to render it.

Next Steps

Invites covers bringing a user into a tenant that has no membership row for them yet.