npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@setu-ts/multi-tenancy-plugin

v0.11.0

Published

Tenant resolution by subdomain, header, path, or JWT, with schema/database/column isolation

Readme

@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.

| 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.