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

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 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"