141 lines
6.7 KiB
Markdown
141 lines
6.7 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`
|