@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 } })), never findById(id) with a client-supplied id.
  • Writes: use updateWhere(id, { tenantId }, data) and deleteWhere(id, { tenantId }), which apply only while the stored row still carries that tenant. Key-only update(id, data) and delete(id) select by key alone, so a client-supplied id can reach another tenant’s row — putting the tenant column in data does 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.

databaseStrategy 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

OptionTypeDefaultDescription
resolverResolverConfigrequiredResolver or ordered chain.
subdomainSubdomainResolverOptions—Options forwarded to SubdomainResolver when the chain contains one.
headerHeaderResolverOptions—Options forwarded to HeaderResolver.
pathPathResolverOptions—Options forwarded to PathResolver.
jwtJwtResolverOptions—Options forwarded to JwtResolver. The tenant claim is unverified — require authentication middleware alongside it.
databaseDatabaseStrategyKind | ITenantIsolationStrategy'column-per-tenant'Isolation strategy.
dataStoreITenantDataStore | RegistryFactory<ITenantDataStore>MemoryTenantDataStoreBacking store for tenant records. A factory (for example createDatabaseTenantDataStore()) is resolved once at onInit, so it may read a capability such as CAPABILITIES.DATABASE.
cacheTenantCacheOptions—Cache-prefix behaviour.
requiredbooleanfalseShort-circuit when no tenant resolves.
rejectionStatusnumber400Status used when short-circuiting.
middlewarePrioritynumber40Priority passed to ctx.middleware.add.
excludereadonly PathPattern[]six operational pathsPaths 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

ExportKind
getTenantCachePrefixfunction
MultiTenancyPluginfunction
tenantMiddlewarefunction
ColumnPerTenantclass
DatabasePerTenantclass
HeaderResolverclass
JwtResolverclass
MemoryTenantDataStoreclass
PathResolverclass
SchemaPerTenantclass
SubdomainResolverclass
TenantDataStoreNotReadyErrorclass
TenantNotResolvedErrorclass
CAPABILITIESconst
TENANT_CACHE_PREFIX_STATE_KEYconst
HeaderResolverOptionsinterface
IMultiTenancyServiceinterface
ITenantinterface
ITenantDataStoreinterface
ITenantRepositoryinterface
ITenantResolverinterface
JwtResolverOptionsinterface
MemoryTenantDataStoreOptionsinterface
MultiTenancyPluginOptionsinterface
PathResolverOptionsinterface
SubdomainResolverOptionsinterface
TenantCacheOptionsinterface
DatabaseStrategyKindtype
ITenantIsolationStrategytype
ResolverConfigtype

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

OptionTypeRequiredDescription
resolver'subdomain' | 'header' | 'path' | 'jwt' | ITenantResolver | ITenantResolver[]yesDiscriminant for tenant resolution method, or a custom resolver / array of resolvers
subdomain.baseDomain?stringnoStripped from host to isolate tenant label; absent → first host label is tenant id
header.name?stringnoHeader to read; default 'x-tenant-id'
path.segment?numbernoPath segment index to read; default 0
jwt.claim?stringnoJWT claim to read; default 'tenant_id'
jwt.headerName?stringnoAuthorization header name; default 'authorization'
jwt.decode?(token: string) => Record<string, unknown> | nullnoCustom decoder; falls back to IJwtService.decode from CAPABILITIES.JWT if absent
database'column-per-tenant' | 'schema-per-tenant' | 'database-per-tenant' | ITenantIsolationStrategynoIsolation strategy the data store is expected to implement; default 'column-per-tenant'
dataStore?ITenantDataStore | RegistryFactory<ITenantDataStore>noInjected 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?booleannoWhen true, stamps resolved prefix into ctx.state via getTenantCachePrefix()
cache.separator?stringnoDefault ':' — used by prefixCacheKey and the ctx.state stamp
required?booleannoWhen true, short-circuits unresolved requests at 400 without calling next()
rejectionStatus?numbernoStatus code for required-tenant short-circuit; default 400
exclude?readonly (string | RegExp)[]noPaths 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?numbernoPriority for auto-added middleware; default 40

Exports

SymbolKindDescription
MultiTenancyPluginfunctionPlugin factory — registers IMultiTenancyService under CAPABILITIES.MULTI_TENANCY
tenantMiddlewarefunctionMiddleware factory — resolve tenant, set ctx.request.tenant, short-circuit when required
getTenantCachePrefixfunctionAccessor reading the tenant cache prefix from ctx.state
TENANT_CACHE_PREFIX_STATE_KEYconstModule-level key constant ('multi-tenancy-plugin:cache-prefix')
SubdomainResolverclassResolves tenant from URL host subdomain
HeaderResolverclassResolves tenant from configurable HTTP header
PathResolverclassResolves tenant from configurable path segment index
JwtResolverclassResolves tenant from JWT claim (uses unverified decode)
ColumnPerTenantclassColumn-per-tenant isolation strategy (default)
SchemaPerTenantclassSchema-per-tenant isolation strategy
DatabasePerTenantclassDatabase-per-tenant isolation strategy
MemoryTenantDataStoreclassZero-dependency in-process default store with cross-tenant isolation
TenantDataStoreNotReadyErrorclassThrown by getRepository/getRepositoryFor before a factory dataStore resolves in onInit
TenantNotResolvedErrorclassThrown by getRepository when tenant has not been resolved
MultiTenancyPluginOptionsinterfaceThe argument type of MultiTenancyPlugin(...)
ResolverConfigtypeThe resolver option’s type — discriminant string, custom resolver, or chain
DatabaseStrategyKindtypeThe database option’s discriminant strings
SubdomainResolverOptions, HeaderResolverOptions, PathResolverOptions, JwtResolverOptionsinterfacesPer-resolver option shapes, also the resolver constructors’ argument types
TenantCacheOptionsinterfaceThe cache option shape (prefix, separator)
MemoryTenantDataStoreOptionsinterfaceMemoryTenantDataStore constructor options (generateId)
ITenantDataStoreinterfaceInternal port for tenant-scoped CRUD (app injection seam)
ITenantIsolationStrategytypeDiscriminated union with 'column' | 'schema' | 'database' arms; narrow on kind
Re-exported from common: IMultiTenancyService, ITenantRepository, ITenant, ITenantResolver, CAPABILITIEStypes/constConvenience 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.
  • JwtResolver uses unverified decode. Tenant identity comes from an unsigned token claim. This is acceptable only alongside auth middleware which separately verifies the token.
  • PathResolver reads path segments by index, not router :param values. The resolver receives IRequest (not IRequestContext), so it cannot access ctx.params.
  • SubdomainResolver’s baseDomain constrains resolution, it does not merely strip. With baseDomain: '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. Without baseDomain, the first label of any multi-label host is the tenant and a single-label host (localhost) resolves to none.
  • MemoryTenantDataStore hands out detached snapshots. Every entity returned by create/findAll/find/findById/update is a shallow copy, so mutating a returned object never rewrites the stored record. Read paths (findAll/find/findById/delete/a missing update) 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 via useIsolation(). MemoryTenantDataStore derives 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 receives useIsolation partitions by raw tenant id.
  • A strategy NAMES the isolation; a store delivers it. The shipped database bridge, createDatabaseTenantDataStore from @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 throws TenantStoreStrategyUnsupportedError at 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 own findById, so a primary key not named id is addressed as its adapter is configured. A register() warning fires when a non-'column' strategy is selected with no dataStore injected, and the JwtResolver arm of resolver warns 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.

Agent card · TypeDoc API reference