Files
2026-05-27 23:29:53 +01:00

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 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<TenantContext | null> }.

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"