11 KiB
Capability Spec Delta: api-tenant-provider (api-tenant-provider)
Package: @suaveplan/api-tenant-provider
Status: PROPOSED
Every requirement carries a
**Verified by:**line.
ADDED Requirements
Requirement: TenantProvider contract
The package SHALL export TenantProvider: list(opts?: ListOpts): AsyncIterable<TenantRecord>, count(): Promise<number>, lookup(tenantId: TenantId): Promise<TenantRecord | null>, dispose(): Promise<void>. lookup MUST return null (not throw) when the tenant is not found.
Scenario: every method present
- WHEN
expectTypeOf<TenantProvider>()is checked - THEN all four methods are declared
Scenario: lookup returns null for missing tenant
- WHEN a stub provider with no tenants is given an arbitrary id
- THEN
lookup(id)resolves tonull
Verified by: src/provider/provider.spec-conformance.test.ts::"TenantProvider declares list count lookup dispose" AND src/provider/provider.test.ts::"lookup returns null for missing tenant"
Requirement: import z from "zod" everywhere
Every Zod import MUST use the default form.
Scenario: no named import of z
- WHEN the test greps
import zfrom"zod" - THEN zero matches
Verified by: tests/zod-import-form.test.ts::"every src file uses default-import z"
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 TenantContext.slug 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"
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.
Three built-in strategies SHALL be tried in fixed order, first-match-wins: (1) X-Tenant-Id header (configurable via opts.headerName), (2) JWT claim when opts.jwtClaimExtractor is supplied, (3) Subdomain when opts.subdomainBase is supplied. Each strategy resolves a tenant-id string, then calls provider.lookup(id) and maps the resulting TenantRecord to a TenantContext via recordToContext. When the lookup returns null the chain MUST advance; when no strategy hits the chain MUST resolve to null.
Optional per-tenant-id memoisation (opts.cache: { maxEntries, ttlMs }) collapses repeated inventory lookups; misses are also 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: 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: 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
Verified by: src/chain/chain.test.ts::"resolves via X-Tenant-Id header (default)" 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 header missing" AND src/chain/chain.test.ts::"memoises lookups for the same tenant id"
Requirement: recordToContext bridges TenantRecord to TenantContext
The package SHALL export recordToContext(record: TenantRecord): TenantContext from the ./chain subpath. It MUST drop status and createdAt, preserve id/slug, and pass through optional displayName/metadata when present. It MUST NOT 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 }
Scenario: preserves displayName and metadata when present
- 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 both displayName and metadata together"
Requirement: Status filter — active-by-default
list(opts) MUST default to enumerating only tenants whose status === "active". list({ includeSuspended: true }) MUST also enumerate suspended tenants. archived tenants are NEVER enumerated by list — they are only reachable via lookup.
Scenario: default excludes suspended and archived
- WHEN the provider holds [active, suspended, archived] AND
list()is consumed - THEN only the active tenant is yielded
Scenario: includeSuspended surfaces suspended
- WHEN
list({ includeSuspended: true })is consumed - THEN both active and suspended tenants are yielded; archived is NOT yielded
Verified by: src/provider/status-filter.test.ts::"default list excludes suspended and archived" AND src/provider/status-filter.test.ts::"includeSuspended surfaces suspended but not archived"
Requirement: Pagination cursor round-trip
ListOpts.cursor MUST round-trip — consuming part of list({ pageSize: 10 }), capturing a cursor, and resuming with list({ pageSize: 10, cursor }) MUST yield the remainder without duplication or omission. Invalid cursors MUST throw TENANT_PROVIDER.INVALID_CURSOR.
Scenario: cursor resumes without gap or overlap
- WHEN 25 tenants exist AND the caller pulls 10 then resumes with the cursor
- THEN the resumption yields exactly 15 tenants AND no duplicates
Scenario: invalid cursor rejected
- WHEN an arbitrary string is passed as cursor
- THEN
TENANT_PROVIDER.INVALID_CURSORis thrown
Verified by: src/pagination/pagination.test.ts::"cursor resumes without gap or overlap" AND src/pagination/pagination.test.ts::"invalid cursor rejected"
Requirement: CapabilityId augmentation contributes "tenant.provider"
The package SHALL export the runtime constant TENANT_PROVIDER_CAPABILITY_ID = "tenant.provider" and the type alias TenantProviderCapabilityId = typeof TENANT_PROVIDER_CAPABILITY_ID. The package SHALL NOT declare a widened CapabilityId type union (type CapabilityId = BaseCapabilityId | TenantProviderCapabilityId) — that widening alias is removed because @suaveplan/api-types/capabilities ships "tenant.provider" directly on the foundation CapabilityIdRegistry. The package SHALL re-export the canonical CapabilityId from @suaveplan/api-types/capabilities for source compatibility with consumers that previously imported the widened alias from @suaveplan/api-tenant-provider/capability-id.
Third-party packages adding their own capability id SHALL extend CapabilityIdRegistry via TypeScript declaration-merging rather than re-introducing widening aliases on the consumer side.
Scenario: tenant.provider assignable to CapabilityId without widening
- WHEN
TENANT_PROVIDER_CAPABILITY_IDis assigned to a binding of typeCapabilityIdimported from@suaveplan/api-types/capabilities - THEN the assignment type-checks without any local widening
Scenario: CapabilityId re-export remains source-compatible
- WHEN a consumer imports
type CapabilityIdfrom@suaveplan/api-tenant-provider/capability-id - THEN the resolved type is structurally identical to
CapabilityIdfrom@suaveplan/api-types/capabilities
Verified by: src/capability-id/capability-id.test.ts::"narrows as a string literal at the type level" AND src/capability-id/capability-id.test.ts::"widened CapabilityId accepts both base and tenant.provider"
Requirement: Error codes registry frozen
The package SHALL export TENANT_PROVIDER_CODES containing exactly: TENANT_NOT_FOUND, SOURCE_UNAVAILABLE, INVALID_CURSOR.
Scenario: every code reachable
- WHEN the test enumerates
TENANT_PROVIDER_CODES - THEN every code has a production throw site
Verified by: src/error-codes/error-codes.spec-conformance.test.ts::"every TENANT_PROVIDER_CODES entry thrown by production path"
Requirement: Conformance harness for providers
The package SHALL export runTenantProviderConformance({ factory, label }). Every concrete provider (in-memory in Stage 4, DB-backed downstream) MUST pass it.
Scenario: harness covers list, count, lookup, status filter, pagination, dispose
- WHEN the harness runs against a stub provider
- THEN test categories exist for each documented behavior
Verified by: src/testing/conformance.test.ts::"harness covers list count lookup status-filter pagination dispose"