Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
# `chain` — `TenantResolverChain` factory backed by a `TenantProvider`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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.