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,75 @@
# `request-pipeline-driver` — §6 11-step orchestrator
## Purpose
This module exports the `runPipeline` function: the orchestrator that chains the §6 step gates (`request-pipeline-steps`), the Zod validators (`request-pipeline-validation`), the validation pre-step (`request-pipeline-outcomes.runRequestValidation`), the handler invocation, and the success / failure result builders (`request-pipeline-outcomes`). It also emits the per-stage observability events the `PipelineEventsBus` contract promises — every §6 stage is wrapped in a `beginStage` pair so observers see the full entered/(completed|rejected|skipped) lifecycle.
The driver function never throws — every failure path is converted into a `PipelineResult` with a fully-shaped `ErrorEnvelope` by the time it returns. The `RequestPipeline` class is now a thin shell that builds a `DriverConfig`, starts the root OTel span, emits the wrapping `request-id` stage events, and delegates to `runPipeline`.
## Features
- Single-pass orchestration: route-match → tenant → auth → csrf → capability → rate-limit → idempotency → handler → response-validate → audit.
- Per-stage `PipelineEventsBus` events at every boundary — `entered` on entry, one of `completed` / `rejected` / `skipped` on exit, with `durationMs` and (for rejections) `cause` populated.
- Skip detection re-derives the short-circuit branches inside `runCsrfStep` / `runRbacStep` / `runIdempotencyStep` via the predicates in `request-pipeline-stage-skip.ts`, so the stage event stream can distinguish "ran + passed" from "not applicable".
- Re-exports `DriverConfig` so external consumers do not need to know whether it lives in `outcomes` or `driver` — the type appears on the public surface here.
- Decoupled from the `RequestPipeline` class: tests or admin routes can call `runPipeline(config, request, requestId, rootSpan, startedAt)` directly with their own config.
## Usage: Basic
```ts
import { runPipeline } from "@suaveplan/api-host/request-pipeline-driver";
const rootSpan = config.options.span.start("api.request", null);
const result = await runPipeline(
config,
request,
requestId,
rootSpan,
config.now()
);
rootSpan.end(config.now());
return result.response;
```
## Usage: Advanced
Subscribe to the per-stage event stream via the configured bus to drive operator-grade dashboards or assert in tests:
```ts
import { InMemoryPipelineEventsBus } from "@suaveplan/api-host";
const bus = new InMemoryPipelineEventsBus();
const config: DriverConfig = {
...realConfig,
pipelineEvents: bus,
};
const result = await runPipeline(config, req, "test-id", span, 0);
const events = bus.byRequest("test-id");
expect(events.filter((e) => e.outcome === "entered")).toHaveLength(10); // 10 stages from driver; the wrapping `request-id` is emitted by `RequestPipeline.handle`.
```
The dispatch order is fixed; reordering steps requires editing this module and updating both the unit tests, the conformance harness's order invariant, and the §6 design doc, since later steps assume earlier-step invariants (e.g. `runRbacStep` assumes session resolution has run).
## API Reference
```ts
export { type DriverConfig } from "./request-pipeline-outcomes.js";
function runPipeline(
config: DriverConfig,
request: Request,
requestId: string,
rootSpan: OtelSpanLike & BoundarySpan,
startedAt: number
): Promise<PipelineResult>;
```
## Implementation Notes
The driver is split into three async helper functions (`runPipeline`, `runHandlerAndAfter`, `runResponseValidateAndAudit`/`emitAuditAndSucceed`) to keep the file under the CLAUDE.md soft LOC budget — the linear flow is preserved by passing a `carry: OutcomeArgs` value between them. Each helper handles a coherent stage range, so the stage-event wiring stays local to the code that drives the step.
`emitReject` is a free function (not a closure capture) so it can be shared between the route-match stage, the gate `onFail` closure, the handler short-circuit, and the response-validate short-circuit without re-defining the emission shape in four places. The function does NOT touch `failResult` — it only emits the event and returns; the caller still owns the `PipelineResult` construction.
`responseValidation === "warn"` still emits `completed` on the response-validate stage (the handler's response is propagated even though Zod flagged a mismatch). The audit stage runs after a warn-mode response-validate, matching the §6 spec — the metric counter is incremented exactly once whether the host runs in `enforce` or `warn` mode.
The `request-id` stage is NOT emitted by the driver — it is emitted by the enclosing `RequestPipeline.handle` so the entered/completed pair wraps the entire driver invocation. This keeps the driver's stage emissions a tight 10-stage block (route-match through audit); the outer `request-id` stage covers everything including the driver's own setup work.

View File

@@ -0,0 +1,77 @@
# `request-pipeline-emitters` — audit, telemetry, failure-response builders
## Purpose
This module owns the three side-effecting closers the §6 pipeline calls once a request has reached its outcome: `emitAudit` writes an `auditable: true` envelope to the configured sink, `recordTelemetry` increments the `api.requests_total` counter and the `api.request_duration_ms` histogram, and `buildFailureResponse` shapes the JSON `Response` + `ErrorEnvelope` returned on failure paths. These were the bulk of the pre-split `RequestPipeline.fail` / `succeed` methods.
Extracting them keeps the orchestrator focused on chain control flow and lets the emitters be reused identically from the success and failure paths without copying the audit + telemetry + envelope-shaping code.
## Features
- `emitAudit` swallows sink throws — a failing audit sink MUST NOT transform a 200 into a 500. The boundary's error path still logs the failure on the next iteration.
- `recordTelemetry` respects `HostInstruments.tenantLabelOptIn`; when off, the `tenant` attribute is omitted to keep cardinality bounded.
- `buildFailureResponse` always stamps `X-Request-Id` on the failure response and includes the `cause` chain (a single level deep, per the wire-format spec) when the boundary captured one.
- All three functions are pure functions of their arguments; no module-scoped state and no `this`.
- Failure responses always set `content-type: application/json` so downstream proxies do not mis-classify them.
## Usage: Basic
```ts
import {
buildFailureResponse,
emitAudit,
recordTelemetry,
} from "@suaveplan/api-host/request-pipeline-emitters";
await emitAudit({
audit,
request,
requestId,
route,
principal,
tenantCtx,
status: 200,
outcome: "success",
now: () => Date.now(),
});
const { response, envelope } = buildFailureResponse(failure, requestId);
```
## Usage: Advanced
`recordTelemetry` accepts `route: null` so callers can emit telemetry for the 404 path (no matched route). The counter and histogram attributes are derived from the inputs — there is no implicit fallback to a "default" route label:
```ts
recordTelemetry({
instruments,
outcome: "failure",
route: null, // 404 — no route resolved
principal: null,
tenantCtx: null,
status: 404,
startedAt,
now,
});
```
`buildFailureResponse` returns the envelope separately from the response so the caller can attach it to the `PipelineResult.envelope` field without re-deriving the wire shape (this is the only contract the host API tests assert against).
## API Reference
```ts
function emitAudit(args: EmitAuditArgs): Promise<void>;
function recordTelemetry(args: RecordTelemetryArgs): void;
function buildFailureResponse(
failure: BoundaryFailure,
requestId: string
): { response: Response; envelope: ErrorEnvelope };
```
## Implementation Notes
`emitAudit` wraps the sink call in `Promise.resolve(...)` so synchronous sinks (the testing default) are handled identically to async ones. The try/catch deliberately swallows every throw — instrumentation failure must be observable via the boundary's logger, not via a 500 response.
`recordTelemetry` calls `args.now()` twice indirectly (once to compute the duration delta against `startedAt`), but only once per invocation in this module. The caller is responsible for ensuring `startedAt` was sampled with the same clock that `now` reads from; mixing a fake clock for `startedAt` with `Date.now` here would yield nonsense durations.
`buildFailureResponse` only embeds the top-level `cause`. The boundary captures deeper cause chains for the log line but the wire format intentionally truncates to one level — a deeper chain belongs in the request id'd log entry, not in the response body.

View File

@@ -0,0 +1,70 @@
# `request-pipeline-helpers` — pure utilities for the request pipeline
## Purpose
This module concentrates the pure (no-`this`, no side-effecting) helpers the §6 request pipeline relies on: request-id generation, URL parsing tolerant of relative forms, JSON body reader with a size cap, `Response` wrapping for handler-returned values, an `X-Request-Id` header stamper, and the `RequestContext` builder handed to plugin handlers. The split is mechanical — every function used to live inside `request-pipeline.ts` and was extracted verbatim when the file outgrew the CLAUDE.md per-file LOC budget.
Keeping these helpers in their own module lets the driver, the step gates, and the outcome emitters all consume them without circular imports, and lets each helper be unit-tested via the existing end-to-end pipeline tests with zero behavioural drift.
## Features
- `defaultRequestId()` — 32-char hex string drawn from `crypto.getRandomValues`, with a `Math.random` fallback only when WebCrypto is missing.
- `parseUrl(url)` — returns `URL` or `null`; tolerates absolute and relative inputs without throwing.
- `safeReadBody(request)` — JSON body reader that enforces a 5 MiB cap and converts size or parser errors into `HOST.REQUEST_VALIDATION_FAILED`.
- `toResponse(value)` — wraps handler return values as `Response` (passes through existing `Response` unchanged).
- `withRequestHeaders(response, id)` — clones a `Response` with `X-Request-Id` set.
- `buildRequestContext(args)` — assembles the typed `RequestContext` (including the documented `tenant`-may-be-null escape hatch for the foundation-gap-5 default behaviour).
- `DEFAULT_IDEMPOTENCY_TTL_MS` (10 minutes) and `HANDLER_VALIDATION_LIMIT_BYTES` (5 MiB) constants.
## Usage: Basic
```ts
import {
buildRequestContext,
defaultRequestId,
parseUrl,
toResponse,
withRequestHeaders,
} from "@suaveplan/api-host/request-pipeline-helpers";
const id = defaultRequestId();
const url = parseUrl(request.url);
const response = withRequestHeaders(toResponse({ ok: true }), id);
```
## Usage: Advanced
`safeReadBody` is the size-bounded JSON reader the request-validation step uses; consumers needing identical semantics outside the pipeline (e.g. an admin endpoint) can call it directly:
```ts
import { safeReadBody } from "@suaveplan/api-host/request-pipeline-helpers";
try {
const body = await safeReadBody(request);
// body is `unknown`; downstream Zod parse expected.
} catch (cause) {
// HOST.REQUEST_VALIDATION_FAILED — already shaped for the boundary.
throw cause;
}
```
## API Reference
```ts
const DEFAULT_IDEMPOTENCY_TTL_MS: number;
const HANDLER_VALIDATION_LIMIT_BYTES: number;
function defaultRequestId(): string;
function parseUrl(url: string): URL | null;
function safeReadBody(request: Request): Promise<unknown>;
function toResponse(value: unknown): Response;
function withRequestHeaders(response: Response, requestId: string): Response;
function buildRequestContext(args: BuildRequestContextArgs): RequestContext;
```
## Implementation Notes
Every helper is referentially transparent except `safeReadBody` (which reads the request stream) and `defaultRequestId` (which calls into WebCrypto). All other functions take their inputs by parameter and never touch module-scoped state, so they are safe to call from any tier — universal, server, browser, or test.
`buildRequestContext` performs a documented cast for `tenant`/`tenantId`: the shared `RequestContext` type declares both as non-nullable for typed-handler ergonomics, but foundation-gap-5 made the runtime value nullable when the resolver chain misses and `defaultTenant` is `null` (the new default). Handlers consuming tenant-bound capabilities must defensively gate on `ctx.tenant === null`.
The 5 MiB limit on `safeReadBody` is intentional — larger payloads should use a streaming upload route (`streaming: true`) that bypasses request validation buffering entirely. The constant is exported so operators can audit it without grepping the implementation.

View File

@@ -0,0 +1,90 @@
# `request-pipeline-outcomes` — driver config + success/failure result builders
## Purpose
This module ties the bare emitters and validators together for the orchestrator. It exposes `DriverConfig` (the frozen per-pipeline settings bundle), `OutcomeArgs` (the common argument shape carried through every gate-fail branch), `runRequestValidation` (a thin adapter that parses the URL query, short-circuits on streaming / opt-out routes, and emits the dedicated `api.validation.failures_total{where=request}` counter), and the `successResult` / `failResult` builders that translate an outcome into a fully-shaped `PipelineResult`.
These helpers were extracted from `request-pipeline.ts` during the file-size refactor. Behaviour is byte-for-byte identical to the pre-split implementation; the only change is that the shared argument bundle is now reified as a type so each branch can use `...spread` instead of re-listing eight parameters.
## Features
- `DriverConfig` freezes the constructor inputs once and is threaded through every helper, so step-level functions see the same `boundary`, `now`, `instruments`, `defaultTenant`, and `responseValidation` mode.
- `runRequestValidation` is the only piece that knows about the request URL's query — the orchestrator does not parse it directly anymore.
- `successResult` always increments `api.requests_total{outcome=success}` and the duration histogram before returning.
- `failResult` increments the same counter/histogram with `outcome=failure`; the audit envelope is only emitted when a route matched (the 404 path skips audit per spec).
- `OutcomeArgs` is exported so the orchestrator can declare a single `carry` variable and reuse it across every fail branch.
## Usage: Basic
```ts
import {
type DriverConfig,
failResult,
runRequestValidation,
successResult,
} from "@suaveplan/api-host/request-pipeline-outcomes";
const validation = await runRequestValidation(config, { request, route, rootSpan });
if (validation.kind === "fail") {
return failResult(config, { ...carry, failure: validation.failure });
}
return successResult(config, { ...carry, response: handlerResponse });
```
## Usage: Advanced
`DriverConfig` decouples the orchestrator from the `RequestPipeline` class — tests can construct a config object directly and call `runPipeline` to verify behaviour without instantiating the full class:
```ts
const config: DriverConfig = {
options,
instruments,
boundary: new ErrorBoundary({ instruments, logger }),
now: () => 1000,
idempotencyTtlMs: 60_000,
responseValidation: "enforce",
defaultTenant: null,
};
```
`OutcomeArgs.route` is nullable so the 404 path can reuse the same `failResult` shape. Tests that assert "no audit envelope on 404" rely on this branch — supplying a non-null route would silently emit an audit entry.
## API Reference
```ts
interface DriverConfig {
options: RequestPipelineOptions;
instruments: HostInstruments;
boundary: ErrorBoundary;
now: () => number;
idempotencyTtlMs: number;
responseValidation: "enforce" | "warn";
defaultTenant: TenantContext | null;
}
interface OutcomeArgs {
request: Request;
requestId: string;
rootSpan: OtelSpanLike & BoundarySpan;
startedAt: number;
route: PipelineRoute | null;
principal: Principal | null;
tenantCtx: TenantContext | null;
}
function runRequestValidation(
config: DriverConfig,
args: { request: Request; route: PipelineRoute; rootSpan: OtelSpanLike & BoundarySpan }
): Promise<RequestValidationOk | RequestValidationFail>;
function successResult(config: DriverConfig, args: OutcomeArgs & { response: Response }): Promise<PipelineResult>;
function failResult(config: DriverConfig, args: OutcomeArgs & { failure: BoundaryFailure }): Promise<PipelineResult>;
```
## Implementation Notes
`runRequestValidation` is the ONLY function that derives `parsedQuery` from the request URL. Centralising it ensures all three early-validation outcomes (streaming-skip, opt-out, schema-pass) return the same query record shape — without that, the handler ctx could see `{}` on one path and the parsed record on another.
`successResult` calls `withRequestHeaders` exactly once, deliberately. The original implementation also called it once and the post-extracted form preserves that — re-cloning the response would burn an unnecessary `Headers` allocation per request.
`failResult` always emits the `api.requests_total{outcome=failure}` counter, including on the 404 path. That contract is depended on by the `api.errors_by_step` dashboard the host's STATUS docs reference; removing it (e.g. as an optimisation for the no-route case) would silently break the metric.

View File

@@ -0,0 +1,74 @@
# `request-pipeline-stage-emit` — per-stage event helper
## Purpose
This module wraps the `PipelineEventsBus.emit` calls the §6 11-step driver makes around every stage. The helper centralises the entered-at timestamp capture, the matching exit emission, and the `durationMs` calculation so the driver stays readable instead of duplicating the same six-line emit dance on every step.
The helper is private to `@suaveplan/api-host` — consumers wire their own `PipelineEventsBus` via `HostAdapters.pipelineEvents` and never call `beginStage` directly. The conformance harness in `./testing/pipeline-events-conformance/` validates the resulting event stream.
## Features
- One `beginStage(bus, stage, requestId, now)` entry point that emits `entered` and returns a closer.
- Closer exposes `complete()` / `reject(cause)` / `skip()` so the driver can flow control naturally.
- Double-call guard — invoking `complete` then `reject` (or any other combination) on the same emitter only emits the first outcome.
- `durationMs` populated on every exit event using the supplied `now()` function (deterministic under fake-clock tests).
- Cause payload is conditionally attached only when the outcome is `rejected` AND the caller supplies it, keeping `completed` / `skipped` events lean.
## Usage: Basic
```ts
import { beginStage } from "./request-pipeline-stage-emit.js";
const tenant = beginStage(bus, "tenant", requestId, now);
const outcome = await runTenantStep(...);
if (outcome.kind === "fail") {
tenant.reject({ code: outcome.failure.code, message: outcome.failure.message });
return failureResult(...);
}
tenant.complete();
```
## Usage: Advanced
For conditionally-skipped stages the driver invokes `skip()` instead of `complete()` so the conformance harness's `skip` invariant can distinguish "stage didn't run because it wasn't applicable" from "stage ran and passed":
```ts
const csrf = beginStage(bus, "csrf", requestId, now);
const csrfOutcome = runCsrfStep({ ... });
if (csrfOutcome.kind === "skip") {
csrf.skip();
} else if (csrfOutcome.kind === "fail") {
csrf.reject({ code: csrfOutcome.failure.code, message: csrfOutcome.failure.message });
return failureResult(...);
} else {
csrf.complete();
}
```
The closer's double-call guard means an over-cautious caller that invokes both `skip()` and `complete()` only triggers the first one — the conformance harness still treats the stage as having a single exit. Real driver code is structured so only one of the three branches runs.
## API Reference
```ts
export interface StageEmitter {
complete(): void;
reject(cause: PipelineStageCause): void;
skip(): void;
readonly enteredAt: number;
}
export function beginStage(
bus: PipelineEventsBus,
stage: PipelineStage,
requestId: string,
now: () => number
): StageEmitter;
```
## Implementation Notes
The emitter holds a `closed` flag rather than throwing on a double-close because the driver's failure paths can call `reject()` and then walk through cleanup code that defensively closes the same stage if it sees an unfinished emitter. A throw on double-close would convert a recoverable cleanup into a fatal pipeline error.
`enteredAt` is exposed so callers can compute alternative durations (e.g. for OTel histograms scoped to a single stage) without re-reading `now()`. The conformance harness does NOT assert on this value — only on the existence of `entered` + matching exit events.
The conditional cause attach uses a spread guarded by `cause !== undefined` rather than always attaching the field, because the `PipelineStageCause` interface marks the `cause` field as optional and a serialiser that flattens `undefined` to `null` would corrupt the assertion contract.

View File

@@ -0,0 +1,71 @@
# `request-pipeline-stage-skip` — per-stage skip predicates
## Purpose
The §6 11-step request pipeline has four stages (`csrf`, `capability`, `idempotency`, `audit`) that may be conditionally skipped based on route options or request shape. The underlying step functions (`runCsrfStep`, `runRbacStep`, `runIdempotencyStep`) collapse the skip branches into a single `pass` outcome — that is the right shape for the gate-based control flow but it loses the "skipped vs passed" distinction the per-stage event stream needs.
This module re-derives those predicates so the driver can emit `skipped` (not `completed`) for stages that did not actually apply. Without this distinction the conformance harness's "skip" invariant cannot be verified end-to-end.
## Features
- `isCsrfSkipped(request, route, principal)` — GET/HEAD, streaming, csrf-off, or anonymous request.
- `isCapabilitySkipped(route)` — `auth: null | undefined` (public route).
- `isIdempotencySkipped(request, route)` — route opts out OR no `Idempotency-Key` header.
- `isAuditSkipped(route)` — `auditable: false`.
## Usage: Basic
```ts
import { isCsrfSkipped } from "./request-pipeline-stage-skip.js";
const skipped = isCsrfSkipped(request, route, principal);
const stage = beginStage(bus, "csrf", requestId, now);
const outcome = runCsrfStep({ csrf, route, request, principal });
if (outcome.kind === "fail") {
stage.reject({ ... });
} else if (skipped) {
stage.skip();
} else {
stage.complete();
}
```
## Usage: Advanced
The predicates are pure functions so they are trivially testable in isolation without standing up the full driver. The conformance harness exercises the skip path by issuing a GET request (forces `csrf` skip) against an unauthenticated route (forces `capability` skip) without an `Idempotency-Key` header (forces `idempotency` skip) and asserts the three `skipped` events are present in the captured FIFO.
```ts
import { isAuditSkipped } from "./request-pipeline-stage-skip.js";
const route = { options: { auditable: false } } as any;
expect(isAuditSkipped(route)).toBe(true);
```
## API Reference
```ts
export function isCsrfSkipped(
request: Request,
route: { readonly options: { readonly streaming?: boolean; readonly csrf?: boolean } },
principal: Principal | null
): boolean;
export function isCapabilitySkipped(route: {
readonly options: { readonly auth?: string | null };
}): boolean;
export function isIdempotencySkipped(
request: Request,
route: { readonly options: { readonly idempotent?: boolean } }
): boolean;
export function isAuditSkipped(route: {
readonly options: { readonly auditable?: boolean };
}): boolean;
```
## Implementation Notes
The route argument is typed as a structurally-narrow shape rather than the full `PipelineRoute` so each predicate is independent of unrelated route fields. The driver passes the full route in but the predicate never reaches past the documented surface — that keeps the predicate tests honest about what they assert.
Keep these predicates in lock-step with the corresponding step functions in `request-pipeline-steps.ts`. If you change the skip logic in one without updating the other, the conformance harness will fail with a "skip outcome inconsistent with step pass branch" assertion. The unit tests live in `request-pipeline-stage-skip.test.ts` and exercise every branch of every predicate.

View File

@@ -0,0 +1,77 @@
# `request-pipeline-steps` — §6 step gate functions
## Purpose
This module hosts the six per-request gate functions the §6 pipeline runs after route match and before plugin handler invocation: tenant resolution, session resolution, CSRF check, RBAC scope gate, rate-limit consume, and idempotency-key replay check. Each function returns a discriminated `GateOutcome` so the orchestrator can chain `pass → next step` and `fail → failure response` without inheriting the LOC weight of the inline implementations.
The functions were extracted from `request-pipeline.ts` verbatim during the file-size refactor. No behaviour, code path, telemetry counter, or audit envelope was changed; every existing end-to-end pipeline test passes against the extracted forms without modification.
## Features
- `runTenantStep` resolves the tenant via the configured chain, applies the `defaultTenant` fallback for public routes, and emits `api.tenant.unresolved_total` on `auth`-required misses.
- `runSessionStep` resolves the session principal through the boundary; throws map to `HOST.SESSION_REQUIRED` and increment `api.auth.failures_total{reason}`.
- `runCsrfStep` (post-session) returns `pass` for GET/HEAD, streaming routes, non-cookie public requests, and `csrf` flag unset; otherwise consults the validator.
- `runRbacStep` compares `principal.scopes` to `route.options.auth`; failures emit `api.rbac.denials_total{plugin, route, missing_scope}`.
- `runRateLimitStep` buckets by principal id, else tenant id, else `"anon"`; failures emit `api.rate_limit.exceeded_total{key_kind}`.
- `runIdempotencyStep` consults the configured store for `idempotent: true` routes carrying an `Idempotency-Key`; replays emit `api.idempotency.replays_total`.
## Usage: Basic
```ts
import {
runRbacStep,
runTenantStep,
} from "@suaveplan/api-host/request-pipeline-steps";
const tenantOutcome = await runTenantStep({
boundary,
tenant: tenantChain,
route,
span,
request,
defaultTenant,
instruments,
});
if (tenantOutcome.kind === "fail") {
return failResult(config, { ...args, failure: tenantOutcome.failure });
}
```
## Usage: Advanced
`GateOutcome` is a discriminated union shared by every step in this module, so the orchestrator can collapse multiple gates with a uniform fail-side closure:
```ts
const onFail = (g: Extract<GateOutcome<unknown>, { kind: "fail" }>) =>
failResult(config, { ...carry, failure: g.failure });
const csrf = runCsrfStep({ csrf, route, request, principal });
if (csrf.kind === "fail") return onFail(csrf);
const rbac = runRbacStep({ route, principal, instruments });
if (rbac.kind === "fail") return onFail(rbac);
```
Note: side-effecting gates (`runRateLimitStep` consumes a bucket token; `runIdempotencyStep` writes the key entry) MUST run sequentially with early-return on `fail` — running them in parallel changes observable outcomes.
## API Reference
```ts
type GateOutcome<T> =
| { kind: "pass"; value: T }
| { kind: "fail"; failure: BoundaryFailure };
function runTenantStep(args): Promise<GateOutcome<TenantContext | null>>;
function runSessionStep(args): Promise<GateOutcome<Principal | null>>;
function runCsrfStep(args): GateOutcome<undefined>;
function runRbacStep(args): GateOutcome<undefined>;
function runRateLimitStep(args): GateOutcome<undefined>;
function runIdempotencyStep(args): GateOutcome<undefined>;
```
## Implementation Notes
Tenant + session both go through `boundary.runStep` because their resolver implementations can throw (network failure, upstream auth down). CSRF / RBAC / rate-limit / idempotency are synchronous and do not need boundary wrapping — they emit dedicated counters but never throw.
`runRbacStep` short-circuits to `pass` when `route.options.auth` is `null` or `undefined`. That is the public-route signal; the call-site does NOT need to gate on it. A request that reaches `runRbacStep` with `principal === null` and a non-public route raises `HOST.SESSION_REQUIRED` (401), not `HOST.RBAC_DENIED` — the audit log shows that the access was rejected for missing identity, not missing scope.
The `idempotency` step writes the TTL entry only on `pass`. A replay returns `fail` without touching the store, preserving the original entry's expiry. Routes that are `idempotent: true` but receive no `Idempotency-Key` short-circuit to `pass` without storing anything, matching the spec's "key is optional on idempotent routes" provision.

View File

@@ -0,0 +1,90 @@
# `request-pipeline-validation` — Zod gates for request + response
## Purpose
This module owns the two Zod-validation functions the §6 pipeline runs: `validateRequest` (step 6) parses body / query / params against per-route schemas, and `validateResponse` (step 9) confirms the handler-returned JSON matches the declared response shape for the resolved status. Both functions wrap their work in the shared `ErrorBoundary` so a throw becomes a fully-tagged `BoundaryFailure` instead of an unhandled rejection.
The extraction from `request-pipeline.ts` is purely mechanical — the bodies were lifted verbatim and reshaped to take an `ErrorBoundary` parameter instead of accessing it via `this`. No behaviour changed; every existing pipeline test continues to pass.
## Features
- `validateRequest` returns a discriminated union `{ ok: true, value: { body, query } } | { ok: false, failure }`. Body, query, and params are each opt-in via `route.options.request.{body,params,query}`.
- `validateResponse` short-circuits to `{ ok: true }` for streaming routes, routes without declared `responses`, statuses not enumerated in `responses`, and non-JSON content types.
- Zod errors are truncated to the first 10 issues to keep envelope `context.issues` bounded.
- Body size is enforced by `safeReadBody`, so over-limit bodies surface as `HOST.REQUEST_VALIDATION_FAILED` with `context.bytes`.
- Both functions emit `api.errors_total` through the boundary's standard channel — the caller adds the dedicated `api.validation.failures_total{where=request|response|response.warn}` counter.
## Usage: Basic
```ts
import {
validateRequest,
validateResponse,
} from "@suaveplan/api-host/request-pipeline-validation";
const reqOutcome = await validateRequest(
boundary,
request,
route,
parsedQuery,
span
);
if (!reqOutcome.ok) return failureFromBoundary(reqOutcome.failure);
const { body, query } = reqOutcome.value;
```
## Usage: Advanced
For warn-mode response validation, the caller increments a counter and proceeds with the original response:
```ts
const resOutcome = await validateResponse(boundary, route, response, span);
if (!resOutcome.ok && mode === "warn") {
instruments.counters["api.validation.failures_total"].add(1, {
where: "response.warn",
plugin: route.plugin,
route: route.path,
});
logger.error("response validation warning", {
code: resOutcome.failure.code,
});
// continue with the handler's original response
}
```
Tests can invoke either function directly with a minimal `ErrorBoundary` to confirm step-level wiring without setting up the full pipeline.
## API Reference
```ts
type RequestValidationOutcome =
| { ok: true; value: { body: unknown; query: Readonly<Record<string, string>> } }
| { ok: false; failure: BoundaryFailure };
type ResponseValidationOutcome =
| { ok: true }
| { ok: false; failure: BoundaryFailure };
function validateRequest(
boundary: ErrorBoundary,
request: Request,
route: PipelineRoute,
query: Readonly<Record<string, string>>,
span: BoundarySpan
): Promise<RequestValidationOutcome>;
function validateResponse(
boundary: ErrorBoundary,
route: PipelineRoute,
response: Response,
span: BoundarySpan
): Promise<ResponseValidationOutcome>;
```
## Implementation Notes
Both functions delegate every error path to `HostError` so the boundary can map them to `BoundaryFailure` consistently. Direct `throw new Error(...)` inside the closure would bypass the host-code registry and surface as `HOST.HANDLER_THREW` instead of the more specific `HOST.REQUEST_VALIDATION_FAILED` / `HOST.RESPONSE_VALIDATION_FAILED` — the audit and metric labels for those two paths are spec-required.
`validateResponse` clones the response before reading the body so the original stream remains intact for downstream consumers (the failure-response builder, the audit envelope, the eventual return to the caller). Failing to clone would empty the body and the caller would serialise an empty payload.
Streaming routes (`route.options.streaming === true`) skip response validation entirely. Streaming bodies are by definition unbuffered, and forcing a `.clone().json()` on them would defeat the streaming intent and risk OOMing on long-lived responses.

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.