@fuzdev/fuz_app 0.134.0 → 0.135.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/actions/CLAUDE.md +91 -9
  2. package/dist/actions/register_action_ws.d.ts +23 -3
  3. package/dist/actions/register_action_ws.d.ts.map +1 -1
  4. package/dist/actions/register_action_ws.js +57 -37
  5. package/dist/actions/transports.d.ts +23 -0
  6. package/dist/actions/transports.d.ts.map +1 -1
  7. package/dist/actions/transports.js +23 -0
  8. package/dist/actions/transports_ws_backend.d.ts +132 -9
  9. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  10. package/dist/actions/transports_ws_backend.js +236 -33
  11. package/dist/actions/ws_endpoint_spec.d.ts +9 -0
  12. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  13. package/dist/actions/ws_outbound_writer.d.ts +206 -0
  14. package/dist/actions/ws_outbound_writer.d.ts.map +1 -0
  15. package/dist/actions/ws_outbound_writer.js +431 -0
  16. package/dist/auth/CLAUDE.md +2 -2
  17. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  18. package/dist/auth/audit_log_routes.js +3 -1
  19. package/dist/auth/cell_actions.js +1 -1
  20. package/dist/auth/role_schema.d.ts +14 -3
  21. package/dist/auth/role_schema.d.ts.map +1 -1
  22. package/dist/auth/role_schema.js +14 -2
  23. package/dist/db/migrate.d.ts +1 -1
  24. package/dist/db/migrate.js +2 -2
  25. package/dist/http/CLAUDE.md +2 -1
  26. package/dist/http/body_limit.d.ts +61 -0
  27. package/dist/http/body_limit.d.ts.map +1 -0
  28. package/dist/http/body_limit.js +87 -0
  29. package/dist/http/db_routes.js +2 -2
  30. package/dist/http/error_schemas.d.ts +2 -0
  31. package/dist/http/error_schemas.d.ts.map +1 -1
  32. package/dist/http/error_schemas.js +2 -0
  33. package/dist/realtime/outbound_budget.d.ts +91 -0
  34. package/dist/realtime/outbound_budget.d.ts.map +1 -0
  35. package/dist/realtime/outbound_budget.js +108 -0
  36. package/dist/realtime/sse.d.ts +36 -3
  37. package/dist/realtime/sse.d.ts.map +1 -1
  38. package/dist/realtime/sse.js +58 -6
  39. package/dist/realtime/sse_constants.d.ts +12 -0
  40. package/dist/realtime/sse_constants.d.ts.map +1 -1
  41. package/dist/realtime/sse_constants.js +12 -0
  42. package/dist/server/app_server.d.ts.map +1 -1
  43. package/dist/server/app_server.js +3 -6
  44. package/dist/testing/CLAUDE.md +70 -10
  45. package/dist/testing/admin_integration.js +1 -1
  46. package/dist/testing/audit_completeness.d.ts +1 -1
  47. package/dist/testing/audit_completeness.js +1 -1
  48. package/dist/testing/audit_drift_guard.d.ts +1 -1
  49. package/dist/testing/audit_drift_guard.js +1 -1
  50. package/dist/testing/cross_backend/body_size_smuggling.d.ts +9 -9
  51. package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
  52. package/dist/testing/cross_backend/body_size_smuggling.js +110 -60
  53. package/dist/testing/cross_backend/capabilities.d.ts +18 -15
  54. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  55. package/dist/testing/cross_backend/capabilities.js +1 -0
  56. package/dist/testing/cross_backend/default_backend_configs.js +5 -5
  57. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
  58. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
  59. package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
  60. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
  61. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  62. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
  63. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  64. package/dist/testing/cross_backend/testing_backdoor.js +8 -4
  65. package/dist/testing/cross_backend/testing_reset_actions.d.ts +82 -2
  66. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  67. package/dist/testing/cross_backend/testing_reset_actions.js +101 -2
  68. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  69. package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -7
  70. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
  71. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
  72. package/dist/testing/cross_backend/ws_outbound_overflow.js +211 -0
  73. package/dist/testing/integration.js +1 -1
  74. package/dist/testing/schema_introspect.d.ts +10 -2
  75. package/dist/testing/schema_introspect.d.ts.map +1 -1
  76. package/dist/testing/schema_introspect.js +15 -7
  77. package/dist/testing/transports/ws_transport.d.ts +4 -2
  78. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  79. package/dist/testing/transports/ws_transport.js +2 -1
  80. package/dist/testing/ws_round_trip.d.ts +32 -2
  81. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  82. package/dist/testing/ws_round_trip.js +26 -4
  83. package/package.json +1 -1
@@ -10,36 +10,44 @@ import '../assert_dev_env.js';
10
10
  * body bytes would be parsed as the start of the next request — a classic
11
11
  * request-smuggling vector. This suite probes the boundary by **pipelining**:
12
12
  * it opens a raw TCP socket and sends, in one write, an oversized `POST`
13
- * immediately followed by a second `GET`. The assertion forks on the backend's
14
- * declared `oversized_reject_closes_connection` capability:
13
+ * immediately followed by a second `GET`. What it requires forks on the
14
+ * backend's declared `oversized_reject_closes_connection` capability:
15
15
  *
