Initial Commit
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# foundation-fix-gap-2-tenant-chain
|
||||
|
||||
- **Status**: PROPOSED
|
||||
- **Spec deltas**:
|
||||
- ./specs/api-tenant-provider/spec.md
|
||||
- ./specs/api-tenant-provider-memory/spec.md
|
||||
|
||||
## What & why
|
||||
|
||||
The post-Stage-4 foundation-coherence audit (Gap #2) flagged a shape
|
||||
divergence between `TenantRecord` (the inventory row exposed by
|
||||
`@suaveplan/api-tenant-provider`) and `TenantContext` (the per-request
|
||||
shape exposed by `@suaveplan/api-types/tenant`): `TenantContext`
|
||||
requires `slug`, but `TenantRecord` did not carry one. Every
|
||||
`TenantProvider`-backed resolver was forced to synthesise
|
||||
`slug = String(record.id)` at the boundary — the e2e foundation server
|
||||
did exactly that, and the `api-tenant-resolver-jwt` resolver hardcodes
|
||||
`slug = tenantId` for the same reason.
|
||||
|
||||
A second half of the gap: hosts that already have a `TenantProvider`
|
||||
have to hand-roll the `header -> provider.lookup -> TenantContext`
|
||||
chain at the start of every fixture or server. No reusable factory
|
||||
existed.
|
||||
|
||||
This change closes both halves:
|
||||
|
||||
1. **`TenantRecord` gains required `slug: string` + optional `metadata`.**
|
||||
Both are mirrored from `TenantContext`. `tenantRecordSchema` is
|
||||
updated; every in-tree fixture passes the slug explicitly so the
|
||||
`slug = id` synthesis bug cannot resurface.
|
||||
2. **`@suaveplan/api-tenant-provider/chain` ships
|
||||
`tenantResolverChainFromProvider(provider, opts?)` +
|
||||
`recordToContext(record)`.** Three built-in strategies in fixed
|
||||
order (header / JWT-claim / subdomain), optional LRU memoisation,
|
||||
first-match-wins semantics. Returns a `TenantResolverChain`
|
||||
conforming to `@suaveplan/api-types/tenant-resolver`.
|
||||
3. **e2e/foundation-stack/src/server.ts** drops the hand-rolled chain
|
||||
in favour of the factory, and the in-line fixtures gain explicit
|
||||
slugs.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
- `@suaveplan/api-tenant-provider` — `TenantRecord` gains required
|
||||
`slug` and optional `metadata`. Source-incompatible: existing
|
||||
literal-constructed records without slug fail typecheck and
|
||||
schema parse. In-tree consumers are updated as part of this change
|
||||
(memory adapter, e2e fixture, all tests). Adds `./chain` subpath
|
||||
export with `tenantResolverChainFromProvider` + `recordToContext`.
|
||||
- `@suaveplan/api-tenant-provider-memory` — passes the `slug` through
|
||||
unchanged; all internal tests updated.
|
||||
- `@suaveplan/e2e-foundation-stack` — `buildTenantChain()` now wraps
|
||||
the provider via the new factory; in-line fixtures gain
|
||||
`slug: "tenant-1" | "tenant-2"`.
|
||||
- `@suaveplan/api-tenant-resolver-jwt` — unchanged; still synthesises
|
||||
`slug = tenantId` because the JWT carries only the id, not a slug.
|
||||
Documenting this as deliberate (the JWT resolver has no inventory
|
||||
lookup, so it cannot read slug from a record).
|
||||
|
||||
### Backward compatibility
|
||||
|
||||
`TenantRecord.slug` is REQUIRED — no v0.1.0 compatibility shim. Genesis
|
||||
is bottom-up rebuild; no shipped consumer constructs `TenantRecord`
|
||||
outside the packages updated in this change.
|
||||
|
||||
`tenantResolverChainFromProvider` is purely additive: a new subpath
|
||||
export. No existing import path moves.
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
This is a genesis-original fix; no upstream parity table required.
|
||||
|
||||
## Verification
|
||||
|
||||
`bunx turbo lint typecheck test build --filter=@suaveplan/api-tenant-provider --filter=@suaveplan/api-tenant-provider-memory --filter=@suaveplan/api-tenant-resolver-jwt`
|
||||
→ green.
|
||||
|
||||
`bunx turbo test:coverage --filter=@suaveplan/api-tenant-provider --filter=@suaveplan/api-tenant-provider-memory`
|
||||
→ 100/100/100/100 statements/branches/functions/lines.
|
||||
|
||||
E2E smoke against the new chain:
|
||||
|
||||
```bash
|
||||
bun run src/server.ts &
|
||||
curl -sH 'x-tenant-id: t1' http://127.0.0.1:3500/api/echo/tenanted
|
||||
# => {"msg":"tenanted","tenantId":"t1","principal":null}
|
||||
```
|
||||
@@ -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"`
|
||||
@@ -0,0 +1,16 @@
|
||||
# Tasks — foundation-fix-gap-2-tenant-chain
|
||||
|
||||
- [x] Add `slug: string` REQUIRED + optional `metadata` to `TenantRecord` interface and `tenantRecordSchema`.
|
||||
- [x] Update `tenant-record.test.ts` to assert slug presence/required/empty rejection and metadata round-trip.
|
||||
- [x] Update `runTenantProviderConformance` harness's `record()` helper to include `slug`.
|
||||
- [x] Update `api-tenant-provider/src/provider/provider.test.ts` literal records to include slug.
|
||||
- [x] Update `api-tenant-provider-memory/src/provider/provider.test.ts` `record()` helper + INVALID_RECORD fixtures to include slug.
|
||||
- [x] Add `src/chain/{chain.ts,chain.md,chain.test.ts,index.ts}` exporting `tenantResolverChainFromProvider` + `recordToContext` + supporting types.
|
||||
- [x] Wire `./chain` subpath export in `api-tenant-provider/package.json`.
|
||||
- [x] Re-export `recordToContext` and `tenantResolverChainFromProvider` from `api-tenant-provider/src/index.ts`.
|
||||
- [x] Update `e2e/foundation-stack/src/server.ts` to drop the hand-rolled chain and use the new factory; add slugs to in-line fixtures.
|
||||
- [x] Add `@suaveplan/api-tenant-provider` dependency to `e2e/foundation-stack/package.json`.
|
||||
- [x] Verify gate: `bunx turbo lint typecheck test build --filter=@suaveplan/api-tenant-provider --filter=@suaveplan/api-tenant-provider-memory --filter=@suaveplan/api-tenant-resolver-jwt`.
|
||||
- [x] Verify coverage: 100% across statements/branches/functions/lines on both packages.
|
||||
- [x] E2E manual smoke: header-driven chain returns correct `TenantContext.id`.
|
||||
- [x] Fold deltas into canonical `openspec/specs/api-tenant-provider/spec.md` and `openspec/specs/api-tenant-provider-memory/spec.md`; move folder to `openspec/archive/2026-05-26-foundation-fix-gap-2-tenant-chain/`.
|
||||
Reference in New Issue
Block a user