MainSDK
Search⌘K
Interfaces

Identity and access

Tenants

tenant

Give every request a tenant and keep each customer's records, files, keys and logs apart, in the region they chose.

Building for 0.3ships as a stable APIPhase 1 · Core, public betaSpec tenancyFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

tenant tells your app which customer a request belongs to. The adapter sets it first, and every db query, kv key, file path and log record is scoped to it. Core, free on every plan.

Spec tenancy, milestone 0.3. The module is built and runs on in-memory fakes: the tenant directory is held in memory today, and durable directory drivers are planned.

At a glance

tenancy:
  isolation: shared                  # shared | schema | database
  resolve: [apikey, session, subdomain]
  host: acme.app
  personal: true
  directory: eu
  roles: [owner, admin, member, viewer]
  system: { allow: [src/admin/**, scripts/**] }
  suspended: { allow: [billing.*, tenant.export] }
  erase: { wait: 7d }
  global: [Plan, Country]              # models shared by every tenant
  databases:                           # isolation: database only
    tnt_acme: secret:ACME_DATABASE_URL

Calls

  • tenant.current()
  • tenant.maybe()
  • tenant.run(id, fn)
  • tenant.system(reason, fn)
  • tenant.anonymous(fn)
  • tenant.create({ name, kind?, region, slug?, plan? })
  • tenant.suspend(id, { reason })
  • tenant.resume(id)
  • tenant.export(id)
  • tenant.erase(id, { reason })
  • tenant.cancelErase(id)
  • new MemoryDirectory()
  • globMatch(globs, path)
  • overrideLoosens(direction, base, { value })
  • regionOfMarket(market)
  • uuidv7(now)
  • tenant.active()
  • tenant.actor()
  • tenant.as(who, fn)
  • tenant.audit(action, id, attributes?)
  • tenant.base
  • tenant.carry()
  • tenant.configValue(key)
  • tenant.forgetPerson(user)
  • tenant.handle(req, own, fn)
  • tenant.hold(id)
  • tenant.isGlobal(model)
  • tenant.isolationTarget()
  • tenant.loadOverrides(raw, base, direction)
  • tenant.personHash(user)
  • tenant.personalFor(person)
  • tenant.prefix(key)
  • tenant.principal()
  • tenant.register(driver)
  • tenant.release(id)
  • tenant.resolve(req, own?)
  • tenant.roles()
  • tenant.runJob(id, fn)
  • tenant.scope(call)
  • tenant.setPlan(id, plan)
  • tenant.store(stores)
  • tenant.subject()
  • tenant.sweep()
  • tenant.tag()
  • tenant.tenantOf(id)
  • tenant.verify(key, data, signature)

Guarantees

Whatever the driver.

  • Every request has a tenant, and every query, key and file path is scoped to it
  • A record, file or team of another tenant reads as not found
  • Each tenant is pinned to one region and nothing is written elsewhere
  • tenant.system() runs only in listed admin files and is audited with its reason
  • Log records carry the tenant, and each tenant has its own exportable audit chain

Drivers

1 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in (core (default)
tenant:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-TENANT-0010Checkmainsdk check, before deployA raw query on a tenant-scoped table has no tenant_id condition
MAIN-TENANT-0011Checkmainsdk check, before deploytenant.system() used in a file outside tenancy.system.allow
MAIN-TENANT-0012Checkmainsdk check, before deployA per-tenant config or limit override loosens the app value
MAIN-TENANT-0013Checkmainsdk check, before deployShared isolation on a driver with row-level security, but the migrations don't enable it
MAIN-TENANT-0001RuntimeError responses and logsA tenant-scoped call ran with no tenant in context (500)
MAIN-TENANT-0002RuntimeError responses and logsThe tenant doesn't exist, or the caller isn't a member or key holder (404, same body either way)
MAIN-TENANT-0003RuntimeError responses and logsThe tenant is suspended; tenant-scoped calls fail closed (403)
MAIN-TENANT-0004RuntimeError responses and logstenant.system() called from a file outside tenancy.system.allow
MAIN-TENANT-0005RuntimeError responses and logsA record, file, team or reference of another tenant was used; treated as not found (404)
MAIN-TENANT-0006RuntimeError responses and logsThe tenant's region store or database is not configured or not reachable; nothing is written elsewhere (503)
MAIN-TENANT-0007RuntimeError responses and logsErasure refused: the tenant is active, under a legal hold, or still in its waiting period (409)
MAIN-TENANT-0008RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
tenant@1 contract and the implicit default tenantPhase 10.3tenancy#contract
Tenant in context with tenant.current() and tenant.maybe()Phase 10.3tenancy#context
Tenant resolved from API keyPhase 10.3tenancy#resolvers
tenant.run() for jobs and audited tenant.system()Phase 10.3tenancy#run-system
Tenant directory, tenant.create() and personal tenantsPhase 10.3tenancy#directory
Rows isolated by tenant_id with row-level securityPhase 10.3tenancy#db-shared
Schema-per-tenant and database-per-tenant isolationPhase 10.3tenancy#db-schema-database
kvPhase 10.3tenancy#kv-files-limits
Logs tagged and one audit chain per tenantPhase 10.3tenancy#log-audit
A key per tenant for crypto-erasurePhase 10.3tenancy#keys-tenant
Tenant in every access subject and resourcePhase 10.3tenancy#access-tenant
Suspend, export and erase a tenantPhase 10.3tenancy#lifecycle
Flags by tenant and per-tenant config that only tightensPhase 10.3tenancy#config-flags
Check rules for unfiltered queries and tenant.system() outside admin codePhase 10.3tenancy#checks
Two-tenant test helperPhase 10.3tenancy#two-tenants

Quick start

main.yaml
tenancy:
  isolation: shared
  resolve: [apikey, session, subdomain]
  personal: true
app/api/orders/route.ts
import { auth, models, s, tenant } from "mainsdk";

const { orders } = models({
  orders: s.object({ item: s.string(), total: s.int() }),
}); // declared once in lib/orders.ts, shown inline so the example stands alone

export async function GET(req: Request) {
  const session = await auth.require(req);
  const acme = tenant.current();                       // set by the adapter
  const page = await orders.for(session.user).list({ limit: 20 });   // only this tenant's rows
  return Response.json({ tenant: acme.id, items: page.items });
}

tenant.current() returns { id, kind, name, slug, region, plan, status, roles }. A call that needs a tenant and finds none fails with MAIN-TENANT-0001. Use tenant.maybe() where a tenant is optional: it returns the tenant or null. Without a tenancy section, every call runs in one implicit tenant, default.

Configuration

Every key lives under tenancy:.

main.yaml
tenancy:
  isolation: shared                  # shared | schema | database
  resolve: [apikey, session, subdomain]
  host: acme.app                     # the subdomain resolver reads <slug>.acme.app
  personal: true                     # a personal tenant for each person at first sign-in
  roles: [owner, admin, member, viewer]
  global: [plans, countries]         # db models shared by all tenants; no personal fields
  system: { allow: [src/admin/**, scripts/**] }
  suspended: { allow: [billing.*, tenant.export] }
  erase: { wait: 7d }
KeyWhat it does
isolationshared keeps rows in one database with a tenant_id and row-level security. schema and database give each tenant its own
resolveHow the tenant is found, in order. The first match wins. header is a selector, never proof
hostThe domain the subdomain resolver reads: acme.app names tenant slug acme from acme.acme.app
personalCreate a personal tenant for each person at first sign-in
rolesThe roles a member can hold. Default owner, admin, member, viewer
globaldb models shared by all tenants. Only tenant.system() writes them
system.allowThe only files that may call tenant.system()
suspended.allowCalls that still work while a tenant is suspended
erase.waitThe waiting period before a tenant is erased. Default 7 days

A tenant is pinned to one region at create (us, eu or in) and nothing it owns is written elsewhere. The key that names where the directory lives is planned with the durable directory drivers.

What you get

CallWhat it does
tenant.current() and tenant.maybe()The current tenant, or null from maybe() when there is none
tenant.run(id, fn)Run a job or script as one tenant. An unknown tenant fails with MAIN-TENANT-0002
tenant.system(reason, fn)Admin code across tenants, from listed files only, with an audit record
tenant.create({ name, kind?, region, slug?, plan? })Create a tenant pinned to one region
tenant.suspend(id, { reason }) and tenant.resume(id)Stop and restart a tenant's calls
tenant.export(id)One signed bundle of the tenant's data
tenant.erase(id, { reason }) and tenant.cancelErase(id)Erase a tenant after the waiting period, or stop it

Always:

  • Every request has a tenant, and every query, key and file path is scoped to it.
  • A record, file or team of another tenant reads as not found.
  • Each tenant is pinned to one region and nothing is written elsewhere.
  • tenant.system() runs only in listed admin files and is audited with its reason.
  • Log records carry the tenant, and each tenant has its own exportable audit chain.

Examples

Run a job as a tenant

A job has no request, so it names the tenant once with tenant.run(id, fn). It refuses an unknown or suspended tenant.

jobs/digest.ts
import { tenant } from "mainsdk";

declare function sendDigest(): Promise<void>;   // your own code

export async function sendDigests(tenantIds: string[]) {
  for (const id of tenantIds) {
    await tenant.run(id, () => sendDigest());   // db, kv, files and logs inside are this tenant's
  }
}

Admin code across tenants

Cross-tenant work goes through tenant.system(reason, fn). It works only in files listed under tenancy.system.allow, and it writes an audit record with your reason.

Before anyone has signed in there is no tenant yet. tenant.anonymous(fn) runs fn in the implicit default tenant; the sign-in routes use it to send a link to an address that has no session. It grants no access to any customer's records.

src/admin/report.ts
import { tenant } from "mainsdk";

export async function countTenants() {
  return tenant.system("monthly usage report", async (sys) => {
    const page = await sys.list({ limit: 100 });
    return page.items.length;
  });
}

Use it only in admin paths. mainsdk check fails with MAIN-TENANT-0011 when it appears anywhere else.

Create a tenant

auth.orgs.create() is the call for a signed-in person making an organisation. Use tenant.create() in admin code and scripts.

scripts/seed.ts
import { tenant } from "mainsdk";

const acme = await tenant.create({ name: "Acme", kind: "org", region: "eu", slug: "acme", plan: "pro" });
console.log(acme.id);   // tnt_…

Suspend, export and erase

src/admin/offboard.ts
import { tenant } from "mainsdk";

const id = "tnt_acme";
await tenant.suspend(id, { reason: "unpaid for 60 days" });   // calls fail closed with MAIN-TENANT-0003
const bundle = await tenant.export(id);                       // { files, manifest }: JSON per driver and a signed manifest
const done = tenant.erase(id, { reason: "customer request" });   // a promise that settles after tenancy.erase.wait
await tenant.cancelErase(id);                                 // changes your mind inside the waiting period; `done` rejects
await tenant.resume(id);

tenant.erase() schedules the erasure and its promise resolves with the signed record only once the waiting period has passed and every driver has erased, so do not await it in a request. Calling tenant.cancelErase() rejects it with MAIN-TENANT-0007 and returns the tenant to suspended. Erasure needs the tenant suspended first and no legal hold, or it fails with MAIN-TENANT-0007. A tenant id or slug that is invalid or taken, or a missing name or region, is refused when you create it.

Reference: more calls

The calls below are for adapters, admin code and tests. Most apps never call them, because the adapter and db use them for you.

Read the context

CallWhat it does
tenant.active()The tenant of this context when the app has a tenancy section, else null. Interfaces use it to scope their records.
tenant.principal()The person or agent of this context, as set by tenant.as() or the signed-in actor. Empty outside one.
tenant.actor()The member or system actor of this context, or undefined in code with no caller.
tenant.subject()The API key subject of this request, or undefined when it was not made with a key.
tenant.roles()The caller's roles in the tenant they came in through. They carry into tenant.system().
tenant.tag()The id that tags log and audit records: the tenant, system inside tenant.system(), or undefined.
tenant.tenantOf(id)The tenant behind an id as tenant.current() would show it, or null for an unknown or erased tenant.
tenant.isolationTarget()Where the current tenant's rows live under schema and database isolation.
tenant.isGlobal(model)true when a model is listed under tenancy.global, so it has no tenant column.
tenant.configValue(key)A config value for the current tenant: its accepted override, else the app value.

Scope a call

CallWhat it does
tenant.scope(call)For a call named like files.url, the current tenant, or null when the app has no tenancy section. It throws MAIN-TENANT-0001 with no tenant, and MAIN-TENANT-0003 when the tenant is suspended and the call isn't under tenancy.suspended.allow.
tenant.prefix(key)Prefix a kv, cache or limits key with the tenant, as t:<id>:<key>.
tenant.store(stores)Pick the store for the current tenant's region from { us, eu, in }. It throws MAIN-TENANT-0006 when that region has none, and never returns another region's store.

Resolve and enter

CallWhat it does
tenant.resolve(req, own?)Resolve a request's tenant with the resolvers in tenancy.resolve, first match wins. A subdomain or header only selects: the caller must be a member or hold a key, or the answer is the same 404 as an unknown tenant. Returns null when no resolver names one.
tenant.handle(req, own, fn)Resolve the request's tenant and run fn inside it. With no tenant resolved, fn runs with none. Adapters call this.
tenant.baseA property: identity sources every request gets, such as API key resolution. Per-call sources win.
tenant.as(who, fn)Run fn as a person or an agent, and the person it acts for, in the current tenant. For routes and tests.
tenant.runJob(id, fn)Run a job for the tenant it was enqueued in. A suspended tenant's job is deferred, not run, and logged.
tenant.carry()The id to store with a job when you enqueue it, so the worker can restore the tenant. null with no tenant.
tenant.personalFor(person)On first sign-in with tenancy.personal: true, create the person's personal tenant in the region of their market, or return the one they have. Returns null when personal tenants are off.

Lifecycle and audit

CallWhat it does
tenant.setPlan(id, plan)Set the plan name shown in the directory and in tenant.current().plan. entitlements calls it.
tenant.hold(id) and tenant.release(id)Put a legal hold on a tenant, so tenant.erase() refuses it, and lift it.
tenant.register(driver)Register a driver that holds tenant data, so export and erase reach it.
tenant.sweep()Finish every erasure whose waiting period ended, such as after a restart lost the timers.
tenant.audit(action, id, attributes?)Write a record on the system audit chain with the acting caller as actor.
tenant.verify(key, data, signature)Check a signature made by an export manifest or an erasure record.
tenant.personHash(user)A keyed hash of a user id, for the directory's pseudonymous membership index.
tenant.forgetPerson(user)Delete a person's index entries when they are erased.
tenant.loadOverrides(raw, base, direction)Load per-tenant config overrides from tenancy.overrides or a push. An override that loosens is refused with MAIN-TENANT-0012, logged, and the last good value stays.

Helpers

These pure functions are exported for adapters and tests.

FunctionWhat it does
regionOfMarket(market)The region of a market code: India to in, European markets to eu, everything else to us.
overrideLoosens(direction, base, { value })true when an override moves a number the way the schema forbids: above the base for lower, below it for raise.
globMatch(globs, path)true when a path matches any glob, such as src/admin/**. * stays within a folder, ** crosses folders.
uuidv7(now)A UUIDv7 string: 48 bits of the given time in milliseconds, then random bits.
new MemoryDirectory()The in-memory tenant directory. Set down = true to make every call reject, to test the 60-second last-known-status fallback.

Errors and checks

CodeWhenFix
MAIN-TENANT-0001A tenant-scoped call ran with no tenant in contextRun jobs inside tenant.run(id, fn)
MAIN-TENANT-0002The tenant doesn't exist, or the caller isn't a member or key holder (404)Switch to a tenant you belong to
MAIN-TENANT-0003The tenant is suspendedResume it, or list the call under tenancy.suspended.allow
MAIN-TENANT-0004tenant.system() called from an unlisted fileMove the admin code under a listed path
MAIN-TENANT-0005A record, file or team of another tenant was used (404)Read it inside its own tenant
MAIN-TENANT-0006The tenant's region store isn't configured or reachableConfigure the store for that region
MAIN-TENANT-0007Erasure refusedSuspend first, lift the hold, or wait
MAIN-TENANT-0008tenant.create or auth.orgs.create got input it can't use: no name, a region other than us, eu or in (400), or an id or slug that is invalid or taken (409)Pass { name, region }, and an id such as tnt_acme or a slug nobody has
MAIN-TENANT-0010 to 0013Check findings: an unfiltered raw query, tenant.system() outside admin code, a loosening override, missing row-level securitySee check codes

Swapping drivers

tenant has no driver. Isolation is an option of the db driver (shared, schema or database), so you change it under tenancy.isolation and db.

Testing

createTestApp() reads your tenancy section and keeps the directory in memory. Create two tenants and run the same code in each with tenant.run().

test/tenant.test.ts
import { it } from "node:test";
import assert from "node:assert/strict";
import { db, s, tenant } from "mainsdk";
import { createTestApp } from "@mainsdk/testing";

it("tenancy AC8 keeps one tenant's rows from another", async () => {
  createTestApp({ yaml: "schema: 1\nproject: prj_test\ntenancy:\n  resolve: [session]\n" });
  const { orders } = db.define({ orders: s.object({ item: s.string() }) });
  const a = await tenant.create({ name: "A", region: "eu" });
  const b = await tenant.create({ name: "B", region: "us" });
  await tenant.run(a.id, () => orders.for("u1").create({ item: "x" }));
  const seen = await tenant.run(b.id, () => orders.for("u1").list({ limit: 20 }));
  assert.equal(seen.items.length, 0);
});

A suite that runs every test as two tenants and fails on any cross-tenant read is planned (spec tenancy). See the testing kit.

Security and privacy

  • A record, file or team of another tenant reads as not found, with the same body as one that does not exist.
  • Each tenant has its own key under the region key, so erasing the key erases the tenant's data.
  • The directory holds ids, slugs, regions and status only. It holds no personal data.
  • Every log record carries the tenant, and each tenant has its own audit chain that can be exported alone.

FAQ

Do I pass the tenant id to db? No. The query is scoped for you. Passing ids by hand is how data leaks.

My app has one customer. Do I need this? No. Without a tenancy section there is one implicit tenant, default.

Can a person belong to several tenants? Yes. auth.orgs.switch() sets the active one for the session.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet