4.4 KiB
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
runTenantStepresolves the tenant via the configured chain, applies thedefaultTenantfallback for public routes, and emitsapi.tenant.unresolved_totalonauth-required misses.runSessionStepresolves the session principal through the boundary; throws map toHOST.SESSION_REQUIREDand incrementapi.auth.failures_total{reason}.runCsrfStep(post-session) returnspassfor GET/HEAD, streaming routes, non-cookie public requests, andcsrfflag unset; otherwise consults the validator.runRbacStepcomparesprincipal.scopestoroute.options.auth; failures emitapi.rbac.denials_total{plugin, route, missing_scope}.runRateLimitStepbuckets by principal id, else tenant id, else"anon"; failures emitapi.rate_limit.exceeded_total{key_kind}.runIdempotencyStepconsults the configured store foridempotent: trueroutes carrying anIdempotency-Key; replays emitapi.idempotency.replays_total.
Usage: Basic
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:
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
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.