first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user