@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.
- package/dist/actions/CLAUDE.md +91 -9
- package/dist/actions/register_action_ws.d.ts +23 -3
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +57 -37
- package/dist/actions/transports.d.ts +23 -0
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +23 -0
- package/dist/actions/transports_ws_backend.d.ts +132 -9
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +236 -33
- package/dist/actions/ws_endpoint_spec.d.ts +9 -0
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/actions/ws_outbound_writer.d.ts +206 -0
- package/dist/actions/ws_outbound_writer.d.ts.map +1 -0
- package/dist/actions/ws_outbound_writer.js +431 -0
- package/dist/auth/CLAUDE.md +2 -2
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +3 -1
- package/dist/auth/cell_actions.js +1 -1
- package/dist/auth/role_schema.d.ts +14 -3
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/role_schema.js +14 -2
- package/dist/db/migrate.d.ts +1 -1
- package/dist/db/migrate.js +2 -2
- package/dist/http/CLAUDE.md +2 -1
- package/dist/http/body_limit.d.ts +61 -0
- package/dist/http/body_limit.d.ts.map +1 -0
- package/dist/http/body_limit.js +87 -0
- package/dist/http/db_routes.js +2 -2
- package/dist/http/error_schemas.d.ts +2 -0
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +2 -0
- package/dist/realtime/outbound_budget.d.ts +91 -0
- package/dist/realtime/outbound_budget.d.ts.map +1 -0
- package/dist/realtime/outbound_budget.js +108 -0
- package/dist/realtime/sse.d.ts +36 -3
- package/dist/realtime/sse.d.ts.map +1 -1
- package/dist/realtime/sse.js +58 -6
- package/dist/realtime/sse_constants.d.ts +12 -0
- package/dist/realtime/sse_constants.d.ts.map +1 -1
- package/dist/realtime/sse_constants.js +12 -0
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +3 -6
- package/dist/testing/CLAUDE.md +70 -10
- package/dist/testing/admin_integration.js +1 -1
- package/dist/testing/audit_completeness.d.ts +1 -1
- package/dist/testing/audit_completeness.js +1 -1
- package/dist/testing/audit_drift_guard.d.ts +1 -1
- package/dist/testing/audit_drift_guard.js +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.d.ts +9 -9
- package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
- package/dist/testing/cross_backend/body_size_smuggling.js +110 -60
- package/dist/testing/cross_backend/capabilities.d.ts +18 -15
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -0
- package/dist/testing/cross_backend/default_backend_configs.js +5 -5
- package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
- package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
- package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +8 -4
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +82 -2
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +101 -2
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -7
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
- package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_outbound_overflow.js +211 -0
- package/dist/testing/integration.js +1 -1
- package/dist/testing/schema_introspect.d.ts +10 -2
- package/dist/testing/schema_introspect.d.ts.map +1 -1
- package/dist/testing/schema_introspect.js +15 -7
- package/dist/testing/transports/ws_transport.d.ts +4 -2
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +2 -1
- package/dist/testing/ws_round_trip.d.ts +32 -2
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +26 -4
- 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`.
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
*
|
|
32
|
-
* `fetch` by `describe_body_size_cross_tests`; this test owns
|
|
33
|
-
* connection-handling half. A **positive control** (two pipelined
|
|
34
|
-
* `>= 2` responses) proves a second response *would* be seen if a
|
|
35
|
-
* request were processed — without it the close-posture `<= 1` would
|
|
36
|
-
* on a server that never reuses connections — and that the counter
|
|
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
|
|
42
|
-
*
|
|
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,
|
|
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 →
|
|
162
|
+
test('oversized POST + pipelined GET → no request smuggled', async () => {
|
|
116
163
|
const oversized_len = BODY_LIMIT_DEFAULT_BYTES + 1024;
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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
|
-
|
|
127
|
-
const response = await send_raw(base_url, payload,
|
|
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
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* as request
|
|
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
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* the
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
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
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
|
80
|
-
//
|
|
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.
|
|
9
|
-
*
|
|
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
|
|
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.
|
|
49
|
-
*
|
|
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;
|
|
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
|
-
* `
|
|
8
|
-
* `_testing_action_manifest` are privileged
|
|
9
|
-
* wire never exposes —
|
|
10
|
-
* row, raw fact insert)
|
|
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),
|
|
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
|