Cross-backend front-door contract: what every transport answers before its
request reaches per-action dispatch.
Each spine composes its front door differently. The TS spine mounts one
stack at /api/* — body limit, then origin, then session and bearer — and
each surface adds its own gates behind it. The Rust spine stacks global
layers (body limit, then origin) and has each handler resolve its credential
and run its own gates. Two compositions can agree on every per-surface suite
and still disagree on which refusal answers when a request trips several, so
this suite pins the answer per transport as a table:
| surface | foreign Origin | unreadable Origin | duplicated Origin | over cap + foreign Origin | no credential | narrowed token |
|---|
REST POST (/logout) | 403 forbidden_origin | 403 forbidden_origin | 403 forbidden_origin | 413 payload_too_large | 401 authentication_required | 403 credential_type_required |
REST POST (/password) | — | — | — | — | 401 authentication_required | — |
RPC POST | 403 forbidden_origin | 403 forbidden_origin | 403 forbidden_origin | 413 payload_too_large | 401 authentication_required | 403 token_scope_required (rpc:<method>) |
RPC GET | 403 forbidden_origin | 403 forbidden_origin | 403 forbidden_origin | — | 401 authentication_required | 403 token_scope_required (rpc:<method>) |
| WS upgrade | 403 forbidden_origin | 403 forbidden_origin | 403 forbidden_origin | — | 401 authentication_required | 403 token_scope_required (surface:ws_upgrade) |
| SSE (audit stream) | 403 forbidden_origin | 403 forbidden_origin | 403 forbidden_origin | — | 401 authentication_required | 403 credential_type_required |
- foreign
Origin carries the keeper's session cookie, so only the
origin check can refuse it — a front door missing that check answers the
surface's success instead. - unreadable
Origin is the allowed origin with a byte past ASCII
(0xE9), with the keeper's session — a check that reads such a value as
absent lets it through. - duplicated
Origin sends the allowed origin on two header lines, with
the keeper's session — refused whether a backend sees the two lines (the
Rust spine) or one value joined from them (the TS runtimes), so a check that
reads only the first lets it through. - over cap + foreign
Origin declares a Content-Length one byte over
the shared 1 MiB cap and carries no credential: the cap answers on the
header, ahead of both the origin check and auth. Only the surfaces that
take a body have the column. - no credential sends an allowed
Origin and nothing else. - narrowed token sends a bearer minted with a method-scoped scope, and no
Origin (a bearer in a browser context is discarded, not refused). The RPC
probe calls a method the scope doesn't name. A narrowed token reaches no
non-RPC surface, but on a session-only surface the credential gate answers
first — the REST probe is session-only /logout, and the audit stream is
session-only on both spines.
Each case asserts the status and the refusal body: a flat {error} (plus
its named fields) or, past the RPC front door, the JSON-RPC error envelope
with the reason on error.data.reason. A WebSocket refusal's body is
required where the backend declares capabilities.ws_refusal_body; where it
doesn't (Node's @hono/node-ws drops a refused upgrade's body), an empty
body passes on its status and a body that is sent is still asserted.
Transport. Given a base_url, every probe is written to the socket
(raw_http_exchange): an upgrade has to be, and an over-cap request sends
its head alone, as body_size.ts explains — the 413 must arrive without a
body byte. With base_url: null the probes go through the fixture's
in-process transport, whole bodies included, and the WebSocket rows skip
(there is no socket to upgrade).
Wire it for any backend that serves the spine's account routes, RPC
endpoint, WebSocket endpoint and audit stream; the WS and SSE rows gate on
capabilities.ws and capabilities.sse.