16
- * - **Closes (Node / Deno / hyper)** — the reject closes the socket with the
17
- * GET bytes unconsumed, so **at most one** response comes back. `<= 1` rather
18
- * than "exactly the 413" because the impls close differently at the TCP level
19
- * (node-server graceful close delivers the 413 first; hyper's RST can drop the
20
- * in-flight 413 before the client reads it), so demanding a cleanly-read 413
21
- * would be flaky.
22
- * - **Drains + keepalives (Bun)** — `Bun.serve` reads the full declared
23
- * `Content-Length` body and answers the *correctly-framed* pipelined GET, so
24
- * **two** responses come back. This is **not** a smuggle: the GET is delimited
25
- * by the body's `Content-Length`, not the unread body reinterpreted as a
26
- * request — Bun answers it with a clean `400` (`missing method`), not the `x`
27
- * body bytes reparsed. The security property asserted here is **no desync**
28
- * (`<= 2`): a real desync would reframe the 1 MiB of `x` into bogus request
29
- * lines and push the count past two.
16
+ * - **Closes (Node / Deno / hyper)** — the pipelined GET goes unanswered, so
17
+ * **at most one** response comes back. `<= 1` rather than "exactly the 413"
18
+ * because the impls close differently at the TCP level (a graceful close
19
+ * delivers the 413 first; hyper's RST can drop the in-flight 413 before the
20
+ * client reads it), so demanding a cleanly-read 413 would be flaky. Node
21
+ * closes at once with the body unread — `create_app_server`'s body limit
22
+ * (`http/body_limit.ts`) refuses on the header and answers
23
+ * `Connection: close`, which is what makes `@hono/node-server` close. Deno
24
+ * closes once the whole declared body has arrived, which this probe's
25
+ * single write delivers; a client that withholds the body holds a Deno
26
+ * connection open, and no request behind the body is served either way.
27
+ * - **Keeps the connection (Bun)** — `Bun.serve` sends that same 413, header
28
+ * included, then keeps the socket open, skips the declared body, and serves
29
+ * the GET. This is not a smuggle — the GET is delimited by the POST's
30
+ * `Content-Length`, not carved out of the body — so the property held here
31
+ * is **no desync**: the bytes read back are **exactly two** well-formed
32
+ * responses, the first the 413 and the second the same status and body the
33
+ * backend gives that GET on a connection of its own. Body bytes parsed as a
34
+ * request would show up as a different second response or as extra ones.
35
+ * Fewer than two fails too, with a message naming the capability: a backend
36
+ * on this arm that closes after the 413 belongs on the other one.
30
37
  *
31
- * Either way the oversized body is rejected *with* a 413 — pinned reliably over
32
- * `fetch` by `describe_body_size_cross_tests`; this test owns only the
33
- * connection-handling half. A **positive control** (two pipelined requests →
34
- * `>= 2` responses) proves a second response *would* be seen if a trailing
35
- * request were processed — without it the close-posture `<= 1` would be vacuous
36
- * on a server that never reuses connections — and that the counter isn't
37
- * undercounting.
38
+ * The oversized body is rejected *with* a 413 on every backend — pinned
39
+ * reliably over `fetch` by `describe_body_size_cross_tests`; this test owns
40
+ * only the connection-handling half. A **positive control** (two pipelined
41
+ * requests → `>= 2` responses) proves a second response *would* be seen if a
42
+ * trailing request were processed — without it the close-posture `<= 1` would
43
+ * be vacuous on a server that never reuses connections — and that the counter
44
+ * isn't undercounting.
38
45
  *
39
46
  * Raw-socket by necessity (the `FetchTransport` can't pipeline two requests on
40
47
  * one connection), so — unlike `body_size.ts` — this is **cross-process only**
41
- * (no in-process leg; there is no socket in-process). The connection-close half
42
- * is capability-gated; the no-desync half holds on every spine.
48
+ * (no in-process leg; there is no socket in-process — the in-process pin is
49
+ * that the reject never reads the request body, in
50
+ * `server/create_app_server.db.test.ts`).
43
51
  *
44
52
  * Cited property: `docs/security.md` §"Body Size Limiting" (connection handling
45
53
  * on oversized reject).
@@ -51,6 +59,12 @@ import { describe, test, assert } from 'vitest';
51
59
  import { SPINE_RPC_PATH } from './spine_surface_constants.js';
52
60
  /** The shared 1 MiB cap (see `body_size.ts` for why it's a local constant). */
53
61
  const BODY_LIMIT_DEFAULT_BYTES = 1024 * 1024;
62
+ /**
63
+ * How long each raw exchange waits for the server to finish answering. A
64
+ * backend that keeps the connection never closes it, so its exchange runs the
65
+ * whole window.
66
+ */
67
+ const RAW_READ_TIMEOUT_MS = 2000;
54
68
  /**
55
69
  * Open a raw TCP socket to `base_url`, write `request_bytes` once, and collect
56
70
  * everything the server sends back until it closes the connection or
@@ -93,6 +107,39 @@ const send_raw = (base_url, request_bytes, read_timeout_ms) => new Promise((reso
93
107
  * server genuinely returns 2.
94
108
  */
95
109
  const count_responses = (raw) => (raw.match(/HTTP\/1\.[01] \d{3}/g) ?? []).length;
