first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
# `RequestPipeline` — §6 11-step per-request driver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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 in `request-pipeline-steps.ts`, the Zod validators in `request-pipeline-validation.ts`, the audit / telemetry / failure-response builders in `request-pipeline-emitters.ts`, and pure helpers (request-id, URL parse, body reader, ctx builder) in `request-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 to `HOST.SESSION_REQUIRED` via the boundary.
- CSRF runs only on non-GET/HEAD requests with `csrf: true`, and is skipped on `streaming: true` routes per design.
- RBAC compares `principal.scopes` to `route.options.auth`. Missing scope → `HOST.RBAC_DENIED` (403) + `api.rbac.denials_total` counter.
- Rate limit bucket keyed by principal id (when present) else tenant id; bucket-empty → `HOST.RATE_LIMIT_EXCEEDED` (429).
- Idempotency-Key consumed for `idempotent: true` routes; 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 in `details`.
- Plugin handler invoked with a `RequestContext` carrying 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: true` routes (admin-API sink shared by default).
- Per-request OTel span tree: `api.request` root + `plugin.handler` child. Counter + histogram emitted on every response.
## Usage: Basic
```ts
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:
```ts
const pipeline = new RequestPipeline(
{ ...adapters, responseValidation: "warn" },
instruments
);
```
Custom `requestId` + `now` injection point lets snapshot tests stay
deterministic:
```ts
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
```ts
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.