Skip to content

Package reference

Mirrors the package README (single source). Install @basaltkit/tenancy v3.1.0 — npm · source.

<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>

@basaltkit/tenancy ​

Multi-tenancy for Basalt applications: automatically identifies which customer (tenant) each request belongs to — by subdomain, custom domain, header, or route — and makes it available at ctx().tenant throughout the application.

You need this module when the same application serves multiple customers/organizations with separate data (the typical SaaS model).

What this module solves ​

Multi-tenancy means a single application deployment serves several "tenants" — companies, teams, or organizations — each with its own data, like a building where each unit has its own key. The challenge is: when an HTTP request arrives, how does the application know which tenant it belongs to? And how do you ensure the code that runs afterward always "knows" which tenant it's in, without passing that value from function to function?

This module solves both parts. First, the resolvers: small functions that look at the request and identify the tenant — by subdomain (acme.myapp.com → tenant acme), by a customer's own domain (app.acme.com), by a header (x-tenant-id), or by a route parameter (/t/acme/...). You can combine several: the ones the platform controls (subdomain, domain, route) decide first, and a header is only a fallback.

Second, the context: once resolved, the tenant is placed in ctx().tenant (using Node's AsyncLocalStorage — an "invisible thread" that follows each request), accessible in any handler, service, or hook without passing arguments. You can also run code "as" a tenant outside an HTTP request (jobs, migrations) with tenancy.run(), and iterate over all tenants with tenancy.forEach().

Installation ​

bash
pnpm add @basaltkit/tenancy

Get started in 5 minutes ​

  1. Define where tenants come from (in production, your database; for experimenting, memory):
ts
import { MemoryTenantSource } from '@basaltkit/tenancy'

const source = new MemoryTenantSource()
  .add({ id: 'acme', name: 'Acme Inc' })
  .add({ id: 'globex', name: 'Globex' })
  1. Register the plugin with a resolver:
ts
import { createApp, ctx } from '@basaltkit/core'
import { fastifyPlugin, route } from '@basaltkit/fastify'
import { tenancyPlugin, headerResolver, MemoryTenantSource } from '@basaltkit/tenancy'

const source = new MemoryTenantSource().add({ id: 'acme', name: 'Acme Inc' })

const app = await createApp({
  plugins: [
    tenancyPlugin({
      source,
      resolvers: [headerResolver()], // reads the x-tenant-id header
    }),
    fastifyPlugin({
      routes: [
        route({
          method: 'GET',
          url: '/whoami',
          async handler() {
            return { tenant: ctx().tenant?.id ?? null }
          },
        }),
      ],
    }),
  ],
}).boot()
  1. Test it:
bash
curl http://localhost:3000/whoami -H 'x-tenant-id: acme'
# → { "tenant": "acme" }

curl http://localhost:3000/whoami
# → { "tenant": null }  (request with no tenant — allowed, because required is false)
  1. To always require a tenant, pass required: true — unresolved requests get a 404 TENANCY_NOT_RESOLVED.

Usage guide ​

Choosing the resolver ​

ts
import {
  subdomainResolver, domainResolver, headerResolver, routeResolver,
} from '@basaltkit/tenancy'

// acme.myapp.com → tenant "acme" (ignores www, the base domain, and nested subdomains)
subdomainResolver({ base: 'myapp.com' })

// Customer's own domain: app.acme.com → source.findByDomain('app.acme.com')
domainResolver()

// HTTP header (default x-tenant-id; customizable)
headerResolver({ header: 'x-org' })

// Route parameter: /t/:tenant/... (default 'tenant')
routeResolver({ param: 'tenant' })

You can pass several in resolvers: [...]. They come in two kinds:

  • Authoritative — subdomainResolver, domainResolver, routeResolver, and any custom resolver wrapped in authoritative(fn). They read something the platform controls, so they are always consulted first (in list order; the first reference that loads a tenant wins). If one of them names a tenant that does not exist, the request resolves to no tenant — it does not fall through. nosuch.myapp.com + x-tenant-id: globex is not globex, and a header can never override a real subdomain, whatever the list order.
  • Fallback — headerResolver and unmarked custom resolvers. Consulted, in list order, only when no authoritative resolver named anything (the bare apex, localhost, www); the first that loads an existing tenant wins.

A reference whose id fails the tenant-id grammar (validateTenantId, default isValidTenantId) or whose domain is not a hostname counts as not found and never reaches the source. Want disagreement to be an error rather than a precedence rule? Pass onConflict: 'error': every resolver runs, and two that load different tenants answer 400 TENANCY_CONFLICT.

Connecting to your database (TenantSource) ​

ts
import type { TenantSource, Tenant } from '@basaltkit/tenancy'

const source: TenantSource = {
  async find(id) { /* SELECT ... WHERE id = ? */ return null },
  // Optional — required for domainResolver():
  async findByDomain(domain) { /* SELECT ... WHERE domain = ? */ return null },
  // Optional — required for tenancy.forEach():
  async list() { return [] },
}

The Tenant type only requires id: string; add whatever fields you want (name, plan, domains, …). Note: MemoryTenantSource's findByDomain looks up the tenant's domains: string[] field.

Running code as a tenant (jobs, scripts) ​

Outside an HTTP request there's no resolver — use the Tenancy facade:

ts
import { TENANCY } from '@basaltkit/tenancy'
import { ctx } from '@basaltkit/core'

const tenancy = app.container.get(TENANCY)

// Runs the function with ctx().tenant = acme (outer context preserved and restored)
await tenancy.run('acme', async () => {
  console.log(ctx().tenant?.id) // 'acme'
})

// Bulk maintenance: visits every tenant, each in its own context,
// with limited concurrency (default 5)
await tenancy.forEach(
  async (tenant) => { /* e.g. run the tenant's migrations */ },
  { concurrency: 2 },
)

run() accepts either the Tenant object or the id (which is loaded from the source; if it doesn't exist, it throws TenantNotFoundError). Either way the id must pass the tenant-id grammar — run({ id: '../x' }, …) throws InvalidTenantIdError (400) instead of making ../x the context tenant.

Fail-closed scoping — tenantScoped() and friends ​

Identifying the tenant is only half the job; every query still has to use it. The risk is specific and silent: with Prisma, where: { tenantId: undefined } doesn't filter — it drops the condition and returns every tenant's rows. A helper that returns undefined when there is no tenant is therefore a cross-tenant leak waiting for one missing context.

The tenantScoped() family fails closed instead. All three throw TenantRequiredError (TENANT_REQUIRED, HTTP 400) rather than producing a filter that quietly disappears:

ts
import { requireTenant, requireTenantId, tenantScoped } from '@basaltkit/tenancy'

// The whole Tenant record, or throw
const tenant = requireTenant()

// Just the id, or throw
const id = requireTenantId()

// A `where` clause that is always scoped
const rows = await db.project.findMany({ where: tenantScoped({ archived: false }) })
// → { archived: false, tenantId: '<context tenant>' }
FunctionSignatureBehaviour
requireTenant()(): TenantThe active context's tenant, or throws.
requireTenantId(fallback?)(fallback?: string): stringThe context tenant id; else fallback; else throws.
tenantScoped(where?)<W>(where?: W): W & { tenantId: string }Your where with tenantId spread last.

Two properties matter:

Anti-widening. A tenant in context always wins over fallback. fallback may carry client input, and it must never be able to widen or switch the scope of a request. It is honoured only when there is no context tenant at all — which is what lets system code (jobs, CLI commands) pin one tenant deliberately.

Non-overridable filter. tenantScoped spreads tenantId last, so a tenantId smuggled into where by client input cannot override the context tenant. A tenantId in where is never used as a fallback either: where is routinely built from client input ({ ...req.query }), so with no context tenant tenantScoped() throws TenantRequiredError rather than let the client pick the tenant. System code that must pin one calls requireTenantId(id) explicitly, or runs inside tenancy.run(id, …).

Use them everywhere a repository touches tenant-owned data. requireTenantId() inside a queue job throws unless you wrapped the job body in tenancy.run(tenantId, …) — which is the point: a background job that lost its tenant should fail, not read the whole table.

Reacting to tenant changes (hook) ​

Whenever execution enters a tenant context (in a resolved HTTP request or via run), the tenancy:switched hook fires:

ts
app.hooks.on('tenancy:switched', ({ tenant }) => {
  console.log('working for tenant', tenant.id)
})

CLI commands (basalt tenant:*) ​

Registering tenancyPlugin wires five CLI commands (run via the @basaltkit/cli runner):

bash
basalt tenant:list                          # every tenant (needs source.list)
basalt tenant:create acme --name=Acme       # needs source.create or save; refuses an existing id
basalt tenant:migrate                       # run onMigrate for every tenant…
basalt tenant:migrate --tenant=acme         # …or just one
basalt tenant:seed --tenant=acme            # run onSeed inside the tenant context
basalt tenant:run acme queue:stats          # run ANY command as that tenant

tenant:migrate / tenant:seed run the per-tenant hooks you pass to the plugin — you own the DB-specific work, the framework iterates tenants and enters each context for you:

ts
tenancyPlugin({
  source, resolvers,
  onMigrate: (tenant) => runPrismaMigrateFor(tenant),
  onSeed:    (tenant) => seedDefaultsFor(tenant),
})

tenant:run <id> <command> [args] resolves any plugin-registered command and runs it inside <id>'s context — e.g. basalt tenant:run acme queue:retry.

API reference ​

tenancyPlugin(options) ​

NameTypeRequired?DefaultDescription
sourceTenantSourceYes—Where tenants are loaded from.
resolversTenantResolver[]Yes—Authoritative resolvers (subdomain, domain, route, authoritative(fn)) first — an unknown tenant they name resolves to none; fallbacks (header) only when no authoritative resolver named anything. Within each group, the first that loads a tenant wins.
onConflict'precedence' | 'error'No'precedence''error' runs every resolver and answers 400 TENANCY_CONFLICT when two load different tenants.
requiredboolean | { except: (string | RegExp)[] }Nofalsetrue → a request with no tenant gets a 404 TENANCY_NOT_RESOLVED. { except: ['/health'] } exempts those paths (matched without the query string) and guards everything else.
onMigrate(tenant: Tenant) => void | Promise<void>No—Per-tenant migration work for basalt tenant:migrate. The framework iterates tenants and enters each context; you do the DB-specific part. Without it the command errors.
onSeed(tenant: Tenant) => void | Promise<void>No—Per-tenant seeding for basalt tenant:seed, same contract.
onProvision(tenant: Tenant) => void | Promise<void>No—Brings a NEW tenant's storage into existence, inside its context, from tenancy.create() and basalt tenant:create. Without it a tenant is routable before its schema exists.
onDeprovision(tenant: Tenant) => void | Promise<void>No—Tears that storage down, inside the tenant's context, from tenancy.destroy(). Without it the record goes and the schema stays.
provision'inline' | 'deferred'No'inline''inline' — create() waits, so the tenant is usable when it returns. 'deferred' — create() returns immediately with status provisioning and the resolver answers 503 until tenancy.provision(id) runs.
canonicalDomain(tenant: Tenant) => string | undefinedNo—The address a new tenant is reachable at, added to tenant.domains by tenancy.create() before the record is persisted — so every creation path gets it instead of each one remembering. Return undefined to decline.

A route can override required for itself with meta.tenant — false marks it central, true requires a tenant even when the app-wide default is off:

ts
route({ method: 'GET', url: '/pricing', meta: { tenant: false }, handler })

Without canonicalDomain a tenant is created with no domains entry, and nothing says so: subdomainResolver answers from the Host without consulting the table. The tenant works; what is missing is the record that the address is its own, so domainResolver cannot find it and nothing stops a second tenant claiming the same one.

ts
tenancyPlugin({ source, resolvers, canonicalDomain: (tenant) => `${tenant.id}.${process.env.APP_DOMAIN}` })

It is added to whatever the tenant already declares, never substituted: sources replace the whole domain set on save, so substituting would erase a customer's own domain the next time anything called create().

The plugin also adds the tenancy:active marker to the container metadata. Other packages read it — string-keyed, so no package coupling — to adopt tenant-safe defaults; @basaltkit/cache, for example, fails closed on a missing tenant scope once the app is known to be multi-tenant.

The plugin registers the facade in the container under the TENANCY token, and an HTTP enricher that resolves the tenant for each request, places it in ctx().tenant, and emits tenancy:switched.

Before a resolved tenant is placed in the context, the enricher checks its status (assertTenantServing(tenant), also exported):

statusResult
absent, null or readyServes. A record with no status predates provisioning and must keep serving.
provisioning / failed / deleting503 TENANT_NOT_READY — the storage is not (or no longer) usable; a client may retry.
suspended403 TENANT_SUSPENDED — the app locked the account out; retrying will not help.
anything else (active, disabled, …)500 TENANT_STATUS_UNKNOWN — fails closed rather than guessing.

isTenantReady(tenant) is the boolean form: true only for the first row.

Tenancy class ​

Normally created by the plugin. To build one directly (a test, a script without the plugin), pass a TenancyOptions object:

ts
const tenancy = new Tenancy({
  source,                          // TenantSource — required
  resolvers: [headerResolver()],   // TenantResolver[] — required
  hooks,                           // HookBus — where tenancy:* events are emitted
  onProvision, onDeprovision,      // same as the plugin options
  provisionMode: 'deferred',       // the plugin's `provision` option; default 'inline'
  canonicalDomain,
  validateTenantId,                // default isValidTenantId
  onConflict: 'error',             // default 'precedence'
})

The positional form new Tenancy(source, resolvers, hooks?, onProvision?, provisionMode?, onDeprovision?, canonicalDomain?, validateTenantId?, { onConflict }?) still works, but nine positional arguments are easy to misalign — prefer the object.

MethodReturnsDescription
current()Tenant | undefinedThe tenant of the active context.
find(id)Promise<Tenant | null>Looks it up in the source.
create(tenant)Promise<Tenant>Persists a new tenant, applies canonicalDomain, runs onProvision and emits tenancy:created. The creation path — the source only writes the row. An id that already exists is refused with TenantAlreadyExistsError (409) before anything is written; retry a failed tenant with provision(id), update one with source.save().
provision(tenantOrId)Promise<Tenant>Runs onProvision for a tenant left provisioning by provision: 'deferred', then flips it to ready.
destroy(id, { force? })Promise<void>Marks the tenant deleting, runs onDeprovision in its context and removes the record. force removes it even if the teardown threw.
resolve(request)Promise<Tenant | null>Runs the resolvers over { headers?, params?, url? } — authoritative first, fallbacks only if none named a tenant. Throws TenantResolutionConflictError under onConflict: 'error'.
run(tenantOrId, fn)Promise<T>Runs fn with ctx().tenant set; emits tenancy:switched. Throws InvalidTenantIdError for an id outside the grammar.
forEach(fn, { concurrency? })Promise<void>Runs fn for each tenant (requires source.list); default concurrency 5.

Resolvers ​

FunctionOptionsReturns
subdomainResolver(options)base: string (required){ id: subdomain }; ignores www, the base domain, nested subdomains, and the port. Authoritative.
domainResolver()—{ domain: host }; requires source.findByDomain. Authoritative.
headerResolver(options?)header?: string (default 'x-tenant-id'){ id: headerValue }. Fallback (client-controlled).
routeResolver(options?)param?: string (default 'tenant'){ id: params[param] }. Authoritative.
authoritative(fn)—Marks a custom resolver as authoritative (e.g. one reading a claim your gateway signed).

A Host outside the hostname grammar (userinfo, path, %, non-ASCII, IP literal) matches nothing.

A TenantResolver is (request: ResolutionRequest) => TenantRef | null | Promise<...> with an optional authoritative?: boolean property, where TenantRef is { id: string } or { domain: string }. You can write your own — it's just a function; unmarked it is a fallback. (Advanced.)

Types and errors ​

ExportDescription
Tenant{ id: string; [key: string]: unknown }.
TenantSourcefind (required), findByDomain?, list?, create? (insert-only — refuses an existing id), save? (upsert), delete?.
MemoryTenantSourceIn-memory source with a chainable .add(tenant) — dev/tests.
ResolutionRequest, TenantRef, TenantResolverResolver types. Advanced.
TENANCYInjection token: container.get(TENANCY) → Tenancy.
requireTenant, requireTenantId, tenantScopedFail-closed scoping helpers — see above.
CustomDomains, MemoryDomainStore, DomainStore, CustomDomain, DnsVerification, CustomDomainsOptionsCustom-domain registration + DNS TXT ownership verification.
normalizeDomain(input) / tryNormalizeDomain(input)Canonicalizes a domain/Host: lowercase, trim, strip a numeric port and trailing dots, then validate the RFC 1123 hostname grammar ([a-z0-9.-] labels, ≤ 253 chars, last label not all digits). Anything else — userinfo (acme.app@evil.com), a path, %65, full-width or other non-ASCII characters, IPv4/IPv6 literals — is rejected, never rewritten: normalizeDomain throws InvalidDomainError (400), tryNormalizeDomain returns null. IDNs must be passed in xn-- form (domainToASCII() from node:url). The same function backs registration, lookup and the Host resolver.
findByVerifiedDomain(customDomains, find)Builds a findByDomain that resolves only verified domains — wire it into your TenantSource so a forged Host header can never resolve.

Custom domains ​

CustomDomains adds ownership proof around the domain resolver: register a domain (unverified), publish the returned _basalt-verify.<domain> TXT record, then verify(). Only verified domains resolve, via findByVerifiedDomain.

An unverified claim does not hold a domain forever: after claimTtlMs (72 h by default) another tenant's add() takes it over. With challengeSecret set, the real owner does not have to wait — it publishes the record from challenge(tenantId, domain) and its add() wins immediately, verified (the verified TXT wins). Set reservedDomains to your platform apex so no tenant can claim it or a subdomain of it. verify(tenantId, domain, { force: true }) re-checks an already-verified domain and un-verifies it if the record is gone. For a scheduled job, use the system-level reverify(domain) / reverifyAll({ domains? }) instead: they need no tenant id, un-verify only when DNS definitively says the record is gone (NXDOMAIN, no TXT, no matching value — a timeout or SERVFAIL is reported as dns-error and changes nothing), and un-verify conditionally (a claim that changed hands meanwhile is left alone). reverifyAll() reads DomainStore.listVerified(), or takes { domains }.

A stale verified claim also yields to the new owner of a lapsed domain: with challengeSecret set, the new owner publishes its challenge() record and calls add(); when that lookup no longer shows the incumbent's record, the domain is handed over, verified. While the incumbent's record is still published (or the lookup fails), add() throws DomainTakenError.

CustomDomainsOptions:

OptionTypeDefaultPurpose
storeDomainStoreMemoryDomainStoreWhere domain records live. A durable store must back add() with a UNIQUE constraint — it is the atomic uniqueness gate that stops a second tenant stealing a domain.
now() => numberDate.nowInjectable clock.
token() => string24 random bytes, base64urlVerification-token generator.
resolveTxt(hostname) => Promise<string[][]>node:dns/promises resolveTxtInjectable DNS lookup (tests, or a custom resolver).
claimTtlMsnumber72 * 60 * 60 * 1000How long an unverified claim holds a domain before another tenant can take it. Verified domains never expire (a stale one yields only to a challenge() record).
reservedDomainsstring[][]Your platform's own domains; each and every subdomain of it is refused with DomainReservedError.
challengeSecretstring—Enables challenge(): the owner proves DNS control and takes over a squatted unverified claim at once. Same value on every instance.

A durable DomainStore should also implement replace(expected, next) as a conditional update, so handing an expired claim over (and un-verifying one in reverify()) is atomic, and listVerified() for reverifyAll().

Failure modes & troubleshooting ​

ErrorCodeHTTPWhen
TenantRequiredErrorTENANT_REQUIRED400requireTenant() / requireTenantId() / tenantScoped() ran with no tenant in context and no fallback. Fails closed rather than querying unscoped.
TenancyNotResolvedErrorTENANCY_NOT_RESOLVED404No resolver produced a tenant and the plugin was configured required: true.
TenantNotFoundErrorTENANT_NOT_FOUND500run() was given an id absent from the source — also raised by forEach() when the source has no list().
TenantNotReadyErrorTENANT_NOT_READY503A request resolved to a tenant whose status is provisioning, failed or deleting. The tenant exists; its storage is not serving.
TenantSuspendedErrorTENANT_SUSPENDED403A request resolved to a tenant whose status is suspended (set by the app — billing, abuse). Retrying will not help.
TenantStatusUnknownErrorTENANT_STATUS_UNKNOWN500A request resolved to a tenant whose status is none of the above (active, disabled, a typo). Fails closed: tenancy cannot tell whether its storage is usable. Store ready (or no status) to serve, suspended to lock out.
TenantAlreadyExistsErrorTENANT_ALREADY_EXISTS409tenancy.create() (or a source's create()) for an id that already exists. Nothing is written and onProvision does not run. A failed/provisioning tenant is retried with tenancy.provision(id); an intentional update is source.save().
DomainTakenErrorDOMAIN_TAKEN409The domain is already registered (by any tenant).
DomainNotFoundErrorDOMAIN_NOT_FOUND404Acting on a domain that isn't registered.
DomainForbiddenErrorDOMAIN_FORBIDDEN403Acting on a domain that belongs to a different tenant.
DomainReservedErrorDOMAIN_RESERVED403add() for a domain in reservedDomains or a subdomain of one.
InvalidDomainErrorDOMAIN_INVALID400A value that is not a hostname (see normalizeDomain).
InvalidTenantIdErrorTENANT_ID_INVALID400create(), run(), provision(id) or destroy(id) with an id outside the grammar.
TenantResolutionConflictErrorTENANCY_CONFLICT400onConflict: 'error' and two resolvers loaded different tenants.

TenantNotFoundError declares no status, so adapters surface it as a generic 500 INTERNAL_ERROR; the others carry the code above.

  • TENANT_REQUIRED inside a queue job — jobs don't inherit the request context. Wrap the body in tenancy.run(tenantId, …), or pass an explicit fallback to requireTenantId(id).
  • ctx().tenant is undefined even though the header is set — either the header named an unknown (or grammar-invalid) id, or an authoritative resolver already named a tenant from the Host: a header is only consulted when the subdomain/domain/route resolvers named nothing.
  • A verified custom domain stopped resolving — a reverify() / reverifyAll() (or force) re-check found the TXT record missing and un-verified it. Re-publish the record and verify again.

Hooks & events ​

HookPayloadWhen
tenancy:switched{ tenant: Tenant }Whenever execution enters a tenant context — a resolved HTTP request, or tenancy.run() (including each iteration of forEach()).

The plugin also declares ctx().tenant?: Tenant on RequestContext, so the context is typed everywhere once this package is installed.

Common errors and solutions (FAQ) ​

"ctx().tenant is always undefined." Check: (1) tenancyPlugin is registered before you read the context; (2) the request actually carries what the resolver expects (correct header, correct subdomain); (3) the tenant exists in the source — an unknown id from a header is ignored, and an unknown subdomain/domain resolves to no tenant at all (it does not fall through to the header).

"404 TENANCY_NOT_RESOLVED on requests that should pass." You have required: true and no resolver managed to load a tenant. For "central" routes (landing page, sign-up) use required: false and handle the absence of a tenant in the handler.

"subdomainResolver doesn't catch a.b.myapp.com." Intentional: it only accepts a single level of subdomain; www and the base domain are also ignored.

"domainResolver always returns null." Your TenantSource needs to implement findByDomain. In MemoryTenantSource, the tenant must have the domains: ['app.acme.com'] field.

"tenancy.forEach() throws TenantNotFoundError." Your source doesn't implement the optional list() method — it's required for forEach.

"Tenants disappear on restart." MemoryTenantSource lives in memory; implement TenantSource on top of your database.

How it connects to other modules ​

  • @basaltkit/core — provides the per-request context (ctx(), AsyncLocalStorage) where the tenant is placed, and the hook bus (tenancy:switched).
  • @basaltkit/fastify — runs the enricher that resolves the tenant on each HTTP request.
  • @basaltkit/auth — independent, but complementary: auth says who the user is, tenancy says where (in which organization) the request is happening. API keys created within a tenant are scoped to it.
  • @basaltkit/permissions — uses ctx().tenant.id as the default scope: permissions granted in one tenant don't apply in another.
  • @basaltkit/teams — teams are the members of a tenant; team routes require ctx().tenant to be set by this module.

Security best practices ​

  • Never trust a tenant header coming from the browser in production. headerResolver is great for development and internal traffic, but a user can manually send x-tenant-id: another-customer. In production, prefer subdomainResolver/domainResolver (DNS is under your control) and always verify that the authenticated user belongs to the resolved tenant (the teamRole guard from @basaltkit/teams does this).
  • Isolate tenant data in your queries. This module identifies the tenant; it's up to your code to use ctx().tenant.id in every database query. A query without a tenant filter is a data leak between customers.
  • Use required: true in application areas so that a misrouted request fails loudly (404) instead of running with no tenant and touching global data.
  • Be careful with custom domains: only accept a domain in findByDomain after the customer has proven they control it. CustomDomains + findByVerifiedDomain do exactly this — without them, someone can point a domain at your application and impersonate another tenant.
  • Scope with tenantScoped(), not by hand. A hand-written where that forgets tenantId, or supplies undefined, reads every tenant's rows.

Guides: Tenancy · Creating a tenant · Database per tenant · Teams · Authorization.

Released under the MIT License.