Initial Commit
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-tenant-provider-memory (foundation-fix-gap-2-tenant-chain)
|
||||
|
||||
**Package**: `@suaveplan/api-tenant-provider-memory`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Boundary validation through tenantRecordSchema
|
||||
|
||||
Every record entering the backing store SHALL be parsed through
|
||||
`tenantRecordSchema` from
|
||||
`@suaveplan/api-tenant-provider/tenant-record`. As of the
|
||||
foundation-fix-gap-2-tenant-chain change, `tenantRecordSchema`
|
||||
requires a non-empty `slug` and accepts an optional `metadata` bag
|
||||
— callers MUST supply `slug` on every record entering the store.
|
||||
Schema failures (including missing/empty `slug`) raise
|
||||
`TENANT_PROVIDER_MEMORY.INVALID_RECORD`.
|
||||
|
||||
#### Scenario: seed with malformed record is rejected
|
||||
|
||||
- **WHEN** `createMemoryTenantProvider({ tenants: [{ id: "", slug: "x", status: "active", ... }] })` is called
|
||||
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
|
||||
|
||||
#### Scenario: seed with missing slug is rejected
|
||||
|
||||
- **WHEN** `createMemoryTenantProvider({ tenants: [{ id: "t1", status: "active", createdAt }] })` (no slug) is called
|
||||
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
|
||||
|
||||
#### Scenario: add() validates input
|
||||
|
||||
- **WHEN** `add({ id: "", slug: "x", ... })` is called with an empty id
|
||||
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
|
||||
|
||||
#### Scenario: invalid options shape rejected
|
||||
|
||||
- **WHEN** `createMemoryTenantProvider({ tenants: "not-an-array" })` is called
|
||||
- **THEN** the call throws `TenantProviderMemoryError` with code `TENANT_PROVIDER_MEMORY.INVALID_RECORD`
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"rejects malformed seed record (INVALID_RECORD)"` AND `src/provider/provider.test.ts::"add rejects malformed record (INVALID_RECORD)"` AND `src/provider/provider.test.ts::"rejects invalid options shape"`.
|
||||
@@ -0,0 +1,140 @@
|
||||
<!-- 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"`
|
||||
Reference in New Issue
Block a user