Multi-tenancy for Node, framework-agnostic. A tenant context built on AsyncLocalStorage plus automatic, fail-closed query scoping for Mongoose. Single-database, row-level isolation.
The idea is borrowed from Tenancy for Laravel and rebuilt from scratch for Node and TypeScript. The core has zero dependencies. The Mongoose adapter is optional and loaded from a separate entrypoint.
ESM-only. This package ships ES modules. Use it from an ESM project (
"type": "module"or.mjs). It cannot berequire()d from CommonJS.
In a single-database SaaS, every tenant shares the same collections. Isolation usually means remembering to add { ownerId } to every query. Forget one and you leak another tenant's data.
tenantkit makes the scope implicit and fail-closed: set the tenant once per request, and every scoped query filters automatically. Writes are stamped with the active tenant, and the active tenant always wins over a value sent by the client. A query that runs with no tenant context throws instead of returning everyone's rows.
flowchart TD
A[HTTP request] --> B["tenantMiddleware(resolve)"]
B -->|await resolve req| C{tenant id?}
C -->|yes| D["runWithTenant(id, () => next())"]
C -->|"no (required: true)"| E["next(MissingTenantContextError)"]
D --> F[("AsyncLocalStorage<br/>tenant context")]
F --> G[route handler]
G --> H["Model.find / create / update / aggregate / bulkWrite"]
H --> I["tenantScopePlugin pre-hooks"]
I --> J["resolveTenantScope() reads the context"]
J -->|tenant present| K["inject filter / $match<br/>force-stamp writes"]
J -->|"no context (fail-closed)"| L["throw MissingTenantContextError"]
K --> M[("MongoDB<br/>scoped to the tenant")]
Two layers:
- Core (
@flavio.martil/tenantkit, zero dependencies): theAsyncLocalStoragetenant context and an identification middleware. Reusable in any Node app. - Mongoose adapter (
@flavio.martil/tenantkit/mongoose): a schema plugin that reads the context inside Mongoose pre-hooks and scopes reads, writes, aggregations and bulk writes.
bun add @flavio.martil/tenantkit
# or: npm install @flavio.martil/tenantkitMongoose is an optional peer dependency, only needed for the adapter.
- Set the tenant per request. The resolver may be sync or async. By default the middleware is fail-closed: if it cannot resolve a tenant, it forwards
MissingTenantContextErrorto your error handler.
import { tenantMiddleware } from "@flavio.martil/tenantkit";
app.use(tenantMiddleware((req) => req.user?.ownerId));
// async resolver and a custom miss handler
app.use(
tenantMiddleware(async (req) => resolveTenantFromApiKey(req.headers["x-api-key"]), {
onMissing: (_req, res) => res.status(401).end(),
})
);
// public routes that legitimately have no tenant
app.use("/public", tenantMiddleware(() => undefined, { required: false }));- Register the plugin on the models you want scoped.
import { tenantScopePlugin } from "@flavio.martil/tenantkit/mongoose";
caseSchema.plugin(tenantScopePlugin, { tenantKey: "ownerId", ref: "User" });
// string / UUID / slug tenant ids
orgSchema.plugin(tenantScopePlugin, { tenantKey: "orgId", idType: "string" });- Query normally. The filter and the stamp are applied for you.
await Case.find(); // scoped to the current tenant
await Case.create({ title: "x" }); // stamped with the current tenant
await Case.create({ ownerId: otherTenant, title: "x" }); // ownerId is overwritten with the active tenantrunWithTenant accepts a string or anything with a meaningful toString() (a Mongoose ObjectId works) and stores the string form. Plain objects and arrays are rejected with InvalidTenantIdError, so a value coming from untrusted input such as ?tenant[$ne]= cannot turn into a NoSQL operator.
With the default idType: "objectId", a 24-hex tenant id is cast to an ObjectId before filtering so it matches an ObjectId field. For string/UUID/slug ids, pass idType: "string".
Work that runs outside a request (cron jobs, webhooks, seeds, migrations) has no tenant. Pick one:
import { runWithTenant, runWithoutTenant } from "@flavio.martil/tenantkit";
// run a job as a specific tenant
await runWithTenant(ownerId, () => processTenant(ownerId));
// run an admin task across all tenants, scoping disabled.
// await INSIDE the callback so the context covers query execution.
await runWithoutTenant(() => Case.find().exec());Or let a specific model run unscoped when there is no context:
auditLogSchema.plugin(tenantScopePlugin, { allowUnscoped: true });Scope is read at query execution time, bound to the async context active when the query runs. Hand work to a queue or an interval that started outside runWithTenant and you must re-establish the context inside the worker.
Auto-scoped reads and deletes: find, findOne, findOneAndUpdate, findOneAndDelete, findOneAndReplace, countDocuments, distinct, exists, updateOne, updateMany, deleteOne, deleteMany, replaceOne, aggregate, and bulkWrite.
Auto-stamped writes: save, create, insertMany, plus the replacement document on replaceOne / findOneAndReplace. The active tenant overwrites any tenant key in the payload, and update operations cannot reassign the tenant key (it is forced back and stripped from $unset / $rename).
These bypass Mongoose middleware by design and stay your responsibility. Keep manual tenant filters on them.
- Raw driver access:
Model.collection.find/updateMany/deleteMany,Model.db.collection(...). Skips every plugin hook. Never run untrusted input through the raw driver. - Aggregation across collections:
$lookup,$graphLookup,$unionWithsub-pipelines read foreign collections unscoped. Add a tenant$matchinside each sub-pipeline. - Aggregation write stages:
$outand$mergewrite to a target collection unscoped. estimatedDocumentCount(): takes no filter, returns the collection-wide count.
Core (@flavio.martil/tenantkit):
runWithTenant(tenantId, fn)runsfnwith the tenant in context.runWithoutTenant(fn)runsfnwith scoping disabled.getTenantId()/requireTenantId()/hasTenantContext()/isUnscoped().tenantMiddleware(resolve, options?),options: { required?: boolean (default true); onMissing?: (req, res, next) => void }.MissingTenantContextError,InvalidTenantIdError.
Mongoose adapter (@flavio.martil/tenantkit/mongoose):
tenantScopePlugin(schema, options).options: { tenantKey?: string (default "tenantId"); allowUnscoped?: boolean; addField?: boolean; ref?: string; idType?: "objectId" | "string" }.- Pure helpers exported for custom hooks:
resolveTenantScope,castTenantId,forceStamp,sanitizeUpdate,scopeBulkOps,injectAggregateMatch.
Concept from Tenancy for Laravel. Hardened against a consolidated review (Claude, GPT-5.4 and GLM) that closed cross-tenant write injection, bulk-write fail-open, tenant-key reassignment and NoSQL operator injection.
MIT