Initial Commit
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# `api-host` — Foundation gap 5: configurable default tenant
|
||||
|
||||
**Package**: `@suaveplan/api-host`
|
||||
**Status**: PROPOSED
|
||||
**Type**: behavior-change
|
||||
|
||||
## Why
|
||||
|
||||
`packages/api-stack/api/api-host/src/request-pipeline/request-pipeline.ts`
|
||||
hard-coded a `{id: "public" as TenantId, slug: "public"}` fallback
|
||||
whenever the configured `TenantResolverChain` returned `null` on an
|
||||
auth-optional route. The `"public"` literal became load-bearing — every
|
||||
handler that introspected `ctx.tenantId` had to treat the string
|
||||
`"public"` as a sentinel rather than receiving an honest absence.
|
||||
|
||||
This blocked deployments that:
|
||||
|
||||
- Genuinely run multi-tenant traffic where there is no public tenant
|
||||
row in the database, and want resolver miss → `400` not silent fallback.
|
||||
- Run an actual tenant with `id="public"` and need to distinguish "the
|
||||
literal public tenant" from "no tenant resolved".
|
||||
- Want to defer the policy choice (`null` vs. stub vs. error) to the
|
||||
operator's bootstrap module rather than the framework.
|
||||
|
||||
## What changes
|
||||
|
||||
ADDED: `HostConfig.defaultTenant?: TenantContext | null` and
|
||||
`RequestPipelineOptions.defaultTenant?: TenantContext | null`.
|
||||
|
||||
MODIFIED: §6 step 1 ("Tenant resolve") of the request pipeline.
|
||||
|
||||
- When the resolver chain returns `null` AND the route's `auth` policy
|
||||
is `undefined`/`null` (auth-optional):
|
||||
- `defaultTenant: undefined` (the new default) → `ctx.tenant === null`,
|
||||
`ctx.tenantId === null`.
|
||||
- `defaultTenant: null` (explicit) → identical to `undefined`.
|
||||
- `defaultTenant: TenantContext` → inject verbatim. This preserves
|
||||
pre-foundation-gap-5 behaviour for migrators by setting
|
||||
`{ id: "public", slug: "public" }`.
|
||||
|
||||
- When the route's `auth` is required AND the resolver chain misses,
|
||||
the pipeline still raises `HOST.TENANT_NOT_RESOLVED` (400) regardless
|
||||
of `defaultTenant` — the fallback is a convenience for public routes,
|
||||
not a silent bypass of tenant-required policy.
|
||||
|
||||
Downstream pipeline steps now tolerate `tenantCtx === null`:
|
||||
|
||||
- Rate-limit key falls back to `"anon"` when no principal AND no tenant
|
||||
is bound.
|
||||
- Per-request logger meta carries `tenantId: null`.
|
||||
- `succeed()` and `fail()` propagate `tenantId: null` into
|
||||
`PipelineResult.tenantId`.
|
||||
- `PipelineCapabilityResolver.forRequest` accepts
|
||||
`tenant: TenantContext | null`.
|
||||
|
||||
`RequestContext.tenant` / `RequestContext.tenantId` remain declared as
|
||||
non-nullable in `@suaveplan/api-types`; handlers consuming tenant-bound
|
||||
capabilities MUST gate on `tenant === null` first when their host is
|
||||
configured with `defaultTenant: null`. The contract is documented in
|
||||
`host.md` and `request-pipeline.types.ts`.
|
||||
|
||||
## Migration
|
||||
|
||||
Operators relying on the legacy `"public"` stub MUST set
|
||||
`defaultTenant: { id: "public", slug: "public" }` in `createHost(...)`.
|
||||
The E2E foundation-stack server has been migrated this way to keep its
|
||||
existing `tenantId === "public"` assertion green; new deployments SHOULD
|
||||
leave the field unset.
|
||||
@@ -0,0 +1,46 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-host (foundation-fix-gap-5-default-tenant)
|
||||
|
||||
**Package**: `@suaveplan/api-host`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Request pipeline runs §6 steps in order and is the only entry point for plugin traffic
|
||||
|
||||
The package SHALL provide a `RequestPipeline.handle(request: Request)` driver that executes design doc §6 steps in the exact documented order:
|
||||
|
||||
1. Route match (host) — returns `HOST.ROUTE_NOT_FOUND` (404) when no route matches.
|
||||
2. Tenant resolve — non-public routes that fail to resolve a tenant return `HOST.TENANT_NOT_RESOLVED` (400). Public (auth-optional) routes that fail to resolve a tenant fall back according to `HostConfig.defaultTenant` (and the equivalent `RequestPipelineOptions.defaultTenant`): when `undefined` or `null` (the default) `ctx.tenant` and `ctx.tenantId` are propagated as `null`; when set to a `TenantContext` value the value is injected verbatim, preserving the pre-foundation-gap-5 `{id: "public", slug: "public"}` semantics for migrators who opt in explicitly.
|
||||
3. Session resolve via the configured `SessionResolverChain` — resolver throws emit `api.auth.failures_total`.
|
||||
4. CSRF — required when `route.options.csrf === true`, the request method is not `GET`/`HEAD`, the route is not `streaming`, AND a principal was resolved. Invalid token → `HOST.CSRF_TOKEN_INVALID` (403).
|
||||
5. RBAC — `null` principal on a non-public route → `HOST.SESSION_REQUIRED` (401); missing scope → `HOST.RBAC_DENIED` (403) + `api.rbac.denials_total`.
|
||||
6. Rate limit — keyed by principal id (when present) else tenant id; when neither is bound the limiter keys off the literal `"anon"`. Bucket-empty → `HOST.RATE_LIMIT_EXCEEDED` (429) + `api.rate_limit.exceeded_total`.
|
||||
7. Idempotency — `route.options.idempotent === true` + `Idempotency-Key` header present: replay → `HOST.IDEMPOTENCY_REPLAY` (409) + `api.idempotency.replays_total`.
|
||||
8. Request validation — Zod body / params / query when `route.options.runtimeValidation !== false` and route is not streaming; failures emit `HOST.REQUEST_VALIDATION_FAILED` (400) + `api.validation.failures_total{where=request}`.
|
||||
9. Plugin handler — invoked through `ErrorBoundary.runHandler` with a `RequestContext` carrying tenant, principal, capabilities, parsed body/params/query. When `defaultTenant` is `null` AND the resolver missed, `ctx.tenant` and `ctx.tenantId` are runtime-`null`; handlers consuming tenant-bound capabilities MUST gate on `tenant === null` before dereferencing.
|
||||
10. Response validation — `enforce` (production) → `HOST.RESPONSE_VALIDATION_FAILED` (500) on mismatch; `warn` (development) → success + `api.validation.failures_total{where=response.warn}`.
|
||||
11. Audit envelope (when `auditable: true`) + OTel telemetry close — every request emits `api.requests_total{route, plugin, status, outcome}` and `api.request_duration_ms` histogram.
|
||||
|
||||
The pipeline SHALL always set the `X-Request-Id` response header. Streaming routes (`streaming: true`) SHALL bypass CSRF, request validation buffering, and response validation.
|
||||
|
||||
#### Scenario: 11 steps execute in order on happy path
|
||||
|
||||
- **WHEN** a `POST /billing/invoices` with valid principal + scope is dispatched
|
||||
- **THEN** the response is 200 AND `X-Request-Id` is set AND an audit envelope is emitted for `auditable: true` routes.
|
||||
|
||||
#### Scenario: auth-optional route + resolver miss + defaultTenant unset
|
||||
|
||||
- **WHEN** the route has no `auth` policy AND every resolver in the chain returns `null` AND `HostConfig.defaultTenant` is unset
|
||||
- **THEN** the pipeline propagates `ctx.tenant === null`, `ctx.tenantId === null`, and `PipelineResult.tenantId === null`.
|
||||
|
||||
#### Scenario: auth-optional route + resolver miss + explicit defaultTenant
|
||||
|
||||
- **WHEN** the route has no `auth` policy AND every resolver in the chain returns `null` AND `HostConfig.defaultTenant = { id: "public", slug: "public" }`
|
||||
- **THEN** the pipeline injects the supplied `TenantContext` verbatim into `ctx.tenant` / `ctx.tenantId` / `PipelineResult.tenantId`.
|
||||
|
||||
#### Scenario: auth-required route + resolver miss + defaultTenant set
|
||||
|
||||
- **WHEN** the route declares `auth: <scope>` AND every resolver returns `null` AND `HostConfig.defaultTenant` is set to a non-null `TenantContext`
|
||||
- **THEN** the pipeline raises `HOST.TENANT_NOT_RESOLVED` (400) — the fallback is NEVER used to silently bind an auth-required request to a public tenant.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Tasks — foundation gap 5: configurable default tenant
|
||||
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 Add `defaultTenant?: TenantContext | null` to
|
||||
`RequestPipelineOptions` (`request-pipeline.types.ts`).
|
||||
- [x] 1.2 Add `defaultTenant?: TenantContext | null` to `HostConfig`
|
||||
(`host/host.types.ts`).
|
||||
- [x] 1.3 Wire `config.defaultTenant ?? null` through
|
||||
`host.ts` → `RequestPipeline` constructor.
|
||||
- [x] 1.4 Replace hard-coded `{id: "public", slug: "public"}` injection
|
||||
in `request-pipeline.ts` with config-aware logic.
|
||||
- [x] 1.5 Widen `PipelineCapabilityResolver.forRequest`'s `tenant`
|
||||
parameter to `TenantContext | null`.
|
||||
- [x] 1.6 Update downstream pipeline call sites (`rateKey`, logger meta,
|
||||
`buildRequestContext`, `succeed()`/`fail()` signatures) to tolerate
|
||||
`tenantCtx === null`.
|
||||
|
||||
## 2. Tests
|
||||
|
||||
- [x] 2.1 Replace the legacy "public stub" test with a "null tenant by
|
||||
default" assertion.
|
||||
- [x] 2.2 Add a test asserting an explicit `defaultTenant` is propagated
|
||||
verbatim to `ctx.tenant` / `ctx.tenantId` / `PipelineResult.tenantId`.
|
||||
- [x] 2.3 Add a test asserting auth-required routes still 400 on a
|
||||
resolver miss regardless of `defaultTenant`.
|
||||
|
||||
## 3. Migration
|
||||
|
||||
- [x] 3.1 Migrate the foundation-stack E2E (`e2e/foundation-stack/src/server.ts`)
|
||||
to opt into the legacy fallback (`defaultTenant: { id: "public", ... }`)
|
||||
so the existing `tenantId === "public"` assertion remains green.
|
||||
|
||||
## 4. Docs
|
||||
|
||||
- [x] 4.1 Document the new behaviour in
|
||||
`packages/api-stack/api/api-host/src/host/host.md`.
|
||||
- [x] 4.2 Document the per-step interaction in
|
||||
`request-pipeline.types.ts` (`RequestPipelineOptions.defaultTenant`).
|
||||
|
||||
## 5. Spec
|
||||
|
||||
- [x] 5.1 Author MODIFIED Requirements delta under
|
||||
`specs/api-host/spec.md`.
|
||||
- [x] 5.2 Fold delta into `openspec/specs/api-host/spec.md`.
|
||||
- [x] 5.3 Move change folder to `openspec/archive/2026-05-26-foundation-fix-gap-5-default-tenant/`.
|
||||
|
||||
## 6. Gate
|
||||
|
||||
- [x] 6.1 `bunx turbo lint typecheck test build --filter=@suaveplan/api-host` green.
|
||||
- [x] 6.2 `bunx turbo test:coverage --filter=@suaveplan/api-host` —
|
||||
`request-pipeline.ts` reports 100% line + branch coverage; touched
|
||||
lines in `host.ts` are exercised by existing `createHost(...)` tests.
|
||||
Reference in New Issue
Block a user