110
+ /**
111
+ * Split a raw byte stream into the HTTP responses it holds, each framed on
112
+ * its own `Content-Length` (a response without one runs to the end of the
113
+ * stream). The stream is `latin1`-decoded, so a character is a byte.
114
+ *
115
+ * @returns the responses in order, or `null` when the bytes are not a
116
+ * sequence of whole responses — text where a status line belongs, a head
117
+ * with no end, a body shorter than its declared length
118
+ */
119
+ const parse_responses = (raw) => {
120
+ const responses = [];
121
+ let offset = 0;
122
+ while (offset < raw.length) {
123
+ const head_end = raw.indexOf('\r\n\r\n', offset);
124
+ if (head_end === -1)
125
+ return null;
126
+ const [status_line, ...header_lines] = raw.slice(offset, head_end).split('\r\n');
127
+ const status_match = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(status_line);
128
+ if (!status_match)
129
+ return null;
130
+ const body_start = head_end + 4;
131
+ const length_line = header_lines.find((line) => /^content-length:/i.test(line));
132
+ const body_end = length_line === undefined
133
+ ? raw.length
134
+ : body_start + Number.parseInt(length_line.slice(length_line.indexOf(':') + 1), 10);
135
+ // also false for a `NaN` length
136
+ if (!(body_end >= body_start && body_end <= raw.length))
137
+ return null;
138
+ responses.push({ status: Number(status_match[1]), body: raw.slice(body_start, body_end) });
139
+ offset = body_end;
140
+ }
141
+ return responses;
142
+ };
96
143
  export const describe_body_size_smuggling_cross_tests = (options) => {
97
144
  const { base_url } = options;
98
145
  const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
@@ -106,58 +153,61 @@ export const describe_body_size_smuggling_cross_tests = (options) => {
106
153
  test('control: two pipelined requests → ≥2 responses (connection reuse is real)', async () => {
107
154
  const two_requests = `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\n\r\n` +
108
155
  `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
109
- const response = await send_raw(base_url, two_requests, 2000);
156
+ const response = await send_raw(base_url, two_requests, RAW_READ_TIMEOUT_MS);
110
157
  const n = count_responses(response);
111
158
  assert.ok(n >= 2, `expected ≥2 responses on one keep-alive connection (got ${n}); without ` +
112
159
  `connection reuse — or with an undercounting matcher — the smuggling ` +
113
160
  `assertion below is not a real signal. Raw head: ${response.slice(0, 120)}`);
114
161
  });
115
- test('oversized POST + pipelined GET → smuggled request not processed', async () => {
162
+ test('oversized POST + pipelined GET → no request smuggled', async () => {
116
163
  const oversized_len = BODY_LIMIT_DEFAULT_BYTES + 1024;
117
- // One write: an over-cap POST (rejected on Content-Length, body never
118
- // read) immediately followed by a GET. If the server wrongly drained
119
- // the unread body it would reach + answer this GET — a smuggle.
164
+ const pipelined_get = `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\n\r\n`;
165
+ // One write: an over-cap POST (rejected on Content-Length) immediately
166
+ // followed by a GET.
120
167
  const payload = `POST ${rpc_path} HTTP/1.1\r\n` +
121
168
  `Host: ${host}\r\n` +
122
169
  `Content-Type: application/json\r\n` +
123
170
  `Content-Length: ${oversized_len}\r\n` +
124
171
  `\r\n` +
125
172
  'x'.repeat(oversized_len) +
126
- `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\n\r\n`;
127
- const response = await send_raw(base_url, payload, 2000);
173
+ pipelined_get;
174
+ const response = await send_raw(base_url, payload, RAW_READ_TIMEOUT_MS);
128
175
  const n = count_responses(response);
129
176
  if (closes_connection) {
130
- // Strong posture (Node / Deno / hyper): the reject closes the
131
- // connection *without reading the body*, so the pipelined GET is
132
- // never reached — **at most one** response comes back (the 413, or
133
- // none if the close raced the read). A *second* response would mean
134
- // the body was drained and the GET processed. We assert `<= 1` rather
135
- // than "exactly the 413" because the impls close differently at the
136
- // TCP level — node-server closes gracefully (413 delivered first),
137
- // hyper sends an RST that can drop the in-flight 413 before the client
138
- // reads it — and demanding a cleanly-read 413 would be flaky. The
139
- // 413-ness itself is pinned reliably (over `fetch`) by
140
- // `describe_body_size_cross_tests`. The control above proves a second
141
- // response *would* be seen if the GET were processed, so `<= 1` is a
142
- // real signal, not vacuous.
177
+ // The reject closes the connection, so the pipelined GET is never
178
+ // reached — **at most one** response comes back (the 413, or none if
179
+ // the close raced the read). A *second* response means the connection
180
+ // was kept and the GET served. The control above proves a second
181
+ // response *would* be seen, so `<= 1` is a real signal.
143
182
  assert.ok(n <= 1, `expected at most one response (oversized reject closes the connection; the ` +
144
183
  `pipelined GET must not be reached). Saw ${n}. Raw head: ${response.slice(0, 120)}`);
184
+ return;
145
185
  }
146
- else {
147
- // Drain-and-keepalive posture (Bun): `Bun.serve` reads the full
148
- // declared `Content-Length` body and keeps the socket alive, so it
149
- // answers the *correctly-framed* pipelined GET — **two** responses
150
- // (the 413 + a well-formed reply to the GET). This is not a smuggle:
151
- // the GET is delimited by the body's `Content-Length`, not the unread
152
- // body reinterpreted as request bytes. The security property here is
153
- // **no desync** — the body bytes are never reparsed as request(s). A
154
- // real desync would reframe the 1 MiB of `x` into bogus request
155
- // lines, pushing the count past two; `<= 2` is the no-desync bound,
156
- // and the control above proves the counter isn't undercounting.
157
- assert.ok(n <= 2, `expected at most two responses (the 413 + one framed reply to the ` +
158
- `pipelined GET); more means the oversized body was reparsed as requests ` +
159
- `(a desync). Saw ${n}. Raw head: ${response.slice(0, 120)}`);
160
- }
186
+ // The backend keeps the connection (Bun): the 413, then its answer to
187
+ // the GET, and nothing else. Hold it to **no desync** — the body bytes
188
+ // were skipped as a body, never parsed as a request.
189
+ assert.ok(n >= 2, `expected two responses (the 413 + the reply to the pipelined GET) from a ` +
190
+ `backend declared to keep the connection after an oversized-body 413. Saw ` +
191
+ `${n} within ${RAW_READ_TIMEOUT_MS}ms. If this backend closes the connection ` +
192
+ `after the 413, declare \`oversized_reject_closes_connection: true\` for it ` +
193
+ `(this suite's \`closes_connection\`). Raw head: ${response.slice(0, 120)}`);
194
+ assert.ok(n <= 2, `expected exactly two responses (the 413 + one reply to the pipelined GET); ` +
195
+ `more means the oversized body was parsed as requests (a desync). ` +
196
+ `Saw ${n}. Raw head: ${response.slice(0, 120)}`);
197
+ const responses = parse_responses(response);
198
+ assert.ok(responses !== null, `expected the bytes read back to be whole HTTP responses, each framed on its ` +
199
+ `Content-Length. Raw head: ${response.slice(0, 240)}`);
200
+ assert.strictEqual(responses.length, n, `expected one status line per framed response; a difference means a status ` +
201
+ `line sits inside another response's body. Raw head: ${response.slice(0, 240)}`);
202
+ const [first, second] = responses;
203
+ assert.strictEqual(first.status, 413, 'the first response is the oversized reject');
204
+ // A request carved out of the body would not be this GET, so the reply
205
+ // must be the one the same GET gets on its own connection.
206
+ const alone = parse_responses(await send_raw(base_url, `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`, RAW_READ_TIMEOUT_MS));
207
+ assert.ok(alone?.length === 1, 'expected one response to the GET sent alone');
208
+ assert.deepStrictEqual(second, alone[0], `expected the second response to be the backend's reply to the pipelined ` +
209
+ `GET (same status and body as that GET sent alone); anything else means ` +
210
+ `the bytes after the 413 were not framed as that request`);
161
211
  });
162
212
  });
