Files
suaveplan/openspec/specs/api-tenant-provider/spec.md
2026-05-27 23:29:53 +01:00

210 lines
11 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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 to `null`
**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 z` from `"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 `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"`
---
### 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: t1` AND 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 `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: 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
**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 `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 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_CURSOR` is 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_ID` is assigned to a binding of type `CapabilityId` imported 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 CapabilityId` from `@suaveplan/api-tenant-provider/capability-id`
- **THEN** the resolved type is structurally identical to `CapabilityId` from `@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"`