@setu-ts/multi-tenancy-plugin
Multi-tenancy: tenant resolution, data isolation, and cache-key scoping. Registers an
IMultiTenancyService under CAPABILITIES.MULTI_TENANCY ('multi-tenancy') and a tenant
middleware at priority 40.
Installation
import { MultiTenancyPlugin } from '@setu-ts/multi-tenancy-plugin';
Usage
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { MultiTenancyPlugin } from '@setu-ts/multi-tenancy-plugin';
import { CAPABILITIES, type IMultiTenancyService, type IRequestContext } from '@setu-ts/common';
// Your application's own entity — a stand-in so this compiles as written.
interface Order {
id: string;
total: number;
}
const app = createApplication({
plugins: [
RuntimePlugin(),
MultiTenancyPlugin({
resolver: 'subdomain',
subdomain: { baseDomain: 'example.com' },
database: 'column-per-tenant',
required: true,
}),
],
});
// Plugins register during `start()`, so the capability is resolvable only after it.
await app.start({ port: 3000 });
const tenancy = app.services.get<IMultiTenancyService>(CAPABILITIES.MULTI_TENANCY);
// In a route handler the middleware has resolved `ctx.request.tenant` first.
export async function listOrders(ctx: IRequestContext): Promise<readonly Order[]> {
const orders = tenancy.getRepository<Order, string>(ctx, 'Order');
return orders.findAll();
}
The resolved tenant is also available as ctx.request.tenant.
Resolvers
'subdomain', 'header', 'path', and 'jwt' — or an array of them, tried in order.
subdomain takes an optional baseDomain. With one, it constrains resolution to that domain: a
request for an unrelated host does not resolve a tenant. Without one, the first label of any
multi-label host is the tenant id, and a single-label host such as localhost resolves nothing.
jwt reads an UNVERIFIED claim — the tenant id comes from a token whose signature nobody has
checked, so a client can mint a token naming any tenant. Use it only alongside authentication
middleware which separately verifies the token (e.g. AuthPlugin). A register() warning fires
whenever the resolved chain contains a JwtResolver.
Tenant from the signed-in principal
Use a custom ITenantResolver when the tenant must come from a verified principal rather than the
built-in resolver’s unverified JWT decode. Register it above passive authentication’s priority 300,
for example 310:
import { none, some, type ITenantResolver } from '@setu-ts/common';
import { MultiTenancyPlugin } from '@setu-ts/multi-tenancy-plugin';
const resolver: ITenantResolver = {
resolve(request) {
const tenantId = request.user?.claims?.tenantId;
return Promise.resolve(typeof tenantId === 'string' ? some({ id: tenantId }): none());
},
};
const tenancy = MultiTenancyPlugin({ resolver, middlewarePriority: 310, required: true });
Compose tenancy with AuthPlugin configured to verify credentials. Everything before 310 sees no
tenant: localization’s tenantLocale at 45, and any cache, rate-limit or feature-flag lookup in the
40–310 band cannot use it. Move dependent readers after resolution or use them per route. See
session tenant binding
for the session check that also handles late resolution.
ITenantRepository offers findAll(), findById, find(filter), create, update, and
delete; it has no paging, ordering or expression-filter options. For those, use IDatabaseService
repositories and scope every operation to the verified tenant yourself:
- Reads: filter on the tenant column (
findOne({ where: { id, tenantId } })), neverfindById(id)with a client-supplied id. - Writes: use
updateWhere(id, { tenantId }, data)anddeleteWhere(id, { tenantId }), which apply only while the stored row still carries that tenant. Key-onlyupdate(id, data)anddelete(id)select by key alone, so a client-supplied id can reach another tenant’s row — putting the tenant column indatadoes not prevent that. - Where a data source lacks conditional writes (the call rejects with
conditional-write), do not fall back to a key-only write or to a read-then-write ownership check, which races a concurrent delete-and-recreate. Isolate the tenant’s data physically instead (a schema or database per tenant).
Isolation strategies
A strategy NAMES the isolation an ITenantDataStore is expected to implement — selecting one does
not by itself create schemas or databases. The shipped MemoryTenantDataStore uses the strategy’s
label as its partition-map key, so all three isolate correctly on it, and a store may ignore
isolation metadata entirely (see ITenantDataStore.useIsolation).
The shipped database bridge, createDatabaseTenantDataStore from @setu-ts/database-plugin, is the
one store told the strategy over a real backend. It supports 'column-per-tenant' only: it
conjoins the tenant column to every read and stamps it on every write. Selecting
'schema-per-tenant' or 'database-per-tenant' with it throws
TenantStoreStrategyUnsupportedError at startup, because IRepository offers no schema or database
switch — a silent accept would be a store that claims isolation it does not deliver.
'schema-per-tenant' and 'database-per-tenant' therefore still require a store you inject
yourself.
database | Strategy names |
|---|---|
'column-per-tenant' | a tenant column on shared tables (default) |
'schema-per-tenant' | a per-tenant schema the injected store implements |
'database-per-tenant' | a per-tenant database the injected store implements |
You may also pass a custom ITenantIsolationStrategy.
A register() warning fires when a non-'column-per-tenant' strategy is selected and no
dataStore is injected: the default memory store cannot deliver physical isolation, so the
selection is flagged rather than silently logical-only.
Options
| Option | Type | Default | Description |
|---|---|---|---|
resolver | ResolverConfig | required | Resolver or ordered chain. |
subdomain | SubdomainResolverOptions | — | Options forwarded to SubdomainResolver when the chain contains one. |
header | HeaderResolverOptions | — | Options forwarded to HeaderResolver. |
path | PathResolverOptions | — | Options forwarded to PathResolver. |
jwt | JwtResolverOptions | — | Options forwarded to JwtResolver. The tenant claim is unverified — require authentication middleware alongside it. |
database | DatabaseStrategyKind | ITenantIsolationStrategy | 'column-per-tenant' | Isolation strategy. |
dataStore | ITenantDataStore | RegistryFactory<ITenantDataStore> | MemoryTenantDataStore | Backing store for tenant records. A factory (for example createDatabaseTenantDataStore()) is resolved once at onInit, so it may read a capability such as CAPABILITIES.DATABASE. |
cache | TenantCacheOptions | — | Cache-prefix behaviour. |
required | boolean | false | Short-circuit when no tenant resolves. |
rejectionStatus | number | 400 | Status used when short-circuiting. |
middlewarePriority | number | 40 | Priority passed to ctx.middleware.add. |
exclude | readonly PathPattern[] | six operational paths | Paths skipped entirely; a match goes straight to next(). [] exempts nothing. |
An empty resolver chain and a malformed injected dataStore both fail at register(), not
per request.
Non-HTTP ingress (a tenant concern in a behaviour)
getRepositoryFor(tenantId, entity) is the ctx-free entry point: the IRequestContext-taking
members are unreachable from a non-HTTP path, which has no request to resolve a tenant from. An
ingress behaviour reads the tenant id from the work item’s own payload and scopes through it. The id
is TRUSTED INPUT — nothing resolves it — so on the HTTP path keep using getRepository(ctx, …),
which reads the middleware-resolved tenant.
import type { IIngressBehavior, IngressContext, RegistryFactory } from '@setu-ts/common';
import { CAPABILITIES, type IMultiTenancyService } from '@setu-ts/common';
import { MessagingPlugin } from '@setu-ts/messaging-plugin';
// The tenant concern, expressed ONCE for this ingress kind. RegistryFactory
// entries resolve during `onInit`, so the behaviour holds the resolved service.
const tenantScopedWrite: RegistryFactory<IIngressBehavior> = (services) => {
const tenancy = services.get<IMultiTenancyService>(CAPABILITIES.MULTI_TENANCY);
return {
handle: (ctx: IngressContext, next: () => Promise<void>) => {
const payload = ctx.payload as { tenantId: string; event: { id: string } };
const audit = tenancy.getRepositoryFor<{ id: string }>(payload.tenantId, 'Audit');
return audit.create({ id: payload.event.id }).then(() => next());
},
};
};
export const messaging = MessagingPlugin({
broker: 'memory',
behaviors: [tenantScopedWrite],
});
Cache isolation
prefixCacheKey(tenantId, key) scopes cache keys so one tenant cannot read another’s entries. The
middleware publishes the active prefix under TENANT_CACHE_PREFIX_STATE_KEY, readable with
getTenantCachePrefix(ctx).
Exports
| Export | Kind |
|---|---|
getTenantCachePrefix | function |
MultiTenancyPlugin | function |
tenantMiddleware | function |
ColumnPerTenant | class |
DatabasePerTenant | class |
HeaderResolver | class |
JwtResolver | class |
MemoryTenantDataStore | class |
PathResolver | class |
SchemaPerTenant | class |
SubdomainResolver | class |
TenantDataStoreNotReadyError | class |
TenantNotResolvedError | class |
CAPABILITIES | const |
TENANT_CACHE_PREFIX_STATE_KEY | const |
HeaderResolverOptions | interface |
IMultiTenancyService | interface |
ITenant | interface |
ITenantDataStore | interface |
ITenantRepository | interface |
ITenantResolver | interface |
JwtResolverOptions | interface |
MemoryTenantDataStoreOptions | interface |
MultiTenancyPluginOptions | interface |
PathResolverOptions | interface |
SubdomainResolverOptions | interface |
TenantCacheOptions | interface |
DatabaseStrategyKind | type |
ITenantIsolationStrategy | type |
ResolverConfig | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
API reference
Multi-Tenancy Plugin (@setu-ts/multi-tenancy-plugin)
Provides multi-tenancy support: tenant resolution, tenant context, tenant-scoped repositories,
cache-key isolation, and pluggable database-isolation strategies. The cache-key isolation is applied
by cacheMiddleware (from @setu-ts/cache-plugin) reading ctx.request.tenant and composing a
length-prefixed tenant segment into the cache key — not by the cache.prefix string stamped into
ctx.state, which no package reads.
Registration
import { MultiTenancyPlugin } from '@setu-ts/multi-tenancy-plugin';
app.register(
MultiTenancyPlugin({
resolver: 'header',
header: { name: 'x-tenant-id' },
database: 'column-per-tenant',
cache: { prefix: true },
}),
);
Usage
app.router.get('/users', async (ctx) => {
const tenancy = ctx.services.get<IMultiTenancyService>('multi-tenancy');
const tenant = ctx.request.tenant;
// Tenant-aware repository — reads ctx.request.tenant set by middleware
const userRepo = tenancy.getRepository<User>(ctx, 'User');
const users = await userRepo.findAll(); // Scoped to current tenant
// Cache-key prefixing
const prefixed = tenancy.prefixCacheKey(tenant!.id, 'users:list'); // 'acme:users:list'
return ctx.response.json(users);
});
Note: getRepository requires ctx because the framework has no ambient request context (no
AsyncLocalStorage). The middleware resolves the tenant first; getRepository reads it from
ctx.request.tenant. Calling getRepository before the middleware runs throws
TenantNotResolvedError.
Non-HTTP work uses getRepositoryFor(tenantId, entity) (since 0.4.0) — the ctx-free entry
point, modelled on prefixCacheKey: a queue processor, scheduled job, or ingress behaviour holds no
IRequestContext, so the tenant id comes from the work item’s own payload and the repository scopes
to the id GIVEN. The id is trusted input — nothing resolves it — so keep using
getRepository(ctx, …) on the HTTP path, where the middleware-resolved tenant is authoritative:
// Inside an ingress behaviour (or any background work): no IRequestContext exists.
const payload = ctx.payload as { tenantId: string };
const repo = tenancy.getRepositoryFor<{ id: string }>(payload.tenantId, 'Order');
await repo.create({ id: 'ord-1' });
Options
| Option | Type | Required | Description |
|---|---|---|---|
resolver | 'subdomain' | 'header' | 'path' | 'jwt' | ITenantResolver | ITenantResolver[] | yes | Discriminant for tenant resolution method, or a custom resolver / array of resolvers |
subdomain.baseDomain? | string | no | Stripped from host to isolate tenant label; absent → first host label is tenant id |
header.name? | string | no | Header to read; default 'x-tenant-id' |
path.segment? | number | no | Path segment index to read; default 0 |
jwt.claim? | string | no | JWT claim to read; default 'tenant_id' |
jwt.headerName? | string | no | Authorization header name; default 'authorization' |
jwt.decode? | (token: string) => Record<string, unknown> | null | no | Custom decoder; falls back to IJwtService.decode from CAPABILITIES.JWT if absent |
database | 'column-per-tenant' | 'schema-per-tenant' | 'database-per-tenant' | ITenantIsolationStrategy | no | Isolation strategy the data store is expected to implement; default 'column-per-tenant' |
dataStore? | ITenantDataStore | RegistryFactory<ITenantDataStore> | no | Injected CRUD backend; default MemoryTenantDataStore. A factory (for example createDatabaseTenantDataStore()) is resolved once at onInit, so it may read a capability such as CAPABILITIES.DATABASE — DatabasePlugin may be registered after the tenancy plugin |
cache.prefix? | boolean | no | When true, stamps resolved prefix into ctx.state via getTenantCachePrefix() |
cache.separator? | string | no | Default ':' — used by prefixCacheKey and the ctx.state stamp |
required? | boolean | no | When true, short-circuits unresolved requests at 400 without calling next() |
rejectionStatus? | number | no | Status code for required-tenant short-circuit; default 400 |
exclude? | readonly (string | RegExp)[] | no | Paths that skip tenant resolution entirely (exact string or RegExp.test); default the six operational probes (/live, /ready, /health, /metrics, /openapi.json, /docs); [] restores the previous behaviour |
middlewarePriority? | number | no | Priority for auto-added middleware; default 40 |
Exports
| Symbol | Kind | Description |
|---|---|---|
MultiTenancyPlugin | function | Plugin factory — registers IMultiTenancyService under CAPABILITIES.MULTI_TENANCY |
tenantMiddleware | function | Middleware factory — resolve tenant, set ctx.request.tenant, short-circuit when required |
getTenantCachePrefix | function | Accessor reading the tenant cache prefix from ctx.state |
TENANT_CACHE_PREFIX_STATE_KEY | const | Module-level key constant ('multi-tenancy-plugin:cache-prefix') |
SubdomainResolver | class | Resolves tenant from URL host subdomain |
HeaderResolver | class | Resolves tenant from configurable HTTP header |
PathResolver | class | Resolves tenant from configurable path segment index |
JwtResolver | class | Resolves tenant from JWT claim (uses unverified decode) |
ColumnPerTenant | class | Column-per-tenant isolation strategy (default) |
SchemaPerTenant | class | Schema-per-tenant isolation strategy |
DatabasePerTenant | class | Database-per-tenant isolation strategy |
MemoryTenantDataStore | class | Zero-dependency in-process default store with cross-tenant isolation |
TenantDataStoreNotReadyError | class | Thrown by getRepository/getRepositoryFor before a factory dataStore resolves in onInit |
TenantNotResolvedError | class | Thrown by getRepository when tenant has not been resolved |
MultiTenancyPluginOptions | interface | The argument type of MultiTenancyPlugin(...) |
ResolverConfig | type | The resolver option’s type — discriminant string, custom resolver, or chain |
DatabaseStrategyKind | type | The database option’s discriminant strings |
SubdomainResolverOptions, HeaderResolverOptions, PathResolverOptions, JwtResolverOptions | interfaces | Per-resolver option shapes, also the resolver constructors’ argument types |
TenantCacheOptions | interface | The cache option shape (prefix, separator) |
MemoryTenantDataStoreOptions | interface | MemoryTenantDataStore constructor options (generateId) |
ITenantDataStore | interface | Internal port for tenant-scoped CRUD (app injection seam) |
ITenantIsolationStrategy | type | Discriminated union with 'column' | 'schema' | 'database' arms; narrow on kind |
Re-exported from common: IMultiTenancyService, ITenantRepository, ITenant, ITenantResolver, CAPABILITIES | types/const | Convenience re-exports — canonical definitions stay in @setu-ts/common |
Notes
- No npm dependencies. All resolvers parse strings; strategies are pure derivation; memory store is in-process maps; cache prefixing is concatenation.
JwtResolveruses unverified decode. Tenant identity comes from an unsigned token claim. This is acceptable only alongside auth middleware which separately verifies the token.PathResolverreads path segments by index, not router:paramvalues. The resolver receivesIRequest(notIRequestContext), so it cannot accessctx.params.SubdomainResolver’sbaseDomainconstrains resolution, it does not merely strip. WithbaseDomain: 'example.com', only a strict subdomain resolves (acme.example.com→acme, left-most label for deeper hosts, port ignored). The apex (example.com), an unrelated host (evil.com), and a bare suffix match (notexample.com) all resolve to no tenant. WithoutbaseDomain, the first label of any multi-label host is the tenant and a single-label host (localhost) resolves to none.MemoryTenantDataStorehands out detached snapshots. Every entity returned bycreate/findAll/find/findById/updateis a shallow copy, so mutating a returned object never rewrites the stored record. Read paths (findAll/find/findById/delete/a missingupdate) allocate no partition, so requests carrying unknown tenant ids cannot grow the store.- Strategy-derived partitioning. The strategy passed to
register()is handed to the data store viauseIsolation().MemoryTenantDataStorederives its partition scope from it (column-stamping; the schema/database labels as partition-map keys — no physical schema or database is created). A store that never receivesuseIsolationpartitions by raw tenant id. - A strategy NAMES the isolation; a store delivers it. The shipped database bridge,
createDatabaseTenantDataStorefrom@setu-ts/database-plugin, is the one store told the strategy over a real backend, and it supports'column'only: it conjoins the tenant column to every read and stamps it on every write, and throwsTenantStoreStrategyUnsupportedErrorat startup for'schema'/'database'(IRepository offers no schema or database switch). Its column is the strategy’s (new ColumnPerTenant('org_id')names another), and key lookups go through the repository’s ownfindById, so a primary key not namedidis addressed as its adapter is configured. Aregister()warning fires when a non-'column'strategy is selected with nodataStoreinjected, and theJwtResolverarm ofresolverwarns that tenant identity comes from an UNVERIFIED claim. - Auto-added at priority 40. Runs after observability (metrics 20 / telemetry 30), before auth (300). Exported for manual re-ordering.