6.7 KiB
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<TenantRecord>
describing { id: TenantId, slug: string, displayName?: string, status: "active" | "suspended" | "archived", createdAt: string, metadata?: Record<string, unknown> }. 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
idis typed asTenantIdANDslugis 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
slugkey 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<TenantContext | null> }.
Three built-in strategies SHALL be tried in this fixed order, with first-match-wins semantics:
X-Tenant-Idheader (configurable viaopts.headerName);- JWT claim, only when
opts.jwtClaimExtractoris supplied; - Subdomain, only when
opts.subdomainBaseis 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: t1AND 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 carriesX-Custom-Tenant: t2 - THEN the chain resolves to the matching context
Scenario: header wins over JWT when both present
- WHEN both the header and
jwtClaimExtractorproduce 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 isacme.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.comANDsubdomainBase: "example.com" - THEN the chain returns
nullfrom 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 tonull
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.lookupis 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
displayNameandmetadata - 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"