4.3 KiB
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:
- Route match (host) — returns
HOST.ROUTE_NOT_FOUND(404) when no route matches. - 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 toHostConfig.defaultTenant(and the equivalentRequestPipelineOptions.defaultTenant): whenundefinedornull(the default)ctx.tenantandctx.tenantIdare propagated asnull; when set to aTenantContextvalue the value is injected verbatim, preserving the pre-foundation-gap-5{id: "public", slug: "public"}semantics for migrators who opt in explicitly. - Session resolve via the configured
SessionResolverChain— resolver throws emitapi.auth.failures_total. - CSRF — required when
route.options.csrf === true, the request method is notGET/HEAD, the route is notstreaming, AND a principal was resolved. Invalid token →HOST.CSRF_TOKEN_INVALID(403). - RBAC —
nullprincipal on a non-public route →HOST.SESSION_REQUIRED(401); missing scope →HOST.RBAC_DENIED(403) +api.rbac.denials_total. - 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. - Idempotency —
route.options.idempotent === true+Idempotency-Keyheader present: replay →HOST.IDEMPOTENCY_REPLAY(409) +api.idempotency.replays_total. - Request validation — Zod body / params / query when
route.options.runtimeValidation !== falseand route is not streaming; failures emitHOST.REQUEST_VALIDATION_FAILED(400) +api.validation.failures_total{where=request}. - Plugin handler — invoked through
ErrorBoundary.runHandlerwith aRequestContextcarrying tenant, principal, capabilities, parsed body/params/query. WhendefaultTenantisnullAND the resolver missed,ctx.tenantandctx.tenantIdare runtime-null; handlers consuming tenant-bound capabilities MUST gate ontenant === nullbefore dereferencing. - Response validation —
enforce(production) →HOST.RESPONSE_VALIDATION_FAILED(500) on mismatch;warn(development) → success +api.validation.failures_total{where=response.warn}. - Audit envelope (when
auditable: true) + OTel telemetry close — every request emitsapi.requests_total{route, plugin, status, outcome}andapi.request_duration_mshistogram.
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/invoiceswith valid principal + scope is dispatched - THEN the response is 200 AND
X-Request-Idis set AND an audit envelope is emitted forauditable: trueroutes.
Scenario: auth-optional route + resolver miss + defaultTenant unset
- WHEN the route has no
authpolicy AND every resolver in the chain returnsnullANDHostConfig.defaultTenantis unset - THEN the pipeline propagates
ctx.tenant === null,ctx.tenantId === null, andPipelineResult.tenantId === null.
Scenario: auth-optional route + resolver miss + explicit defaultTenant
- WHEN the route has no
authpolicy AND every resolver in the chain returnsnullANDHostConfig.defaultTenant = { id: "public", slug: "public" } - THEN the pipeline injects the supplied
TenantContextverbatim intoctx.tenant/ctx.tenantId/PipelineResult.tenantId.
Scenario: auth-required route + resolver miss + defaultTenant set
- WHEN the route declares
auth: <scope>AND every resolver returnsnullANDHostConfig.defaultTenantis set to a non-nullTenantContext - 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.