Initial Commit
This commit is contained in:
128
packages/api-stack/api/api-tenant-provider/src/chain/chain.md
Normal file
128
packages/api-stack/api/api-tenant-provider/src/chain/chain.md
Normal 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.
|
||||
Reference in New Issue
Block a user