Multitenancy
As of 0.42.0 DaloyJS ships tenancy(), a dependency-free, secure-by-default Hooks bundle that resolves the calling tenant once per request, validates and normalizes it, and exposes it on ctx.state.tenant. It is the single source of truth for “who is this request for” so the per-tenant isolation knobs already on the framework (rateLimit, concurrencyLimit, idempotency, responseCache) can all key off the same resolved value via tenantScope().
Quick start
Resolve the tenant from the request subdomain, bound the space with an allowlist, and give every tenant its own rate-limit bucket. Register tenancy() before the isolation middleware so ctx.state.tenant is set by the time they run.
Resolving the tenant
Pass one resolver to resolve, or an array tried in order until one returns a non-empty value (e.g. prefer a verified JWT claim, fall back to the subdomain). A resolver is just a (ctx) => string | undefined, so you can write your own.
| Resolver | Source | Notes |
|---|---|---|
tenantFromSubdomain({ baseDomain }) | acme.example.com → acme | PSL-aware via subdomains(). A Host not under baseDomain resolves to unresolved (host-spoof safe), never a 500. Recommended for production. |
tenantFromHeader("x-tenant-id") | request header | Spoofable. Only trust behind a proxy that overwrites the header on every inbound request. Always pair with allow. |
tenantFromPathPrefix() | /acme/orders → acme | Reads the segment only (does not rewrite the path); your routes still include the tenant segment. |
tenantFromClaim("org") | ctx.state.auth.credentials.org | For a verified JWT/session claim. The auth middleware that populates it must run before tenancy(). |
(ctx) => string | undefined | anything | Custom resolver — derive the id however you like. |
Options reference
| Option | Type | Default | Description |
|---|---|---|---|
resolve | TenantResolver | TenantResolver[] | — (required) | Resolver(s) tried in order; first non-empty wins. |
require | boolean | true | Reject unresolved requests. The secure default — an unresolved request is never served as an ambient “default” tenant. |
allow | string[] | (id, ctx) => boolean | — | Bound the tenant space. Array entries are validated at construction. A disallowed id is rejected with invalidStatus. |
normalize | (raw) => string | undefined | trim + lowercase + strict charset | Validate/canonicalize the raw id. Return undefined to reject. The default accepts only [a-z0-9_-], 1–63 chars. |
stateKey | string | "tenant" | ctx.state key the resolved id is written to. |
unresolvedStatus | 400 | 401 | 403 | 404 | 400 | Status when require is true and nothing resolved. |
invalidStatus | 400 | 403 | 404 | 404 | Status for a resolved-but-disallowed/malformed id. 404 avoids tenant enumeration. |
Per-tenant isolation with tenantScope()
tenantScope() returns a (ctx) => string key function that reads ctx.state.tenant and returns a tenant:<id>partition key. Drop it into the isolation knobs so each tenant gets its own bucket / namespace and cannot exhaust, read, or poison another tenant's:
Ordering matters. tenancy() resolves in beforeHandle, and so do these consumers. Register tenancy() first — as a global hook (new App({ hooks: tenancy(...) }) or the first app.use(...) — so the tenant is populated before any keyGenerator / scope callback runs. If a limiter runs first, its key falls back to tenant:unknown.
Typing ctx.state.tenant
Augment AppState so the resolved tenant is strongly typed in every handler and hook:
Security posture
- Refuse-unresolved by default. With
require: true, a request whose tenant cannot be resolved is rejected rather than silently served as a default tenant — the failure mode that leaks one tenant's data to another. - Format-validated ids. Resolved ids are normalized to a conservative
[a-z0-9_-]charset before they are stored or used as a key. A spoofable header value cannot smuggle newlines,:,/, or*into rate-limit keys, cache keys, or log lines (key/log injection, cache poisoning). - No enumeration. A resolved-but-unknown tenant is
404by default, indistinguishable from a missing route, so attackers cannot probe for valid tenant names. - Host-spoof safe.
tenantFromSubdomaintreats aHostthat is not under the declaredbaseDomainas unresolved instead of trusting it. - Header resolution is opt-in and spoofable. Only use
tenantFromHeaderbehind a trusted proxy that overwrites the header, and bound it withallow.
Runnable example
examples/multitenancy-demo.ts wires subdomain resolution + an allowlist + per-tenant rate limiting + a per-tenant in-memory store. The Node adapter builds the request URL from the Host header, so you can exercise subdomains locally without DNS: