Files
suaveplan/packages/api-stack/api/api-tenant-provider/src/chain/chain.md
2026-05-27 23:29:53 +01:00

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-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

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 — 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.