163
213
  };
@@ -123,22 +123,25 @@ export interface BackendCapabilities {
123
123
  */
124
124
  readonly account_status: boolean;
125
125
  /**
126
- * On an oversized-body `413` reject the backend **closes the connection
127
- * without reading the body** (the defense-in-depth posture), rather than
128
- * draining the declared `Content-Length` and keeping the socket alive.
129
- * Gates the strong half of `describe_body_size_smuggling_cross_tests`: when
130
- * `true`, the pipelined GET is never reached (at most one response); when
131
- * `false`, the suite instead asserts the weaker but still-load-bearing
132
- * no-desync property (the body is framed on `Content-Length`, not reparsed
133
- * as request bytes).
126
+ * After an oversized-body `413` the backend **closes the connection**, so a
127
+ * request that follows on the same connection is never served. Read by
128
+ * `describe_body_size_smuggling_cross_tests`, which pipelines a `GET` behind
129
+ * an oversized `POST`: with it `true` the `GET` must go unanswered (at most
130
+ * one response); with it `false` the suite requires **exactly two**
131
+ * responses — the 413, then the backend's own answer to that `GET`, which
132
+ * shows the next request was framed on `Content-Length` and the body bytes
133
+ * were not parsed as a request. Fewer than two fails as well: a backend
134
+ * declared `false` that closes after the 413 fails the suite until the flag
135
+ * is flipped to `true`.
134
136
  *
135
- * `true` for the Node / Deno (`@hono/node-server` graceful close) and Rust
136
- * (hyper RST) backends; `false` for Bun — `Bun.serve` reads the full body
137
- * and processes the correctly-framed pipelined request even when the `413`
138
- * carries `Connection: close`. Bun is not insecure (no desync — it answers
139
- * the cleanly-delimited GET with a proper `400`); the flag records the
140
- * connection-handling divergence so the suite stays green without losing the
141
- * smuggle detector. See `docs/security.md` §"Body Size Limiting".
137
+ * `true` for Node (closes at once, the body unread), Deno (closes once the
138
+ * declared body has arrived — observed on Deno 2.8.3) and the Rust spine
139
+ * (hyper closes at once); `false` for Bun. `Bun.serve` sends the 413
140
+ * with its `Connection: close` header and then keeps the connection open,
141
+ * skips the declared body, and serves the request after it — observed on
142
+ * Bun 1.3.11, whatever the handler does with the request body. Requests
143
+ * stay correctly framed there; what is missing is the close. See
144
+ * `docs/security.md` §"Body Size Limiting".
142
145
  */
143
146
  readonly oversized_reject_closes_connection: boolean;
144
147
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"capabilities.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA4C9B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,iBAAiB;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,kCAAkC,EAAE,OAAO,CAAC;IACrD;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,uBAAuB,EAAE,OAAO,CAAC;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAC;IACrC;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,wBAAwB,EAAE,OAAO,CAAC;IAC3C;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAsBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,OAAO,EAAE,MAAM,MAAM,EAAE,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAG,IAMrF,CAAC"}
1
+ {"version":3,"file":"capabilities.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA4C9B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,iBAAiB;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,QAAQ,CAAC,kCAAkC,EAAE,OAAO,CAAC;IACrD;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,uBAAuB,EAAE,OAAO,CAAC;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAC;IACrC;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,wBAAwB,EAAE,OAAO,CAAC;IAC3C;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAuBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,OAAO,EAAE,MAAM,MAAM,EAAE,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAG,IAMrF,CAAC"}
@@ -61,6 +61,7 @@ export const in_process_capabilities = Object.freeze({
61
61
  fact_serving: true,
62
62
  ready: true,
63
63
  account_status: true,
64
+ // Nothing reads it in-process: the smuggling probe needs a real socket.
64
65
  oversized_reject_closes_connection: true,
65
66
  peer_request: false,
66
67
  // Cross-process-only, like `peer_request`: the case needs a real bound socket
@@ -32,9 +32,9 @@ export const ts_default_capabilities = Object.freeze({
32
32
  // `GET /api/account/status` is bundled into `create_account_route_specs`, so
33
33
  // every TS backend mounting account routes serves it.
34
34
  account_status: true,
35
- // Node/Deno close the socket on an oversized-body reject (the default
36
- // posture). A Bun-served consumer overrides to `false` (see the bun spine
37
- // config) — fail-loud rather than silently skipping the smuggle detector.
35
+ // Node and Deno close the connection after an oversized-body `413`. A
36
+ // Bun-served consumer overrides to `false` (see the bun spine config) —
37
+ // `Bun.serve` keeps the connection and serves the next request.
38
38
  oversized_reject_closes_connection: true,
39
39
  // Off by default like `sse`/`ready`: the WS backend transport *can* initiate
40
40
  // server→client requests (`BackendWebsocketTransport.request_connection`),
@@ -76,8 +76,8 @@ export const rust_default_capabilities = Object.freeze({
76
76
  // The Rust `account_router` bundles `/status` into the account routes, so
77
77
  // every Rust spine serving the account surface serves it.
78
78
  account_status: true,
79
- // hyper sends an RST on the oversized-body reject — the connection closes
80
- // and the pipelined request is never reached.
79
+ // hyper closes the connection on the oversized-body reject, so a pipelined
80
+ // request is never reached.
81
81
  oversized_reject_closes_connection: true,
82
82
  // Server-initiated requests landed Rust-first canonical — the Rust spine
83
83
  // drives the `peer/ping` round-trip, so the family opts into the suite.
@@ -0,0 +1,6 @@
1
+ import '../assert_dev_env.js';
2
+ import type { RpcPathCrossSuiteOptions } from './setup.js';
3
+ /** Options for the role-grammar suite (the standard RPC-dispatched shape). */
4
+ export type RoleGrantRoleGrammarCrossTestOptions = RpcPathCrossSuiteOptions;
5
+ export declare const describe_role_grant_role_grammar_cross_tests: (options: RoleGrantRoleGrammarCrossTestOptions) => void;
6
+ //# sourceMappingURL=role_grant_role_grammar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"role_grant_role_grammar.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/role_grant_role_grammar.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAsC9B,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAG3D,8EAA8E;AAC9E,MAAM,MAAM,oCAAoC,GAAG,wBAAwB,CAAC;AAoC5E,eAAO,MAAM,4CAA4C,GACxD,SAAS,oCAAoC,KAC3C,IAoGF,CAAC"}
@@ -0,0 +1,117 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend parity suite for an **off-grammar `role`** on the grant verbs.
4
+ *
5
+ * `role_grant_assign` and `role_grant_offer_create` declare `role` as
6
+ * `RoleName` (lowercase letters and underscores, no leading or trailing
7
+ * underscore, at most 64 characters). A role string outside that grammar is refused at input
8
+ * validation — JSON-RPC `invalid_params`, with no `data.reason` — and writes
9
+ * no audit row: a role is caller-chosen text until it passes the grammar, and
10
+ * the grant verbs record the role they refuse in their failure rows.
11
+ *
12
+ * - **refused at validation** — each off-grammar role, on each verb, answers
13
+ * `invalid_params` with no reason, so the refusal is the input schema's and
14
+ * not a grant-path or authorizer one. One of them is all lowercase letters
15
+ * and one character past the length cap: the cap is part of the grammar, so
16
+ * a failure row never carries an arbitrarily long caller-chosen string.
17
+ * - **no audit row** — after the refusals, no grant audit row carries an
18
+ * off-grammar role. The control keeps that read from being vacuous: an
19
+ * on-grammar role no registry holds is refused past validation with
20
+ * `role_not_web_grantable`, the refusal a failure row records — as is one
21
+ * exactly at the length cap, which pins the boundary.
22
+ *
23
+ * Exists because the action-manifest parity gate captures no param schemas
24
+ * while the Rust spine parses params by hand — a spine that let an off-grammar
25
+ * role reach its grant path would answer with a different error and write the
26
+ * caller's text into the audit log. Both verbs are on every spine's standard
27
+ * surface, so the suite is ungated. Cross-process only: it reads the audit log
28
+ * through `_testing_drain_effects`, which the spine binaries mount.
29
+ *
30
+ * @module
31
+ */
32
+ import { assert, describe, test } from 'vitest';
33
+ import { ERROR_ROLE_NOT_WEB_GRANTABLE } from '../../http/error_schemas.js';
34
+ import { JSONRPC_ERROR_CODES } from '../../http/jsonrpc_errors.js';
35
+ import { cross_rpc_call, error_reason } from './cell_cross_helpers.js';
36
+ import { SPINE_RPC_PATH } from './spine_surface_constants.js';
37
+ const NIL_UUID = '00000000-0000-0000-0000-000000000000';
38
+ /**
39
+ * The longest role name the grammar admits — an independent copy of the
40
+ * producers' `ROLE_NAME_LENGTH_MAX`, so a cap moved on one spine fails here.
41
+ */
42
+ const ROLE_NAME_LENGTH_MAX = 64;
43
+ /** All lowercase letters, so only its length is off the grammar. */
44
+ const OVER_LENGTH_ROLE = 'a'.repeat(ROLE_NAME_LENGTH_MAX + 1);
45
+ /** On the grammar at exactly the length cap, held by no registry. */
46
+ const AT_LENGTH_ROLE = 'a'.repeat(ROLE_NAME_LENGTH_MAX);
47
+ /** Each breaks one rule of the `RoleName` grammar. */
48
+ const OFF_GRAMMAR_ROLES = [
49
+ 'Admin', // uppercase
50
+ 'role-name', // hyphen
51
+ 'role1', // digit
52
+ '_role', // leading underscore
53
+ 'role_', // trailing underscore
54
+ 'role name', // space
55
+ '', // empty
56
+ OVER_LENGTH_ROLE // on the alphabet, one past the length cap
57
+ ];
58
+ /** On the grammar, held by no registry. */
59
+ const UNREGISTERED_ROLE = 'not_a_registered_role';
60
+ const GRANT_METHODS = ['role_grant_assign', 'role_grant_offer_create'];
61
+ /** The audit event types the grant verbs write. */
62
+ const GRANT_EVENT_TYPES = ['role_grant_create', 'role_grant_offer_create'];
63
+ export const describe_role_grant_role_grammar_cross_tests = (options) => {
64
+ const { setup_test } = options;
65
+ const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
66
+ describe('grant verbs refuse an off-grammar role at validation', { timeout: 30_000 }, () => {
67
+ for (const method of GRANT_METHODS) {
68
+ test(`${method}: an off-grammar role is invalid_params with no reason`, async () => {
69
+ const fixture = await setup_test();
70
+ const t = fixture.fresh_transport();
71
+ for (const role of OFF_GRAMMAR_ROLES) {
72
+ const res = await cross_rpc_call(t, rpc_path, method, { to_account_id: NIL_UUID, role }, fixture.create_session_headers());
73
+ const label = `${method} role=${JSON.stringify(role)}`;
74
+ assert.strictEqual(res.ok, false, `${label}: expected a refusal`);
75
+ assert.strictEqual(res.error?.code, JSONRPC_ERROR_CODES.invalid_params, label);
76
+ // a validation refusal, not a grant-path or authorizer one
77
+ assert.strictEqual(error_reason(res), undefined, `${label}: no data.reason`);
78
+ }
79
+ });
80
+ }
81
+ test('an off-grammar role writes no audit row; an unregistered on-grammar one is refused past validation', async () => {
82
+ const fixture = await setup_test();
83
+ const t = fixture.fresh_transport();
84
+ const session = fixture.create_session_headers();
85
+ for (const method of GRANT_METHODS) {
86
+ for (const role of OFF_GRAMMAR_ROLES) {
87
+ const res = await cross_rpc_call(t, rpc_path, method, { to_account_id: NIL_UUID, role }, session);
88
+ assert.strictEqual(res.error?.code, JSONRPC_ERROR_CODES.invalid_params, `${method} ${JSON.stringify(role)}`);
89
+ }
90
+ }
91
+ // control: the same call with an on-grammar role reaches the grant-path gate
92
+ const control = await cross_rpc_call(t, rpc_path, 'role_grant_assign', { to_account_id: NIL_UUID, role: UNREGISTERED_ROLE }, session);
93
+ assert.strictEqual(error_reason(control), ERROR_ROLE_NOT_WEB_GRANTABLE);
94
+ // and the length cap is inclusive: a role exactly at it is on the grammar
95
+ const at_length = await cross_rpc_call(t, rpc_path, 'role_grant_assign', { to_account_id: NIL_UUID, role: AT_LENGTH_ROLE }, session);
96
+ assert.strictEqual(error_reason(at_length), ERROR_ROLE_NOT_WEB_GRANTABLE, 'a role at the length cap passes validation');
97
+ // a barrier before reading the log: both spines write audit rows off
98
+ // the request path
99
+ const drained = await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, '_testing_drain_effects', undefined, fixture.create_daemon_token_headers());
100
+ assert.ok(drained.ok, `_testing_drain_effects failed: ${JSON.stringify(drained.error)}`);
101
+ for (const event_type of GRANT_EVENT_TYPES) {
102
+ const listed = await cross_rpc_call(t, rpc_path, 'audit_log_list', { event_type }, session);
103
+ assert.ok(listed.ok, `audit_log_list failed: ${JSON.stringify(listed.error)}`);
104
+ const events = listed.result.events;
105
+ const roles = events.map((event) => event.metadata?.role);
106
+ for (const role of roles) {
107
+ assert.ok(typeof role !== 'string' || !OFF_GRAMMAR_ROLES.includes(role), `${event_type}: an audit row records the off-grammar role ${JSON.stringify(role)}`);
108
+ }
109
+ if (event_type === 'role_grant_create') {
110
+ // the controls' failure rows are in the same read, so it is not vacuous
111
+ assert.ok(roles.includes(UNREGISTERED_ROLE), 'the control refusal wrote its failure row');
112
+ assert.ok(roles.includes(AT_LENGTH_ROLE), 'the at-cap refusal wrote its failure row');
113
+ }
114
+ }
115
+ });
116
+ });
117
+ };
@@ -5,9 +5,8 @@ export declare const RUST_SPINE_STUB_BIN_ENV = "FUZ_TESTING_RUST_SPINE_STUB_BIN"
5
5
  /**
6
6
  * Env var overriding the stub's Postgres URL when `database_url` is omitted —
7
7
  * e.g. a scratch cluster on a spare port, keeping the run off the machine's
8
- * default server. Pair it with the libpq `PGHOST` / `PGPORT` the runner's
9
- * idempotent `createdb` reads, so the database it ensures is the one the URL
10
- * names.
8
+ * default server. A runner that creates the stub's database before the spawn
9
+ * must create the one this URL names, on the server it names.
11
10
  */
12
11
  export declare const RUST_SPINE_STUB_DATABASE_URL_ENV = "FUZ_TESTING_RUST_SPINE_STUB_DATABASE_URL";
13
12
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"rust_spine_stub_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/rust_spine_stub_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA0C9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAYzD,uGAAuG;AACvG,eAAO,MAAM,uBAAuB,oCAAoC,CAAC;AAEzE;;;;;;GAMG;AACH,eAAO,MAAM,gCAAgC,6CAA6C,CAAC;AAE3F;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,6CAA6C,CAAC;AAqBnG,kGAAkG;AAClG,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAEjD,0FAA0F;AAC1F,eAAO,MAAM,oCAAoC,sDACG,CAAC;AAErD,MAAM,WAAW,6BAA6B;IAC7C,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC;;;;;;OAMG;IACH,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IAC5C;;;;;OAKG;IACH,QAAQ,CAAC,8BAA8B,CAAC,EAAE,MAAM,CAAC;CACjD;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,GAC1C,UAAS,6BAAkC,KACzC,aAmDF,CAAC"}
1
+ {"version":3,"file":"rust_spine_stub_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/rust_spine_stub_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA0C9B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAYzD,uGAAuG;AACvG,eAAO,MAAM,uBAAuB,oCAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,6CAA6C,CAAC;AAE3F;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,6CAA6C,CAAC;AAqBnG,kGAAkG;AAClG,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAEjD,0FAA0F;AAC1F,eAAO,MAAM,oCAAoC,sDACG,CAAC;AAErD,MAAM,WAAW,6BAA6B;IAC7C,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC;;;;;;OAMG;IACH,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IAC5C;;;;;OAKG;IACH,QAAQ,CAAC,8BAA8B,CAAC,EAAE,MAAM,CAAC;CACjD;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,GAC1C,UAAS,6BAAkC,KACzC,aAmDF,CAAC"}
@@ -45,9 +45,8 @@ export const RUST_SPINE_STUB_BIN_ENV = 'FUZ_TESTING_RUST_SPINE_STUB_BIN';
45
45
  /**
46
46
  * Env var overriding the stub's Postgres URL when `database_url` is omitted —
47
47
  * e.g. a scratch cluster on a spare port, keeping the run off the machine's
48
- * default server. Pair it with the libpq `PGHOST` / `PGPORT` the runner's
49
- * idempotent `createdb` reads, so the database it ensures is the one the URL
50
- * names.
48
+ * default server. A runner that creates the stub's database before the spawn
49
+ * must create the one this URL names, on the server it names.
51
50
  */
52
51
  export const RUST_SPINE_STUB_DATABASE_URL_ENV = 'FUZ_TESTING_RUST_SPINE_STUB_DATABASE_URL';
53
52
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"testing_backdoor.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_backdoor.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwE9B,OAAO,KAAK,EAAE,wBAAwB,EAAe,MAAM,YAAY,CAAC;AA0ExE,kEAAkE;AAClE,MAAM,WAAW,+BAAgC,SAAQ,wBAAwB;IAChF;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,EAAE;QACb,2EAA2E;QAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC1B,+DAA+D;QAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QACzB,sDAAsD;QACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;CACF;AAED,eAAO,MAAM,qCAAqC,GACjD,SAAS,+BAA+B,KACtC,IAyEF,CAAC"}
1
+ {"version":3,"file":"testing_backdoor.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_backdoor.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAyE9B,OAAO,KAAK,EAAE,wBAAwB,EAAe,MAAM,YAAY,CAAC;AA6ExE,kEAAkE;AAClE,MAAM,WAAW,+BAAgC,SAAQ,wBAAwB;IAChF;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,EAAE;QACb,2EAA2E;QAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;QAC1B,+DAA+D;QAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QACzB,sDAAsD;QACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;CACF;AAED,eAAO,MAAM,qCAAqC,GACjD,SAAS,+BAA+B,KACtC,IAyEF,CAAC"}
@@ -4,10 +4,11 @@ import '../assert_dev_env.js';
4
4
  * actions.
5
5
  *
6
6
  * `_testing_reset` / `_testing_mint_session` / `_testing_put_fact` /
7
- * `_testing_schema_snapshot` / `_testing_migration_tracker` /
8
- * `_testing_action_manifest` are privileged test-binary actions the production
9
- * wire never exposes — three direct DB writes (full auth wipe, forged session
10
- * row, raw fact insert) plus three introspection reads (the live schema, the
7
+ * `_testing_flood_notifications` / `_testing_schema_snapshot` /
8
+ * `_testing_migration_tracker` / `_testing_action_manifest` are privileged
9
+ * test-binary actions the production wire never exposes — direct DB writes
10
+ * (full auth wipe, forged session row, raw fact insert), a flood of the
11
+ * caller's own connection, and introspection reads (the live schema, the
11
12
  * `schema_version` migration tracker, and the live RPC registry — the highest
12
13
  * info-leak of the set were the gate to break). Their only structural fence
13
14
  * is the **daemon-token** credential gate on
@@ -78,6 +79,9 @@ const backdoor_methods = [
78
79
  { method: '_testing_reset', params: {} },
79
80
  { method: '_testing_mint_session', params: { account_id: NIL_UUID, expires_in_seconds: -60 } },
80
81
  { method: '_testing_put_fact', params: { content: 'backdoor-probe' } },
82
+ // An empty burst — valid input, so it clears the 400 phase; a refused
83
+ // caller never reaches the handler that would queue it.
84
+ { method: '_testing_flood_notifications', params: { count: 0, size: 0 } },
81
85
  // The schema-dump read — `exclude_tables` is optional, so `{}` is valid
82
86
  // and clears the 400 phase like the writes above.
83
87
  { method: '_testing_schema_snapshot', params: {} },
@@ -8,10 +8,12 @@ import '../assert_dev_env.js';
8
8
  * expired-by-construction server-side session for the expiry conformance
9
9
  * cases), **`_testing_put_fact`** (seed an embedded fact for the fact-serving
10
10
  * suite), **`_testing_schema_snapshot`** (introspect the live schema for
11
- * cross-impl parity diffing against a Rust backend's `fuz_db` snapshot), and
11
+ * cross-impl parity diffing against a Rust backend's `fuz_db` snapshot),
12
12
  * **`_testing_migration_tracker`** (dump the `schema_version` tracker rows for
13
13
  * cross-impl migration-identity parity — the provenance half the snapshot gate
14
- * excludes by design).
14
+ * excludes by design), and **`_testing_flood_notifications`** (queue a burst
15
+ * of notifications onto the caller's own WebSocket connection, to push its
16
+ * outbound queue past its byte budget).
15
17
  *
16
18
  * A further daemon-token action, **`_testing_action_manifest`** (dump the live
17
19
  * RPC registry for cross-impl method-set + auth-shape parity), lives here too
@@ -291,6 +293,84 @@ export declare const testing_put_fact_action_spec: {
291
293
  readonly async: true;
292
294
  readonly description: string;
293
295
  };
296
+ /** The wire method of each notification `_testing_flood_notifications` queues. */
297
+ export declare const TESTING_FLOOD_NOTIFICATION_METHOD = "_testing_flood";
298
+ /**
299
+ * The most notifications one `_testing_flood_notifications` call queues.
300
+ * Empty ones at this count are charged more than
301
+ * `DEFAULT_WS_OUTBOUND_QUEUE_BYTES` between them — each frame's envelope and
302
+ * its overhead — so a burst of tiny frames can overflow a connection on a
303
+ * default transport too. Same value as the Rust spine's.
304
+ */
305
+ export declare const TESTING_FLOOD_MAX_COUNT = 65536;
306
+ /** The largest `payload` one `_testing_flood_notifications` call builds, in bytes. */
307
+ export declare const TESTING_FLOOD_MAX_SIZE: number;
308
+ /**
309
+ * The most payload bytes one `_testing_flood_notifications` call builds across
310
+ * all its notifications — the bound on `count` × `size`. The handler
311
+ * serializes each notification without yielding, so the product is how long
312
+ * one call holds the event loop.
313
+ */
314
+ export declare const TESTING_FLOOD_MAX_TOTAL_BYTES: number;
315
+ /** The `data.reason` of the refusal `_testing_flood_notifications` gives a call over HTTP. */
316
+ export declare const ERROR_TESTING_FLOOD_NO_CONNECTION = "no_connection";
317
+ /**
318
+ * `_testing_flood_notifications` — queue a burst of notifications onto the
319
+ * **caller's own WebSocket connection**, all in one synchronous run, so a suite
320
+ * driving the server from outside can push a connection's outbound queue past
321
+ * its byte budget deterministically — without a second producer, and whether
322
+ * or not its client is reading: the connection's write pump gets no turn while
323
+ * the handler runs, so the burst waits at once. Past the budget the server
324
+ * closes the socket with `WS_CLOSE_OUTBOUND_OVERFLOW`.
325
+ *
326
+ * Each notification is
327
+ * `{"jsonrpc":"2.0","method":"_testing_flood","params":{"index":<i>,"payload":"<size × 'x'>"}}`
328
+ * with `index` counting from 0, so every frame is a little longer than `size`
329
+ * bytes and a client can tell which frames reached it.
330
+ *
331
+ * The result is `{queued: count}` — the number asked for, not the number that
332
+ * fit: when the burst overflows the queue the connection is closed and this
333
+ * response is never written. A burst that fits arrives in order, ahead of the
334
+ * response.
335
+ *
336
+ * A call past `TESTING_FLOOD_MAX_COUNT`, `TESTING_FLOOD_MAX_SIZE`, or
337
+ * `TESTING_FLOOD_MAX_TOTAL_BYTES` (`count` × `size`) is refused with
338
+ * `invalid_params` and queues nothing. Over HTTP there is no connection to
339
+ * queue onto, so the call is refused with `invalid_params`, reason
340
+ * `ERROR_TESTING_FLOOD_NO_CONNECTION`, rather than answering as though it had
341
+ * flooded something.
342
+ *
343
+ * `auth` gates on the daemon-token credential, matching `_testing_reset`. The
344
+ * Rust mirror is `fuz_testing::create_testing_flood_notifications_action_spec`.
345
+ */
346
+ export declare const testing_flood_notifications_action_spec: {
347
+ readonly method: "_testing_flood_notifications";
348
+ readonly kind: "request_response";
349
+ readonly initiator: "frontend";
350
+ readonly auth: {
351
+ readonly account: "required";
352
+ readonly actor: "none";
353
+ readonly credential_types: readonly ["daemon_token"];
354
+ };
355
+ readonly side_effects: false;
356
+ readonly input: z.ZodObject<{
357
+ count: z.ZodNumber;
358
+ size: z.ZodNumber;
359
+ }, z.core.$strict>;
360
+ readonly output: z.ZodObject<{
361
+ queued: z.ZodNumber;
362
+ }, z.core.$strict>;
363
+ readonly async: true;
364
+ readonly error_reasons: readonly ["no_connection"];
365
+ readonly description: string;
366
+ };
367
+ /**
368
+ * Build the standalone `_testing_flood_notifications` action (see the spec
369
+ * doc). The spine binary bundles it via `create_testing_actions`; mount it
370
+ * directly on an in-process WS endpoint whose suite drives an outbound
371
+ * overflow.
372
+ */
373
+ export declare const create_testing_flood_notifications_action: () => RpcAction;
294
374
  /**
295
375
  * `_testing_schema_snapshot` — introspect the live database into a normalized
296
376
  * `SchemaSnapshot` for cross-impl parity diffing. The cross-backend harness