Kynetra FX

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-id header (configurable via header option).
  • Subdomain — if the header is absent, takes the first label of the Host header (e.g. acme.api.example.com yields acme).

If neither source resolves a tenant and required is true (the default), the middleware returns 400 FX_TENANT_REQUIRED.

src/app.ts
import { createFX } from '@kynetra/fx'
import { tenant, getTenantId } from '@kynetra/fx-saas'
 
const app = createFX({ runtime: 'cloudflare' })
 
// Resolve tenant before any route handler
app.use(tenant({ required: true }))
 
app.get('/data', (ctx) => {
const tenantId = getTenantId(ctx) // string
return ctx.json({ tenantId })
})

tenant() options

NameTypeDescription
headerstringRequest header to read the tenant ID from. Defaults to "x-tenant-id".
requiredbooleanWhen 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.

src/handlers/issues.ts
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).

# Request
GET /issues HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJ...
x-tenant-id: org_01HTXYZ

Subdomain-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

On Cloudflare Workers, set a wildcard DNS record (*.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:

src/app.ts
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.

src/handlers/workspace.ts
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 }).

src/app.ts
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.

src/app.ts
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 tenant
app.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

See SaaS Kernel for the full list of repositories that carry tenantId, and Audit Logs for tenant-scoped audit trails.