Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

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.