Apache-2.0 · multi-tenancy

Every tenant’s boundary, held at the database

A tenant directory with DNS-label slugs and three roles, request→tenant resolution split into untrusted extraction and membership-checked authorization, and row-level-security isolation the scoped executor enforces per transaction — the tenantId billing-kit assumes, made true.

Get started$ npm i @quxkit/tenant-kit
Apache-2.0
tenant-kit crystal
0
roles — owner, admin, member — and nothing you did not ask for
1
SqlExecutor, shared with billing-kit — one pool for both
0
ways to “trust the subdomain” by accident — it is not expressible
RLS
forced per table; the scoped executor sets the tenant per transaction
What it is

One library, whole — not a platform you rent

Tenant directory

DNS-label slugs, memberships with three roles, archive-never-delete. The registry the rest of your app resolves against.

Resolution, split

Untrusted extraction and membership-checked authorization are separate steps, so “trust the subdomain” is not expressible by accident.

RLS isolation

tenancy.protect() forces row-level security per table; the scoped executor sets the tenant for the transaction and nothing leaks across.

One SqlExecutor

The same seam billing-kit consumes: one opaque id, one connection pool, no second source of truth for who a tenant is.

Archive, never delete

Offboarding archives; it never destroys history. The audit trail of who belonged when stays intact.

Enterprise adapters

SSO over any OIDC IdP, SCIM 2.0 provisioning, and RBAC-engine bridges — over the same seams, opt-in.

By the shape of it

Isolation you can turn off by forgetting a WHERE clause is not isolation. Hold the boundary at the database.

3 roles
34% Owner
33% Admin
33% Member

Owner, admin, member — the whole authorization surface, on purpose.

How it works

Three moves, in order

01
Extract

Read the tenant hint from the request — subdomain, header, path — as untrusted input.

02
Authorize

Check the caller’s membership against the directory. Only a real member of a real tenant passes.

03
Scope

Run the work inside a transaction the scoped executor has bound to that tenant, with RLS enforcing it.

tenant-kit.ts
import { resolve, protect } from 'tenant-kit';

// untrusted extraction, then a membership-checked authorization
const tenant = await resolve(sql, { host: req.headers.host, user });

// RLS forced per table; the scoped executor binds the tenant per transaction
await protect(sql, tenant, async (db) => {
  return db.query('select * from invoices');   // only this tenant's rows
});

Isolation you can turn off by forgetting a WHERE clause is not isolation. Hold the boundary at the database.

tenant-kit

Embed it in the app you already run