6.1 KiB
RequestPipeline — §6 11-step per-request driver
(no submodules)
Purpose
RequestPipeline implements the design-doc §6 per-request data flow exactly: tenant resolve, CSRF check, session resolve, RBAC gate, rate limit, idempotency, request validation, plugin handler, response validation, audit emission, and telemetry close. The order matters — every later step assumes earlier steps' invariants, and reordering would silently regress the security boundary.
The driver was split into sibling modules during the foundation Wave-2 prep (pipeline split per CLAUDE.md file-size budget; behaviour unchanged). The class in this file is the public entry point and constructor; the orchestration body lives in
request-pipeline-driver.ts, the per-step gates inrequest-pipeline-steps.ts, the Zod validators inrequest-pipeline-validation.ts, the audit / telemetry / failure-response builders inrequest-pipeline-emitters.ts, and pure helpers (request-id, URL parse, body reader, ctx builder) inrequest-pipeline-helpers.ts. Every existing test passes against the split without modification — that is the success metric.
Every step is wrapped by an ErrorBoundary so a throw at any point yields a fully-shaped ErrorEnvelope instead of an unhandled promise rejection. Tests + production both go through pipeline.handle(request) and inspect the returned PipelineResult.response / .envelope.
Features
- Tenant resolver chain is honoured first — non-public routes that fail to resolve a tenant return
HOST.TENANT_NOT_RESOLVED(400) before any other work happens. - Session resolver runs against the configured
SessionResolverChain; resolver throws map toHOST.SESSION_REQUIREDvia the boundary. - CSRF runs only on non-GET/HEAD requests with
csrf: true, and is skipped onstreaming: trueroutes per design. - RBAC compares
principal.scopestoroute.options.auth. Missing scope →HOST.RBAC_DENIED(403) +api.rbac.denials_totalcounter. - Rate limit bucket keyed by principal id (when present) else tenant id; bucket-empty →
HOST.RATE_LIMIT_EXCEEDED(429). - Idempotency-Key consumed for
idempotent: trueroutes; replay →HOST.IDEMPOTENCY_REPLAY(409). - Zod request validation: body (JSON only), params, query. Failure →
HOST.REQUEST_VALIDATION_FAILED(400) carrying up to 10 Zod issues indetails. - Plugin handler invoked with a
RequestContextcarrying capabilities, principal, tenant, span, request id, and the parsed body/params/query attached as discoverable fields. - Response validation strictness
enforce(default) → 500 on mismatch;warn→ success path +api.validation.failures_total{where=response.warn}. - Audit envelope emitted for
auditable: trueroutes (admin-API sink shared by default). - Per-request OTel span tree:
api.requestroot +plugin.handlerchild. Counter + histogram emitted on every response.
Usage: Basic
import { RequestPipeline } from "@suaveplan/api-host/request-pipeline";
import { createHostInstruments } from "@suaveplan/api-host/otel";
const pipeline = new RequestPipeline(
{
router,
auth: sessionResolverChain,
tenant: tenantResolverChain,
capabilities,
rateLimit,
idempotency,
csrf,
audit,
logger,
span: spanFactory,
},
createHostInstruments({ hostId: "h1", hostVersion: "0.1.0" })
);
const result = await pipeline.handle(request);
return result.response;
Usage: Advanced
responseValidation: "warn" is the recommended dev-mode setting — the
handler-supplied response propagates even on a schema mismatch but the
counter spike makes the violation visible:
const pipeline = new RequestPipeline(
{ ...adapters, responseValidation: "warn" },
instruments
);
Custom requestId + now injection point lets snapshot tests stay
deterministic:
let clock = 0;
const pipeline = new RequestPipeline(
{
...adapters,
now: () => (clock += 1),
requestId: () => "01J5DETERMINISTIC",
},
instruments
);
For streaming routes (options.streaming === true), request/response
validation buffering is bypassed and the handler's Response body
flows through unchanged — X-Request-Id is the only header the
pipeline appends.
API Reference
class RequestPipeline {
constructor(
options: RequestPipelineOptions,
instruments: HostInstruments
);
handle(request: Request): Promise<PipelineResult>;
}
interface PipelineResult {
response: Response;
status: number;
route: string | null;
plugin: string | null;
outcome: "success" | "failure";
envelope?: ErrorEnvelope;
tenantId: TenantId | null;
}
const PIPELINE_STEPS: readonly PipelineStepName[];
Implementation Notes
The pipeline runs route-match first (technically step 2 in §6) so
later step gates (auth, csrf, rateLimit, idempotent) can
consult per-route policy. The §6 ordering of "intake → match" is
preserved at the engine adapter; this driver assumes the intake has
already happened (we receive a fully-formed Request). Step 1
"HTTP intake" is the router adapter's job and outside the pipeline's
surface.
Tenant resolution returns a stub { id: "public", slug: "public" }
context for public routes that failed to resolve — keeping downstream
ctor invariants (ctx.tenant is never null) at the cost of one
sentinel that's easy to recognise and filter from telemetry. Non-public
routes still return 400 on null.
Per-step counters are derived directly from the boundary failure: the
boundary emits api.errors_total for every captured throw, the
pipeline adds the dedicated counters (api.auth.failures_total,
api.rbac.denials_total, api.tenant.unresolved_total,
api.rate_limit.exceeded_total, api.idempotency.replays_total,
api.validation.failures_total) so a §12.3 spec slice is one counter
lookup, not a regex over code labels.
Response validation runs after the handler returns. JSON-shape
mismatches → 500 (enforce) / counter (warn). Non-JSON responses skip
validation by design — a Response of an arbitrary media type is the
plugin's contract with the engine.