Cross-backend parity suite for token scoping's non-RPC surface rule —
*a narrowed api token is RPC-only*.
Why this suite exists
Every existing cross-impl token-scope assertion is the rpc:<method> arm
(the token_scope_cases batch in the conformance security slate). The
surface:<name> arm had none — and it is the load-bearing half of the
design. Both spines implement it, at the same named surfaces, returning a
denial body that is an explicit cross-impl contract
(token_scope_denied_body ↔ fuz_auth::token_scope_surface_denied_response),
and nothing compared the two.
That gap has the shape this control family keeps finding: the per-method arm
was the one everybody looked at, so it was the one that got a gate. The
surface arm is where a divergence could sit indefinitely — the schema gate
can't see it (no column), the action-manifest gate can't see it (no method),
and the spec-derived suites can't see it (these are REST routes, not the
declared RPC surface).
What it pins
- The denial body, byte for byte — status,
error, and the exact
required_scope capability string, per surface. A spine that renamed a
surface, dropped the surface: prefix, or emitted the JSON-RPC envelope
from a REST route fails here. - Scope outranks role — a narrowed token whose account *also* lacks the
gating role must hear
token_scope_required, not
insufficient_permissions. This is the observable the TS↔Rust ordering
divergence produced before both spines converged on running the surface
gate ahead of the role gate; it was explicitly *invisible* to the
cross-backend suites, because every scoped-token probe used a token whose
account happened to hold the role.
Two non-vacuity controls hold the negatives honest: the narrowed token must
still reach the RPC method it *does* name (otherwise a spine that rejected
the credential outright would pass every denial), and a full bearer must
reach the same surface (otherwise a route broken for all bearers would pass
too).
Why the WS upgrade isn't here
Rule 3's third probeable surface is the WebSocket upgrade, and it is
deliberately omitted: create_ws_transport threads cookies, not bearer
headers, and a refused upgrade surfaces as a thrown connection error whose
body — the thing this suite exists to compare — isn't readable. The two HTTP
surfaces are where the denial body is observable, which is what the parity
gate needs. The WS gate keeps its per-spine coverage (fuz_app's surface
census pins the call site; the Rust census pins its own).
$lib-free by contract (relative specifiers only), like the sibling
cross-backend suites.