47 lines
4.3 KiB
Markdown
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.
|