# `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-Id` header (configurable via `headerName`), JWT claim (when `jwtClaimExtractor` is supplied), and subdomain (when `subdomainBase` is supplied — e.g. `subdomainBase: "example.com"` matches `tenant-1.example.com`). - First match wins; misses (no matching strategy or `provider.lookup` returns `null`) advance to the next strategy. - Optional LRU memoisation on tenant-id -> `TenantContext` keyed by `{ maxEntries, ttlMs }`. - Zero `slug` synthesis — slug comes off the inventory row directly via `recordToContext`, 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 ```ts 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 ```ts 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` — drops `status`/`createdAt`, preserves `id`/`slug`/optional `displayName`/optional `metadata`. - `type JwtClaimExtractor = (request: Request) => string | null` — pulls a tenant-id-shaped claim out of a request; returning `null` abstains. - `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-resolver` contract. ## 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.