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

4.3 KiB

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.