5.1 KiB
chain — TenantResolverChain factory backed by a TenantProvider
(no submodules)
Purpose
The chain module closes the gap between the inventory-side contract
(TenantProvider, owned by this package) and the per-request contract
(TenantResolver / TenantResolverChain, owned by
@suaveplan/api-types/tenant-resolver). Hosts that already have a
TenantProvider instance no longer need to hand-roll the
header -> provider.lookup -> TenantContext chain at the boundary of
every fixture or server file — they import
tenantResolverChainFromProvider(provider, opts?) and wire the result
straight into HostAdapters.tenant.
recordToContext is exposed alongside so downstream packages
(api-tenant-provider-postgres, custom resolvers) can map a
TenantRecord to a TenantContext without reimplementing the four-line
projection (and crucially without re-introducing the slug = String(id)
synthesis bug that motivated Gap #2).
Features
- Three built-in strategies tried in fixed order:
X-Tenant-Idheader (configurable viaheaderName), JWT claim (whenjwtClaimExtractoris supplied), and subdomain (whensubdomainBaseis supplied — e.g.subdomainBase: "example.com"matchestenant-1.example.com). - First match wins; misses (no matching strategy or
provider.lookupreturnsnull) advance to the next strategy. - Optional LRU memoisation on tenant-id ->
TenantContextkeyed by{ maxEntries, ttlMs }. - Zero
slugsynthesis — slug comes off the inventory row directly viarecordToContext, which is the canonical projection. - Pure functions plus a single closure-scoped cache; no module-level state. Safe to instantiate one chain per host.
Usage: Basic
import { createMemoryTenantProvider } from "@suaveplan/api-tenant-provider-memory";
import { tenantResolverChainFromProvider } from "@suaveplan/api-tenant-provider/chain";
const provider = createMemoryTenantProvider({
tenants: [
{
id: "t1",
slug: "acme",
status: "active",
createdAt: "2026-05-26T00:00:00Z",
},
],
});
const chain = tenantResolverChainFromProvider(provider);
const ctx = await chain.resolve(
new Request("https://api.example/", { headers: { "X-Tenant-Id": "t1" } })
);
// => { id: "t1", slug: "acme" }
Usage: Advanced
import { tenantResolverChainFromProvider } from "@suaveplan/api-tenant-provider/chain";
import type { Principal } from "@suaveplan/api-types/principal";
declare const provider: import("@suaveplan/api-tenant-provider/provider").TenantProvider;
const chain = tenantResolverChainFromProvider(provider, {
headerName: "X-Custom-Tenant",
subdomainBase: "example.com",
jwtClaimExtractor: (req) => {
const principal = (req as unknown as { principal?: Principal | null })
.principal;
return principal?.tenantId ?? null;
},
cache: { maxEntries: 1024, ttlMs: 60_000 },
});
// `chain.resolvers` exposes the configured order — useful for diagnostics
// and for adapter-level instrumentation that wants to record which
// resolver hit.
for (const r of chain.resolvers) {
// eslint-disable-next-line no-console
console.log(r.id);
}
API Reference
function recordToContext(record: TenantRecord): TenantContext— dropsstatus/createdAt, preservesid/slug/optionaldisplayName/optionalmetadata.type JwtClaimExtractor = (request: Request) => string | null— pulls a tenant-id-shaped claim out of a request; returningnullabstains.interface ChainCacheOptions { readonly maxEntries: number; readonly ttlMs: number }.interface TenantResolverChainFromProviderOptions { headerName?, subdomainBase?, jwtClaimExtractor?, cache? }.function tenantResolverChainFromProvider(provider, opts?): TenantResolverChain— returns{ resolvers, resolve(req) }matching the@suaveplan/api-types/tenant-resolvercontract.
Implementation Notes
The chain order is fixed by the factory because mixing strategies via a
freely-ordered array invites caller-side bugs (subdomain-before-header
flips precedence at scale). Hosts that need a different order compose
their own TenantResolverChain directly — the same record-to-context
projection (recordToContext) is exposed for that case.
Subdomain matching is conservative: the leading segment must be a single
DNS label (no dots), the host suffix must equal .${base}, and an exact
match against the base alone yields null (so example.com -> no
tenant, but acme.example.com -> acme).
LRU eviction is implemented over a plain Map. Map's insertion-order
semantics give O(1) oldest-entry removal via keys().next().value plus
delete. The cache stores TenantContext | null so a miss is still
memoised — repeated lookups of an unknown tenant do not re-hit the
provider until the TTL expires.
The factory never throws — every strategy resolves to null on miss.
Misconfiguration that should fail loudly (e.g. an empty subdomainBase)
must be surfaced by the caller; the chain is intentionally a
fail-soft pipeline so a bad strategy doesn't blackhole every request.