# Capability Spec Delta: api-tenant-provider (foundation-fix-gap-2-tenant-chain) **Package**: `@suaveplan/api-tenant-provider` **Status**: PROPOSED ## MODIFIED Requirements ### Requirement: TenantRecord schema The package SHALL export `tenantRecordSchema: z.ZodSchema` describing `{ id: TenantId, slug: string, displayName?: string, status: "active" | "suspended" | "archived", createdAt: string, metadata?: Record }`. `id` MUST be branded as `TenantId` (not plain string). `slug` MUST be a non-empty string mirroring the `TenantContext.slug` shape so consumers can map an inventory row to a per-request context without synthesising the slug from the id. `metadata` mirrors `TenantContext.metadata` so adapter-extensible context can be carried end-to-end. Unknown keys MUST be rejected. #### Scenario: minimal record parses - **WHEN** `{ id: "t1", slug: "t1", status: "active", createdAt: "2026-05-13T..." }` is parsed - **THEN** it succeeds AND `id` is typed as `TenantId` AND `slug` is preserved as a string #### Scenario: unknown status rejected - **WHEN** `{ id: "t1", slug: "t1", status: "weird", createdAt: "..." }` is parsed - **THEN** Zod reports an issue at `status` #### Scenario: missing slug rejected - **WHEN** a record without a `slug` key is parsed - **THEN** Zod reports an issue at `slug` #### Scenario: empty slug rejected - **WHEN** `{ id: "t1", slug: "", status: "active", createdAt: "..." }` is parsed - **THEN** Zod reports an issue at `slug` #### Scenario: metadata round-trips - **WHEN** a record with `metadata: { tier: "gold" }` is parsed - **THEN** `record.metadata.tier === "gold"` **Verified by:** `src/tenant-record/tenant-record.test.ts::"parses a minimal active record"` AND `src/tenant-record/tenant-record.test.ts::"rejects unknown status"` AND `src/tenant-record/tenant-record.test.ts::"rejects record without slug"` AND `src/tenant-record/tenant-record.test.ts::"rejects empty slug"` AND `src/tenant-record/tenant-record.test.ts::"accepts a record with metadata"` ## ADDED Requirements ### Requirement: TenantResolverChain factory from a TenantProvider The package SHALL export `tenantResolverChainFromProvider(provider, opts?)` from the `./chain` subpath (and re-export from the root barrel). The returned object MUST satisfy `TenantResolverChain` from `@suaveplan/api-types/tenant-resolver`: `{ resolvers: readonly TenantResolver[]; resolve(req): Promise }`. Three built-in strategies SHALL be tried in this fixed order, with first-match-wins semantics: 1. `X-Tenant-Id` header (configurable via `opts.headerName`); 2. JWT claim, only when `opts.jwtClaimExtractor` is supplied; 3. Subdomain, only when `opts.subdomainBase` is supplied. Each strategy resolves a tenant-id string then calls `provider.lookup(id)`; the resulting `TenantRecord` is mapped to a `TenantContext` via `recordToContext`. When `provider.lookup` returns `null`, the chain MUST advance to the next strategy rather than returning early. When no strategy hits, the chain MUST resolve to `null` (not throw). Optional per-tenant-id memoisation (`opts.cache: { maxEntries, ttlMs }`) SHALL collapse repeated inventory lookups for the same tenant id within the TTL; misses (lookup returns `null`) MUST also be memoised. #### Scenario: header strategy resolves via the default X-Tenant-Id header - **WHEN** a request carries `X-Tenant-Id: t1` AND the provider has a record `{ id: "t1", slug: "acme", ... }` - **THEN** the chain resolves to `{ id: "t1", slug: "acme" }` #### Scenario: custom headerName is honoured - **WHEN** the chain is built with `{ headerName: "X-Custom-Tenant" }` AND a request carries `X-Custom-Tenant: t2` - **THEN** the chain resolves to the matching context #### Scenario: header wins over JWT when both present - **WHEN** both the header and `jwtClaimExtractor` produce different ids - **THEN** the header's record is returned #### Scenario: subdomain strategy extracts a single-label tenant from host - **WHEN** the chain is built with `subdomainBase: "example.com"` AND the request host is `acme.example.com` - **THEN** the chain resolves with the record looked up by id `"acme"` #### Scenario: subdomain rejects exact-base host - **WHEN** the host is `example.com` AND `subdomainBase: "example.com"` - **THEN** the chain returns `null` from the subdomain strategy #### Scenario: chain returns null when no strategy hits - **WHEN** the request has neither the header nor a matching subdomain nor a JWT claim - **THEN** `chain.resolve(req)` resolves to `null` #### Scenario: cached tenant lookups collapse to one provider call - **WHEN** `{ cache: { maxEntries: 10, ttlMs: 60000 } }` is configured AND three requests for the same tenant id are resolved - **THEN** `provider.lookup` is invoked exactly once #### Scenario: cache evicts oldest entry beyond maxEntries - **WHEN** `{ cache: { maxEntries: 2, ttlMs: 60000 } }` is configured AND four distinct tenant ids are resolved with the first one re-requested last - **THEN** the first re-request misses the cache and re-invokes `provider.lookup` **Verified by:** `src/chain/chain.test.ts::"resolves via X-Tenant-Id header (default)"` AND `src/chain/chain.test.ts::"honours custom headerName"` AND `src/chain/chain.test.ts::"header wins over JWT when both present"` AND `src/chain/chain.test.ts::"extracts tenant from subdomain"` AND `src/chain/chain.test.ts::"returns null when host equals base exactly"` AND `src/chain/chain.test.ts::"returns null when header missing"` AND `src/chain/chain.test.ts::"memoises lookups for the same tenant id"` AND `src/chain/chain.test.ts::"evicts oldest entry beyond maxEntries"` --- ### Requirement: recordToContext bridges TenantRecord to TenantContext The package SHALL export `recordToContext(record: TenantRecord): TenantContext` from the `./chain` subpath (and re-export from the root barrel). It MUST drop `status` and `createdAt` (inventory-only fields) and preserve `id`, `slug`, and the optional `displayName` / `metadata` fields when present. It MUST never synthesise `slug` from `id` — slug comes off the record verbatim. #### Scenario: drops status and createdAt - **WHEN** `recordToContext({ id, slug, status, createdAt })` is called - **THEN** the result is exactly `{ id, slug }` (no extra keys) #### Scenario: preserves displayName and metadata - **WHEN** the record carries both `displayName` and `metadata` - **THEN** both are present on the returned `TenantContext` **Verified by:** `src/chain/chain.test.ts::"preserves id and slug, drops status and createdAt"` AND `src/chain/chain.test.ts::"preserves displayName when present"` AND `src/chain/chain.test.ts::"preserves metadata when present"` AND `src/chain/chain.test.ts::"preserves both displayName and metadata together"`