Files
2026-05-27 23:29:53 +01:00

47 lines
4.3 KiB
Markdown

<!-- 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.