Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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"`.

View File

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