Documentation
Tenancy
The tenant() middleware extracts a tenant identifier from each request and decorates the context so all downstream handlers can call getTenantId(ctx). This is the foundation of multi-tenant data isolation in Kynetra FX.
How tenant resolution works#
tenant(options?) from @kynetra/fx-saas tries two sources in order:
- Header — reads the
x-tenant-idheader (configurable viaheaderoption). - Subdomain — if the header is absent, takes the first label of the
Hostheader (e.g.acme.api.example.comyieldsacme).
If neither source resolves a tenant and required is true (the default), the middleware returns 400 FX_TENANT_REQUIRED.
import { createFX } from '@kynetra/fx'import { tenant, getTenantId } from '@kynetra/fx-saas' const app = createFX({ runtime: 'cloudflare' }) // Resolve tenant before any route handlerapp.use(tenant({ required: true })) app.get('/data', (ctx) => { const tenantId = getTenantId(ctx) // string return ctx.json({ tenantId })})tenant() options
| Name | Type | Description |
|---|---|---|
| header | string | Request header to read the tenant ID from. Defaults to "x-tenant-id". |
| required | boolean | When true (default), return 400 FX_TENANT_REQUIRED if no tenant resolves. Set to false to allow requests without a tenant. |
Reading the tenant ID#
After tenant() runs, call getTenantId(ctx) anywhere in the handler chain. It returns the resolved tenant ID string.
import { getTenantId } from '@kynetra/fx-saas'import { kernel } from './kernel' app.get('/issues', async (ctx) => { const tenantId = getTenantId(ctx) // Scope all queries to the tenant const issues = await kernel.users.list({ tenantId }) return ctx.json({ issues })})Header-based tenancy#
The most common pattern for API clients: the caller passes x-tenant-id on every request. Suitable when your API is consumed by a machine (another service or your own frontend that knows the tenant context).
# RequestGET /issues HTTP/1.1Host: api.example.comAuthorization: Bearer eyJ...x-tenant-id: org_01HTXYZSubdomain-based tenancy#
For browser-based SaaS apps, routing per-tenant traffic through subdomains is common. The tenant() middleware extracts the first subdomain label automatically when the header is absent.
# acme.api.example.com -> tenantId = 'acme'# globex.api.example.com -> tenantId = 'globex'Tip
*.api.example.com) and configure your Worker route to match it. Each tenant gets its own subdomain without any extra infrastructure.Custom header name#
Override the default header if you use a different convention:
app.use(tenant({ header: 'x-org-id', required: true }))Tenant-scoped data patterns#
Tenant isolation is enforced at the query layer, not the middleware layer. The middleware only resolves the ID — your repositories must use it consistently.
With the SaaS kernel
All tenant-scoped entities in the SaaS kernel carry a tenantId field. Pass it as a filter to every list() call.
import { getTenantId } from '@kynetra/fx-saas'import { kernel } from './kernel' app.get('/workspaces', async (ctx) => { const tenantId = getTenantId(ctx) const workspaces = await kernel.workspaces.list({ tenantId }) return ctx.json({ workspaces })}) app.post('/workspaces', async (ctx) => { const tenantId = getTenantId(ctx) const body = await ctx.jsonBody<{ name: string }>() const workspace = await kernel.workspaces.create({ id: crypto.randomUUID(), tenantId, name: body.name, createdAt: new Date().toISOString(), }) return ctx.json({ workspace }, { status: 201 })})With D1
When using the D1 store adapter, include tenantId in every SQL predicate. The d1Store adapter passes filter fields as WHERE clauses, so this happens automatically when you call list({ tenantId }).
import { d1Store } from '@kynetra/fx-cloudflare'import { createSaasKernel } from '@kynetra/fx-saas' return { async fetch(request: Request, env: Env) { const store = d1Store(env.DB) const kernel = createSaasKernel({ store }) // All kernel.X.list({ tenantId }) calls scope to the tenant return app.fetch(request) }}Combining tenant with auth#
The standard middleware order is: authenticate first, then resolve the tenant, so that getTenantId and getPrincipal are both available in handlers. Optionally, you can assert the authenticated principal belongs to the resolved tenant.
import { requireAuth, jwtAuth, getPrincipal } from '@kynetra/fx-auth'import { tenant, getTenantId } from '@kynetra/fx-saas'import { FXError } from '@kynetra/fx' app.use(requireAuth([jwtAuth({ secret: env.JWT_SECRET })]))app.use(tenant({ required: true })) // Optional: assert principal belongs to the tenantapp.use(async (ctx, next) => { const principal = getPrincipal(ctx) const tenantId = getTenantId(ctx) if (principal.tenantId && principal.tenantId !== tenantId) { throw new FXError(403, 'FX_FORBIDDEN', 'Tenant mismatch') } return next()})FX_TENANT_REQUIRED#
When required: true and no tenant can be resolved from the header or subdomain, the middleware short-circuits with a 400 response:
// HTTP 400{ "error": { "code": "FX_TENANT_REQUIRED", "message": "Tenant ID is required" }}Note
tenantId, and Audit Logs for tenant-scoped audit trails.