-
Notifications
You must be signed in to change notification settings - Fork 2
Rapid Middleware
Every middleware rapid ships, with every option, its default, its unit, what is validated at build, and the pitfalls each one has. The rules shared by all of them come first; read those once.
- One shape:
import { cors } from '@tundralibs/rapid/middlewares'(or the root), call the factory with an options object, register withapp.use()or per route. Options are validated when the factory is called — a bad value isRAPID_CONFIGat boot, never on the first request. -
Durations are seconds everywhere (
timeout(5),session({ idleTtl: 1800 }),rateLimit({ window: 60 })). Fractions are allowed where a sub-second value makes sense (timeout(0.5)). - Every non-standard header name is an option. A client integrating with a different name never needs a fork.
-
Stateful middleware take
hooks, pact-style: a few purpose-named functions (getSession,increment,claim…) you implement over redis, cacher or anything else. Each ships an in-memory default so zero-config works on one replica. Cacher'sWORKERS_KVengine cannot backincrementorclaim: KV has no atomic increment or set-if-absent, and a key accepts one write per second. -
Order is meaning.
app.use(a, b, c)is an onion:awrapsbwrapsc. The catalog states each middleware's place; the summary table below is the order to register them in. - The correlation id, the response-time header and the access log are not
middleware — the core does them (
headers,logger.accessconfig). - With
server.metricson, every decision the shipped middleware take is a series:rapid_middleware_events_total{middleware,event,action}— themiddlewarefamily in the configuration reference.
import { Application } from '@tundralibs/rapid';
import {
compress,
cors,
csrf,
etag,
idempotency,
rateLimit,
secureHeaders,
session,
timeout,
} from '@tundralibs/rapid/middlewares';
import { pactAuth } from '@tundralibs/rapid/middlewares/pact';
import type { Pact } from '@tundralibs/pact'; // also: deno add @tundralibs/pact
declare const pact: Pact<{ READ: 1n }, 'Admin'>;
const { authenticate, authorize } = pactAuth(pact);
const app = await Application.initialize({
name: 'api',
secret: 'at-least-thirty-two-characters-long!',
});
app.use(
secureHeaders(), // stamp first — survives every error override
cors({ origin: ['https://app.example'] }),
timeout(10), // outermost deadline for everything below
rateLimit({ max: 100, window: 60 }),
compress(), // outside etag: the tag hashes the identity body
etag(),
csrf(), // outside session: re-binds its token on the rotating response
session(),
authenticate, // fills ctx.auth for everything below
idempotency({
scope: (ctx) =>
(ctx.auth as { principal: { id: string } } | undefined)?.principal.id,
}),
);
app.get(
'/admin',
authorize('Admin', 'READ'),
() => ({ content: { ok: true } }),
);| Position | Middleware | Why there |
|---|---|---|
| outermost |
secureHeaders, cors
|
stamp headers before next(), so a 429/500 from anything inside still carries them |
| outer | timeout |
its budget covers everything registered after it |
| outer | rateLimit |
reject before any work is done |
| before etag | compress |
etag must hash the identity body; compress then weakens the tag |
| before session | csrf |
reads the session cookie the inner session() issued on THIS response to re-bind its token |
| after csrf | session |
loads lazily; must be inside csrf
|
| after session |
authenticate (pact) |
may read the session cookie; sets ctx.auth for guards and idempotency's scope
|
| after auth | idempotency |
needs the caller's identity for its key; must be outside the handler it protects |
| per route | authorize(...) |
route-level guards; liveness is the health() endpoint, an ordinary route |
Scope helpers narrow any middleware to one transport or surface:
onlyHTTP(m) / onlySOCKET(m) / onlyJOB(m) run it there and pass through
elsewhere; guardHTTP(m) / guardSOCKET(m) / guardJOB(m) run it there and
block other transports (fail-closed — use these for auth); onlyApi(m) /
onlyUi(m) run it on one HTTP surface (and, deliberately, on every off-HTTP
invocation: onlyApi(authenticate) must never unguard a socket frame).
The shape. A middleware is (ctx, next) => Promise<void> (RapidMiddleware). It
receives the full transport context (HTTPContext, SOCKETContext or
JOBContext) and a next() that runs the rest of the chain. Call next()
at most once and return or await it — a second call is a 500
(next() called multiple times), and an abandoned void next() is logged,
because a handler rejection landing after the middleware returned would
otherwise be an unhandled rejection instead of a disclosed response.
Where it runs. app.use() middleware is the universal onion: it runs on
every HTTP request, every socket frame and every job firing, in
registration order. Route- and command-scoped middleware go inline before
the handler (app.get('/x', a, b, handler)) and run after the universal
chain, in that order; a decorated route declares the same chain with
@GET(path, { middleware: [a, b] }), and @Module(name, { middleware })
prepends one to every route and command in the class. Both chains are
composed once at registration, not per request. Inside a RapidModule, @Use guards module-to-module
invoke() only and never runs for a transport request. A job whose
middleware never called next() is a distinct, logged outcome
(handlerRan: false), not a silent success.
What a middleware may do.
-
Short-circuit: set
ctx.response = { status: 429, content: {...} }and return withoutnext(). The transports treat the response like a handler's. -
Enrich before
next(): headers (ctx.setHeader), cookies,ctx.state,ctx.setAuth(). A header set beforenext()survives an error inside the chain — the error override replaces the body, not the accumulated headers. That is whysecureHeadersandcorsstamp first. -
Post-process after
await next(): readctx.response,ctx.statusandctx.responseHeaders(a copy — usesetHeaderto change anything) and replace the reply withctx.response = { content, headers }. The setter keeps the interpretedstatusunless you give one, mergesheadersper key and appendsset-cookie, so a body transform never resets a 404 to 200 or wipes another middleware's headers. -
Throw a
RapidError(or let one propagate). It reaches disclosure like a handler's: logged,app.onErrorconsulted, the mode-aware envelope sent.
Transport awareness. Branch on ctx.type, or wrap the middleware:
| Wrapper | On its transport | Elsewhere |
|---|---|---|
onlyHTTP(m) / onlySOCKET(m) / onlyJOB(m)
|
runs | skipped — next() is called for it |
guardHTTP(m) / guardSOCKET(m) / guardJOB(m)
|
runs |
rejected with 403 RAPID_ACCESS_DENIED (fail-closed — auth) |
onlyApi(m) / onlyUi(m)
|
runs on that HTTP surface | skipped on the other surface; runs off-HTTP so a surface gate can never unguard a socket frame |
middlewareScope(m) reads the transport metadata a wrapper stamps
(MIDDLEWARE_SCOPE); it is informational — tooling can tell a
transport-bound middleware from a universal one, the framework attaches no
behaviour to it.
Per-invocation state. ctx.state is built from app.state per
stateMode. Under SHARE every invocation gets the same object, so a
middleware that writes per-invocation values into it must declare that with
markStateKeyUser(mw); the boot then refuses stateMode: 'SHARE' for that
app instead of letting concurrent requests read each other's values.
Writing a factory. Follow the catalog's conventions so yours composes with the rest:
import { RapidError } from '@tundralibs/rapid';
import type { RapidMiddleware } from '@tundralibs/rapid';
type StampOptions = {
/** Header name to stamp. @default 'x-served-by' */
header?: string;
/** Cache lifetime in SECONDS. @default 0 */
maxAge?: number;
};
export function stamp(options: StampOptions = {}): RapidMiddleware {
const header = options.header ?? 'x-served-by';
const maxAge = options.maxAge ?? 0;
// Validate at build: a bad option is RAPID_CONFIG at boot, never a
// TypeError on the first request.
if (!Number.isInteger(maxAge) || maxAge < 0) {
throw new RapidError('RAPID_CONFIG', {
message: 'stamp maxAge must be a non-negative integer number of seconds',
details: { maxAge },
});
}
return async (ctx, next) => {
if (ctx.type !== 'HTTP') return await next();
ctx.setHeader(header, 'rapid'); // before next(): survives an error
await next();
if (maxAge > 0 && ctx.status === 200) {
ctx.setHeader('cache-control', `public, max-age=${maxAge}`);
}
};
}Validate every option when the factory is called, keep durations in
seconds, make non-standard header names options, and take persistence as a
small hooks object with an in-memory default rather than a generic store.
Response hardening — helmet's default set. HTTP only. Headers are computed
once at build and stamped before next(), so error responses carry them
too. Document-only policies (COOP, COEP, Origin-Agent-Cluster) are stamped on
the ui surface only; everything else on every HTTP response.
| Option | Type / values | Default | Header |
|---|---|---|---|
contentTypeOptions |
boolean |
true |
X-Content-Type-Options: nosniff |
frameOptions |
'DENY' | 'SAMEORIGIN' | false |
'DENY' |
X-Frame-Options |
referrerPolicy |
one Referrer Policy token, or a comma-separated fallback list, or false
|
'no-referrer' |
Referrer-Policy |
hsts |
false | true | { maxAge?, includeSubDomains?, preload? } |
false |
Strict-Transport-Security |
hsts.maxAge |
non-negative integer seconds (0 tells browsers to forget) |
15552000 (180d) |
|
hsts.includeSubDomains |
boolean |
true |
|
hsts.preload |
boolean — requires maxAge ≥ 31536000 and includeSubDomains
|
false |
|
contentSecurityPolicy |
single-line policy string, or false
|
false |
Content-Security-Policy |
crossOriginOpenerPolicy |
'same-origin' | 'same-origin-allow-popups' | 'unsafe-none' | false |
'same-origin' |
Cross-Origin-Opener-Policy (ui only) |
crossOriginResourcePolicy |
'same-origin' | 'same-site' | 'cross-origin' | false |
'same-origin' |
Cross-Origin-Resource-Policy |
crossOriginEmbedderPolicy |
'require-corp' | 'credentialless' | false |
false |
Cross-Origin-Embedder-Policy (ui only) |
originAgentCluster |
boolean |
true |
Origin-Agent-Cluster: ?1 (ui only) |
permittedCrossDomainPolicies |
'none' | 'master-only' | 'by-content-type' | 'all' | false |
'none' |
X-Permitted-Cross-Domain-Policies |
dnsPrefetchControl |
'off' | 'on' | false |
'off' |
X-DNS-Prefetch-Control |
xssProtection |
boolean (emits 0 — disables the legacy auditor) |
true |
X-XSS-Protection |
permissionsPolicy |
{ feature: ['self' | '*' | origin, ...] }, a raw string, or false
|
false |
Permissions-Policy |
Validated at build: every enum value, Referrer Policy tokens, hsts.maxAge,
the preload prerequisites, single-line CSP/Permissions-Policy strings,
Permissions-Policy feature names and allowlist entries.
Pitfalls. HSTS is opt-in because a service that also answers plain HTTP
locks browsers out for maxAge seconds; turn it on only behind TLS
everywhere. crossOriginOpenerPolicy: 'same-origin' severs window.opener
and breaks sign-in popup flows — use 'same-origin-allow-popups' there.
crossOriginResourcePolicy: 'same-origin' blocks no-cors embedding
(<img>, fonts) of your responses from other sites — assets served for other
origins need 'same-site' or 'cross-origin'. Permissions-Policy has no
sensible universal default, so it is off until you write one.
Cross-Origin Resource Sharing. HTTP only. Headers are stamped before next()
so errors are readable by the browser; preflights (OPTIONS +
Access-Control-Request-Method) are answered 204 and never reach the router.
A disallowed origin is not an error: the response simply carries no CORS
headers and the browser blocks it.
| Option | Type / values | Default |
|---|---|---|
origin |
'*', one serialized origin, a list of them, or (origin) => boolean
|
'*' |
methods |
non-empty list of HTTP tokens | GET, HEAD, PUT, PATCH, POST, DELETE |
allowedHeaders |
list of HTTP tokens; absent → the request's Access-Control-Request-Headers is reflected |
reflected |
exposedHeaders |
list of HTTP tokens | the three server.paging header names; an explicit list is verbatim, [] exposes none |
credentials |
boolean |
false |
maxAge |
non-negative integer seconds (Access-Control-Max-Age) |
unset |
Validated at build: origin strings must be serialized origins
(https://app.example, http://localhost:3000 — no path, lowercase; browsers
send lowercase, so HTTPS://App.example would never match), a non-empty
origin list, non-empty methods, tokens everywhere, maxAge.
Pitfalls. origin: '*' with credentials: true is reflect-any-origin
with credentials — the spec forbids the literal *, so the caller's origin
is echoed instead, which is the classic misconfiguration; pair credentials
with a list or predicate. Reflected allowedHeaders makes the preflight vary
by request, so Vary: Access-Control-Request-Headers is added — a fixed list
does not. Browsers cap maxAge themselves (Chromium 7200 s, Firefox 86400 s).
Vary: Origin is stamped for every origin-bearing request, allowed or not, so
a shared cache never serves one origin's response to another.
gzip/deflate via the Web-standard CompressionStream (no brotli: it is not
available on every runtime). HTTP only; runs after next().
| Option | Type | Default |
|---|---|---|
threshold |
non-negative integer bytes | 1024 |
Behaviour: compresses a compressible content type (text/*, JSON, XML,
JavaScript, SVG, WASM, manifests) when the client accepts gzip or deflate
(RFC 9110 Accept-Encoding semantics, * and q=0 honoured) and the buffered
body is at least threshold bytes; streamed bodies are compressed chunk-wise
regardless of size. Sets Content-Encoding, drops a stale Content-Length,
merges Accept-Encoding into Vary on every compressible response
(encoded or not, so caches key correctly), and weakens an inner etag()'s
strong tag (W/), because the encoded representation is a different one. A
HEAD carries the same headers its GET would. Skips: 204/205/304,
206/Content-Range (byte offsets are identity offsets), already-encoded
bodies.
Pitfall. Register compress() outside etag()
(app.use(compress(), etag())). The other way round, the tag would hash
compressed bytes and change with the negotiated encoding.
Compressed HTML and secrets (BREACH). Compressing a page that carries a
stable secret next to attacker-reflected input (a search-box echo) lets the
secret be recovered from response lengths over HTTPS. view.csrfToken is
safe — it is masked under a fresh pad per response — but any OTHER secret a
template renders (an API key, a signed link) is not; keep compress() off
such pages, or exclude text/html by placing it behind an onlyApi scope.
Strong content-hash ETag and 304 Not Modified for GET/HEAD 200
responses. No options. Weak comparison for If-None-Match (W/, *, lists).
Streamed bodies are not hashed (static file streams carry their own cheap
stat-based weak tag). Register inside compress().
Stateless CSRF protection: a signed, session-bound double-submit token in
a JS-readable cookie that the client echoes in a header (or a form field) on
state-changing methods. No store. HTTP only. Signed with the app secret.
| Option | Type | Default | Meaning |
|---|---|---|---|
cookie |
cookie-name token | 'csrf' |
the token cookie (not HttpOnly — the app's JS must read it) |
header |
header-name token | 'x-csrf-token' |
where the client echoes the token |
field |
non-empty string | '_csrf' |
form field checked when the header is absent |
session |
cookie-name token | 'sid' |
the session cookie the token is bound to (session()'s cookie) |
sameSite |
'Strict' | 'Lax' | 'None' |
'Lax' |
'None' requires secure
|
secure |
boolean |
true |
|
path |
absolute path | '/' |
Validated at build: names are tokens; path is absolute;
sameSite: 'None' with secure: false is refused (browsers drop such a
cookie); a __Host- name needs secure + path: '/', a __Secure- name
needs secure.
How it works. The token is <nonce>.<binding>.<signature> where the
binding is a keyed hash of the session cookie's value ('' when anonymous).
A token minted under one session — or by an attacker from a writable
subdomain — never verifies for another. When the session changes (login,
regenerate(), logout) the token is re-issued on the same response,
provided csrf() is registered outside session(). The token valid for
the current response is published to the view bag as view.csrfToken, so a
first-visit form is never empty — masked: the view gets the token XORed
under a fresh random pad on every response (<hex pad>~<hex masked>), so a
compressed page never carries the same secret bytes twice (the BREACH
defence); csrf() unmasks a header or field value before verifying, and the
bare cookie value the swap runtime echoes stays valid.
Pitfalls. Registered inside session(), the re-issue lands one response
late: the first state-changing request after a login is rejected once. The
default secure: true means the cookie is dropped over plain HTTP — every
request then 403s; set secure: false only for local development. On an api
surface that authenticates with a token (no cookie), CSRF is unnecessary —
scope it with onlyUi(csrf()); on a surface that accepts a cookie
credential, keep it unscoped. Header-based clients never trigger a body
parse; the form-field fallback reads ctx.payload, which shares its bytes
with ctx.rawPayload, so idempotency() and pactAuth may sit on either
side of it.
Pitfalls. csrf() needs the app secret at REQUEST time — installed
without one, every HTTP request (GETs included) is a RAPID_CONFIG 500, not a
boot error.
Cookie-keyed, hook-backed per-client state, loaded lazily — a request that
never calls getSession(ctx) costs no store round-trip and no HMAC. HTTP only.
Two expiries: a rolling idle TTL that slides on requests that touch the
session, and a hard absolute cap.
| Option | Type | Default | Meaning |
|---|---|---|---|
hooks |
SessionHooks |
memorySessionHooks({ maxSessions }) |
persistence — see below |
maxSessions |
positive integer | 100000 |
bound on live sessions in the memory default (oldest evicted) |
cookie |
cookie-name token | 'sid' |
the signed id cookie |
idleTtl |
positive integer seconds |
1800 (30 min) |
idle expiry; also the cookie's Max-Age; must be ≤ absoluteTtl
|
absoluteTtl |
positive integer seconds |
43200 (12 h) |
hard lifetime cap |
rolling |
boolean |
true |
slide the idle window on every touch, not only on writes |
sameSite |
'Strict' | 'Lax' | 'None' |
'Lax' |
'None' requires secure
|
secure |
boolean |
true |
|
path |
absolute path | '/' |
SessionHooks — each may return a value or a promise; ttl is seconds:
import type {
SessionHooks,
SessionRecord,
} from '@tundralibs/rapid/middlewares';
declare const redis: {
get(k: string): Promise<string | null>;
set(k: string, v: string, mode: 'EX', ttl: number): Promise<unknown>;
del(k: string): Promise<unknown>;
expire(k: string, ttl: number): Promise<unknown>;
};
const hooks: SessionHooks = {
async getSession(id) {
const raw = await redis.get(`sess:${id}`);
return raw === null ? undefined : JSON.parse(raw) as SessionRecord;
},
async saveSession(id, record, ttl) {
await redis.set(`sess:${id}`, JSON.stringify(record), 'EX', ttl);
},
async deleteSession(id) {
await redis.del(`sess:${id}`);
},
async touchSession(id, ttl) { // optional — slides the window without rewriting
await redis.expire(`sess:${id}`, ttl);
},
};In a handler: const s = (await getSession(ctx))!; s.get('cart'); s.set(...); s.regenerate() on login (new id, same data — defeats fixation);
s.destroy() on logout. The save phase runs after the handler even when it
threw, so logout-then-throw still logs out.
Pitfalls. memorySessionHooks is per-process: behind two replicas a user
flips between sessions — inject shared hooks the moment you scale out. A
read-only request slides the window through touchSession (or a re-save of
the current stored record when the hook is absent), never by writing back
its own snapshot, so a page render overlapping a POST cannot erase the write.
Values must be structuredClone-able: a function or a class instance in the
session fails that request loudly rather than poisoning the record. Under
stateMode: 'SHARE' nothing changes — the session rides the request context,
not ctx.state.
Fixed-window limiting keyed per client address (HTTP), per connection (sockets); jobs are exempt. Counting is one hook, atomic by contract.
| Option | Type | Default |
|---|---|---|
max |
positive integer hits per window | 60 |
window |
positive integer seconds | 60 |
key |
(ctx) => string | null — null exempts the invocation; '' is a real key (every caller returning it shares ONE bucket) |
address / connection id / null on jobs |
maxKeys |
positive integer — bound on distinct keys in the memory default | 100000 |
hooks |
{ increment(key, window) } |
memoryRateLimitHooks({ maxKeys }) |
headers |
true, false, or { limit?, remaining?, reset?, retryAfter? } header names |
true — x-ratelimit-limit / -remaining / -reset, retry-after
|
increment(key, window) must atomically bump the key's counter for the
current window and return { count, resetAt } (epoch ms), starting a window
that resets window seconds from now when none is live — a redis INCR +
EXPIRE NX. The in-memory default is atomic by being synchronous. headers
renames the four headers; note reset is an absolute epoch second, so it is
not a drop-in for the IETF draft's delta-seconds RateLimit-Reset. On
rejection: 429 RAPID_RATE_LIMITED
with retry-after in seconds.
Pitfalls. Behind a proxy the default key is the proxy's address until
server.trustProxy is set to the hop count — every client then shares one
budget. A fixed window allows up to 2 × max hits across a window boundary;
use a smaller window if that matters.
Safe client retries: a request carrying Idempotency-Key executes once, a
retry replays the first reply, a concurrent duplicate is a 409, and a key
reused for a different request is a 422 (IETF Idempotency-Key draft
semantics — every attempt is fingerprinted over method, path + query string
and raw body).
HTTP only; unmatched requests pass through.
| Option | Type | Default |
|---|---|---|
scope |
required — (ctx) => identity | undefined, or false
|
— |
ttl |
positive integer seconds a completed reply replays |
86400 (24 h) |
pendingTtl |
positive integer seconds a crashed attempt may block its key | 60 |
header |
header-name token | 'idempotency-key' |
replayedHeader |
header-name token (set to true on a replay) |
'idempotency-replayed' |
maxRecords |
positive integer — bound on the memory default | 10000 |
hooks |
IdempotencyHooks |
memoryIdempotencyHooks({ maxRecords }) |
scope is required because a key without an identity replays one caller's
response to anyone who guesses the header: return the session id, auth
subject or API-key id; undefined/'' skips idempotency for that request;
false is the explicit opt-in to a shared key space (a webhook receiver keyed
by the provider's event id). The record key is surface + route pattern +
scope + client key.
IdempotencyHooks — ttl in seconds:
-
getRecord(key)→ record orundefined -
claim(key, pendingRecord, ttl)→boolean— store only if absent (redisSET NX EX);truemeans this call owns the key. This atomicity is what makes a concurrent duplicate a 409 across replicas. -
saveRecord(key, record, ttl)— the completed reply -
release(key)— drop it so a retry re-executes
What replays: content, status, and the headers the inner chain added
(per-request stamps like the request id are re-issued fresh). Not replayed,
and never recorded: thrown errors, streamed or unserialisable bodies,
set-cookie (a login must not sit behind an idempotency key).
Pitfalls. The fingerprint reads the raw body (ctx.rawPayload) — register
idempotency() before anything else that parses the body, or they share the
same cached bytes (fine) but a middleware that replaces the body first would
change the fingerprint. Multipart bodies embed a random boundary per request,
so a multipart retry from a browser will mismatch (422) — keep idempotent
endpoints JSON. With timeout() in the chain, a deadline that fires leaves
the key pending until pendingTtl (the work may still be running); size
pendingTtl above the handler's worst case. Errors: RAPID_IDEMPOTENCY_KEY_INVALID
(400), RAPID_IDEMPOTENCY_IN_FLIGHT (409), RAPID_IDEMPOTENCY_MISMATCH (422),
RAPID_PAYLOAD_TOO_LARGE (413) when reading the body for the fingerprint
exceeds the cap. Behind pact encryption the raw body is the JWE ciphertext,
which changes on every encryption — an encrypted client's retry is a 422, not
a replay; keep idempotency() off encrypted routes.
timeout(seconds) — a deadline on everything registered after it, on every
transport. seconds is a positive number, fractions allowed (timeout(0.5)),
at least one millisecond. On expiry the response is overridden with 504 RAPID_TIMEOUT (details: { seconds, action }).
Pitfalls. JavaScript cannot cancel a promise: the handler keeps running
after the 504, its result discarded. On jobs the abandoned work is handed to
ctx.detach() so the scheduler's overlap guard stays held — otherwise every
tick would start another copy of a wedged handler. Combined with
idempotency() the key stays pending until pendingTtl. A handler that
finishes in a photo-finish with the deadline may still win — best-effort, not
a fence.
pactAuth(pact, options) → { authenticate, authorize, login, logout, refresh, me } — rapid's adapter over pact's neutral middleware core plus the session
handlers over the instance. Options are pact's PactMiddlewareOptions
(carriers per scheme, hmac, encryption, challenge, realm) plus
bearer.cookie, optional defaulting to true, and session (fields,
cookie attributes, refreshCookie, principal projection). Fully
described in
Authentication & authorization.
A middleware is (ctx, next) => Promise<void> (RapidMiddleware); narrow by ctx.type
('HTTP' | 'SOCKET' | 'JOB') and, on HTTP, by ctx.surface. Set headers
before next() when they must survive an error; read the reply after
next() via ctx.response, ctx.status and ctx.responseHeaders; replace it
with ctx.response = { content, headers } (the interpreted status is kept).
Throw a RapidError to fail. If you write per-invocation values into
ctx.state, wrap the middleware with markStateKeyUser(mw) so
stateMode: 'SHARE' refuses it at boot instead of corrupting state under
concurrency. If a body-bound feature needs the raw bytes, read
ctx.rawPayload before anything parses ctx.payload.