testing/cross_backend
98 modules
testing/cross_backend/account_lifecycle.ts
Cross-backend parity suite for the account-lifecycle admin verbs:
account_delete(soft),account_undelete(reactivation), andaccount_purge(keeper hard-delete), plus the keeper guard and the session-only self-delete (an API token can't end its own account; an admin token still deletes another).Like the cell suites, these verbs can't ride the generic describe_rpc_round_trip_tests: they're stateful and destructive (a generic round-trip would tombstone the bootstrapped keeper). They live-mount on every spine's RPC path but stay off the declared surface, so this dedicated suite is their cross-impl validator. Every success
resultis parsed against the verb's declared Zod output schema, so a TS↔Rust envelope drift fails the assertion.testing/cross_backend/action_manifest_parity.ts
Cross-impl action-manifest parity — structural diff + assertion over two ActionManifests captured via the
_testing_action_manifestRPC action.The action-surface twin of
schema_parity.ts: two live impls (the TS fuz_app spine and the Rusttesting_spine_stub) are each other's parity reference. After both bootstrap, dump each one's live RPC method set, diff, fail loudly on drift. The diff entries name the specific divergence (a method only one impl mounts, a per-method input-shape, auth-axis, side-effect, or rate-limit-class mismatch) so the error message points at the source.const manifest_a = await capture_action_manifest(ts_handle); const manifest_b = await capture_action_manifest(rust_handle); assert_action_manifests_equal(manifest_a, manifest_b, {a: 'ts', b: 'rust'});Non-coverage: the manifest captures `{method, input, side_effects, account, actor, roles, credential_types, rate_limit}` — the wire-relevant auth shape plus whether the method takes params at all. It does not capture the input/output schemas themselves (the declared-surface wire shape is gated by
rpc_round_trip) nor the protocol actionsheartbeat/cancel(excluded by construction — seeaction_manifest.ts).testing/cross_backend/action_manifest.ts
Cross-impl RPC action-manifest introspection — a normalized, JSON-serializable dump of a backend's live RPC method set, one entry per method carrying its input shape, auth shape, side-effect flag, and rate-limit class.
The sibling of
schema_introspect.ts's SchemaSnapshot: where that captures the live *database* shape for the schema-parity gate, this captures the live *RPC registry* shape for the action-manifest parity gate. Both are dumped over a daemon-token_testing_*introspection action (_testing_action_manifesthere,_testing_schema_snapshotthere) and diffed across the TS spine and the Rusttesting_spine_stubso a method-set or per-method auth-shape divergence fails loud. This complements the in-repospine_method_coveragegate: that proves *mounted ⟹ covered*; this proves *TS-mount-set ≡ Rust-mount-set* (method set + auth shape).Scope — domain + testing surface, not wire protocol. Protocol actions (
heartbeat/cancel/peer/ping) are excluded: on the TS spine create_testing_action_manifest_action filters them via the protocol_action_specs method set; the Rust stub dropsPROTOCOL_ACTION_SPECS. The two impls organize protocol actions differently (peer/pingis on the TS spine's WS and HTTP-RPC endpoints — it must answerpeer_no_transportover HTTP — while heartbeat/cancel stay WS-only; the Rust stub compiles one shared registry serving both transports), so including them would be a spurious cross-impl diff. This matches the scope of the in-repospine_method_coveragegate (also over build_full_spine_rpc_actions).Paired with
action_manifest_parity.tsfor the diff + assertion helpers.testing/cross_backend/actor_lookup.ts
Cross-backend parity suite for
actor_lookup.actor_lookupis an opt-in batched id → label resolver ({ids} → {actors: [{id, username, display_name?}]}), not folded into the standard bundle. It's live-mounted on the spine RPC path but kept off the declared surface (create_spine_surface_spec) — like cells / ws / sse — so the standard cross suite's generic round-trip never drives it.Its single-request refusals are conformance-table rows on a table given the live mount (
live_rpc_endpoints): the anonymous 401 a generated auth row, the empty-ids400 a hand-written one. This suite holds what a row can't say, because it names the fixture's own actor:- keeper resolves own actor → 200 — the populated round trip: the
returned row carries the keeper's
id+username, and noaccount_id/email/ timestamp / role field (the wire shape's deliberate info-leak posture). This is the assertion that exercises the Rust row→JSON mapping against the TS canonical shape. - a soft-deleted account's actor doesn't resolve — beside a live one,
after the keeper's
account_delete.
Runs both legs via the shared
{setup_test}protocol: the in-process leg (auth/actor_lookup_parity.db.test.ts, plaingro test) and the cross-process leg (cross_backend/spine_suites.cross.test.ts, the TS spine binaries + Rusttesting_spine_stubover real HTTP).actor_lookupis mounted on every spine, so the suite is ungated.- keeper resolves own actor → 200 — the populated round trip: the
returned row carries the keeper's
testing/cross_backend/app_settings.ts
Cross-backend effect suite for the
open_signupapp setting.The declarative conformance table pins the admin gate on
app_settings_get/app_settings_update(401 / 403 / 200). This suite pins the behavioral effect of the toggle end to end: an admin flipsopen_signupviaapp_settings_update, and a subsequent anonymousPOST /signupobserves the new value.- toggle on → anonymous signup without an invite succeeds (200) — with
open_signup: true, the invite gate is skipped. - toggle off → anonymous signup is refused (403
no_matching_invite) — flipping it back restores the invite requirement, proving the gate keys on the live value rather than a one-time read.
The signup handler reads the toggle fresh from the database on every request, so the admin's write is visible to the next signup. This suite runs in a single process, so it validates the read-through *mechanism* — not multi-process consistency (which the fresh-read shape provides by construction but no single-binary test can observe).
Cites
security.md§Signup. Runs both legs via the shared{setup_test}protocol: in-process (auth/app_settings_parity.db.test.ts) + cross-process (cross_backend/spine_suites.cross.test.ts, TS spine binaries + Rusttesting_spine_stub). Mounted on every spine, so the suite is ungated.- toggle on → anonymous signup without an invite succeeds (200) — with
testing/cross_backend/audit_cross_helpers.ts
Audit-row reads for the cross-backend suites, behind the
_testing_drain_effectsbarrier.Both spines write audit rows off the request path — a WS mutation is answered before its effects flush, and the Rust spine's emits are fire-and-forget — so a suite that reads
audit_log_listright after the call it audits can miss the row, and an assertion that a row is absent then passes vacuously. list_audit_events drains before it lists, so no reader can skip the barrier, and holds every row it lists to its event type's metadata schema (assert_audit_event_metadata), so every reader checks the metadata of the rows it reads, on every spine — closed to undeclared keys when the fixture's setup asked for it.testing/cross_backend/backend_config.ts
Cross-process backend configuration.
BackendConfig describes a spawnable test binary — argv, mount paths, env vars, bootstrap credentials, daemon-token discovery path, declared capabilities. Consumer projects ship per-backend factories (
deno_backend_config(),rust_backend_config(),rust_spine_stub_backend_config()) that produce this shape; spawn_backend consumes it.fuz_app ships
rust_spine_stub_backend_config()as a convenience preset (operational dep ontesting_spine_stub— path-based discovery, nopackage.jsoncoupling to the stub's source package). Otherwise backend-specific knowledge (binary paths, port choices, env vars) is a consumer concern; fuz_app's testing library knows nothing about Deno, Cargo, or any specific runtime beyond that preset.testing/cross_backend/bench/bench_report.ts
Reporting adapters over a CrossImplBenchResult — all built on fuz_util's formatters + Welch comparison. Markdown for human eyeballs, a per-scenario TS-vs-reference significance verdict, and a self-describing JSON artifact.
testing/cross_backend/bench/run_cross_impl_bench.ts
Drive identical wire scenarios across several spawned backends and time each round trip, so a TS impl and a Rust impl can be compared apples-to-apples (both cross-process over real HTTP). The reusable cross-impl measurement primitive.
fuz_util's benchmark library is the engine —
Benchmarkruns each scenario as a task andBenchmarkResult.statscarries the percentiles; this module is the thin scenario→task→tagged-result adapter. Reporting (markdown, TS-vs-Rust verdict, JSON artifact) lives in testing/cross_backend/bench/bench_report.ts.testing/cross_backend/bench/scenario.ts
Context handed to a
BenchScenario.run. Carries a ready, pre-authed transport (the bootstrapped keeper's, by default) plus the resolved RPC path and the backend's declared capabilities. A scenario fires one round trip (or a small fixed *idempotent* sequence) against it — no per-call_testing_reset, which is the correctness-test model and would dominate the timing.testing/cross_backend/body_size_smuggling.ts
Cross-backend request-smuggling probe for the body-size limit's connection handling — the security sibling of
body_size.ts.When the server caps the request body it answers
413on theContent-Lengthheader. The strong (defense-in-depth) posture is to close the connection *without reading the oversized body*: HTTP/1.1 forbids reusing a keep-alive connection whose request body wasn't consumed, because unread body bytes would be parsed as the start of the next request — a classic request-smuggling vector. This suite probes the boundary by pipelining: it opens a raw TCP socket and sends, in one write, an oversizedPOSTimmediately followed by a secondGET. Every backend must close after the reject, so the pipelined GET goes unanswered and at most one response comes back.<= 1rather than "exactly the 413" because the impls close differently at the TCP level (a graceful close delivers the 413 first; hyper's RST can drop the in-flight 413 before the client reads it), so demanding a cleanly-read 413 would be flaky.- Node closes at once with the body unread — create_app_server's body
limit (http/body_limit.ts) refuses on the header and answers
Connection: close, which is what makes@hono/node-serverclose. - Bun closes at once too, honouring that header. Bun 1.4.3 is the tested floor: an older Bun kept the connection after the 413 and served the next request (correctly framed), which fails this suite.
- Deno closes once the whole declared body has arrived, which this probe's single write delivers; a client that withholds the body holds a Deno connection open, and no request behind the body is served either way.
- hyper (the Rust spine) closes at once.
The oversized body is rejected *with* a 413 on every backend — pinned reliably by describe_body_size_cross_tests, which sends the over-cap request's head and no body, so no upload races the close; this test owns only the connection-handling half. A positive control (two pipelined requests →
>= 2responses) proves a second response *would* be seen if a trailing request were processed — without it<= 1would be vacuous on a server that never reuses connections — and that the counter isn't undercounting.Content-LengthbesideTransfer-Encoding. The second probe sends a request carrying both headers — the shape a request-smuggling attack hands a server and a proxy that disagree on which one frames the body — with aGETpipelined behind it. What the backends share is the outcome, not the status: the request is refused with400(the Node, Deno and Bun parsers) or answered as its chunked body reads (hyper, which drops theContent-Length), and either way the server closes the connection and the pipelined request is never served. The status of the one response is implementation-defined and not asserted.Raw-socket by necessity (the FetchTransport can't pipeline two requests on one connection), so — unlike
body_size.ts— this is cross-process only (no in-process leg; there is no socket in-process — the in-process pin is that the reject never reads the request body, inserver/create_app_server.db.test.ts).Cited property:
docs/security.md§"Body Size Limiting" (connection handling on oversized reject).- Node closes at once with the body unread — create_app_server's body
limit (http/body_limit.ts) refuses on the header and answers
testing/cross_backend/body_size.ts
Cross-backend parity suite for the request body-size limit.
create_app_server (TS) and the Rust spine both cap the request body at a 1 MiB default (DEFAULT_BODY_LIMIT_BYTES, the same name in TS and in
fuz_http; a backend capping its RPC path elsewhere passesrpc_max_body_bytes) and reject oversized payloads with413and the canonical flat REST body{error: 'payload_too_large'}. A declaredContent-Lengthover the cap is refused on the header, *before* auth, origin, or dispatch run (middleware step 4). Each impl unit-tests this in isolation; this suite pins the cross-impl agreement on the status + body shape, and on the exact>cap boundary (not an off-by-one divergence). The cases:- over-limit POST (cap + 1 byte) → 413
payload_too_large, refused before any handler runs (the limit fires ahead of origin verification + the dispatcher, so an over-cap body is rejected regardless of how well-formed it is). Exactly one byte over — both impls reject on a strict>, so this is the tight upper boundary, and staying just over keeps it clear of any larger framework-default limit that would answer with a different body. - at-limit POST (exactly the cap) → not 413 — one byte under the rejection threshold passes the size gate and reaches the dispatcher (the downstream status is irrelevant; only "not size-rejected" is asserted). The boundary sibling of the case above.
- under-limit POST (small) → 200 — a small, well-formed authenticated
account_verifyenvelope sails through to a successful handler response, the positive control that the route works for normal traffic. - The cap answering a declared length ahead of the origin check and auth —
over-cap with a disallowed
Originand no credential → 413 — is pinned per transport by the front-door table (transport_composition.ts), beside the under-cap 403 that shows the origin check still runs. - over-limit chunked POST (no
Content-Length), authenticated → 413 — a body with no declared length is refused by the bytes that arrive.
Implementation-defined, not pinned here. Without a declared length the two spines measure at different points: the TS spine reads the body up to the cap before any later middleware runs, so its 413 comes ahead of the origin check and auth; the Rust spine counts as the handler reads, so a refusal made on the headers (a disallowed
Origin) answers first and a handler that never reads the body answers its own status. Only the authenticated, allowed-origin chunked case — where nothing else refuses — has one answer on every backend. A repeatedContent-Lengthwith equal values is also the implementation's: the TS spine answers 400invalid_content_lengthwhere its runtime's parser hands the repeat over, and hyper folds it into one value and accepts.Over a real socket the over-cap cases send no byte the server won't read. A server that caps the body answers 413 and may close the connection with the rest of the upload unread — correct HTTP, since an unread request body can't share a keep-alive socket. A client still uploading then has its write fail (
EPIPE) and can lose the 413 the server did send, so a whole over-cap body sent throughfetchreads its answer only some of the time. Given abase_url, the suite speaks to the socket directly and holds back what the refusal doesn't need:- a declared over-cap length is sent as the request head alone
(raw_http_send_head) —
Content-Lengthone over the cap and not one body byte. The 413 must arrive within RAW_HTTP_READ_TIMEOUT_MS, which also pins that the refusal is made on the header: a backend that reads the body before refusing, or has no cap, waits for bytes that never come and fails the case. - the chunked body is sent up to one byte past the cap and never terminated (raw_http_exchange). The server has read every byte sent by the time it has counted past the cap, so nothing is left unread, and the 413 must arrive without the body's end.
With
base_url: nullthere is no socket and no such race (the in-processapp.request), so the same cases send whole bodies through the fixture's transport. The under-cap cases go through the transport either way.Like origin/payload rejection, this is middleware-level flat REST — not the JSON-RPC envelope the conformance-table runner expects — so it's an imperative suite, not a
conformance_tablerow. Runs both legs via the sharedsetup_testprotocol: the in-process leg (auth/body_size_parity.db.test.ts, plaingro test) and the cross-process leg (cross_backend/spine_suites.cross.test.ts, the TS spine binaries + Rusttesting_spine_stubover real HTTP). The body-size limit is on every spine, so the suite is ungated.Cited property:
docs/security.md§"Body Size Limiting".- over-limit POST (cap + 1 byte) → 413
testing/cross_backend/bootstrap_backend.ts
One-call spawn + bootstrap helper.
Composes
spawn_backend(config)andbootstrap({transport, config})so a consumer's vitestglobalSetupreduces to a single await:import {bootstrap_backend} from '@fuzdev/fuz_app/testing/cross_backend/bootstrap_backend.ts'; export default async function ({provide}) { const bootstrapped = await bootstrap_backend(deno_backend_config()); provide('backend_handle', bootstrapped); return async () => { await bootstrapped.teardown(); }; }If
bootstrap()throws — typically a bad token, port collision, or keeper-username mismatch — the spawned binary is torn down before the error propagates so vitest doesn't strand the port.testing/cross_backend/build_test_backend_paths.ts
Per-backend filesystem layout under
os.tmpdir()for cross-process tests.Isolation matters because vitest projects can run in parallel, and other repos' cross runs on the same machine share
os.tmpdir()— a sharedrootwould mix bootstrap and daemon tokens across concurrently-running backends. Each backend gets its own subtree: default_test_backend_paths (what the default builders use) names it after the running repo's directory plus the backend name, so two repos that both name a backend'rust'don't collide; build_test_backend_paths takes an explicit, already-unique prefix.Consumers compose: take the generic paths from either builder and add domain-specific dirs (e.g.
zzz_dir,scoped_dir) under the returnedroot.testing/cross_backend/capabilities.ts
Capability vocabulary for cross-backend integration testing.
Backends declare which optional behaviors they support; suite bodies call
test_if(capabilities.X, ...)to skip cases the backend doesn't implement. Noif (config.name === 'rust')branches anywhere — name- checking is a code smell that says capability vocabulary is missing.In-process Hono via default_in_process_setup declares every capability its transport can exercise (see in_process_capabilities — the cross-process-only flags stay
false). Cross-process backends opt in per-flag on their BackendConfig.Where the per-backend declarations live (this file owns only the vocabulary + the in-process preset):
- in_process_capabilities — here; every flag
trueexcept the cross-process-only ones. - ts_default_capabilities / rust_default_capabilities — consumer-facing
family defaults, in
default_backend_configs.ts— every flag but the MountFlags (FamilyDefaultCapabilities), as full literals, so adding a non-mount capability is a compile error until each family declares it. ts_spine_capabilities/ts_spine_bun_capabilities— fuz_app's own TS spine presets, ints_spine_backend_config.ts(the family default plus every mount flag; Bun flipsws_handshake_pipelining).rust_spine_stub_capabilities— fuz_app's Rust spine-stub preset, inrust_spine_stub_backend_config.ts(the rust family default plus every mount flag).
Mount flags have no default. A flag naming something a backend mounts (a MountFlag) is never inherited, nor is the
testing_backdoorslist: the family defaults are FamilyDefaultCapabilities, which omits them, so every backend config states each one. The family defaults carry only what the family guarantees — the declared spine surface's mounts (account_lifecycle,account_status) and the runtime behavior of its reference runtime (ws_handshake_pipelining,ws_refusal_body: Node for TS, hyper for Rust), which a backend on another runtime overrides.Gating flags only. BackendCapabilities holds only flags a suite actually gates on (each has a
test_if(capabilities.X, ...)reader, or —ws_refusal_body— picks how a case reads a refusal), plus thetesting_backdoorslist the backdoor suite and describe_spine_cross_suites read. A wiring fact no suite reads is not a capability.- in_process_capabilities — here; every flag
testing/cross_backend/cargo_binary.ts
Cargo-built test binaries for cross-backend projects — build before the spawn, then spawn the binary itself.
A config that spawns
cargo runbuilds inside spawn_backend's health window, so a cold build can outlast the startup timeout, and the build's output is held in the spawn's stderr tail rather than shown. prepare_cargo_binary runs the build first, with its output inherited, as a pre-spawn hook (create_cross_backend_global_setup'sbefore_spawn), and the config'sstart_commandnames the built binary (resolve_cargo_binary_path), so the spawn starts at once.The binary's path comes from
cargo metadata: under the workspace's resolvedtarget_directory, soCARGO_TARGET_DIRandbuild.target-dirare honoured, in the profile's output directory.testing/cross_backend/cell_crud.ts
Dedicated stateful cell-CRUD parity suite for the cross-backend harness.
The generic describe_rpc_round_trip_tests can't cover cells: the verbs are stateful (update / delete / get-by-id need a real cell id threaded from a prior create) and
cell_get's input has a top-level.refine(). So cells stay off the standard declared surface (create_spine_surface_spec) — exactly like ws / sse — and this suite plus its sibling describe_cell_relations_cross_tests (grant / field / item / clone / audit) are the cell validators. This one gates oncapabilities.cell_crud; it runs against any backend that live-mounts the cell surface (the TS spine binary, the in-process Hono app, and the Rusttesting_spine_stub).Drives the full lifecycle (create → get → update → delete → list, threading the created id) plus the authz matrix the wire contract guarantees. Every success response is parsed against the verb's declared Zod output schema (CellCreateOutput / CellGetOutput / …), so a TS↔Rust envelope drift — not just a CellJson field drift — fails the suite:
- owner does full CRUD; responses match the output schemas exactly;
- anon sees
publiccells only —privateis 404 (existence not leaked); - a non-owner non-admin editing / reading / deleting another's private cell gets 404 (IDOR mask), never 403;
- admin reaches any cell;
cell_listpages honorlimit+offset, and a page is visible rows: a newer run of cells the caller can't see never crowds the visible ones out, for a non-owner and for anon, and an admin's pages tile every row;- duplicate active
path→ 409 (cell_path_taken); pathwrite by a non-admin → 403 (cell_path_admin_only), on both create and update (even by the owner);cell_getwith neitheridnorpath→invalid_params;- null-auth
cell_listwithcreated_by→invalid_params; - a denied caller (non-editor update / non-admin path create) carrying a
kindinsidedatastill gets the authz error (404 / 403), never the kind error — authz runs before the input-shape checks, on both spines; - an empty-string
kindis rejected on create (cell_kind_empty) and is accepted as a list filter that matches nothing; - a
kindof exactly CELL_KIND_LENGTH_MAX UTF-16 code units is stored whole, and one unit past it isinvalid_paramson create and as a list filter; - a
datathat is no CellData — not an object, or alabel/summarythat is no string — is 400invalid_paramswith the generic message and no reason oncell_create,cell_updateand (withcapabilities.cell_relations)cell_clone'swith_data_patch, ahead of every cell check (a missing or hidden cell refuses it the same way, never with the 404 mask), and writes no cell.
The visibility-manage-tier 403 (
cell_visibility_manage_only) needs a non-owner editor, which only acell_grantcan produce, so it lives in describe_cell_relations_cross_tests alongside the grant verbs rather than here.testing/cross_backend/cell_gated_create.ts
Cross-backend parity suite for the parent-aware cell-creation authorizer (CellCreateAuthorize) — the directory model.
The authorizer adds no method, column, or wire shape, so the schema-snapshot and action-manifest parity gates are blind to a TS↔Rust authorizer divergence (the authorizer in the wrong phase, a different deny shape, the 404/403 split, the moderation outcome). This behavioral cross case is the only gate that catches one — proven *here in fuz_app*, against both reference spines.
Both spines mount the same directory-model policy (the TS spine binary via
create_test_cell_gated_create_authorize, the Rusttesting_spine_stubviaTestCellGatedCreateAuthorize): admin bypasses; a non-admin creating akind: 'space'root is denied (admin-only); a contribution under a space is gated by the space'spolicy[kind] = {min_role, moderation_required}(the root's manage-tierpolicycolumn), with the moderation outcome folded into the verdict; plain parentless creates stay open. The suite asserts all spines agree on:- root-create admin-only — a non-admin
spaceroot → 403cell_create_forbidden; admin → succeeds. - contribution gated by the root's policy — under a public space, an
unauthorized stranger → 403; a
participant→ succeeds. - moderation per
moderation_required— aparticipant'spost(moderation_required: true) is bornmoderation: 'pending'+ private; areact(moderation_required: false) is bornmoderation: 'approved'at the author's visibility. - 404 on a hidden parent vs 403 on a visible one — a contribution under a
private space the caller can't view → 404
cell_not_found(the parent is masked); under a public space → 403 (you see it, you can't contribute). - a soft-deleted root refuses contributions — soft delete doesn't
cascade, so a live post can sit under a deleted space; a comment under it
→ 404
cell_not_found, the same shape as a missing parent (the authorizer never sees a root id without its data). - clone is no side door —
cell_cloneruns the authorizer for every cell it writes as a parentless create, so a non-admin clone of a viewablespaceroot → 403cell_create_forbidden; admin → succeeds. - the root policy is the manager's —
cell_policy_setwrites it (the new and the previous policy audited), answers an editor-grant holder 403cell_policy_manage_onlyand a non-viewer the 404 mask, refuses a contributioncell_policy_root_only(ascell_createrefuses a policy beside aparent_id), and passes each policy through the authorizer's validation (cell_policy_invalid); an editor rewriting the root'sdata.policyleaves moderation as the column says. The refusal with no authorizer mounted (cell_policy_unsupported) can't run here — both spines mount one — and is pinned in-process on each.
Gated on
capabilities.cell_gated_create—trueonly on the reference spine binaries that mount the policy, so it skips for generic consumers and the in-process default app (the authorizer hook's in-process coverage is the standaloneauth/cell_create_authorize.db.test.ts).- root-create admin-only — a non-admin
testing/cross_backend/cell_grant_role.ts
Cross-backend parity suite for role-shaped
cell_grants.The cell CRUD / relations suites exercise only actor-shaped grant principals. This suite covers the role-shaped path and its closed-registry gate — the security-correctness property that the Rust spine previously lacked (it created inert grant rows for any role string). Both spines now validate the role against a closed registry at create.
- role grant admits a holder; excludes a non-holder — an owner grants
{role}on a private cell; an account holding that role cancell_getit (200), an account without it gets the IDOR-mask 404. - unknown role rejected at create (security-correctness) — granting a
role outside the registry is
invalid_params/cell_grant_unknown_role, not a silent inert row. - editor-level role grant admits edit — a holder of an
editor-level role grant cancell_updatethe cell's content.
The holder is seeded via
extra_accountsunder CELL_ROLE_HOLDER_USERNAME holding the suite'srole— CELL_EDITOR_ROLE by default, which has no grant path, so it can't be offered and the bootstrap-cradle seed is the only path. Both legs configure that seed and register CELL_EDITOR_ROLE in their role registry; the same membership is in the Rust stub'sRoleRegistry(which the cell spec set also reads). A backend with only the builtin roles passes ROLE_KEEPER (no admin reach, so every arm still turns on the grant); ROLE_ADMIN is refused, since an admin reaches every cell whatever its grants.Cites
security.md§Authorization (role-shaped cell-grant validation). Runs both legs via the shared{setup_test}protocol: in-process (auth/cell_grant_role_parity.db.test.ts) + cross-process (cross_backend/spine_suites.cross.test.ts). Gated oncapabilities.cell_relations(trueon the in-process app and fuz_app's spine presets; a consumer states it like every MountFlag).- role grant admits a holder; excludes a non-holder — an owner grants
testing/cross_backend/cell_grant_steward.ts
Cross-backend parity suite for the steward-revoke seam (CellGrantRevokeAuthorize) on
cell_grant_revoke.Like the creation authorizer, the revoke hook adds no method, column, or wire shape, so the schema-snapshot and action-manifest parity gates are blind to a TS↔Rust divergence in it — the fence, the refusal shape, the audit stamp. This behavioral case is the gate that catches one.
Both spines mount the same test policy (the TS spine binary via test_cell_grant_steward_revoke_authorize, the Rust
testing_spine_stubviaTestCellGrantStewardRevokeAuthorize): a scopedparticipantgrant stewards its scope. A cell owner shares a private cell withparticipantat a scope; the suite asserts every spine agrees on:- the scope's steward revokes — a non-manager holding
participantat the grant's scope revokes it:{ok: true, still_admitted: false}, the grant gone from the owner's list, and thecell_grant_revokeaudit row stampedsteward: true. - another scope's steward gets the 404 mask —
cell_grant_not_found, the same refusal as no hook, and the grant stays. - a scopeless role grant is fenced off — a caller holding
participantglobally *and* at a scope still gets the 404 on a grant naming no scope: the spine never asks the hook, which would have admitted the global grant had it been asked with a null scope.
Gated on
capabilities.cell_relations(the grant verbs) andcapabilities.cell_gated_create(the reference spines' test cell policies, which this one rides beside). The hook's in-process coverage, including the manager, self-revoke, actor-shaped and deleted-cell fences, isauth/cell_grant_actions.authz.db.test.ts.- the scope's steward revokes — a non-manager holding
testing/cross_backend/cell_moderate.ts
Cross-backend parity suite for the
cell_moderateverb and the moderation read gate, over the directory-model test authorizer both spines mount (test_cell_gated_create_authorize.ts) — the sibling of describe_cell_gated_create_cross_tests incell_gated_create.ts, whose authorizer is what produces apendingcontribution. The suite's cases are listed on describe_cell_moderate_cross_tests.testing/cross_backend/cell_ref_admission.ts
Cross-backend parity suite for possession-gated cell ref admission — a non-admin
cell_create/cell_update/cell_clonemay introduce a fact ref only to a fact its acting actor holds (afact_holderrow, recorded on every put that names the actor), else403with reasoncell_ref_not_held. Twins the Rustfuz_cell_actionscell_authzcases of the same names, over real HTTP:- create admits a new ref only for a holder — a stored fact the caller never put and a hash nothing is stored under are refused with one identical answer (status + error body — no existence oracle), as is a system-put fact; the uploader's public cell serves the bytes to anyone; a second actor putting identical bytes becomes a holder;
- update keeps existing refs — an editor keeps refs another writer introduced, and adding an unheld one (or dropping a ref and adding it back) is refused;
- clone carries source refs per row — a shallow and a deep clone carry each row's source refs without holding them; a patch adding an unheld ref, or carrying a child's ref onto the root, is refused;
- a global admin is not gated;
- a revoked grantee can't re-reference — a reader who saw a hash through a granted cell can't name it in a cell of its own, before or after the revoke (a known hash is not a read capability);
- extraction parity — a ref is a whole string value exactly matching
blake3:+ 64 lowercase hex: a hash in prose or markup,blake3:zz, an uppercase digest, and an object key are not refs on either spine; - admission answers after the masks — an update of a hidden cell, a
create under a hidden parent, and a clone of a hidden source answer
404whatever theirdatanames.
Facts are put through
_testing_put_fact, withholdernaming the actor a real upload would record. Gated oncapabilities.fact_serving(the seeder + the serve routes), and the cases driving a grant, item, or clone verb oncapabilities.cell_relationsas well; runs under everycross_backend_*project.testing/cross_backend/cell_relations.ts
Dedicated cell relation / ACL / audit parity suite for the cross-backend harness — the sibling of describe_cell_crud_cross_tests covering every cell verb beyond plain CRUD:
cell_grant_*,cell_field_*,cell_item_*,cell_clone, andcell_audit_list.Like the CRUD suite (and ws / sse), these verbs are live-mounted on the spine RPC path but stay off the standard declared surface, so the generic describe_rpc_round_trip_tests never drives them. Every success response is parsed against the verb's declared Zod output schema, so a TS↔Rust envelope drift fails the suite — not just a payload-field drift.
Coverage (gated on
capabilities.cell_relations):- grant lifecycle — owner grants an actor-shaped editor; the grantee
gains edit (was 404 before the grant);
cell_grant_listis manage-tier (owner sees it, the editor gets the IDOR 404); revoke drops the edit path. - no owner principal — a grant naming the cell's owner is refused
cell_grant_principal_is_owner, with the owner's actor id sent lowercase or uppercase. cell_visibility_manage_only— the editor-grant holder can edit content but flippingvisibilityis 403 (the case the 5-verb CRUD cut couldn't reach without a grant principal).- fields —
cell_field_setUPSERT, forward + reversecell_field_list, idempotentcell_field_delete. - relation-list caps — past the default page of inbound edges, a
reverse
cell_item_list/cell_field_listwith nolimitanswers the default page, an explicitlimitcaps it in either direction, and the forward list defaults the same — the same counts on both spines. A page is visible edges: a page's worth of private ends older than one public end still leaves the public one on an anonymous page, in all four lists. An out-of-range or mistypedlimit(0,-1,1.5, past the max,null, a string) isinvalid_paramsin every direction, and the reverse lists read oldest edge first. - items —
cell_item_insertat fractional-index positions, forward (lex-ordered) + reversecell_item_list,cell_item_move, idempotentcell_item_delete; an insert or a move onto a taken position →invalid_paramscell_item_position_taken, leaving the rows as they were. - clone — shallow copies item / field *edges* (shared
child_id/target_id); deep clones each viewable child into a fresh cell at the same position. Both nullpathand stamp the caller as owner. shared_with: 'me'— the grant-drivencell_listbranch: a grantee lists exactly the cells that admit them viacell_grant, their own cells excluded, with thecell_grantsenrichment keyed by cell id. Plus the two guards — null-auth iscell_list_shared_with_requires_auth, and any value other than'me'isinvalid_paramson both spines. The cross twin of the in-processauth/cell_actions.list.db.test.ts§"shared_with: me".- audit —
cell_audit_listis manage-tier: the owner reads the cell's timeline; a viewer-grant holder who cancell_getthe cell still gets the IDOR 404 on the timeline (D14). - relation-read visibility (D8) — listing edges toward a cell the caller
can't view filters them out (no-existence-leak-via-edge): an anonymous
viewer of a public parent — and a viewer-grant holder of a private parent —
sees only independently-viewable children in the
cell_getbundle and the forwardcell_item_list/cell_field_list. The cross twin of the in-processauth/cell_relation_visibility.db.test.ts. - clone D8 — a cloner who can view a public parent but not a private child
silently drops that child: an admin (who *can* view it) reading the clone
still sees only the viewable edge/child, and the
cell_cloneaudit row records no skipped-child count — so the source's hidden-child count can't leak to the cloner. The cross twin ofauth/cell_actions.clone.db.test.ts.
Only actor-shaped grants are exercised — role-shaped principals need a closed role registry the Rust spine deliberately lacks, so role-grant parity is out of scope here (the TS impl covers it in-process).
- grant lifecycle — owner grants an actor-shaped editor; the grantee
gains edit (was 404 before the grant);
testing/cross_backend/conformance_case.ts
Declarative conformance-case schema for the cross-backend behavioral + security suite.
A conformance case is a single request → expected-response assertion, carried as data. The case references a
method(an RPC method name or a REST auth-route suffix); the runner (describe_conformance_table_tests) resolves theinput/outputZod schemas from the live action-spec registry / RouteSpec — the case never carries a schema. This is the opinionated behavioral/security layer on top of the spec-derived auto-enumeration (describe_rpc_round_trip_tests / describe_rpc_attack_surface_tests): the same case definition runs in-process (fast, everygro test) and cross-process (the conformance gate) against each impl's real auth resolution.The table is for single-request matrices (credential-type ceiling, privilege gates, IDOR masks, enumeration-equivalence, validation). Multi-step flows stay imperative in their own
describe_*suites, sharing assertion primitives — there is deliberately no declarative setup DSL.testing/cross_backend/conformance_rpc_auth.ts
Per-method RPC auth rows for the conformance table, generated from the declared surface and, when given, the methods a backend live-mounts off it.
describe_rpc_attack_surface_tests walks every declared method's auth, but only in-process against stub apps with no database, so it never reaches a spawned backend. The hand-written conformance rows reach every backend but cover a chosen few methods. A spine that hand-implements a method's gate can drift on any method nobody wrote a row for, and the action-manifest parity gate only compares what the spines *declare*. These rows close that: each method's declared RouteAuth becomes ConformanceCase data that describe_conformance_table_tests fires at the live gate, in-process and cross-process alike.
Rows per method, from its declared auth:
- account or actor required —
anonymous→ 401authentication_required. The 401 body is method-independent, so every such row joins one equivalence group: a caller with no credential learns nothing that tells one method from another. - credential-gated (
credential_types) — each builtin channel the gate does not admit (keeperforsession,tokenforapi_token,daemonfordaemon_token) → 403credential_type_required, echoing the declaredrequired_credential_types. Each of those principals is the keeper. - role-gated (
roles) and admitting a session —fresh_non_admin→ 403insufficient_permissions, echoingrequired_roles. A role gate behind a credential gate that refuses sessions is skipped: every role-less principal would hit the credential gate first. - public — no rows.
Acting. On both spines the authorization phase resolves the acting actor before the credential gate runs, so a keeper with a second actor (
extra_actors) would answeractor_required(400) to a request with noactingon an actor-bound method. Every credential and role row on such a method therefore bindsactingto its principal's own actor at run time (request.acting: 'principal'): a single-actor account resolves the same actor with or without it, a multi-actor keeper resolves the one holding its roles, and either way the gate under test is the first that can refuse. The anonymous rows send none — the 401 precedes actor resolution.Every refusal row sends schema-valid params where they can be generated (generate_valid_body, its generated
actingdropped for the binding), so the gate is the only thing that can refuse. A backend whose gate is missing reaches its handler and answers something else. The rows therefore pin each gate, not the gate order against validation — the 401-before-400 rows of the security slate pin that.Off the declared surface.
live_rpc_endpointsadds the methods a backend mounts beyond the declared surface — cell verbs, opt-in resolvers, the_testing_*backdoors — read from their action specs the same way, a method the declared surface already has taken from there. The rows then need the same endpoints to resolve: pass them to the table runner'slive_rpc_endpointstoo.Skipped: the protocol actions (PROTOCOL_ACTION_METHODS): their auth is fixed by fuz_app's shared specs rather than declared per consumer, they are WebSocket-scoped (
cancelis a notification with no reply to assert,peer/pingis public,heartbeatrides an admitted socket), and the WS suites cover them.- account or actor required —
testing/cross_backend/conformance_table.ts
Table runner for the declarative cross-backend conformance suite.
describe_conformance_table_tests takes a list of ConformanceCase rows plus the standard
{setup_test, surface_source, capabilities}fixture protocol every Tier 1 suite uses — so one runner drives both transports: in-process via default_in_process_setup (fast, everygro test) and cross-process via default_cross_process_setup (the conformance gate, exercising each impl's real auth resolution over real HTTP). Same case definition, transport-parameterized.Each row references a
method; the runner resolves itsinput/outputschema from the live spec registry (RPC) or RouteSpec (the 6 REST auth routes) — the row never carries a schema. The principal the row runsasresolves to a TestFixture accessor via resolve_conformance_principal — no inline credential minting.Every response also passes an always-on no-fingerprint invariant (assert_no_fingerprint_headers over FINGERPRINT_HEADERS —
Server/X-Powered-By/WWW-Authenticatemust stay absent on both spines), and a row may pin further header expectations viaexpect.headers. Headers are deliberately kept out of the equivalence-group{status, body}comparison.Given
ws, the runner sends each case as a frame on a WebSocket instead: the principal opens an admitted socket and the frame meets the per-message gates. The frame carries the same JSON-RPC envelope the HTTP body does, so the same rows hold, read through the status the HTTP binding maps the frame's error code to; the cases a socket can't carry are left out (is_ws_conformance_case).A case binding
request.actinggetsparams.actingfilled with a real actor id at run time (ConformanceActingBinding), so rows can act as a named actor of a multi-actor keeper without knowing its id.A case's RPC method resolves from the declared
rpc_endpointsfirst, then fromlive_rpc_endpointswhen given — the methods a backend live-mounts off the declared surface (cell verbs, opt-in resolvers,_testing_*backdoors), so rows can reach them too.testing/cross_backend/cookie_attributes.ts
Cross-backend parity suite for session cookie attributes over real HTTP.
The session cookie's
Set-Cookieattributes are the load-bearing browser security boundary:HttpOnlykeeps the token out of JS (XSS can't read it),Securekeeps it off plaintext HTTP,SameSite=Strictis the primary CSRF defense (the Origin allowlist is only defense-in-depth), andPath=/scopes it to the whole app. A regression dropping any one of these is a real downgrade — and it's wire-observable, so both spines must emit the same hardened set. The cross-backend conformance table deliberately keepsSet-Cookie*out* of its byte-identity comparison (the signed value legitimately differs per request and per impl), so the attribute contract had no cross-backend pin: the TS spine's attributes were covered bysession_cookie.ts's own tests, but the Rust spine'ssign_session_cookie/ clear_session_cookie strings were asserted nowhere. This suite closes that gap on both impls by parsing the rawSet-Cookieand asserting the attributes directly. Three properties:- successful login sets a hardened cookie — `HttpOnly; Secure;
SameSite=Strict; Path=/
plus a positive integerMax-Age` (the session lifetime). The signed value is opaque here; only the attributes are pinned. - a failed login sets no session cookie — a denial mints no credential,
so there is no
Set-Cookiefor the session name at all. A spine that leaked a (signed-but-unauthenticated) cookie on the 401 would fail here. - logout clears the cookie with
Max-Age=0and the same hardened flags — the clear must not silently dropSecure/HttpOnly/SameSite=Strict(a cleared-but-unhardenedSet-Cookieis a downgrade window).
Both surfaces are flat REST (
POST /api/account/{login,logout}) on every spine, so this is an imperative suite (not aconformance_tablerow) — the sibling oforigin.ts/login_security.ts. Cross-process only: reading the rawSet-Cookieattributes needs the wire response (the in-process parse_session /session_cookie.rsunit tests cover each impl's cookie codec directly). Cited property:docs/security.md§"Session Security" (the "Cookie attributes" bullet —HttpOnly; Secure; SameSite=Strict; Path=/).- successful login sets a hardened cookie — `HttpOnly; Secure;
SameSite=Strict; Path=/
testing/cross_backend/create_cross_backend_global_setup.ts
Generic vitest
globalSetupfactory for cross-backend integration suites.Pairs with make_cross_backend_project: each cross-backend vitest project sets its own
test.name, and this factory derives the backend name from that project name (vitest 4 passes theTestProjectto globalSetup), picks the matching BackendConfig, spawns + bootstraps it via bootstrap_backend, andprovides a serializable handle that*.cross.test.tsfiles read with inject_backend_handle (provided_context.ts).A consumer's
global_setup.tscollapses to:import {create_cross_backend_global_setup} from '@fuzdev/fuz_app/testing/cross_backend/create_cross_backend_global_setup.ts'; import {deno_backend_config, rust_backend_config} from './my_backend_config.ts'; export default create_cross_backend_global_setup({ configs: {deno: deno_backend_config, rust: rust_backend_config}, ensure_databases: true, before_spawn: {rust: () => prepare_cargo_binary(my_cargo_binary)}, });vitest 4's
providehard-rejects non-serializable values, so the livechild/teardown/keeper_transportare stripped via serialize_bootstrapped_handle; the teardown closure stays in the globalSetup process and is returned for vitest to fire after the suite.testing/cross_backend/create_dual_spawn_global_setup.ts
Generic dual-spawn vitest
globalSetupfactory — spawns + bootstraps *two* backends at once andprovides both serialized handles, for any cross-impl gate that needs both alive together. (The per-backend create_cross_backend_global_setup derives one backend from the project name; this brings up a pair.)Its primary use is the schema- / action-manifest-parity gates (capture each backend over a
_testing_*introspection RPC and diff with assert_schema_snapshots_equal / assert_action_manifests_equal) — which is why the defaultprovide_keysareparity_handle_*. The login-security gate (global_setup_login_security.ts) reuses it with its own keys; any future two-backend gate can too.A consumer's dual-spawn
global_setup.tscollapses to:import {create_dual_spawn_global_setup} from '@fuzdev/fuz_app/testing/cross_backend/create_dual_spawn_global_setup.ts'; import {deno_backend_config, rust_backend_config} from './my_backend_config.ts'; export default create_dual_spawn_global_setup({ configs: {a: deno_backend_config, b: rust_backend_config}, });The
.cross.test.tsreads both with inject_parity_handles (provided_context.ts, which declares the default keys) and asserts. Run this project in a latergroupOrderthan the single-backend projects (or with distinct ports) — it reuses both configs' ports, so it must not run concurrently with the per-backend projects.testing/cross_backend/credential_deadline.ts
Cross-process credential deadline suite — a long-lived connection closes when the credential it was opened with expires.
A WebSocket or an audit stream is authorized once, at open, and closed on revocation by identity — but expiry writes nothing a revocation listener could hear. So each spine carries the credential's deadline onto the connection (
ResolvedAuth.credential_deadline: the earlier of a session's cookie and row expiry, an API token's row expiry) and closes it there: a socket with WS_CLOSE_SESSION_REVOKED and WS_CLOSE_CREDENTIAL_EXPIRED_REASON, a stream by ending it as a revoked one ends.Each case pulls a fresh account's credential expiries in to
expiry_secondsfrom now with the daemon-token_testing_shorten_credential_expiryseam, before opening the connection (the deadline is read when the credential resolves), then checks the connection is open short of the deadline and closed at it:- WebSocket × session — 4001, "Credential expired".
- WebSocket × API token — the same.
- Audit stream × session — the stream ends.
- The seam only shortens: a second call asking for a later expiry moves nothing, and the socket still closes at the first deadline.
The fourth reachable combination, API token × audit stream, is not here: the stock audit stream admits sessions only on both spines. Each spine covers it below the wire — the Rust stream route on a widened credential gate, the TS deadline wait shared by both transports.
The cases run concurrently, each on its own account under one reset for the suite: the seam shortens only the account it names, so each case waits out its own deadline and the suite takes about one. Every case keeps the full
expiry_secondsmargin — the "open short of the deadline" check needs it.Gated on
capabilities.ws(cases 1, 2, 4) andcapabilities.sse(case 3); cross-process only — the seam is mounted on the spine test binaries.testing/cross_backend/credential_header_robustness.ts
Cross-backend credential-header robustness probe — the auth sibling of
body_size_smuggling.ts.The auth middleware reads a single credential per header (
Authorization,X-Daemon-Token). What a *duplicated*, *oversized*, or *control-char-injected* credential header does is framework-territory — Hono reads its headers via the WebHeadersAPI, axum viahttp::HeaderMap— and the two could resolve a duplicate differently (first-vs-last), cap header size differently, or parse a malformed value differently. None of that is exercised overfetch(the FetchTransport can't emit a duplicate or malformed header), so it had no cross-impl pin. This raw-socket suite sends hand-framed requests and asserts the security invariants that must hold regardless of how each framework resolves the ambiguity — so it pins a real property without blessing one resolution over the other:- a duplicated credential header never escalates — a request carrying a
valid keeper
X-Daemon-Token*and* a second, conflicting one must not perform a keeper operation. The target omitsconfirm, so the documented confirm guard (purge_not_confirmed) makes the honored-token branch a guaranteed non-2xx with zero side effect, and the discarded-token branch is a non-2xx auth/credential refusal — so whichever copy the framework picks, the outcome is non-2xx. The same shape with two garbageAuthorizationheaders pins the bearer path can't be escalated either. - a control-char-injected credential header can't smuggle or authenticate
— embedded
CRLF/ bareCRin the value never frames a second request (no smuggle) and never authenticates (the truncated / rejected value is no token). - no response desync — each crafted request yields exactly one HTTP response (a duplicate header that reframed the request would surface as a second status line, like the body-size smuggling probe).
- an oversized credential header can't wedge the server — after a request
with a 64 KiB
X-Daemon-Token(well past both frameworks' header caps), a subsequent normal request on a fresh connection still gets a response. The oversized request's own outcome (4xx / 431 / connection close) is don't-care; surviving it is the property.
Raw-socket by necessity (
fetchcan't emit duplicate or malformed headers), so — likebody_size_smuggling.ts— this is cross-process only and fixture-free: it needs only the base URL, the RPC path, and a valid daemon token (handle.daemon_token, read once at spawn and static for the run, the same credential the_testing_resetchannel uses). Cited property:docs/security.md§"Credential Type Hierarchy" (a leaked / conflicting credential header can't escalate the credential ceiling) + §"API Token Security" (bearer handling).- a duplicated credential header never escalates — a request carrying a
valid keeper
testing/cross_backend/cross_rpc.ts
JSON-RPC call-site primitives for the cross-backend suites.
The stateful suites (the cell verbs, account lifecycle, role grants, tokens, facts, audit reads) POST raw JSON-RPC envelopes over a cross-process FetchTransport, threading ids and auth headers across calls, and parse every success
resultagainst the verb's declared Zod output schema — the wire-shape parity gate. A TS↔Rust envelope drift, not just a payload field drift, fails the assertion.testing/cross_backend/default_backend_configs.ts
Family-shared BackendConfig builders for cross-process test backends.
Two consumer-facing factories — make_default_ts_backend_config and make_default_rust_backend_config — own the common shape for the JS-runtime (Deno/Node on V8) and Rust families respectively. Per-backend factories in consumer projects compose a small declaration against one of these and add consumer-specific env vars via
extra_env.Defaults baked in by family:
- TS —
'memory://'(in-memory pglet), 30s startup window. - Rust — caller-supplied real Postgres URL (an in-process WASM database isn't reachable
from
tokio-postgres), 120s startup window (cargo first-build cost), and theFUZ_TESTING_RESET_DB_ON_STARTUP=trueself-wipe gate.
Both builders default
port_env_varto'PORT'. Consumers whose binary reads a different name (e.g. zzz'sZZZ_PORT) override. Both bindHOSTto DEFAULT_TEST_BACKEND_HOST (127.0.0.1), and, given the name of the binary's daemon-token directory env var (daemon_token_dir_env), set it to the{dir}whose{dir}/run/daemon_tokenis the bootstrap'sdaemon_token_path(to_daemon_token_dir).Common across both families:
/api/rpc,/api/ws,/health,/api/account/bootstrap,cookie_name: 'fuz_session', the standard bootstrap block keyed offdefault_test_*constants. Builders calldefault_test_backend_paths(name)(a repo-namespaced tmpdir) internally when the optionalpathsis omitted.- TS —
testing/cross_backend/default_secrets.ts
Default test secrets for cross-process backend bootstrap.
Every cross-backend consumer needs the same shape: a bootstrap token the harness writes to disk before spawn, a keeper username/password the harness POSTs to
/api/account/bootstrap, and cookie keys the binary uses to construct its Keyring. The literals here are dev-only and protected byassert_dev_env; the binaries themselves throw on production load.Each constant is exported individually so consumers can override one without re-deriving the rest. Builders in testing/cross_backend/default_backend_configs.ts thread these defaults into the
BackendConfig.bootstrapblock and theSECRET_FUZ_COOKIE_KEYSenv entry; callers compose thebootstrap_overridesknob when they need a non-default keeper.testing/cross_backend/default_spine_surface.ts
Canonical no-domain spine surface — the standard fuz_app auth/account/admin/audit surface with no consumer domain layer on top.
This is the single source of truth for "the standard spine surface", shared by:
- the Rust
testing_spine_stubcross-process self-tests (which build the AppSurfaceSpec via create_spine_surface_spec and drive the binary's wire shape), - the TS
testing_spine_servercross-process binary (which feeds create_spine_route_specs + spine_rpc_endpoints into a live create_app_server), and - the
cross_backend_ts_*self-test projects.
- the Rust
testing/cross_backend/differential_normalize.ts
Response normalization for the cross-backend differential.
The differential (describe_differential_conformance_tests) sends one conformance case to two backends, each over its own database with its own keeper, so every id, timestamp and token differs between the two answers even when the backends agree. normalize_differential_response maps a response onto a canonical
{status, body, header_names}that keeps what a client can rely on and drops what is fresh per run:- Positional placeholders. Each volatile value is replaced by its kind
and the order it was first seen in that response: the first UUID becomes
<uuid:1>, the next distinct one<uuid:2>, and a repeat of the first is<uuid:1>again. So identity relations within one response survive (theaccount_idof a listed row equals the caller's), while the values themselves may differ across backends. The kinds, each matched anywhere inside a string, are VOLATILE_VALUE_PATTERNS: API tokens, API token ids (tok_…), lowercase UUIDs, second-precision UTC timestamps (ISO8601_SECONDS, the canonical wire shape) and bare 64-character lowercase hex digests (random session ids). Only the canonical spelling of each is placeholdered — an uppercase UUID or a millisecond timestamp stays raw and shows up as a difference, since a format divergence is a wire divergence. - Content addresses are compared verbatim. A
blake3:-prefixed hash names bytes, and equal bytes hash equal on both spines, so it is left as is: a differing fact hash is a real divergence. A fact whose bytes embed a volatile value differs by design, which is a ledger row's job. - Object key order doesn't matter. Keys are sorted, and the walk that numbers placeholders visits them in that order, so two bodies whose keys differ only in order normalize the same.
- Array order does. Arrays keep their order — a list's order is part of its contract.
- Header names, not values.
header_namesis the sorted set of response header names minus VOLATILE_HEADER_NAMES: framing and per-request headers (date,content-length,transfer-encoding,connection,keep-alive) that each HTTP server sets its own way. Values are not compared at all —set-cookiecarries a fresh credential on every login, and the header values that are contracts are pinned per backend by the conformance table'sexpect.headersand its no-fingerprint floor. A header one backend sends and the other doesn't is still a difference.
diff_differential_responses lists every place two normalized responses differ, one entry per leaf path, which is what a known-divergence ledger entry names.
Placeholders are positional, not tied to fixture identity: they keep which values repeat within a response, not which fixture entity each one is, so two ids that appear once each and trade places normalize equal.
Placeholder numbering is per response and per kind. Object keys are normalized too, but sorted by their raw text, so a record keyed by ids would order its entries by values that differ across backends — no conformance response is shaped that way today.
- Positional placeholders. Each volatile value is replaced by its kind
and the order it was first seen in that response: the first UUID becomes
testing/cross_backend/differential.ts
The cross-backend differential: each impl is the oracle for the other.
A conformance case pins its status and reason on one backend at a time, and an output schema is often loose (
z.looseObject, or Zod's default strip), so an extra field from one spine — possibly a leak — passes, as does any body difference on a case pinned by status alone. describe_differential_conformance_tests sends every case to two backends, normalizes both responses (normalize_differential_response), and asserts they are equal: status, header names, and the whole body.Each case runs on a fresh fixture per backend — each backend's own
setup_test, so separate databases and separate keepers, which is why the normalization is positional. The principal resolves and the request is sent through the table runner's own send_conformance_case, so a case means the same thing here as in describe_conformance_table_tests. The per-case expectations (expect.status, error_reason, …) are not asserted here — each backend's conformance leg already holds them; the differential holds the two answers to each other.Every HTTP request asks for an uncompressed response (DIFFERENTIAL_REQUEST_HEADERS): content negotiation is the transport's, not the app's — Deno's
Deno.servecompresses a response whose request accepts gzip, as Node'sfetchsends by default, while other servers send it as is — so letting a server choose would read as acontent-encodingandvarydifference on every case.A difference that is tracked and accepted goes in the known-divergence ledger (KnownDivergence), matched by case names or by methods, naming the paths that differ (
what_differs), each optionally with the value each side must show there. A case may match several entries and differ at any of their paths, with the declared values, and nowhere else: any other difference fails like any other. The ledger cleans itself, with the xfail_until semantics — red when a divergence closes, naming the entry to delete: a case an entry names bycase_namemust still show it, checked in that case's own test, and an entry matched bymethodmust still show it on at least one of its cases, checked by a closing test once they have all run. The closing test also fails an entry that matches no case, or that names a case that doesn't exist, and leaves an entry whose cases didn't all run (a-tfilter, a send that threw) unjudged rather than calling it closed.Use the backends' cross-process fixtures — the differential needs two live backends, so it runs in a dual-spawn project. fuz_app's own wiring is the
cross_backend_parityproject'sdifferential.cross.test.ts.testing/cross_backend/expected_schema.json
testing/cross_backend/fact_serving.ts
Cross-backend fact-serving parity suite — the per-reference (cell-scoped) read model over real HTTP.
Re-proves the D1 fact-access cases (
docs/security.md§ Fact Access Control) against each backend's real auth resolution, twinning fuz_app's server/serve_fact_route.ts and the Rustfuz_fact_servingrouters:- cell-scoped admit — anon reads a fact through a viewable (public) referencing cell → 200 + bytes;
- cross-owner dedup does not leak — A's *private* reference to bytes that
B *also* publishes from a *public* cell stays 404 for everyone but A, even
though the identical bytes are world-readable via B's cell (one deduped
factrow; authz lives on the(cell, hash)edge, never unioned); - 404-mask — a missing cell and a viewable cell that doesn't reference the hash both 404 (never 403, never "exists elsewhere"), and every masked 404 is bodyless, so its shape names no branch;
- unviewable cell hides its refs — for a private cell the caller can't view, a hash it references and one it doesn't answer the same masked 404 (the view check runs first, so the work done is the same too — each spine's own suite pins that order);
- hardening headers — a served fact carries `X-Content-Type-Options:
nosniff
, thedefault-src 'none'; sandboxCSP, andCache-Control: private, max-age=300`, whatever its content type; - bare-hash admin-only —
GET /api/facts/:hashis admin (keeper) only: non-admin → 403, anonymous → 401 (gated onfact_serving_admin_hashtoo); - URL bound to hash — a row whose
external_urlis a well-formedfile:URL naming another hash's file is a masked 404 on both routes, refused before any disk read (each spine's unit suite proves the bytes behind such a URL never serve); - missing disk file — a row whose
file:URL addresses its own hash but whose file isn't on disk is a masked 404 (opened before any 200 commits; needs a backend serving external facts from disk, not by X-Accel); - multi-actor fallthrough — a multi-actor caller resolves to a null
(anonymous) context on the (
acting-less) cell-scoped route, so it can't read its own *private* fact there (admitted only by public cells). Opt-in (needs the multi-actor setup); every spine resolves the acting actor at the dispatcher's authorization phase from account-grain credentials, so the multi-actor account is drivable on TS and Rust alike; - narrowed-token downgrade — a scope-narrowed API token on the cell-scoped route is anonymous, not refused: its owner's private fact 404s exactly as for an anonymous caller, while a public cell still serves.
Facts are seeded embedded via
_testing_put_fact(the cross-process driver has no DB handle), or as a bare external row through itsexternal_url; the referencing cell via thecell_createRPC (the hash, a whole string value indata, is derived intocell.refs). A non-admin owner's cell may reference only a fact it holds, so a fact a non-admin references is put with that owner as itsholder(the keeper is a global admin, whom ref admission doesn't gate). Gated oncapabilities.fact_serving(the cell-gated route + the seeder), the bare-hash cases oncapabilities.fact_serving_admin_hashas well, so a backend serving only the cell-gated route runs the rest; runs under everycross_backend_*project — the TS spine binary and the Rusttesting_spine_stubboth mount both routes + the seeder.testing/cross_backend/full_spine_mount.ts
The full live RPC mount for fuz_app's own spine test binary — the complete action set
testing_spine_server.tsexposes on a single RPC endpoint, in one place.Where
default_spine_surface.tsdefines the declared surface (create_spine_surface_spec / spine_rpc_endpoints — the create_standard_rpc_actions bundle the spec-derived suites auto-enumerate), this module defines its superset: the standard bundle plus the families the binary live-mounts but keeps off the declared surface —- the
_testing_*daemon-token backdoors (create_testing_actions), - the full cell verb set (CRUD + grant + field + item + audit),
- the opt-in
actor_lookup/actor_searchresolvers, - the
_testing_origin_checkprobe the origin conformance table drives, - the
_testing_shorten_credential_expiryseam the credential-deadline suite drives, - the
_testing_action_manifestbackdoor, appended last (it dumps the live method set for the cross-impl manifest-parity gate, so it must enumerate every method above it).
Single-sourcing the mount here lets the binary, the in-process parity setup, and the
spine_method_coveragereconciliation test all build the same list — so a method can never be mounted in one place and forgotten in another.The binary's WS endpoint serves the same list: build_full_spine_mount builds it once and hands the WS mount the protocol actions plus every RPC action, twinning the Rust
testing_spine_stub, which compiles one action registry and serves it on both transports. The reconciliation test enumerates build_full_spine_rpc_actions with stub deps and asserts the live method set equals the tagged coverage manifest; seesrc/test/cross_backend/spine_method_coverage.ts.- the
testing/cross_backend/identity_parity.ts
Cross-backend identity-primitive parity for fuz_app's own spine over real HTTP — the
primitive_schemastwins (Username, UsernameProvided, Email) and the login/signup input handling that enforces them, pinned facet-by-facet so a TS↔Rust divergence in any one surfaces as a failure: how usernames are canonicalized on the login lookup, the ASCII-only creation invariant that bounds what can ever be stored, and the email format rule applied at signup.UsernameProvided (login/lookup) canonicalizes the submitted username via
.trim().toLowerCase(), and the DB matches case-insensitively (LOWER(username) = LOWER($1)); Username (creation) lowercases at store and restricts to ASCII via^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$. The Rust spine must produce the same canonical form, the same case-insensitive lookup, and the same ASCII-only rejection — or a username that logs in on TS silently 401s on Rust, or a non-ASCII username storable on one backend reopens the homograph-collision surface. The spec-derived round-trip + conformance suites never vary username casing or charset, so this corner was unpinned.Canonicalization (login lookup):
- case-insensitive login — an account created
Mixed_Caselogs in via an all-uppercase submission (proves the *lookup* folds case, not merely that the stored form was lowercased). - whitespace-trim login — the same shape logs in with surrounding
whitespace (proves
.trim()on the lookup path). The trimmed set is JSString.prototype.trim's on every spine: U+FEFF padding is trimmed (→ 200, and alone → 400 empty), U+0085 is not (→ 401 lookup miss). - no Unicode case-fold collision (negative) — a Turkish-
İ(U+0130) variant of an existing ASCII username must NOT match. Both JS.toLowerCase()and Ruststr::to_lowercase()mapİ→i+ U+0307 (combining dot above), never plaini, so the cased homograph stays a distinct, non-existent username → 401 on every backend.
Username-or-email login lookup: the login identifier resolves against username or email — TS query_account_by_username_or_email, Rust the converged
query_account_with_password_hashOR-lookup (this was the divergence the suite closed: the Rust spine previously matched username only). An account created with an email logs in via that email, case-insensitively (Email stores the original case; folding rides theLOWER(email) = LOWER($1)lookup), and username login keeps working when an email is present. A non-existent email → 401.Login input validation: malformed login input → 400
invalid_request_bodyon every spine — whitespace-only username (empty after trim), over-long username (> 255), empty password, and an unknown body key (strict object). TS runs the full LoginInput Zod schema; the Rust spine enforces the same shape inaccount_login(the convergence the suite closed: the Rust spine previously let three of these fall through to a lookup-miss 401, and TS let a whitespace-only identifier through to a 401). Asserted via the error reason, not just the status, so a same-status-wrong-body backend still fails.ASCII-only creation invariant: a non-ASCII username is rejected at signup input validation → 400 on every backend, so no Unicode username is ever stored — the precondition the login no-collision case relies on. (ASCII-only is the intended invariant for both spines, not an accident of the TS regex.)
Length + format creation parity: the full Username shape —
[USERNAME_LENGTH_MIN, USERNAME_LENGTH_MAX]=[3, 39]and the regex^[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]$— pinned across both backends. The TS Zod.min()/.max()/.regex()and the Rust hand-rolled byte-scan (is_valid_username_for_creation) are independent reimplementations of the same rule, so each length boundary is tested just-outside (→ 400) and just-inside (→ 403 no-matching-invite, the settle for a valid username on a spine withopen_signupat itsfalsedefault), and each format violation (leading non-letter, trailing punctuation, embedded disallowed char) → 400, with mid-string_/-accepted siblings as the don't-over-reject control. Signup is also strict-object on both spines (TSz.strictObject, Rust#[serde(deny_unknown_fields)]), so an unknown signup body key → 400. Nothing trims a creation username: a padded one is refused 400 at signup and at bootstrap, never trimmed into a legal name. Bootstrap validates its body before its window check, so the padded bootstrap answers 400 even after bootstrap completed.Email format (creation): the optional signup
emailis validated to a looselocal@domain.tldshape on both spines — TS Email (^[^\s@]+@[^\s@]+\.[^\s@]+$plus a 254-byte (RFC 5321 octet) length bound; whitespaceWhite_Space ∪ {U+FEFF}), Rust the hand-rolledis_valid_email. A malformed email → 400, a well-formed one → 403 no-matching-invite (the same accept/reject settle the username tables use). The accepted siblings include the single-char-TLDa@b.cthat Zod'sz.email()would reject — the deliberately-looser rule both spines now share. This closed a real divergence: the Rust spine previously length-checked the email only, accepting any non-empty string TS rejected.Malformed JSON body: a body typed
application/jsonthat doesn't parse ({) or parses to anything but an object ([]) is 400 `{error: "invalid_json_body"}`, with no other field, on login, signup, bootstrap (bootstrap_path, parsed ahead of the window check), and the password change (password_path, default/api/account/password,nullto skip) — parsed only after its credential and session-only gates, so the case sends the keeper's session, and an anonymous unparseable body is still 401.Both surfaces are flat REST on every spine, so this is an imperative suite (not a
conformance_tablerow) and ungated. Runs both legs via the shared{setup_test}protocol: the in-process leg (cross_backend/identity_parity.db.test.ts, plaingro test) and the cross-process leg (cross_backend/spine_suites.cross.test.ts, the TS spine binaries + Rusttesting_spine_stubover real HTTP).- case-insensitive login — an account created
testing/cross_backend/in_process_setup.ts
In-process fixture producers for the cross-backend suite protocol.
default_in_process_setup(options)wraps create_test_app into the SetupTest contract;default_in_process_suite_options(options)emits the full in-process suite bundle ({setup_test, surface_source, capabilities}plus factory-input pass-through). Both reach the in-process Hono app, so this module transitively importshono— it lives apart fromsetup.ts(the shared fixture protocol + the cross-process producer) so a Rust-only consumer driving a spawned backend can import the cross-process helpers without thehonopeer. The cross-process sibling (default_cross_process_setup) implements the same contract by spawning a binary and bootstrapping over real HTTP.testing/cross_backend/login_security.ts
Cross-backend parity suite for login rate limiting + trusted-proxy (X-Forwarded-For) resolution over real HTTP.
Login throttling and client-IP resolution are wired-but-never-crossed: the in-process describe_rate_limiting_tests covers the limiter, and the
fuz_http/ proxy middleware unit tests cover XFF resolution, but no case exercised either over a real socket on both impls — the limiter is nulled on every standard cross backend and the resolved client IP has no wire-observable downstream there. This dedicated suite spawns a backend with the login limiters enabled + the loopback proxy trusted (seeglobal_setup_login_security.ts), then pins two properties end-to-end:- per-IP login limit fires — the first default_login_ip_rate_limit
failed logins from one forwarded IP each return
401, and the next returns429with the canonical `{error: "rate_limit_exceeded", retry_after}body **and** aRetry-After: ceil(retry_after)` header. The 429 wire shape is the cross-impl contract — TS rate_limit_exceeded_response and Rustroute_response::rate_limit_exceededmust agree. - trusted-proxy / XFF resolution is honored — distinct
X-Forwarded-ForIPs get independent buckets: after exhausting one forwarded IP to429, a *different* forwarded IP is unaffected (401, not429). A backend that ignored XFF and keyed on the (loopback) TCP peer would 429 the fresh-IP request too — so the401proves the limiter keys on the resolvedX-Forwarded-Forclient IP. - IPv6 clients share a bucket per
/64— after one forwarded IPv6 address exhausts its bucket, another address in the same/64is429and one in the next/64is not; the login audit rows keep each full address, never the bucket key. A password change, which shares the per-IP login limiter, from an address in an exhausted/64is429too. - a concurrent burst gets at most the cap past the limiter — `2 ×
ip_limit` wrong logins sent at once from one fresh forwarded IP answer at
most
ip_limit401s and429for the rest. A limiter checked before the lookup and recorded after the verify lets the whole burst through; each attempt is reserved instead, counted in flight from the check on. - a burst of correct logins past the cap waits, then succeeds — attempts
count while in flight whatever their outcome, so
2 × ip_limitcorrect logins at once from one address meet the cap; the ones past it wait for a slot (at most the cap of them) and all answer200. Runs on a backend that serves concurrent transactions (concurrent_transactions). - every
X-Forwarded-Forline is read — a client that sends its own line ahead of the one the trusted proxy appended can't choose its bucket: after exhausting one forwarded IP, a raw request carrying a spoofed line first and that IP last still429s. Raw socket only (fetchmerges repeated headers into one line), so it runs whenbase_urlis given. - a malformed hop stops the walk — a trusted proxy that appends a
non-bare-IP token (
ip:porthere) leaves the client's own entry to its left. After exhausting one forwarded IP, a chain with that IP left of anip:portentry answers401, not429: the walk stopped at the malformed entry and keyed the request on the connection IP (its audit row says so), never on the client-written entry past it. - a success does not refund the per-IP budget — the distributed-spray backstop. Failed guesses against *distinct* usernames from one forwarded IP, interleaved with the attacker's own successful logins, must still 429 within a bounded total-request budget. Both spines used to clear the IP bucket on success, which made that budget unbounded: one credential (or, under open signup, none) bought unlimited guesses against arbitrary victims from a single address. Only the account-grain bucket is forgiven now.
Determinism without a limiter reset. Limiter state is in-memory and the per-test
_testing_resetwipes only the DB, never the buckets — so each case uses its own forwarded IP and its own (non-existent) username, keeping every bucket independent across cases and across the two impls (separate processes). The login floor is zeroed on both spines, so the failed-login loop stays fast.Both surfaces are flat REST (
POST /api/account/login) on every spine, so this is an imperative suite (not aconformance_tablerow) — the sibling oforigin.ts/identity_parity.ts. Cross-process only: the limiter+proxy wiring is the point, and the in-process counterparts already exist (describe_rate_limiting_tests + the proxy middleware tests). Cited property:docs/security.md§"Rate Limiting" + §"Trusted Proxy / Client IP".- per-IP login limit fires — the first default_login_ip_rate_limit
failed logins from one forwarded IP each return
testing/cross_backend/make_cross_backend_project.ts
Generic vitest project factory for cross-backend integration suites.
One vitest project per spawned backend; each runs the consumer's shared
*.cross.test.tsfiles against its own bootstrapped binary. The paired create_cross_backend_global_setup (inglobal_setup.ts) reads the project'snameto pick which BackendConfig to spawn, so the project name is the single source of truth for backend selection.Consumers compose these into their
vite.config.ts:const cross_backend_projects = process.env.FUZ_TEST_CROSS_BACKEND ? [ make_cross_backend_project({name: 'cross_backend_ts_deno', global_setup: GLOBAL_SETUP}), make_cross_backend_project({name: 'cross_backend_rust', global_setup: GLOBAL_SETUP}), ] : [];where
GLOBAL_SETUP = './src/test/cross_backend/global_setup.ts'.This module is intentionally dependency-free and
assert_dev_env-free: it runs at vite config time (including production builds, where the consumer gates the projects behind an env flag), so it must not pull in the DEV-only test runtime.testing/cross_backend/method_coverage.ts
Reconcile a backend's live-mounted RPC method set against a tagged coverage manifest, so the off-declared-surface methods stay as drift-proof as the declared ones.
The spec-derived suites (describe_rpc_round_trip_tests / describe_rpc_attack_surface_tests) auto-enumerate the declared surface — add a method to a registry in create_spine_surface_spec and it is tested automatically. But a backend's live RPC endpoint also mounts methods kept *off* that surface (stateful cell verbs, opt-in resolvers,
_testing_*backdoors); those rely on hand-wired imperative suites and get no auto-enumeration. Nothing structurally guaranteed every live method was actually claimed by a suite — so a newly mounted-but-untested method could ship silently.assert_rpc_method_coverage closes that gap: it diffs the live method set against a MethodCoverageEntry manifest (both directions — a mounted-but-unclaimed method *and* a stale manifest row both fail loud) and checks each entry's tier is consistent with the declared surface + the backdoor prefix. The manifest becomes the forcing function — a new method can't reach the live mount without a manifest row naming the suite that covers it.
Pairs with
surface_invariants.tsassert_no_testing_methods (which guards the *reverse* — a backdoor must never leak *onto* the declared surface).testing/cross_backend/narrowed_api_token.ts
Mint a narrowed (method-scoped) api token through the production
account_token_createpath, for suites that probe what a narrowed token is refused.Minted rather than seeded: the token comes out of the same handler a real minter uses, on whichever account owns the session the caller passes — so a suite can put a narrowed token on an account that lacks a gating role, and the mint itself asserts that a narrowed scope is accepted at the mint boundary, not merely stored.
testing/cross_backend/origin_conformance.json
testing/cross_backend/origin_conformance.ts
The origin allowlist conformance table, and the cross-backend suite that runs it against each backend over the wire.
origin_conformance.json(beside this module) is the one statement of the allowlist grammar and matcher both spines implement. It holds two tables:- parse rows —
{raw, ok: true}or{raw, errors: [{index?, kind}]}: an allowlist value (asFUZ_ALLOWED_ORIGINSwould carry it) and whether it parses, or every refusal with its 1-based entry position (absent for a list-level refusal) and its AllowedOriginsErrorKind. - match rows —
{patterns, origin, allowed}: a valid allowlist, oneOriginvalue (nullfor none), and the request-level answer.
A row may carry a
notefor the reader. The TS unit test reads the table directly; describe_origin_conformance_cross_tests sends every row to a backend's_testing_origin_checkaction and compares the answer, so the Rust spine is held to the same rows without reading this file.- parse rows —
testing/cross_backend/origin_scope.ts
Cross-backend parity suite for the origin check's scope: every path, not only
/api.Both spines check the
Originof every request — the TS spine mounts the check on every path (ORIGIN_MIDDLEWARE_PATH), the Rust spine behind every route group offuz_http::compose_stack, the fallback included — and leave a path out only by a named carve-out with its reason. The front-door table (transport_composition.ts) pins the check on the/apitransports; this suite pins it off/api, on the route both spine test servers serve there,GET /ready:- foreign
Origin→ 403forbidden_origin— the check runs on a path outside/api. - absent
Origin→ passes — a probe or CLI client sends none, so the wider scope costs it nothing. - allowed
Origin→ passes — the control: the check admits its own origin rather than refusing every browser.
And on a path neither server routes, which reaches each spine's fallback:
- foreign
Origin→ 403forbidden_origin, absent → the fallback's own answer — the fallback is behind the check, not attached after it. The absent row pins that the check let the request through to the fallback:404by default, orunrouted_statusfor a backend whose fallback serves an SPA shell (200). - an over-cap
POSTwith a declaredContent-Lengthand a foreignOrigin→ 413payload_too_large— the body cap comes ahead of the origin check on the fallback too (413-first holds for a declared length).
The
/readyrows are gated oncapabilities.ready, which every spine test server advertises; the unrouted rows run on every backend. Runs both legs via the shared{setup_test, capabilities}protocol: the in-process leg (cross_backend/origin_scope_parity.db.test.ts, plain `gro test) and the cross-process leg (cross_backend/spine_suites.cross.test.ts`, the TS spine binaries + Rusttesting_spine_stubover real HTTP).- foreign
testing/cross_backend/origin.ts
Cross-backend parity suite for Origin verification.
Origin checking is middleware that runs *before* the RPC dispatcher and refuses only an
Originit doesn't allow. The case here needs a transport that sends noOriginat all (fresh_transport({origin: null})), so this is an imperative suite rather than a conformance-table row. The case:- absent
Origin→ request passes — non-browser / direct-access clients (curl, CLI, server-to-server) carry noOriginand must not be blocked; token auth is the control for those callers.
A disallowed
Origin→ 403forbidden_origin, on every transport, is pinned by the front-door table (transport_composition.ts).Runs both legs via the shared
setup_testprotocol: the in-process leg (auth/origin_parity.db.test.ts, plaingro test) and the cross-process leg (cross_backend/spine_suites.cross.test.ts, the TS spine binaries + Rusttesting_spine_stubover real HTTP). Origin middleware is on every spine, so the suite is ungated.- absent
testing/cross_backend/peer_ping_ws.ts
Cross-process server-initiated
peer/pingsuite — the machinery proof for ActionPeer (a backend initiating a JSON-RPC request to a connected client and awaiting its typed reply). The sibling of the one-way notification suite (role_grant_offer_notification_ws.ts), extended from server→client *notifications* to server→client *request/response*.peer/pingisinitiator: both: the client→server direction already exists asheartbeat; the new server→client direction is what this exercises. The observable trigger is the client invoking thepeer/pingaction over its own socket — the handler turns around and *initiates* apeer/pingrequest back to that socket, awaits the client's echo, validates it against PingResponse, and returns the validated shape. So one client RPC drives the whole round-trip, and every outcome (success /Timeout/ wrong-shape / client-error) surfaces as that RPC's wire response — directly assertable.The client side attaches an
on_requestresponder at construction (via create_ws_transport's seam) so the server-initiated request is answered as soon as it arrives. Security negatives use the rawWsClient.sendto inject unsolicited / cross-connection frames.Per-spec auth.
peer/pingisauth: public(a liveness echo is non-sensitive — see the design doc); the WS upgrade itself still authenticates, so the suite drives it over the keeper's session.Gated on
capabilities.peer_request, which a backend declares once it mountspeer/pingon a WS endpoint and the HTTP RPC endpoint — the TS spine binaries (ConnectionRegistry.request_connection) and the Rust spine both do. Cross-process only: create_ws_transport needs a real bound socket, so wire it from a*.cross.test.ts.testing/cross_backend/postgres_database.ts
Postgres database helpers for cross-backend global setups — creating the database a backend's URL names, emptying one a setup owns, and printing such a URL safely.
The harness never creates a database by itself: a setup opts in (create_cross_backend_global_setup's
ensure_databases, or a call to ensure_database_for_url), so a test role withoutCREATEDBis never required by default.testing/cross_backend/provided_context.ts
The vitest
ProvidedContextkeys the cross-backend global setups provide, and the helpers a*.cross.test.tsfile reads them with.create_cross_backend_global_setup provides one backend's handle as
backend_handle; create_dual_spawn_global_setup provides two, asparity_handle_a/parity_handle_bby default. This module augments vitest'sProvidedContextwith those keys, so a test file that imports inject_backend_handle (or inject_parity_handles) gets the typedinjectwith no augmentation of its own. A project providing other keys — a dual spawn under its ownprovide_keys, a probe result — augmentsProvidedContextfor those in its own module, beside this one.vitest refuses a value that isn't serializable, so the handles cross as SerializableBootstrappedBackendHandle; the helpers rebuild a usable handle with reconstruct_bootstrapped_handle.
testing/cross_backend/query_shape.ts
Cross-backend query-string shape parity suite.
The TS pipeline reads queries through Hono's
c.req.query()— a duplicated key resolves to its first occurrence — and only routes that declare az.strictObjectquery schema 400 unknown keys, in the pipeline's params → query → 401 → authz → 403 phase order. A derived-serde axum extractor diverges on every axis (a plain-text pre-handler "duplicate field" 400 observable anonymously, silently ignored unknown keys, query rejection ahead of the 401), so each spine surface that reads a query is pinned here on both backends:GET /api/account/status— NO query schema: a duplicatedactingreads first-wins (the persona resolves), a malformedactinganswers account-only (never a default-actor resolve), unknown keys are ignored, and an anonymous caller with junk query hears 401, never a query 400.- the JSON-RPC GET endpoint —
method/id/paramsread first-wins; a duplicatedmethodmust dispatch the first one on every backend (a last-wins reader dispatches a different method per backend on the same request). Every response the GET handler writes — a dispatched answer and its own query refusal alike — isCache-Control: no-store. GET /api/facts/:hash(gated oncapabilities.fact_serving_admin_hash) — strict query schema: unknown keys and a malformedactingare 400invalid_query_paramseven for an anonymous caller (query precedes the 401 guard), while a duplicatedactingtakes the first occurrence.
The per-surface in-process TS pins live beside each module's own tests (
account_status.test.ts,action_rpc.test.ts,serve_fact_route.db.test.ts); this suite is the cross-impl gate, so it is cross-process only — likelogin_security.tsandtesting_backdoor.ts.testing/cross_backend/ready.ts
Cross-backend parity suite for the
/readyschema-drift deploy gate.The
/readymechanism already ships on both twins (TS create_ready_route_spec / db/schema_ready.ts; Rustfuz_http::ready/fuz_db::schema_ready), each with its own drift →503unit tests. This suite is the missing automated cross-impl gate: an anonymousGET /readyreturns200 {ready: true}over real HTTP on both spine test servers, proving the success path is wire- identical and that both backends read the same committedexpected_schema.json(column-presence is engine-portable, so one fixture is the cross-impl contract)./readyis a plain public REST route — not an RPC method, not one of the six REST auth routes — and it's deliberately off the declared spine surface (create_spine_surface_spec), like ws/sse/cells/fact-serving. So it needs a bespoke imperative suite (à la describe_origin_cross_tests), gated oncapabilities.ready, rather than aconformance_tablerow or generic round-trip enumeration. The drift →503path stays per-impl unit tests.Runs both legs via the shared
{setup_test, capabilities}protocol: the in-process leg (cross_backend/ready_parity.db.test.ts, plaingro test) and the cross-process leg (cross_backend/spine_suites.cross.test.ts, the TS spine binaries + Rusttesting_spine_stubover real HTTP).testing/cross_backend/revocation_drivers.ts
The account-wide revocations the cross-process WS and SSE round-trip suites both close a live connection on — one table, so a revocation added here is covered on both transports.
Each row drives one revocation of a target account's credentials and asserts it succeeded; the suite opens the target's connections first and asserts their close after. describe_cross_process_ws_tests and describe_cross_process_sse_tests iterate create_account_revocations. The revocations whose close is narrower than the account (one session, one token, a cap eviction) or that a suite drives differently (session revoke-all, account delete) stay in their suites.
The drivers are exported on their own for a consumer suite that closes a transport of its own on the same revocations.
testing/cross_backend/role_grant_cross_helpers.ts
Role-grant fixtures for the cross-process suites — a pending offer, and an active grant materialized through the real offer + accept handlers — shared by the suites that need a grant to revoke (describe_role_grant_offer_notification_ws_tests, describe_ws_role_gated_cross_tests).
Both drive the standard role-grant-offer RPC actions over the fixture's HTTP transport, as the keeper unless a grantor is given.
testing/cross_backend/role_grant_offer_enumeration.ts
Cross-backend parity suite for the role_grant_offer_accept enumeration boundary — the deliberate 403-vs-404 split on the accept path.
role_grant_offer_acceptdistinguishes two denials, and the distinction is a conscious decision (not an accident to mask away):- a genuinely-nonexistent offer (or one on another account) → 404
role_grant_offer_not_found. The account-scoped IDOR guard refuses to confirm an offer id the caller's account doesn't own — so a cross-account prober learns nothing. - an offer that exists on the caller's OWN account but is targeted to a
different actor (a sibling persona) → 403
role_grant_offer_actor_mismatch. This reveals "an offer exists for a sibling actor" to a co-account caller — but there is no cross-account leak: every actor in the distinction belongs to the one account already authenticated, so the 403 discloses nothing a principal can't already see about its own account. Masking this to 404 would only obscure a legitimate "not yours to accept, pick the right persona" signal. So 403 is the chosen, defensible behavior — and both spines must agree on it (a future Rust or TS change that over-masked the sibling case to 404, or under-masked the cross-account case to 403, is the regression this suite catches).
The actor-mismatch arm only fires for an *actor-targeted* offer accepted by a sibling actor, so the suite needs a multi-actor recipient. The keeper is the only fixture account that can be seeded multi-actor (
extra_actors), and an account can't offer to itself — so the grantor is a separate admin account and the recipient is the keeper (actor A =fixture.actoris the offer target; actor B =fixture.extra_actors[0]is the rejected sibling).Multi-step (create → accept) and using the
actingselector, so this is an imperative suite (not aconformance_tablerow). The accept verb is on every spine's standard RPC surface, so the suite is ungated. Cross-process only: the sibling-actor / actor-targeted-offer setup is a wire flow. Cited property:docs/security.md§"Authorization" (the 404-over-403 mask is scoped to cross-principal leaks; an intra-account sibling-actor offer stays a 403role_grant_offer_actor_mismatch).- a genuinely-nonexistent offer (or one on another account) → 404
testing/cross_backend/role_grant_offer_lifecycle.ts
Cross-backend parity suite for the role-grant-offer decline and retract refusals — the error paths beneath the happy-path WS notification suite (describe_role_grant_offer_notification_ws_tests), which is the only other cross-process caller of these two verbs.
What both spines must agree on, per the handlers (auth/role_grant_offer_actions.ts and the queries behind them):
- not yours → 404
role_grant_offer_not_found. Decline is scoped to the recipient account and retract to the grantor actor, so an offer the caller is not party to answers the samenot_foundas an id that names no offer (docs/security.md§"404-over-403 is the general mask"), and the offer is left pending. The scope check comes before the terminal check, so a stranger learns nothing of an offer's state either. - a decline or retract answers the terminal offer — the offer as the
history then lists it,
declined_at(with anydecline_reason) orretracted_atstamped, asacceptanswers its own. - already decided → 400
invalid_requestwith reasonrole_grant_offer_terminal— a decline or retract after an accept, a second decline, a second retract, and either one after the other. - a grantor's lost authority ends its pending offers. Revoking a
grantor's grant, or deleting its account, supersedes every pending offer
it made — gone from the recipient's inbox, an accept answers
role_grant_offer_terminal, and arole_grant_offer_supersederow records it (role_grant_revokedwith the grant as cause,account_deletedwith the account). The keeper's ownadmingrant refuses a revoke, 403cannot_revoke_keeper_admin. - one audit row per success, none per refusal. A decline writes
role_grant_offer_declineattributed to the recipient and targeting the grantor, a retractrole_grant_offer_retractattributed to the grantor and targeting the recipient, each with its event's metadata; the refusals above write nothing. Read through list_audit_events, which drains first and parses each row's metadata against its event's schema.
Multi-step (each case needs an offer in a given state), so this is an imperative suite; the single-request rows — decline and retract of an id that names no offer — live in the conformance security slate. The verbs are on every spine's standard RPC surface, so the suite is ungated. The keeper is the grantor, so the fixture's keeper must hold ROLE_ADMIN (
extra_keeper_roles), and it offers an admin-grantable role — theroleoption, SPINE_PARTICIPANT_ROLE by default; ROLE_ADMIN serves a backend with only the builtin roles.- not yours → 404
testing/cross_backend/role_grant_offer_notification_ws.ts
Cross-process role-grant-offer lifecycle WS notification suite — the machinery proof for the consentful-role-grants notification fan-out across any spine backend. Covers all seven server-initiated notifications:
role_grant_offer_received→ recipient (offer created)role_grant_offer_accepted→ grantor (recipient accepts)role_grant_offer_declined→ grantor (recipient declines)role_grant_offer_retracted→ recipient (grantor retracts)role_grant_revoke(flat, omitsrevoked_by) → revokee (active grant revoked)role_grant_offer_supersede→ each superseded sibling's grantor, fired on BOTH the accept-cascade (reason: 'sibling_accepted') and the revoke-cascade (reason: 'role_grant_revoked')
These exercise only spine primitives (accounts, role-grants, offers, WS notifications) — zero consumer domain — so the suite lives here and runs against any backend that wires the standard RPC actions'
notification_senderand mounts a registered WS socket: fuz_app's own spine self-tests (testing_spine_server+ the Rusttesting_spine_stub) and downstream twin-impl consumers (the fuz_forge Deno/Hono + Rustfuz_forge_serverbackends) alike.Each case is a *targeted* server-initiated notification (vs the broadcast in a
repo_updated-style suite), so it opens the affected counterparty's socket, drives the lifecycle RPC over HTTP, then asserts the frame lands on that socket and strict-parses against the canonical wire schema — the guard against serialization drift (field / null / datetime / the flat revoke shape / the supersedereason+cause_id). Every case also holds a third account's admitted socket open — a bystander, party to nothing the case drives — and, once the targeted frame has landed, asserts the bystander received nothing but the answers to its own requests, behind aheartbeatbarrier on its socket (assert_bystander_heard_nothingsays why the barrier makes the silence meaningful). A fan-out that reached past its target account fails there.Sends are queued on the post-commit drain (handler-emit, not audit-derived), so a frame may land a beat after the RPC resolves —
WsClient.wait_forpolls already-received messages then waits, absorbing the fan-out latency without a sleep, and its method+predicate filter ignores unrelated frames (e.g. thereceivedpush the recipient also gets). ROLE_ADMIN is the only admin-grantable role; accounts that already hold it can still be offered it again (a fresh pending row — the prior accept is terminal, no already-granted guard), and accept stays idempotent on the role_grant while still superseding pending siblings. Gated oncapabilities.ws.The revoke case revokes the revokee's only ROLE_ADMIN grant. On an endpoint gated on that role (
ws_required_roles) the revokee's socket loses its access by the same revoke: it is closed with WS_CLOSE_PERMISSION_REVOKED, and the notification may or may not reach it first (nothing orders the two), so the case asserts the close there and the frame on an ungated endpoint.Cross-process only: create_admitted_ws_transport needs a real bound socket, so wire it from a
*.cross.test.tsfile, never an in-process setup. Authed cookies come from the per-account session minted byfixture.create_account/fixture.create_session_headers.testing/cross_backend/role_grant_participation.ts
Cross-backend parity suite for role-gated participation conferral — the success paths. The imperative escape hatch beneath the declarative
conformance_participation_cases.ts: the multi-step flows that need a real recipient account (so a static conformance row can't express them) — an admin assigns theparticipantapp-role, and an admin offers it through the consent flow and the recipient accepts.Proves the two conferral *write* paths agree on both spines (TS spine binary + Rust
testing_spine_stub):- immediate assign —
role_grant_assignof the admin-grantableparticipantrole lands a grant and returns{ok, role_grant_id}; re-assigning the active grant is idempotent (same id). - consent flow —
role_grant_offer_createofparticipant(admin-only) → the recipientrole_grant_offer_accepts → a role_grant lands. - offer replay — re-accepting an accepted offer whose grant still
stands answers
200with the samerole_grant_idand writes no audit row; once the grant is revoked, the replay is refusedinvalid_requestwith reasonrole_grant_offer_terminal(never the revoked grant's id). - no self-offer, in either case — an admin offering to its own
account is refused
invalid_paramswith reasonrole_grant_offer_self_target, whether the id is sent lowercase or uppercase: both spines canonicalize the id before comparing it with the grantor's account. - scoped assign —
role_grant_assignofparticipantwith a pairedscope_kind+scope_idlands a scoped grant carrying both, read back throughadmin_account_list, distinct from the global grant.
The single-request gate/denial matrix (grantability refusal, admin-only conferral, dispatcher admin gate, auth) lives in the declarative table (
conformance_participation_cases.ts) — keep new single-request assertions there; this suite is only for flows the table cannot carry.The
participantrole is registered admin-grantable on both spines (spine_roles / the Rust stub'sRoleRegistry), so the suite is ungated; every spine mounts the standard RPC surface it drives. The role is theroleoption,participantby default: a backend without it passes another admin-grantable role (ROLE_ADMIN for one with only the builtins, which skips the scoped case — builtins are global-only).- immediate assign —
testing/cross_backend/role_grant_role_grammar.ts
Cross-backend parity suite for an off-grammar
roleon the grant verbs.role_grant_assignandrole_grant_offer_createdeclareroleas RoleName (lowercase letters and underscores, no leading or trailing underscore, at most 64 characters). A role string outside that grammar is refused at input validation — JSON-RPCinvalid_params, with nodata.reason— and writes no audit row: a role is caller-chosen text until it passes the grammar, and the grant verbs record the role they refuse in their failure rows.- refused at validation — each off-grammar role, on each verb, answers
invalid_paramswith no reason, so the refusal is the input schema's and not a grant-path or authorizer one. One of them is all lowercase letters and one character past the length cap: the cap is part of the grammar, so a failure row never carries an arbitrarily long caller-chosen string. - no audit row — after the refusals, no grant audit row carries an
off-grammar role. The control keeps that read from being vacuous: an
on-grammar role no registry holds is refused past validation with
role_not_web_grantable, the refusal a failure row records — as is one exactly at the length cap, which pins the boundary.
Exists because the action-manifest parity gate captures no param schemas while the Rust spine parses params by hand — a spine that let an off-grammar role reach its grant path would answer with a different error and write the caller's text into the audit log. Both verbs are on every spine's standard surface, so the suite is ungated. Cross-process only: it reads the audit log through
_testing_drain_effects, which the spine binaries mount.- refused at validation — each off-grammar role, on each verb, answers
testing/cross_backend/rpc_adversarial.ts
Cross-process malformed-envelope and invalid-params suite for a JSON-RPC endpoint.
describe_rpc_attack_surface_tests runs its envelope and params groups in-process against stub apps, so they never reach a spawned backend — and the Rust spine parses envelopes and params by hand, while the action-manifest parity gate compares declarations and is blind to param schemas. This suite sends the same requests over
fixture.transportto each backend.Two groups:
- Envelopes — create_rpc_envelope_cases, the in-process group's set:
non-JSON body, wrong or missing
jsonrpc, missingmethodorid, a batch array, non-object POST params, an unknown method,GETmissingmethodorid,GETinvalid-JSON params,GETnon-object params on every read, and aGETof a mutating method. Each is sent by the principal its method's auth admits, so the envelope check is what refuses, and each pins status, JSON-RPC code, the echoedid, anddata.reason(exact where the TS dispatcher sets one, absent otherwise). Three ordering cases go without a credential: an anonymousGETof a mutating method is refusedinvalid_requestrather than 401 (the read restriction runs after the method lookup and before authorization), an anonymous batch array is refusedinvalid_requestwithid: null(the envelope parse runs before auth, and batches are unsupported), and an anonymousGETwith non-object params is the 401 (params validation runs after authorization). - Params — for each declared method (and each live-mounted one, below),
generate_rpc_params_test_cases
(the in-process generator minus its structural cases), each sent as the
method's authorized principal and owing 400
invalid_paramswith thedata.reasonthe broken field declares (invalid_reasonin its.meta(), read by expected_invalid_reason) — none for an undeclared field or a whole-body case. One test per method, reporting every case that answers otherwise;xfail_paramsruns named cases of a method as their own xfail_until tests, for a known divergence on one backend.
The authorized principal is the keeper on the first channel the method's auth admits: its session, else its daemon token or API token over a no-
Origintransport (resolve_conformance_principal), mirroring the in-process select_auth_app. Preconditions: the keeper holds every role a declared method gates on (default_cross_process_setup seedskeeperandadmin; passextra_keeper_rolesfor others), and it has a single actor. No generated body carries a validacting— the generator's base fills required and defaulted fields, and an optional one only where a refinement needs it (each alone first, so anactingdeclared ahead of an at-least-one or exactly-one-of pair stays out) — and a malformedactingis read as omitted ahead of validation on both spines, so a single-actor keeper resolves its actor and validation is what refuses. A keeper with a second actor answersactor_requiredfirst; such a suite lists the actor-required methods inskip_methods.Off the declared surface.
live_rpc_endpointsadds the methods the backend live-mounts atrpc_pathbeyond the declared surface — cell verbs, opt-in resolvers, the_testing_*backdoors. Both groups run over them too: the envelope cases built from those methods, in a group of their own, and a params test per method,_testing_*included (a daemon-only backdoor is sent with the keeper's daemon token). A spine that keeps a method family off its declared surface still owes the twin's answer to bad params there.Skipped:
_testing_*methods on the declared surface (a declared surface lists them only by accident; the live mount is where they are walked), and the protocol actions (PROTOCOL_ACTION_METHODS, fixed shared specs covered by the WS suites, which generate_rpc_auth_conformance_cases skips too).Cross-process only: in-process, describe_rpc_attack_surface_tests runs the same envelope cases and params generator.
- Envelopes — create_rpc_envelope_cases, the in-process group's set:
non-JSON body, wrong or missing
testing/cross_backend/rpc_success_fixtures.ts
Success fixtures for the standard spine RPC methods that act on an existing row — revoke a token or a session, delete an invite, revoke a role grant, accept, decline, or retract an offer. The generic round-trip sends nil ids, so on its own it only ever sees these methods refuse with
not_found; each fixture here builds the real row through the backend's own RPC surface and returns the params (and, where the row must belong to the caller, the caller's headers) that drive a success response, which describe_rpc_round_trip_tests then validates against the method's output schema on every backend.Spine-generic: the fixtures use only standard RPC actions and the builtin ROLE_ADMIN, so any consumer mounting create_standard_rpc_actions can pass the map to describe_standard_cross_process_tests (as
rpc_success_fixtures) or describe_rpc_round_trip_tests (assuccess_fixtures). Each fixture mints its own accounts and calls as one of them, so none touches or calls as the suite's shared identities.testing/cross_backend/rust_spine_consumer_surface.ts
The declared surface of a consumer served by the Rust spine, for its cross suites — create_rust_spine_consumer_surface_spec.
The usual Rust spine consumer server mounts the same spine routes whatever its domain:
/health,fuz_auth::account_routerunder/api/account(ACCOUNT_ROUTER_PATHS), the bootstrap and signup routers beside it, and one RPC endpoint carrying the standard admin / role-grant-offer / account actions plus the consumer's own. This module builds that surface from the hono-free route shapes and the consumer's action specs, so a consumer supplies only what is its own. A server that mounts no signup router (a single-keeper daemon) is not this shape.The route shapes come from create_account_route_shapes and friends, never the live route factories, which statically pull
hono/cookieandhono/streaming. Handlers are never invoked — the suites readmethod,path,authand the schemas, and live dispatch happens in the spawned binary — so routes and actions carry no-op handlers.testing/cross_backend/rust_spine_stub_backend_config.ts
Cross-process BackendConfig preset for the non-domain spine consumer,
testing_spine_stub— a Rust binary that mounts only the spine surface (auth / account / admin / audit / role-grant offers) with no domain layer. fuz_app drives it fromsrc/test/cross_backend/*.cross.test.tsto verify its TS spec against the Rust spine end-to-end with no domain implementation in the loop — drift becomes a fuz_app failure rather than a downstream consumer's failure with mixed signals.Binary discovery — env-supplied, never hardcoded. The binary lives in a sibling Rust workspace, not in fuz_app, so the preset never bakes a path in.
FUZ_TESTING_RUST_SPINE_STUB_BIN(or thebinary_pathoption) must point at a prebuilt binary; the preset throws a clear error when neither is set rather than guessing. Build once withcargo build -p testing_spine_stub --profile cross-test(the workspace's non-LTO test profile;--releaseworks too, slower to rebuild) and point the env var at the resultingtarget/cross-test/testing_spine_stub; operators / CI cache the binary across runs for fast spawns.Operator setup — the target Postgres database must exist before the stub boots. The harness creates it only when a setup asks (ensure_database_for_url, or create_cross_backend_global_setup's
ensure_databases), so a test role needs noCREATEDBgrant otherwise.FUZ_TESTING_RUST_SPINE_STUB_DATABASE_URLpoints the stub at another server (e.g. a scratch cluster on a spare port).The binary self-wipes the auth-namespace schema on every boot (
FUZ_TESTING_RESET_DB_ON_STARTUP=true, set by the Rust-family builder), so no manualDROP TABLEbetween sessions is needed; per-test reset is the orthogonal_testing_resetRPC action default_cross_process_setup fires.testing/cross_backend/session_cap.ts
Cross-backend parity suite for the per-account concurrent-session cap over real HTTP.
Both spines cap concurrent sessions per account (the TS query_session_enforce_limit / DEFAULT_MAX_SESSIONS, the Rust
create_capped_session). Each spine's own tests pin its half in-process; nothing in them crosses the wire, so a spine that lost the cap would fail no shared test. This suite is the pin that runs on *both*. It pins:- the cap bounds concurrent sessions, by eviction not refusal — logging in
max_sessions + 1times succeeds every time (a login is never denied for being the one over the line), and the newest cookie resolves afterward. - the evicted session is the oldest — the first login's cookie no longer authenticates once the cap is exceeded. A spine with no cap authenticates it fine, which is exactly the defect this catches.
- the session list marks the caller's own session
is_current— each login'saccount_session_listmarks exactly one row, its own, over HTTP and (whenwsmounts the account actions) over a socket the login opened, and a bearer or daemon-token caller's list marks none (no session authenticated it), even when a session cookie rides beside the daemon token. Computed per request, so two logins see different current rows. - an evicted session's socket is closed (when
wsis supplied) — the cap deletes the session row, and a WebSocket never re-reads its session, so the login that evicts it must close the socket it opened (WS_CLOSE_SESSION_REVOKED), once the eviction has committed. A spine that only deletes the row leaves the socket running on a dead session.
Why
max_sessions + 1logins and notmax_sessions. The starting session count isn't zero and isn't guaranteed equal across impls —create_accountseeds its own session on the Rust cradle. Overshooting by one guarantees at least one eviction whatever the baseline is, and the *oldest* login is the first to go either way. Asserting on the first and last sessions rather than on a row count keeps the case wire-observable (no DB channel) and baseline-independent.Both surfaces are flat REST (
POST /api/account/login,GET /api/account/status) on every spine, so this is an imperative suite (not aconformance_tablerow) — the sibling ofcookie_attributes.ts/origin.ts/login_security.ts. Cross-process only, and load-bearingly so: each login is held on its ownfresh_transportand identified by that transport's cookie jar, which in-process is a jar-less passthrough. That also keeps the suite free of a fourthSet-Cookieparser — reading the raw header iscookie_attributes.ts's job, and this case only needs "does this login still authenticate".What it does not pin: concurrent creators. Two simultaneous logins can each evict against a stale count under Read Committed and both commit above the cap — see query_session_enforce_limit's race note. That needs a barrier-based test against one backend, not a parity case.
- the cap bounds concurrent sessions, by eviction not refusal — logging in
testing/cross_backend/session_cookie_codec.ts
Cross-backend parity suite for the signed session cookie codec over real HTTP: which spellings of a correctly signed cookie each spine accepts.
The cookie is
${token}:${expires_at}.${signature}, the signature the padded standard base64 of an HMAC-SHA256 MAC. Both spines must accept exactly one spelling of a given signed payload, or a cookie that one spine refuses would authenticate on the other. The suite holds the test keyring, so it signs payloads itself and sends variants a lenient decoder would take:- signature spelling — the last data character bumped so the trailing
bits are non-zero (
atobdecodes the same MAC), and the=padding dropped. Both must be refused. expires_atspelling —+N,N,N.0, and an all-digit value pastu64::MAX(the Rust spine parses it asu64), each signed with the real key. All must be refused. A leading-zero0Nreads asNon both, so it still authenticates — the bound is on the value, not the digit count.
The positive controls run first: re-signing the keeper cookie's payload reproduces the cookie byte for byte (the suite's keyring is the backend's), and that cookie authenticates. Authentication is probed with the
account_verifyRPC action. The per-impl unit tests cover each codec directly; this suite is what catches the two drifting apart.- signature spelling — the last data character bumped so the trailing
bits are non-zero (
testing/cross_backend/setup.ts
Per-test fixture protocol shared by in-process and cross-process transports.
Each standard suite body takes a required
setup_test: () => Promise<TestFixture>callback and invokes it once per test. The fixture carries everything a test needs to fire requests and assert on a single bootstrapped keeper account — transport, account / actor identity, three header builders, a multi-account mint factory, and (in-process only) the in-memory keyring + raw backend.The cross-process producer default_cross_process_setup lives here, alongside the spawn-a-backend transport plumbing — it implements the SetupTest contract by spawning a binary and bootstrapping over real HTTP. The in-process producers (default_in_process_setup / default_in_process_suite_options, which wrap create_test_app) live in the sibling
in_process_setup.tsso this module — and the cross-process consumers that import it — stay free of the in-process Hono app and its optionalhonopeer dependency.testing/cross_backend/simple_request.ts
Cross-backend parity suite for refusing CORS simple requests.
A
POSTwith noContent-Type, or atext/plain/ form / multipart one, is what a browser sends cross-origin without a preflight — carrying the session cookie when the page is same-site, which any otherlocalhostport is. Every spine's JSON routes requireapplication/jsonand refuse anything else with a flat415{error: 'unsupported_content_type'}before reading a credential or the body: the TS spine per route (apply_route_specs, everyPOSTthat isn'traw_body), the Rust spine in each JSON handler. The cases carry an allowedOriginand, where it matters, the keeper's session — the exact shape of a same-site page's forged request, which the origin allowlist alone admits when it's a wildcard — and a refused request must leave that session untouched.Runs both legs via the shared
setup_testprotocol: in-process (auth/simple_request_parity.db.test.ts) and cross-process (cross_backend/spine_suites.cross.test.ts). Every spine has the gate, so the suite is ungated.testing/cross_backend/spawn_backend.ts
Spawn a test backend binary, wait for it to come up, and return a handle the test harness drives.
Lifecycle:
- Refuse when
config.base_url's port already accepts a TCP connection — another cross run or a stale backend holds it, and the health probe would otherwise drive that process instead of the one spawned here. - Write the bootstrap token (
config.bootstrap.token) toconfig.bootstrap.token_pathso the binary picks it up at startup, and delete anyconfig.bootstrap.daemon_token_patha previous run left, so step 5 can only read the token this binary writes. child_process.spawn(...)the binary withdetached: true— creates a new process group so aSIGTERMto the negative PID tears down any descendants the binary spawned (PTYs, child workers). vitest worker death +SIGINT/SIGTERM/SIGHUPhandlers also fire the group teardown so ports never strand.- Poll
{base_url}{health_path}until it returns 2xx orstartup_timeout_mselapses — each probe bounded by its own timeout, so a listener that accepts but never answers can't outlast the deadline. - Read
config.bootstrap.daemon_token_pathto load the binary's deterministic daemon token; thread it onto BackendHandle so_testing_resetand other keeper-credential calls can authenticate. - Log one line with the time from spawn to step 5 done, so a run shows what each backend costs to bring up.
The child's stderr is kept as a bounded tail (STDERR_TAIL_MAX_BYTES): a startup failure reports it with the exit code or signal, and an exit after startup that teardown didn't cause is logged with it.
Bootstrapping (
POST /api/account/bootstrap) is a separate concern — the caller composesbootstrap()from testing/transports/bootstrap.ts against a FetchTransport built aroundhandle.config.base_url. Splitting the two keeps spawn_backend consumer-agnostic — fuz_app knows nothing about specific binary contents.- Refuse when
testing/cross_backend/spine_suites.ts
One call that runs every shipped single-backend cross suite a backend's capabilities admit —
describe_spine_cross_suites(handle, options).A consumer's cross project otherwise wires one file per upstream suite, so a suite added here never reaches it unless someone adds the file. The one-call form runs SUITE_NAMES — the closed registry of shipped suites, each named by its module — and builds every suite's options from the handle: the paths, cookie name and capabilities from
handle.config(to_cross_suite_options), the daemon token from the handle, and each suite'ssetup_testthrough default_cross_process_setup with whatever seeding that suite needs (the multi-actor keeper, the offer grantor, the cell role holder). Each suite gates its own cases on the capabilities, so a backend runs what it mounts.Options are additive.
excludenames a suite with the reason it can't run on this backend; the reason is required and shows in the run as a skipped test, so a skip is stated in code and visible in the report.testing_backdoorcan't be excluded, by suite or by case, nor itscapabilities.testing_backdoorschanged: it checks the declaration the backdoor gating reads.exclude_casesnames single tests of a suite that does run, each by its name path (describe titles and test name, joined with' > ') with a required reason; onebeforeEachskips them at run time, so each shows as skipped with its reason, and an entry matching no collected test of its suite fails the closing check test.overridesreplaces a suite's derived options, field by field (a shallow merge over what the handle gives) — a role list the backend's WS upgrade requires, anxfailfor a tracked divergence, a narrower capability set. An entry can be a function of the derived options instead, for a change built on a derived value. A required option the merge leavesundefinedthrows at collection.rpc_success_fixtures/rpc_skip_methods/rpc_input_overridesgo to the standard bundle's RPC round-trip;rpc_input_overridesalso seedsrpc_adversarial's params cases (each override is the valid base they break one field of). A key naming a method on neither the declared surface norlive_rpc_endpointsthrows at collection.ws_required_rolesnames the roles the backend's WS upgrade requires, so the suites that open sockets for accounts they create give them those roles.suite_rolesnames the roles the role-shaped suites use when the backend's registry lacks the spine's own (cell_editor,participant); the call seeds the holder each needs.
Inputs the handle doesn't carry. The declared surface (
surface_source,session_options,rpc_endpoints,roles) is a required option. Paths and roles that name what the reference spines mount (the role-gated WS endpoint and its roles) default to theSPINE_*constants, and a suite whose capability flag is off never reaches them. Anything else a suite needs comes from the handle when it can (session_cookie_codec'scookie_keysfrom the config'sSECRET_FUZ_COOKIE_KEYS), else fromoverrides; when neither has it, the call throws naming the suite — provide it or exclude the suite.Backdoors. The backend's
capabilities.testing_backdoorsmust declare SPINE_SUITES_REQUIRED_BACKDOORS (the per-test reset and the audit barrier), and_testing_put_factwhen it declaresfact_serving; the call throws otherwise. A suite every case of which drives another backdoor (credential_deadline,origin_conformance,ws_outbound_overflow) skips itself on a backend not declaring it, shown like an exclude with a reason naming the backdoor — so an exclude, override orexclude_casesentry for it is refused as dead.Not in the one call (STANDALONE_CROSS_SUITES, with the reasons): the suites that need two handles (the differential), a backend spawned with live limiters (
login_security,ws_action_rate_limit— the standard suites would exhaust them), and the conformance runner, which runs the consumer's own cases. Each keeps its own entry point.Suites are registered without a wrapping
describe, so their test names are the same as when each is called on its own — and anexclude_casespath is the name the reporter prints after the file and anydescribewrapping the call.testing/cross_backend/spine_surface_constants.ts
Pure spine-surface constants — wire paths, roles, the fixture URL, the
_testing_*seams' sharedauthshape, and the env-var names the backend configs set for the spawned spine binaries. A hono-free leaf split out ofdefault_spine_surface.ts.Cross-process suite modules (which drive a separately-spawned backend binary over HTTP) need only the wire path / role / fixture-URL, not the in-process route handlers. Importing them from
default_spine_surface.tsused to drag its eageraccount_routes.ts/signup_routes.tsimports — and through themsession_middleware→hono/cookie— onto a backend-spawning consumer with nohonopeer installed (a Rust-only spine consumer). Keeping these constants on this handler-free leaf lets such a consumer import the path without the peer.default_spine_surface.tsimports the ones it uses from here.The env-var names live here so both sides of each contract share one constant: the backend configs that set them (
default_backend_configs.ts,ts_spine_backend_config.ts,rust_spine_stub_backend_config.ts) and the spawned TS spine binary that reads them, which imports this leaf rather than the config builders.testing/cross_backend/sse_round_trip.ts
Cross-process SSE round-trip suite — the cross-process counterpart to the in-process testing/sse_round_trip.ts harness.
Where the in-process harness reads a Hono
Response.bodydirectly, this suite opens a real streamingfetchagainst a spawned backend's audit-log SSE endpoint via create_sse_transport, threading the fresh-per-test keeper's session cookie. It is the only coverage of the spawned binary's live SSE path — the standard cross-process bundle (describe_standard_cross_process_tests) omits SSE by design, so consumers call this alongside it (paralleling describe_cross_process_ws_tests).Its cases follow the in-process SSE self-test against fuz_app's standard audit-log stream:
- connects — the stream opens and emits the
: connectedcomment. - data frame (gated on
rpc_path) — a minted secondary's sessions are revoked over the keeper's admin channel (admin_session_revoke_all), broadcasting asession_revoke_allaudit event as onedata:frame to the subscribed keeper without closing its stream (the event targets the secondary, not the subscriber). The secondary is minted *before* the stream opens socreate_account's own audit events (invite / signup / login / token) don't land on it. - close-on-revoke, account-wide (gated on
rpc_path) — the subscriber's *own* sessions are revoked (account_session_revoke_all), which closes every stream of the account once the revoke commits (the handler's close, repeated by the audit guard on thesession_revoke_allrow). Asserted viaSseTransport.wait_for_close. - close-on-revoke, session-scoped (gated on
rpc_path) — the subscriber's *own* single session is revoked (account_session_revoke), which closes only the stream registered under that session's hash (the distinct close cases 2–3 don't reach). - close on account delete (gated on
rpc_pathandcapabilities.account_lifecycle) — the keeper soft-deletes a second admin whose stream is open; the delete revokes its credentials and closes its stream. - close on the other account-wide revocations (gated on
rpc_path; purge also oncapabilities.account_lifecycle) —admin_token_revoke_all,POST /logout,POST /password, andaccount_purgeof a second admin each close its stream — the rows create_account_revocations shares with the WS suite. - per-session cap — one stream past
max_per_scopeon one session closes the oldest (evict-oldest, no final frame) and leaves every other stream open. Needs no RPC: the cap runs inside the subscribe itself.
The close-on-revoke matrix is layered: cases 3–6 exercise the account-wide and session-scoped paths cross-process;
token_revoke(token-scoped — the stock stream admits sessions only, so its bearer close is covered on the WebSocket by describe_cross_process_ws_tests) androle_grant_revoke(role-matched) are covered by the spine'sfuz_realtimeSSE-registry unit tests and the in-process guard self-test.Gated on
capabilities.sse— backends without an end-to-end SSE stream skip (the cases still surface as.skipin the report). Cross-process only: create_sse_transport needs a real bound socket, so wire it from a*.cross.test.tsfile, never an in-process setup.- connects — the stream opens and emits the
testing/cross_backend/standard.ts
One call wiring the standard suites that run against a spawned backend — the ones whose option shape is
{setup_test, surface_source, capabilities, ...}and whose bodies fire requests throughfixture.transportrather than touching the in-processBackend. Consumers wire one call against a spawned binary instead of repeating the sibling calls per file.Suites included — always run:
- describe_standard_integration_tests
- describe_round_trip_validation
- describe_rpc_round_trip_tests
- describe_data_exposure_tests
Gated on
roles— included when the consumer supplies a RoleSchemaResult:Suites omitted — the ones that don't survive a process boundary, documented here so per-consumer files don't have to repeat the bookkeeping:
- describe_rate_limiting_tests — builds a fresh TestApp per test to
inject tight per-test rate-limiter overrides. That path requires
in-process construction of
Backend+ rate limiter; the spawned binary has neither knob nor restart-per-test budget. - describe_audit_completeness_tests — reaches into FK-structural
introspection that only the in-process backend exposes. Wire-level
audit observability lives in the consumer's own audit
.cross.test.tsdrivingaudit_log_list/audit_log_role_grant_history.
testing/cross_backend/test_cell_gated_create_authorize.ts
Test CellCreateAuthorize policy mounted on both reference spines — the TS spine binary's full mount (
full_spine_mount.ts) and the Rusttesting_spine_stub— so the cross-backendcell_gated_createsuite proves the parent-aware cell-creation authorizer agrees TS↔Rust. The twin of the RustTestCellGatedCreateAuthorize, directory model.A global admin bypasses everything (`{allow: true, moderation_required: false}
) — a scopedadmin` row is no admin here. For anyone else:- Root creation (
root_idnull):kind: 'space'is admin-only (denied); every other parentless kind stays open, so the plain-createcell_crud/cell_relationssuites are unaffected. - Contribution (
root_idset): the governing root'spolicy[kind] = {min_role?, moderation_required?}decides — a missing entry (or no policy) denies, a presentmin_rolethe actor lacks globally denies, and otherwise it admits with the entry'smoderation_requiredfolded into the verdict. The root's policy arrives ininput.root_policy(the handler read it in-tx), so the predicate is pure — no DB read of its own (which also dodges the single-connection in-memory deadlock a separate handle would hit — pglet, PGlite).
Its
validate_policyaccepts a policy whose every entry is an object with an optional stringmin_roleand an optional booleanmoderation_required, and refuses any other with ERROR_CELL_POLICY_INVALID.- Root creation (
testing/cross_backend/test_cell_grant_steward_revoke_authorize.ts
Test CellGrantRevokeAuthorize policy mounted on both reference spines — the TS spine binary's full mount (
full_spine_mount.ts) and the Rusttesting_spine_stub— so the cross-backendcell_grant_stewardsuite proves the steward-revoke seam agrees TS↔Rust. The twin of the RustTestCellGrantStewardRevokeAuthorize.The caller stewards a scope when it holds an active
participantgrant bound to that scope. The spine asks only for a non-manager, non-grantee caller revoking a role-shaped grant with a scope, so the policy reads the scope alone; it is pure, with no DB read of its own.testing/cross_backend/testing_backdoor.ts
Cross-backend suite for the
_testing_*backdoor actions: the backend's declaration of which it mounts, and the credential gate on each.TESTING_BACKDOOR_METHODS (
_testing_reset,_testing_drain_effects,_testing_mint_session,_testing_put_fact,_testing_flood_notifications,_testing_schema_snapshot,_testing_migration_tracker,_testing_action_manifest,_testing_origin_check,_testing_shorten_credential_expiry) are privileged test-binary actions the production wire never exposes — direct DB writes (full auth wipe, forged session row, raw fact insert), the audit barrier, a flood of the caller's own connection, introspection reads (the live schema, theschema_versionmigration tracker, and the live RPC registry — the highest info-leak of the set were the gate to break), a probe of the origin allowlist parser, and a rewrite of an account's credential expiries. Their only structural fence is the daemon-token credential gate on each spec'sauthaxis. A test binary live-mounts them on its RPC endpoint (and, on fuz_app's spine binary and the Rust stub, its WS endpoint) but keeps them off the declared surface — so the spec-derived describe_rpc_attack_surface_tests never enumerates them. This suite fires them with non-daemon credentials to prove the gate holds end-to-end, against each impl's real auth resolution; a conformance table given the live mount (live_rpc_endpoints) fires the same refusals through the auth rows generate_rpc_auth_conformance_cases generates for them.The declaration. A backend states which it mounts in
capabilities.testing_backdoors, and the suite holds it to that: each known backdoor the declaration leaves out is sent anonymously and must answermethod_not_found(404) — both spines look a method up before they authenticate the caller, so an unmounted method answers that whatever the credential — and each declared one runs the rows below, whose anonymous row (a 401, notmethod_not_found) proves it mounted. A wrong declaration fails either way, so the suites that skip on an undeclared backdoor (describe_spine_cross_suites) skip only what the backend truly lacks. The check runs on every backend, one mounting none of them included.For every declared backdoor, and each
extra_methodsseam (a consumer's own daemon-token action), three principals:- anonymous (no credential) →
401(pre-authorization auth refuses an account-less caller before anything else). - session (the keeper's browser-context cookie) →
403credential_type_required— a session cookie, even one carrying the keeper role, tops out below the daemon-token channel. - bearer (the keeper's api-token, non-browser context) →
403credential_type_required— same ceiling; an api token cannot reach keeper operations.
Over WebSocket (when
wsis supplied), the same methods sent on a socket opened with the keeper's session cookie answer the sameforbiddenerror withcredential_type_required: the upgrade admits a session, but each message runs the same per-action gates as HTTP RPC, so a socket is no way around the daemon-token channel. Anonymous and bearer have no WS case — an anonymous upgrade is refused before any message, and the upgrade client threads cookies.Each method is sent with valid params so each case varies only the credential. (Validation runs after the gates — the order is 401 → authz → 403 → 429 → 400 — so the shapes are belt-and-suspenders here rather than load- bearing.) The handler never runs (the gate refuses first), so the writes never execute.
Complements the spec-level gate check (which pins that each spec *declares*
credential_types: ['daemon_token']) and the surface-absence invariant (assert_no_testing_methods) — this one pins the runtime 401/403 behavior on both impls. Cited property:security.md§Test Backdoor Actions (daemon-token-gated, off-surface, DEV-excluded).The credential rows run cross-process only — the
_testing_*actions are mounted on the spawned binary, not the in-process app — like the ws/sse suites; there, wire from a*.cross.test.ts, whose setup fires_testing_reset, so the backend must mount that one. In-process, an app mounting none declares none, and the suite runs the declaration rows alone, one per known backdoor. The WS cases run whenwsis passed — for a backend that also serves its backdoors on its WS endpoint.- anonymous (no credential) →
testing/cross_backend/testing_origin_check.ts
_testing_origin_check— the testing seam behind the cross-impl origin conformance table (origin_conformance.json): its input and output schemas, the pure check it answers with, its spec, and the action builder.Split from
testing_reset_actions.ts, whose other seams reach the in-process app server and through it the optionalhonopeer. This leaf is hono-free, so a consumer whose backend is a spawned Rust binary can run the conformance table without installinghono.testing/cross_backend/testing_reset_actions.ts
Test-binary RPC actions for cross-process integration tests.
The daemon-token-authed actions bundled by create_testing_actions:
_testing_reset(DB wipe + keeper re-seed),_testing_drain_effects(audit barrier),_testing_mint_session(forge an expired-by-construction server-side session for the expiry conformance cases),_testing_put_fact(seed an embedded fact for the fact-serving suite),_testing_schema_snapshot(introspect the live schema for cross-impl parity diffing against a Rust backend'sfuz_dbsnapshot),_testing_migration_tracker(dump theschema_versiontracker rows for cross-impl migration-identity parity — the provenance half the snapshot gate excludes by design), and_testing_flood_notifications(queue a burst of notifications onto the caller's own WebSocket connection, to push its outbound queue past its byte budget).Two further daemon-token actions live here but are not bundled by create_testing_actions, and are appended at the full-mount layer (build_full_spine_rpc_actions) instead:
_testing_action_manifest(dump the live RPC registry for cross-impl method-set + auth-shape parity — it must enumerate *every* mounted method, so it goes where the complete list exists) and_testing_shorten_credential_expiry(pull an account's credential expiries in to seconds from now, shorten-only, for the credential-deadline cases) — the latter kept out of the bundle so a consumer whose Rust binary doesn't mount its mirror keeps a green manifest gate._testing_origin_check, mounted the same way, lives in the hono-freetesting_origin_check.ts._testing_reset— full DB wipe + keeper re-seed + optional secondary-account seeding. The handler first fires the consumer-supplied domain-state callback (domain rows depend on auth rows, so a domain table with a restricting FK toactorempties before the rows it references go), then wipes every auth-namespace row (no keeper-preserve filter), flipsbootstrap_lockback to its post-bootstrap shape, seeds a fresh keeper account inline (reusing create_test_account_with_credentials so cross-process matches in-process write semantics), queues the daemon-token cache refresh to point at the new keeper once the reset commits, and seeds any caller-requestedextra_accounts(also direct-inserted at this setup step). The new keeper + secondary credentials return as the action output so the per-test fixture closes over them.In-process and cross-process keeper lifetimes match: both modes run against a freshly bootstrapped keeper per test, so mutation-cascade tests (password change, revoke-all, hardcoded-username signup uniqueness) and direct keeper-vs-admin probes work uniformly cross-process.
Keeper ≠ admin. The
keeperandadminroles are independent. Keeper authorizes daemon-token / bootstrap paths; admin authorizes the user-facing admin RPC surface._testing_resetseeds the keeper account with[ROLE_KEEPER, ROLE_ADMIN]by default — matching the production bootstrap_account flow — plus any roles passed viaextra_keeper_roles. Tests probing the keeper-vs-admin separation (a keeper-only account must 403 on admin RPCs) declare a secondary viaextra_accounts: [{username, roles: [ROLE_KEEPER]}]so the account is seeded at this same bootstrap-equivalent step.No free-form runtime bypass. There is no action for arbitrary direct role grants: a runtime bypass would let tests skip the production consent flow's side-effects (audit emit, WS fan-out) and silently mask bugs in those paths. The bypass that does exist —
extra_accounts— is framed as bootstrap-time seeding, the same shape bootstrap_account itself uses to grant the initialKEEPER+ADMINpair. Tests that want a role on a *post-bootstrap* account must route throughrole_grant_offer_create+role_grant_offer_accept(the production path); they observe the full event chain.Production safety: this module lives under
cross_backend/and starts withimport '../assert_dev_env.ts';— production bundles either tree-shake the module out or throw at startup. The Rust mirror (fuz_testingcrate) ships a parallel action; `cargo xtask check-releaseblocksfuz_testing` from entering production dep graphs.testing/cross_backend/testing_server_bun.ts
Bun runtime adapter for spawnable cross-process test server binaries.
Binds
Bun.serveand@hono/bun's module-levelupgradeWebSocket+websockethandler. The shared testing/cross_backend/testing_server_core.ts owns the rest. Third sibling to testing/cross_backend/testing_server_node.ts / testing/cross_backend/testing_server_deno.ts — together the three isolate the JS-runtime axis (Node V8 / Deno V8 / Bun JSC) on identical TS surfaces, and the Rust spine binary covers the cross-language axis.Needs the optional
@hono/bunpeer dep (the Bun counterpart to Node's@hono/node-server+@hono/node-ws);Bun.serveis built in. RuntimeDeps reuse create_node_runtime — Bun implements thenode:fs/node:processsurface RuntimeDeps + testing/daemon_info.ts touch.Bun.serveis declared locally (mirroring testing/cross_backend/testing_server_deno.ts'sDenodeclaration) so this module typechecks under fuz_app's Node-based config without@types/bun. It is only ever *run* under Bun.testing/cross_backend/testing_server_core.ts
Runtime-agnostic core for spawnable cross-process test server binaries.
A test binary mounts a fuz_app-derived surface over a real HTTP socket so the
cross_backend/*suites (and the cross-impl bench) can drive it the same way they drive the Rust spine. This module owns the runtime-neutral orchestration — stale-daemon check, daemon-info write, serve, post-serve WS attach, graceful shutdown — and delegates the runtime-boundary primitives (HTTP serve, WS upgrade construction, signals, pid, exit) to a TestingServerAdapter. The shipped adapters are testing/cross_backend/testing_server_node.ts (@hono/node-server+@hono/node-ws), testing/cross_backend/testing_server_deno.ts (Deno.serve+@hono/deno), and testing/cross_backend/testing_server_bun.ts (Bun.serve+@hono/bun).The app itself — routes, RPC, DB,
_testing_reset, WS endpoints — is the caller'sStartTestingServerOptions.build_appseam, so this core stays domain-free. WebSocket endpoints mount through create_app_server'sws_endpoints:build_appasks its context to prepare the runtime's upgrade helper and passes the resulting factory ascreate_upgrade_websocket; the core attaches it to the server onceservereturns (Node'sinjectWebSocket). fuz_app's owntesting_spine_serverpasses a no-domain build; consumers (fuz_forge) pass their domain build.NEVER ships in a release. This module lives under
cross_backend/and opens withimport '../assert_dev_env.ts';, which throws on production-bundle load. The runtime adapters reach for the optional@hono/node-server/@hono/node-wspeer deps; only test binaries import them.testing/cross_backend/testing_server_deno.ts
Deno runtime adapter for spawnable cross-process test server binaries.
Binds
Deno.serveand@hono/deno's module-levelupgradeWebSocket(the optional@hono/denopeer dep). The shared testing/cross_backend/testing_server_core.ts owns the rest. Counterpart to testing/cross_backend/testing_server_node.ts — together they isolate the JS-runtime axis (Deno vs Node V8) on identical TS surfaces, and the Rust spine binary covers the cross-language axis.Denoglobals are declared locally (mirroring runtime/deno.ts) so this module typechecks under fuz_app's Node-based config without adeno.jsonor@types/deno. It is only ever *run* under Deno.testing/cross_backend/testing_server_node.ts
Node runtime adapter for spawnable cross-process test server binaries.
Binds
@hono/node-server'sserve()and@hono/node-ws's two-phasecreateNodeWebSocket({app})/injectWebSocket(server): the factoryprepare_websocketreturns builds the first half from the app create_app_server assembles, and the core runs the second onceserve()returns. The shared testing/cross_backend/testing_server_core.ts owns the rest. A test binary builds this adapter and hands it to start_testing_server alongside itsbuild_appseam.@hono/node-server+@hono/node-wsare optional peer deps (same posture asws) — only test binaries import them; production bundles never reach this module (theassert_dev_envguard throws on prod load).testing/cross_backend/token_lifetime.ts
Cross-backend parity suite for the API token lifetime axis of
account_token_create— the round-trip that keeps a one-sided landing impossible.Why this suite exists
The action-manifest parity gate captures
{method, side_effects, auth}and no param schemas, while the TS spine validates params against a ZodstrictObjectand the Rust spine hand-parses withparams.get(...). So a spine that silently ignored thelifetimefield would answer the same mint request with a 200 and an eternal token — same method, same status, nothing red anywhere. The forcing function has to observe the *stored* state: mint with a TTL, then readexpires_atback off the wire.What it pins
- A TTL mint stores a bounded expiry —
account_token_createwith{kind:'ttl', days}returns a non-nullexpires_at(create output *and*account_token_list), landing within a generous window aroundnow + days. A spine that dropped the field returnsnulland fails. - An eternal mint stores NULL — the control:
{kind:'eternal'}yieldsexpires_at: nullon both reads. - An omitted lifetime is refused —
lifetimeis required; a mint without it must 400invalid_paramson both spines. This is the direct pin on the silently-ignoring-unknown-shapes hazard. - Out-of-range days are refused —
days: 0400s identically, so the two hand-rolled bounds checks can't drift. - The
namecap — the same blind spot on another field of the same mint: a name of exactly API_TOKEN_NAME_LENGTH_MAX UTF-16 code units mints and reads back whole, and one unit past it isinvalid_params, with an astral-plane name at each side of the boundary so the two spines count the same unit. - A token sent beside a winning daemon token is never used — the
credential legs run daemon token → session cookie → bearer on both
spines, the first that authenticates winning, so a request carrying the
daemon token and a bearer never validates the bearer: its
last_used_atstaysnull. A spine resolving the bearer first (or both) bumps it. The positive control is a second token used alone, read back with alast_used_at— sent after the first request, so once its touch is visible the first's would be too. - An invalid daemon token falls through to the bearer beside it — the daemon leg soft-fails a token that is not the current one, so the bearer leg still runs: the request authenticates on the token, which records its last use. A spine hard-failing the daemon token refuses the request; one stopping at the failed leg leaves the request anonymous.
- A TTL mint stores a bounded expiry —
testing/cross_backend/token_scope_surface.ts
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 (thetoken_scope_casesbatch in the conformance security slate). Thesurface:<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 exactrequired_scopecapability string, per surface. A spine that renamed a surface, dropped thesurface:prefix, or emitted the JSON-RPC envelope from a REST route fails here. - The audit stream's channel denial — that surface is gated to
credential_types: ['session']on both spines, so a bearer never reaches its scope gate and the observable iscredential_type_requiredinstead. The case is kept here rather than moved: it is still the bearer-vs-surface probe, and what it now pins is the coarser-gate-first ordering both spines run (a spine that checked scope first would answertoken_scope_required).surface:audit_stream's own capability string is consequently unreachable on the default mount and has no cross-impl pin — each spine's census holds its declaration, the way the WS upgrade's is held. - The account status read answers a narrowed token as anonymous —
GET /api/account/statusanswers anonymous callers, so rule 3 there is the anonymous answer (401plus the bootstrap flag), byte for byte, never the account's email, its verification, or its role grants. A full-scope bearer reading the account is the control. - Scope outranks role — a narrowed token whose account *also* lacks the
gating role must hear
token_scope_required, notinsufficient_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
fact_bare(otherwise a route broken for all bearers would pass too). The second has no audit-stream twin by construction — refusing every bearer is exactly what that route's channel gate is for.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 HTTP surfaces are where a 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).
- The denial body, byte for byte — status,
testing/cross_backend/transport_composition.ts
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 Originunreadable Originduplicated Originover cap + foreign Originno credential narrowed token REST POST(/logout)403 forbidden_origin403 forbidden_origin403 forbidden_origin413 payload_too_large401 authentication_required403 credential_type_requiredREST POST(/password)— — — — 401 authentication_required— RPC POST403 forbidden_origin403 forbidden_origin403 forbidden_origin413 payload_too_large401 authentication_required403 token_scope_required(rpc:<method>)RPC GET403 forbidden_origin403 forbidden_origin403 forbidden_origin— 401 authentication_required403 token_scope_required(rpc:<method>)WS upgrade 403 forbidden_origin403 forbidden_origin403 forbidden_origin— 401 authentication_required403 token_scope_required(surface:ws_upgrade)WS path, no upgrade 403 forbidden_origin— — — 401 authentication_required— SSE (audit stream) 403 forbidden_origin403 forbidden_origin403 forbidden_origin— 401 authentication_required403 credential_type_required- foreign
Origincarries 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
Originis 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
Originsends 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
Origindeclares aContent-Lengthone byte over the route's cap — the shared 1 MiB default, orrpc_max_body_byteson the RPC row — 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
Originand nothing else. - WS path, no upgrade is a
GETon the WebSocket path with noUpgradeheader: the gates answer it as they answer an upgrade, and one they admit is a 404, as is any method butGET(its own cases below the table). - 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 onerror.data.reason. A WebSocket refusal's body is required where the backend declarescapabilities.ws_refusal_body; where it doesn't (Node's@hono/node-wsdrops 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, asbody_size.tsexplains — the 413 must arrive without a body byte. Withbase_url: nullthe probes go through the fixture's in-process transport, whole bodies included, and only the WebSocket upgrade rows skip (there is no socket to upgrade): the WebSocket-path rows that ask for no upgrade gate oncapabilities.wsalone.Multi-actor upgrade. One more WS row needs a keeper seeded with a second actor (
extra_actors), so it runs only givenmulti_actor: a multi-actor keeper's session upgrading with no?acting=→ 400actor_required, the flat body both spines' upgrade-time actor resolution answers. The upgrade resolves an actor there, and a multi-actor account must name one. A backend whose upgrade resolves no actor admits the socket instead;multi_actor.xfailruns the row as xfail_until for it.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.wsandcapabilities.sse.- foreign
testing/cross_backend/ts_spine_backend_config.ts
Cross-process BackendConfig presets for fuz_app's domain-free TS spine test binary (
src/test/cross_backend/testing_spine_server_{node,deno,bun}.ts).The TS analog of rust_spine_stub_backend_config (which spawns the Rust spine): these spawn fuz_app's own TS impl over real HTTP with no domain layer, so the
cross_backend_ts_node/cross_backend_ts_deno/cross_backend_ts_bunself-test projects verify fuz_app's wire path in its own repo across all three JS runtimes. All run against in-memory pglet (memory://) — no external Postgres, unlike the Rust path.The binary writes its daemon token to
{FUZ_TESTING_TS_SPINE_DIR}/run/daemon_token; anchoring the dir topaths.rootmakes that equalpaths.daemon_token_path, which spawn_backend reads after the health probe.testing/cross_backend/wire_shapes.ts
Wire-shape assertions shared by the cross-backend suites.
A field can carry the right value on both backends and still be a parity defect if the two serialize it differently — a millisecond
2026-05-17T12:34:56.789Zand a second-precision2026-05-17T12:34:56Zboth parse as the same instant, so a Zodz.string()check and anassert.ok(value)pass on either. These helpers pin the bytes.testing/cross_backend/ws_action_rate_limit.ts
Cross-backend parity suite for action rate limiting over a real WebSocket, and its budget shared with HTTP RPC.
An action spec's
rate_limitclass ('ip'/'account'/'both') is charged by the dispatcher on both transports, and a server holds one limiter per axis, so an action's budget is per caller, not per transport. The in-process tests pin that through an assembled app; this suite pins it over the wire, against backends spawned with small shared limiters (the action rate-limit toggles — ACTION_RATE_LIMIT_ENABLED_ENV and ACTION_RATE_LIMIT_MAX_ATTEMPTS_ENV — seeglobal_setup_login_security.ts):- account axis over WS —
account_token_create(`rate_limit: 'account'on both spines) with malformed params: the firstmax_attempts` calls each answerinvalid_params(the throttle runs before params validation, so a malformed call still charges), and the next answersrate_limitedwith aretry_after. - one account bucket across transports — calls split between HTTP RPC and
WS exhaust one budget: the call past the cap is refused on WS and on HTTP
(
429). - IP axis, keyed by the resolved client IP —
peer/ping(`rate_limit: 'ip', public) with malformed params, from oneX-Forwarded-For` address over anonymous HTTP and over a WS whose upgrade carried the same header: the call past the cap is refused on both, while a second address keeps its own budget on both. Over HTTP, IPv6 addresses bucket by their/64: another address in the same/64is refused once one has spent the budget, while one in the next/64keeps its own. - account axis at the dispatchers —
cell_create(`rate_limit: 'account'`, on both spines' RPC and WS mounts) with malformed params: the call past the cap answers429over HTTP andrate_limitedover WS, calls split between the two transports exhaust one budget, and a budget split betweencell_createandaccount_token_createis one bucket. - the throttle precedes the dispatcher's own params checks —
account_token_createwith a malformedactingselector, which each spine refuses in its dispatcher (TS's input schema, Rust'scheck_acting_selector) rather than in the handler: the call past the cap still answersrate_limited, so the refusals charged.
Charge site. Both spines charge every classed action — the auth families included — in the shared dispatch core (perform_action), after authorization and ahead of params validation.
account_token_createpins the auth families' class,peer/pingthe IP axis, andcell_createthe account axis of a consumer-shaped spec, over RPC and WS.Isolation. Limiter state is in-memory and
_testing_resetwipes only the database, so every case reads a bucket no other case touched: the account cases each run on the fresh keeper the reset seeds (a new account id), and the IP case uses addresses no other case sends. Login limiters are also on in this project; nothing here logs in.Cross-process only: the composition under test is the spawned binary's (create_app_server threading one limiter pair to both mounts on TS; the stub's dispatcher states on Rust).
- account axis over WS —
testing/cross_backend/ws_connection_cap.ts
Cross-backend parity suite for the per-account WebSocket connection cap over a real upgrade.
Both spines bound an account's concurrent WebSocket connections (ConnectionRegistry's
max_connections_per_accounton the TS side, the Rustfuz_realtimeConnectionRegistry on the other; both default to DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT) with the same policy: evict-oldest — a connection past the cap is always admitted, and the account's oldest is closed with WS_CLOSE_CONNECTION_LIMIT and WS_CLOSE_CONNECTION_LIMIT_REASON. A client reads that close as "superseded": closed until the user acts, not revoked and not reconnecting (FrontendWebsocketClient.superseded). The code, the reason, and which socket closes are therefore wire contract, and this suite is the pin that runs on both. Two cases:- the oldest is superseded, and it is not a revocation — on one session, opening one socket past the cap closes exactly the first with the connection-limit code and reason; every other socket, the newest included, stays open and still dispatches. The session then upgrades again, which a revoked session could not — and that upgrade supersedes the next-oldest in turn.
- a closed connection frees its slot — one socket held open while a cap's worth of others open and close in sequence is never evicted. A backend that leaks closed connections in its registry counts them toward the cap and closes the held socket, which is what this catches.
Sockets open in sequence, each answering a
heartbeatbefore the next opens (create_admitted_ws_transport), so the backend registered them in that order and "oldest" names the same socket on both spines (an upgrade answers its handshake before the connection is registered and admitted, so the handshake alone doesn't order them).Consumer-agnostic, like describe_cross_process_ws_tests: it drives only the
heartbeatprotocol action, present on every WS endpoint. Gated oncapabilities.ws; cross-process only (create_ws_transport needs a real bound socket). Each case opens more than a cap's worth of real sockets, which is why it's a separate call rather than more cases on the WS round-trip suite.testing/cross_backend/ws_endpoint.ts
Where a spawned backend's WebSocket endpoint is, and how the cross suites open a socket on it as one account's session.
The socket-driving cross suites take WsEndpointTarget as their endpoint options (flat, or nested as
wson a suite whose socket cases are optional), and open a socket on a fixture account's session through open_session_ws, so the cookie read, the missing-cookie assertion and the transport choice are written once. Sockets on another credential — a daemon token, a bearer, a session a case logged in on its own transport — and the raw-socket cases open their own way.testing/cross_backend/ws_envelope.ts
Cross-process suite for the WebSocket read loop's frame envelope — what each spine answers to a frame before anything dispatches, pinned over a real upgrade. The HTTP twin is
rpc_adversarial's envelope cases (create_rpc_envelope_cases); a socket classifies its frames in its own loop (TS register_action_ws, Rustfuz_realtime'sws_endpoint), so the HTTP pins say nothing about it.- a parse error — a frame that is not JSON answers
parse_errorwithid: null, and the socket keeps serving - a non-object frame — a JSON scalar answers
invalid_requestwithid: null(the scalar is not read as its own id) - a batch array — answers one
invalid_requestwithid: null; batches are not supported on either transport - a notification — a string method with no id answers no frame
- a stray response — a
{id, result}frame matching no server-initiated request answers no frame (it demuxes as a peer response and is dropped) cancel— the notification answers no frame: for an unknown id, for malformed params, and for a request in flight, which keeps running and answers under its id once it completes (neither spine'speer/pingobserves its signal, so the cancel's effect is invisible here; consumer suites with a signal-observing seam pin it)
Silence is asserted positively: each case marks the received-message count, sends its frames, then round-trips a
heartbeatprobe and reads back everything after the mark. The socket is ordered, so a frame that should produce nothing leaves exactly the probe's response, and the probe doubles as proof the socket survived the frame.Wire codes are independent copies of the producers' constants — parity by test. Gated on
capabilities.ws; the in-flight cancel also oncapabilities.peer_request(it holds a request withpeer/ping).Cross-process only: create_ws_transport needs a real bound socket, so wire it from a
*.cross.test.tsfile.- a parse error — a frame that is not JSON answers
testing/cross_backend/ws_inbound_dispatch.ts
Cross-process suite for the two refusals a WebSocket request can meet ahead of dispatch — the same on both spines, pinned over a real upgrade:
- a request whose id names a request still running on the socket is
answered
invalid_requestwithdata.reason: 'duplicate_request_id'and not dispatched; the running request keeps the id and answers as usual, and once it has answered the id is free again - past the per-socket in-flight ceiling (128 on both spines) a new request is
answered
queue_overflowwith its id and not dispatched; the socket stays open, the running requests still answer, and the duplicate check comes first
A request has to stay in flight for either to be observable, so the suite holds requests with
peer/ping: the server's handler pings the client back and waits for the echo, and the client's responder withholds it until the test releases it. Gated oncapabilities.peer_requestfor that reason.The wire literals are deliberate independent copies of the producers' constants (ERROR_DUPLICATE_REQUEST_ID, MAX_INBOUND_DISPATCHES_PER_CONNECTION, WS_INBOUND_DISPATCH_OVERFLOW_MESSAGE on TS; their Rust twins in
fuz_realtime) — parity by test, so a value renamed on one side fails here instead of moving both at once.Cross-process only: create_ws_transport needs a real bound socket, so wire it from a
*.cross.test.tsfile.- a request whose id names a request still running on the socket is
answered
testing/cross_backend/ws_outbound_overflow.ts
Cross-backend parity suite for the per-connection outbound bound over a real upgrade.
Both spines hold what waits to be written to one WebSocket to a byte budget (ConnectionRegistry's
outbound_queue_byteson the TS side, the Rustfuz_realtimeoutbound queue on the other; both default to 4 MiB, each waiting frame charged its UTF-8 length plus 64 bytes). A frame that does not fit closes the socket with WS_CLOSE_OUTBOUND_OVERFLOW and WS_CLOSE_OUTBOUND_OVERFLOW_REASON; the credential stays good, so a client reconnects under its ordinary backoff. The code and the reason are therefore wire contract, and this suite is the pin that runs on both.It drives the backend's
_testing_flood_notificationsaction, which queues a burst of notifications onto the caller's own connection in one synchronous run — so the burst waits at once whether or not the client is reading, and the overflow is deterministic. The cases:- within the budget — a small burst arrives whole and in order, ahead of its response, and the socket stays open.
- large, within the budget — a burst of a few mebibytes, most of the default budget, arrives the same way: every frame whole, in order, the response last. Megabyte frames can outrun a socket's buffer, and where they do — the Deno TS spine, as observed; Node and Bun write the burst through — the frames behind the first wait on the backend's write pump.
- overflow — a burst far past the budget closes the socket with the code and the exact reason; the response of the flooding request never arrives; any flood frames that did arrive form a contiguous prefix of the burst. A second connection on the same account keeps dispatching throughout.
- refusal — a burst past the action's own size limit is refused with
invalid_params, and the socket stays open. - no connection — over HTTP RPC the action refuses with
invalid_paramsand reasonno_connection, rather than answering as though it flooded. - the action's limits — over HTTP RPC a call at each of the action's
limits (
count,size,count×size) is refused for having no connection, and a call one past each is refusedinvalid_paramswith no reason. Both spines check the limits before they look for the connection and before they build a payload, so the boundaries are read off the refusal and no burst is ever allocated — which pins the two spines' limits equal.
What the suite does not assert, because it is each implementation's own: how many frames accepted before the overflow reach the client (the Rust spine discards them all; the TS spine may deliver the first), and the exact boundary a burst of tiny frames overflows at.
The wire literals are deliberate independent copies of the producers' constants — parity by test, so a value changed on one side fails here instead of moving both at once.
The flood action is gated on the daemon token, so the flooding socket upgrades with it and no
Origin(both spines discard a daemon token presented in a browser context). Gated oncapabilities.ws; cross-process only (create_ws_transport needs a real bound socket), and only for a backend that mounts_testing_flood_notificationson its WS endpoint.testing/cross_backend/ws_role_gated.ts
Cross-process role-gated WebSocket suite — a socket admitted by an endpoint's role gate (register_action_ws's
required_roles) loses it with the role.A role gate decides who may hold a socket, and so who receives the endpoint's server pushes, which reach every admitted connection with no per-recipient filter. So both spines record the gating roles on each registration and close the account's gated sockets when a global grant of a listed role is revoked — with WS_CLOSE_PERMISSION_REVOKED, not the session-revoked code, since the credential is still good — and send nothing after the close. The ungated sockets of the same account, on the same registry, stay open: the gate is per endpoint, not per account. A scoped grant never admits at a role gate, so revoking one closes nothing.
Runs against a backend that mounts, beside its ungated
ws_path, a second endpoint atgated_ws_pathgated ongated_roles— which must include ROLE_ADMIN (the revoked global grant) and a role that can be granted both globally and scoped (the scoped revoke). fuz_app's spine binaries mount SPINE_ROLE_GATED_WS_PATH gated on SPINE_ROLE_GATED_WS_ROLES. Gated oncapabilities.wsandcapabilities.ws_role_gated; cross-process only.testing/cross_backend/ws_round_trip.ts
Cross-process WebSocket round-trip suite — the cross-process counterpart to the in-process testing/ws_round_trip.ts harness.
Where the in-process harness drives register_action_ws against a fake Hono upgrade (no wire), this suite performs a real
WebSocketupgrade against a spawned backend via create_ws_transport (thewsnpm package), so the actual upgrade handshake + per-connection auth + JSON-RPC dispatch over the socket are exercised end-to-end. It is the only coverage of the spawned binary's live WS path — the standard cross-process bundle (describe_standard_cross_process_tests) omits WS by design, so consumers call this alongside it.Consumer-agnostic, apart from the cases
rpc_pathandcapabilities.ws_account_actionsgate. Every other case drives theheartbeatprotocol action, which assert_ws_endpoints_include_protocol_actions guarantees is present on every WS endpoint — so the suite needs no knowledge of a consumer's domain WS methods. It validates the transport, not the domain.Three cases mirror the upgrade route register_action_ws mounts (origin check → require_auth → dispatch): an authenticated upgrade round-trips
heartbeat(the first case), and an anonymous upgrade and a disallowed-origin upgrade are refused (after the spine-parity cases). Per-connection auth is enforced at upgrade time (not per message), so the negative cases assert the upgrade itself rejects rather than a per-message error frame.Spine-parity cases follow the first. Both spines admit a connection only after re-reading its credential, and requests that arrive first wait for that rather than being dropped: requests sent the moment the socket opens are all answered, and — gated on
capabilities.ws_handshake_pipelining, with the ordering forced — so are frames written in the same TCP write as the upgrade request (driven through connect_raw_ws, since a client library sends nothing before itsopenevent).heartbeatanswers the parameterless shapes (absent,{}) and refuses a declared param withinvalid_params, and a message over the backend's cap (max_message_bytes, default DEFAULT_WS_MAX_MESSAGE_BYTES) closes the socket with WS_CLOSE_MESSAGE_TOO_BIG and WS_CLOSE_MESSAGE_TOO_BIG_REASON.The cases after the refused upgrades (gated on
rpc_path) cover server-initiated close. Per-message dispatch never re-checks credential validity, so an open socket ends only when a revocation closes it — which every revocation does, once its transaction commits, and every close here is asserted with WS_CLOSE_SESSION_REVOKED and WS_CLOSE_SESSION_REVOKED_REASON, so a dropped connection can't pass for one. An authenticated socket is dropped when the account's sessions are revoked over the keeper's session channel (account_session_revoke_all), and when its account is deleted (account_delete, also gated oncapabilities.account_lifecycle). The rest of the revocation table follows, each closing a second account's socket:account_token_revokecloses the revoked token's bearer socket and leaves a socket on the account's other token open;admin_token_revoke_all,POST /logout,POST /password, andaccount_purge(also gated oncapabilities.account_lifecycle) close the account's bearer and session sockets — the rows create_account_revocations shares with the SSE suite; and a token the per-account cap evicts (max_tokensfurther mints) has its bearer socket closed. Omitrpc_pathto skip them (consumers without the standard account and admin actions on their RPC endpoint).The last cases are gated on
capabilities.ws_account_actions— the backend's WS endpoint mounts the self-service account actions: a socket that revokes its own sessions *over that socket* is closed with WS_CLOSE_SESSION_REVOKED after reading the reply to its request — both spines answer the revoking request ahead of the close, so the caller is never left without an answer. Under the same gate (andrpc_path), a socket mints an API token over WS and the token'stoken_createaudit row is read back after_testing_drain_effects— the barrier a WS mutation needs, since a WS reply can precede its audit write. The binary's create_testing_actions mount provides the drain.Gated on
capabilities.ws— backends without an end-to-end WS transport skip (the cases still surface as.skipin the report). Cross-process only: create_ws_transport needs a real bound socket, so wire it from a*.cross.test.tsfile, never an in-process setup.testing/cross_backend/xfail.ts
xfail_until — mark a deferred-by-design gap as an expected failure.
A thin wrapper over vitest's
test.failsthat bakes a tracking id + reason into the test label. Two properties make it the right tool for declared gaps (distinct from in-scope gaps, which fail loud as a normal redtest):- Visible — the case shows in the report as a distinct expected
failure, not a silent
.skip, so a deferred gap never disappears from view. - Self-cleaning —
test.failsturns red the moment the body stops throwing (i.e. the impl starts passing), forcing whoever closed the gap to delete the marker. A.skipwould rot silently; this can't.
Sibling to test_if in testing/cross_backend/capabilities.ts. No taxonomy — a one-line marker with a tracking id and a reason, nothing more.
- Visible — the case shows in the report as a distinct expected
failure, not a silent