Files
suaveplan/packages/api-stack/api/api-host/src/request-pipeline/request-pipeline-steps.md
2026-05-27 23:29:53 +01:00

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

  • 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

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.