# `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, { 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 = | { kind: "pass"; value: T } | { kind: "fail"; failure: BoundaryFailure }; function runTenantStep(args): Promise>; function runSessionStep(args): Promise>; function runCsrfStep(args): GateOutcome; function runRbacStep(args): GateOutcome; function runRateLimitStep(args): GateOutcome; function runIdempotencyStep(args): GateOutcome; ``` ## 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.