@fuzdev/fuz_app 0.133.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 (112) 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/CLAUDE.md +10 -7
  24. package/dist/db/create_db.d.ts +8 -6
  25. package/dist/db/create_db.d.ts.map +1 -1
  26. package/dist/db/create_db.js +11 -10
  27. package/dist/db/db.d.ts +9 -7
  28. package/dist/db/db.d.ts.map +1 -1
  29. package/dist/db/db.js +5 -4
  30. package/dist/db/db_pglet.d.ts +43 -0
  31. package/dist/db/db_pglet.d.ts.map +1 -0
  32. package/dist/db/db_pglet.js +55 -0
  33. package/dist/db/migrate.d.ts +1 -1
  34. package/dist/db/migrate.js +2 -2
  35. package/dist/http/CLAUDE.md +2 -1
  36. package/dist/http/body_limit.d.ts +61 -0
  37. package/dist/http/body_limit.d.ts.map +1 -0
  38. package/dist/http/body_limit.js +87 -0
  39. package/dist/http/db_routes.js +2 -2
  40. package/dist/http/error_schemas.d.ts +2 -0
  41. package/dist/http/error_schemas.d.ts.map +1 -1
  42. package/dist/http/error_schemas.js +2 -0
  43. package/dist/realtime/outbound_budget.d.ts +91 -0
  44. package/dist/realtime/outbound_budget.d.ts.map +1 -0
  45. package/dist/realtime/outbound_budget.js +108 -0
  46. package/dist/realtime/sse.d.ts +36 -3
  47. package/dist/realtime/sse.d.ts.map +1 -1
  48. package/dist/realtime/sse.js +58 -6
  49. package/dist/realtime/sse_constants.d.ts +12 -0
  50. package/dist/realtime/sse_constants.d.ts.map +1 -1
  51. package/dist/realtime/sse_constants.js +12 -0
  52. package/dist/server/app_server.d.ts.map +1 -1
  53. package/dist/server/app_server.js +3 -6
  54. package/dist/testing/CLAUDE.md +87 -28
  55. package/dist/testing/admin_integration.js +1 -1
  56. package/dist/testing/app_server.d.ts +5 -5
  57. package/dist/testing/app_server.d.ts.map +1 -1
  58. package/dist/testing/app_server.js +21 -20
  59. package/dist/testing/audit_completeness.d.ts +1 -1
  60. package/dist/testing/audit_completeness.js +1 -1
  61. package/dist/testing/audit_drift_guard.d.ts +1 -1
  62. package/dist/testing/audit_drift_guard.js +1 -1
  63. package/dist/testing/cross_backend/body_size_smuggling.d.ts +9 -9
  64. package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
  65. package/dist/testing/cross_backend/body_size_smuggling.js +110 -60
  66. package/dist/testing/cross_backend/capabilities.d.ts +18 -15
  67. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  68. package/dist/testing/cross_backend/capabilities.js +1 -0
  69. package/dist/testing/cross_backend/default_backend_configs.d.ts +5 -5
  70. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  71. package/dist/testing/cross_backend/default_backend_configs.js +6 -6
  72. package/dist/testing/cross_backend/in_process_setup.d.ts +2 -2
  73. package/dist/testing/cross_backend/in_process_setup.js +3 -3
  74. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
  75. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
  76. package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
  77. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
  78. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  79. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
  80. package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +1 -1
  81. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  82. package/dist/testing/cross_backend/testing_backdoor.js +8 -4
  83. package/dist/testing/cross_backend/testing_reset_actions.d.ts +84 -4
  84. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  85. package/dist/testing/cross_backend/testing_reset_actions.js +102 -3
  86. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +2 -2
  87. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  88. package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -7
  89. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
  90. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
  91. package/dist/testing/cross_backend/ws_outbound_overflow.js +211 -0
  92. package/dist/testing/db.d.ts +49 -60
  93. package/dist/testing/db.d.ts.map +1 -1
  94. package/dist/testing/db.js +104 -101
  95. package/dist/testing/integration.js +1 -1
  96. package/dist/testing/rate_limiting.d.ts +2 -1
  97. package/dist/testing/rate_limiting.d.ts.map +1 -1
  98. package/dist/testing/rate_limiting.js +2 -2
  99. package/dist/testing/schema_introspect.d.ts +10 -2
  100. package/dist/testing/schema_introspect.d.ts.map +1 -1
  101. package/dist/testing/schema_introspect.js +15 -7
  102. package/dist/testing/sse_round_trip.d.ts +1 -1
  103. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  104. package/dist/testing/sse_round_trip.js +2 -2
  105. package/dist/testing/stubs.js +1 -1
  106. package/dist/testing/transports/ws_transport.d.ts +4 -2
  107. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  108. package/dist/testing/transports/ws_transport.js +2 -1
  109. package/dist/testing/ws_round_trip.d.ts +32 -2
  110. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  111. package/dist/testing/ws_round_trip.js +26 -4
  112. package/package.json +6 -1
@@ -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
@@ -482,8 +562,8 @@ export interface CreateTestingActionsOptions {
482
562
  * Consumer-supplied callback invoked after the auth-table reset, passed
483
563
  * the same transactional `Db` the auth wipes ran on. DB-domain consumers
484
564
  * (e.g. fuz_forge truncating its cell / fact / file tables) MUST use this
485
- * `db` rather than a separately-pooled connection — under PGlite's single
486
- * connection a second connection deadlocks against this still-open
565
+ * `db` rather than a separately-pooled connection — under the in-memory
566
+ * drivers' single connection (pglet, PGlite) a second connection deadlocks against this still-open
487
567
  * transaction. `testing_zzz_server` clears in-memory workspace registry +
488
568
  * terminals + scoped-FS scratch (ignores `db`); `testing_spine_stub` has
489
569
  * no domain layer and omits the option. Runs inside the same RPC dispatch
@@ -1 +1 @@
1
- {"version":3,"file":"testing_reset_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_reset_actions.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmEG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,EAAc,KAAK,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAMzE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AACnE,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,gBAAgB,CAAC;AAoCzC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;QAiBpC;;;;;;;;WAQG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAWyC,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,iCAAiC;;;;;;;;;;;;;;;;CAWA,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,gCAAgC;;;;;;;;;;;;;;;;;;;CAwBC,CAAC;AAE/C;;;;;;;;GAQG;AACH,eAAO,MAAM,mCAAmC,GAAI,OAAO,YAAY,KAAG,SAIvE,CAAC;AAEJ;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;;;;;;;CAeK,CAAC;AAE/C;;;;;;;;;GASG;AACH,eAAO,MAAM,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAWF,CAAC;AAE/C;;;;GAIG;AACH,eAAO,MAAM,qCAAqC,QAAO,SAGvD,CAAC;AAEH;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qCAAqC;;;;;;;;;;;;;;;;;;;;CAYJ,CAAC;AAE/C;;;;GAIG;AACH,eAAO,MAAM,uCAAuC,QAAO,SAGzD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAaF,CAAC;AAkB/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qCAAqC,GACjD,SAAS,aAAa,CAAC,SAAS,CAAC,KAC/B,SAMF,CAAC;AAEF,4CAA4C;AAC5C,MAAM,WAAW,2BAA2B;IAC3C;;;;;OAKG;IACH,QAAQ,CAAC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACjD;;;;;;OAMG;IACH,QAAQ,CAAC,kBAAkB,EAAE,gBAAgB,CAAC;IAC9C;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACxD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,OAAO,EACb,SAAS,2BAA2B,KAClC,KAAK,CAAC,SAAS,CA4IjB,CAAC"}
1
+ {"version":3,"file":"testing_reset_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_reset_actions.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,EAAc,KAAK,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAOzE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AACnE,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,gBAAgB,CAAC;AAoCzC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;QAiBpC;;;;;;;;WAQG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAWyC,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,iCAAiC;;;;;;;;;;;;;;;;CAWA,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,gCAAgC;;;;;;;;;;;;;;;;;;;CAwBC,CAAC;AAE/C;;;;;;;;GAQG;AACH,eAAO,MAAM,mCAAmC,GAAI,OAAO,YAAY,KAAG,SAIvE,CAAC;AAEJ;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;;;;;;;;;;CAeK,CAAC;AAE/C,kFAAkF;AAClF,eAAO,MAAM,iCAAiC,mBAAmB,CAAC;AAElE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C,sFAAsF;AACtF,eAAO,MAAM,sBAAsB,QAAmB,CAAC;AAEvD;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B,QAAmB,CAAC;AAE9D,8FAA8F;AAC9F,eAAO,MAAM,iCAAiC,kBAAkB,CAAC;AAEjE;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,uCAAuC;;;;;;;;;;;;;;;;;;;;CAwBN,CAAC;AAE/C;;;;;GAKG;AACH,eAAO,MAAM,yCAAyC,QAAO,SAmB1D,CAAC;AAEJ;;;;;;;;;GASG;AACH,eAAO,MAAM,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAWF,CAAC;AAE/C;;;;GAIG;AACH,eAAO,MAAM,qCAAqC,QAAO,SAGvD,CAAC;AAEH;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qCAAqC;;;;;;;;;;;;;;;;;;;;CAYJ,CAAC;AAE/C;;;;GAIG;AACH,eAAO,MAAM,uCAAuC,QAAO,SAGzD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,mCAAmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAaF,CAAC;AAkB/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qCAAqC,GACjD,SAAS,aAAa,CAAC,SAAS,CAAC,KAC/B,SAMF,CAAC;AAEF,4CAA4C;AAC5C,MAAM,WAAW,2BAA2B;IAC3C;;;;;OAKG;IACH,QAAQ,CAAC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACjD;;;;;;OAMG;IACH,QAAQ,CAAC,kBAAkB,EAAE,gBAAgB,CAAC;IAC9C;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACxD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,OAAO,EACb,SAAS,2BAA2B,KAClC,KAAK,CAAC,SAAS,CA6IjB,CAAC"}
@@ -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
@@ -72,6 +74,7 @@ import { Uuid } from '@fuzdev/fuz_util/id.ts';
72
74
  import { fact_hash_bytes } from '@fuzdev/fuz_util/fact_hash.ts';
73
75
  import { FactHashSchema } from '@fuzdev/fuz_util/hash_schemas.ts';
74
76
  import { rpc_action } from '../../actions/action_rpc.js';
77
+ import { jsonrpc_errors } from '../../http/jsonrpc_errors.js';
75
78
  import { emit_after_commit } from '../../http/pending_effects.js';
76
79
  import { protocol_action_specs } from '../../actions/protocol.js';
77
80
  import { query_put_fact } from '../../db/fact_queries.js';
@@ -291,6 +294,101 @@ export const testing_put_fact_action_spec = {
291
294
  description: 'Test-binary only — seed an embedded fact (blake3 of the UTF-8 content) and return its hash, ' +
292
295
  'so the cross-process fact-serving suite can store bytes without a DB handle.'
293
296
  };
297
+ /** The wire method of each notification `_testing_flood_notifications` queues. */
298
+ export const TESTING_FLOOD_NOTIFICATION_METHOD = '_testing_flood';
299
+ /**
300
+ * The most notifications one `_testing_flood_notifications` call queues.
301
+ * Empty ones at this count are charged more than
302
+ * `DEFAULT_WS_OUTBOUND_QUEUE_BYTES` between them — each frame's envelope and
303
+ * its overhead — so a burst of tiny frames can overflow a connection on a
304
+ * default transport too. Same value as the Rust spine's.
305
+ */
306
+ export const TESTING_FLOOD_MAX_COUNT = 65_536;
307
+ /** The largest `payload` one `_testing_flood_notifications` call builds, in bytes. */
308
+ export const TESTING_FLOOD_MAX_SIZE = 16 * 1024 * 1024;
309
+ /**
310
+ * The most payload bytes one `_testing_flood_notifications` call builds across
311
+ * all its notifications — the bound on `count` × `size`. The handler
312
+ * serializes each notification without yielding, so the product is how long
313
+ * one call holds the event loop.
314
+ */
315
+ export const TESTING_FLOOD_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
316
+ /** The `data.reason` of the refusal `_testing_flood_notifications` gives a call over HTTP. */
317
+ export const ERROR_TESTING_FLOOD_NO_CONNECTION = 'no_connection';
318
+ /**
319
+ * `_testing_flood_notifications` — queue a burst of notifications onto the
320
+ * **caller's own WebSocket connection**, all in one synchronous run, so a suite
321
+ * driving the server from outside can push a connection's outbound queue past
322
+ * its byte budget deterministically — without a second producer, and whether
323
+ * or not its client is reading: the connection's write pump gets no turn while
324
+ * the handler runs, so the burst waits at once. Past the budget the server
325
+ * closes the socket with `WS_CLOSE_OUTBOUND_OVERFLOW`.
326
+ *
327
+ * Each notification is
328
+ * `{"jsonrpc":"2.0","method":"_testing_flood","params":{"index":<i>,"payload":"<size × 'x'>"}}`
329
+ * with `index` counting from 0, so every frame is a little longer than `size`
330
+ * bytes and a client can tell which frames reached it.
331
+ *
332
+ * The result is `{queued: count}` — the number asked for, not the number that
333
+ * fit: when the burst overflows the queue the connection is closed and this
334
+ * response is never written. A burst that fits arrives in order, ahead of the
335
+ * response.
336
+ *
337
+ * A call past `TESTING_FLOOD_MAX_COUNT`, `TESTING_FLOOD_MAX_SIZE`, or
338
+ * `TESTING_FLOOD_MAX_TOTAL_BYTES` (`count` × `size`) is refused with
339
+ * `invalid_params` and queues nothing. Over HTTP there is no connection to
340
+ * queue onto, so the call is refused with `invalid_params`, reason
341
+ * `ERROR_TESTING_FLOOD_NO_CONNECTION`, rather than answering as though it had
342
+ * flooded something.
343
+ *
344
+ * `auth` gates on the daemon-token credential, matching `_testing_reset`. The
345
+ * Rust mirror is `fuz_testing::create_testing_flood_notifications_action_spec`.
346
+ */
347
+ export const testing_flood_notifications_action_spec = {
348
+ method: '_testing_flood_notifications',
349
+ kind: 'request_response',
350
+ initiator: 'frontend',
351
+ auth: TESTING_ACTION_AUTH,
352
+ side_effects: false,
353
+ input: z.strictObject({
354
+ count: z.number().int().min(0).max(TESTING_FLOOD_MAX_COUNT).meta({
355
+ description: 'How many notifications to queue.'
356
+ }),
357
+ size: z.number().int().min(0).max(TESTING_FLOOD_MAX_SIZE).meta({
358
+ description: "The byte length of each notification's `payload` string."
359
+ })
360
+ }),
361
+ output: z.strictObject({
362
+ queued: z.number().int().meta({
363
+ description: 'The number of notifications asked for — not the number that fit.'
364
+ })
365
+ }),
366
+ async: true,
367
+ error_reasons: [ERROR_TESTING_FLOOD_NO_CONNECTION],
368
+ description: "Test-binary only — queue a burst of notifications onto the caller's own WebSocket " +
369
+ 'connection in one synchronous run, to push its outbound queue past its byte budget.'
370
+ };
371
+ /**
372
+ * Build the standalone `_testing_flood_notifications` action (see the spec
373
+ * doc). The spine binary bundles it via `create_testing_actions`; mount it
374
+ * directly on an in-process WS endpoint whose suite drives an outbound
375
+ * overflow.
376
+ */
377
+ export const create_testing_flood_notifications_action = () =>
378
+ // not `async`: the burst must be queued in one synchronous run
379
+ rpc_action(testing_flood_notifications_action_spec, ({ count, size }, ctx) => {
380
+ if (count * size > TESTING_FLOOD_MAX_TOTAL_BYTES) {
381
+ throw jsonrpc_errors.invalid_params(`count * size must be at most ${TESTING_FLOOD_MAX_TOTAL_BYTES}`);
382
+ }
383
+ if (ctx.connection_id === undefined) {
384
+ throw jsonrpc_errors.invalid_params('_testing_flood_notifications needs a WebSocket connection', { reason: ERROR_TESTING_FLOOD_NO_CONNECTION });
385
+ }
386
+ const payload = 'x'.repeat(size);
387
+ for (let index = 0; index < count; index++) {
388
+ ctx.notify(TESTING_FLOOD_NOTIFICATION_METHOD, { index, payload });
389
+ }
390
+ return { queued: count };
391
+ });
294
392
  /**
295
393
  * `_testing_schema_snapshot` — introspect the live database into a normalized
296
394
  * `SchemaSnapshot` for cross-impl parity diffing. The cross-backend harness
@@ -527,7 +625,7 @@ export const create_testing_actions = (deps, options) => {
527
625
  // fuz_forge cell/fact/file truncation, or no-op for spine_stub).
528
626
  // Pass the transactional `ctx.db` so DB-domain truncation runs
529
627
  // on the same connection — a separate pool connection deadlocks
530
- // against this open transaction under PGlite.
628
+ // against this open transaction under the in-memory drivers.
531
629
  if (reset_state)
532
630
  await reset_state(ctx.db);
533
631
  return { ...keeper, extra_accounts: extras, extra_actors };
@@ -555,6 +653,7 @@ export const create_testing_actions = (deps, options) => {
555
653
  return { hash };
556
654
  }),
557
655
  create_testing_drain_effects_action(deps.audit),
656
+ create_testing_flood_notifications_action(),
558
657
  create_testing_schema_snapshot_action(),
559
658
  create_testing_migration_tracker_action()
560
659
  ];
@@ -7,7 +7,7 @@ import '../assert_dev_env.js';
7
7
  * these spawn fuz_app's own TS impl over real HTTP with no domain layer, so
8
8
  * the `cross_backend_ts_node` / `cross_backend_ts_deno` / `cross_backend_ts_bun`
9
9
  * self-test projects verify fuz_app's wire path in its own repo across all
10
- * three JS runtimes. All run against in-memory PGlite (`memory://`) — no
10
+ * three JS runtimes. All run against in-memory pglet (`memory://`) — no
11
11
  * external Postgres, unlike the Rust path.
12
12
  *
13
13
  * The binary writes its daemon token to `{FUZ_TESTING_TS_SPINE_DIR}/run/daemon_token`;
@@ -32,7 +32,7 @@ export declare const TS_SPINE_BUN_ENTRY = "src/test/cross_backend/testing_spine_
32
32
  export interface TsSpineBackendConfigOptions {
33
33
  /** Listening port. Defaults per runtime (`1178` Node, `1179` Deno, `1180` Bun). */
34
34
  readonly port?: number;
35
- /** Database URL. Default `'memory://'` (in-memory PGlite). */
35
+ /** Database URL. Default `'memory://'` (in-memory pglet). */
36
36
  readonly database_url?: string;
37
37
  /**
38
38
  * Enable the per-IP + per-account login rate limiters on the spawned binary
@@ -1 +1 @@
1
- {"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAsDzD,6FAA6F;AAC7F,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,iDAAiD;AACjD,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,gDAAgD;AAChD,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAE9C,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,uDAAuD,CAAC;AAEvF,MAAM,WAAW,2BAA2B;IAC3C,mFAAmF;IACnF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;;;;OASG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IAC5C;;;;;OAKG;IACH,QAAQ,CAAC,8BAA8B,CAAC,EAAE,MAAM,CAAC;CACjD;AAWD;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAoBF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,GACvC,UAAS,2BAAgC,KACvC,aAoBF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAiCF,CAAC"}
1
+ {"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAwDzD,6FAA6F;AAC7F,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,iDAAiD;AACjD,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,gDAAgD;AAChD,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAE9C,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,uDAAuD,CAAC;AAEvF,MAAM,WAAW,2BAA2B;IAC3C,mFAAmF;IACnF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;;;;OASG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IAC5C;;;;;OAKG;IACH,QAAQ,CAAC,8BAA8B,CAAC,EAAE,MAAM,CAAC;CACjD;AAWD;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAoBF,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,GACvC,UAAS,2BAAgC,KACvC,aAoBF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAiCF,CAAC"}
@@ -23,13 +23,15 @@ const ts_spine_capabilities = Object.freeze({
23
23
  /**
24
24
  * Capabilities for the **Bun** spine binary — `ts_spine_capabilities` with
25
25
  * `oversized_reject_closes_connection: false` and
26
- * `ws_handshake_pipelining: false`. `Bun.serve` drains the declared
27
- * `Content-Length` of an oversized-body `413` reject and keeps the socket
28
- * alive (processing the correctly-framed pipelined request) even when the
29
- * response carries `Connection: close`, unlike `@hono/node-server` / Deno /
30
- * hyper, which close. Bun is not insecure — it frames on `Content-Length`, so
31
- * there is no desync — but the smuggling suite's strong "connection closes"
32
- * assertion doesn't hold; this flag routes Bun onto the suite's no-desync arm.
26
+ * `ws_handshake_pipelining: false`.
27
+ *
28
+ * `Bun.serve` does not close the connection after an oversized-body `413`:
29
+ * the response carries `Connection: close`, and Bun keeps the socket open,
30
+ * skips the declared body, and serves the request that follows it (observed
31
+ * on Bun 1.3.11). The next request is framed on `Content-Length`, so nothing
32
+ * is desynchronized, but the smuggling suite's "the pipelined request is never
33
+ * served" assertion does not hold; the flag puts Bun on the suite's no-desync
34
+ * arm, which requires exactly the 413 and the reply to the pipelined request.
33
35
  * See `docs/security.md` §"Body Size Limiting".
34
36
  *
35
37
  * `Bun.serve` also answers `400` to an upgrade request that carries WebSocket
@@ -0,0 +1,29 @@
1
+ import '../assert_dev_env.js';
2
+ import { type BackendCapabilities } from './capabilities.js';
3
+ import type { SetupTest } from './setup.js';
4
+ /** Options for the outbound-overflow parity suite. */
5
+ export interface WsOutboundOverflowCrossTestOptions {
6
+ /** Per-test fixture producer (`default_cross_process_setup(handle)`). */
7
+ readonly setup_test: SetupTest;
8
+ /** Backend capability flags; every WebSocket case gates on `capabilities.ws`. */
9
+ readonly capabilities: BackendCapabilities;
10
+ /** Base URL the backend is reachable at (e.g. `http://localhost:1178`). */
11
+ readonly base_url: string;
12
+ /** WebSocket endpoint path on the backend (e.g. `/api/ws`). */
13
+ readonly ws_path: string;
14
+ /** Origin for the session socket's upgrade. Defaults to `base_url`. */
15
+ readonly origin?: string;
16
+ /**
17
+ * The backend's RPC endpoint path, for the over-HTTP refusal. Omitted, that
18
+ * case is skipped.
19
+ */
20
+ readonly rpc_path?: string;
21
+ }
22
+ /**
23
+ * Register the outbound-overflow parity suite over a real upgrade: a burst
24
+ * within the budget arrives whole, one past it closes the socket with
25
+ * `WS_CLOSE_OUTBOUND_OVERFLOW` and no response, and the account's other
26
+ * connection is unaffected.
27
+ */
28
+ export declare const describe_ws_outbound_overflow_cross_tests: (options: WsOutboundOverflowCrossTestOptions) => void;
29
+ //# sourceMappingURL=ws_outbound_overflow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws_outbound_overflow.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_outbound_overflow.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAiE9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAEtE,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,YAAY,CAAC;AAiCzD,sDAAsD;AACtD,MAAM,WAAW,kCAAkC;IAClD,yEAAyE;IACzE,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;GAKG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,kCAAkC,KACzC,IA0LF,CAAC"}
@@ -0,0 +1,211 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend parity suite for the **per-connection outbound bound** over a
4
+ * real upgrade.
5
+ *
6
+ * Both spines hold what waits to be written to one WebSocket to a byte budget
7
+ * (`BackendWebsocketTransport`'s `outbound_queue_bytes` on the TS side, the
8
+ * Rust `fuz_realtime` outbound queue on the other; both default to 4 MiB, each
9
+ * waiting frame charged its UTF-8 length plus 64 bytes). A frame that does not
10
+ * fit closes the socket with `WS_CLOSE_OUTBOUND_OVERFLOW` and the reason
11
+ * `outbound queue overflow`; the credential stays good, so a client reconnects
12
+ * under its ordinary backoff. The code and the reason are therefore wire
13
+ * contract, and this suite is the pin that runs on both.
14
+ *
15
+ * It drives the backend's `_testing_flood_notifications` action, which queues
16
+ * a burst of notifications onto the caller's own connection in one synchronous
17
+ * run — so the burst waits at once whether or not the client is reading, and
18
+ * the overflow is deterministic. The cases:
19
+ *
20
+ * - **within the budget** — a small burst arrives whole and in order, ahead of
21
+ * its response, and the socket stays open.
22
+ * - **large, within the budget** — a burst of a few mebibytes, most of the
23
+ * default budget, arrives the same way: every frame whole, in order, the
24
+ * response last. Megabyte frames can outrun a socket's buffer, and where
25
+ * they do — the Deno TS spine, as observed; Node and Bun write the burst
26
+ * through — the frames behind the first wait on the backend's write pump.
27
+ * - **overflow** — a burst far past the budget closes the socket with the code
28
+ * and the exact reason; the response of the flooding request never arrives;
29
+ * any flood frames that did arrive form a contiguous prefix of the burst.
30
+ * A second connection on the same account keeps dispatching throughout.
31
+ * - **refusal** — a burst past the action's own size limit is refused with
32
+ * `invalid_params`, and the socket stays open.
33
+ * - **no connection** — over HTTP RPC the action refuses with `invalid_params`
34
+ * and reason `no_connection`, rather than answering as though it flooded.
35
+ *
36
+ * What the suite does **not** assert, because it is each implementation's own:
37
+ * how many frames accepted before the overflow reach the client (the Rust
38
+ * spine discards them all; the TS spine may deliver the first), and the exact
39
+ * boundary a burst of tiny frames overflows at.
40
+ *
41
+ * The wire literals are deliberate independent copies of the producers'
42
+ * constants — parity by test, so a value changed on one side fails here
43
+ * instead of moving both at once.
44
+ *
45
+ * The flood action is gated on the daemon token, so the flooding socket
46
+ * upgrades with it and no `Origin` (both spines discard a daemon token
47
+ * presented in a browser context). Gated on `capabilities.ws`; cross-process
48
+ * only (`create_ws_transport` needs a real bound socket), and only for a
49
+ * backend that mounts `_testing_flood_notifications` on its WS endpoint.
50
+ *
51
+ * @module
52
+ */
53
+ import { assert, describe } from 'vitest';
54
+ import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
55
+ import { create_admitted_ws_transport } from '../transports/ws_transport.js';
56
+ import { is_notification, is_response_for } from '../transports/ws_client.js';
57
+ import { test_if } from './capabilities.js';
58
+ import { cross_rpc_call } from './cell_cross_helpers.js';
59
+ /** The close code both spines send on an outbound overflow. */
60
+ const WS_CLOSE_OUTBOUND_OVERFLOW = 4005;
61
+ /** The close reason both spines send with it. */
62
+ const OUTBOUND_OVERFLOW_REASON = 'outbound queue overflow';
63
+ const FLOOD_METHOD = '_testing_flood_notifications';
64
+ const FLOOD_NOTIFICATION_METHOD = '_testing_flood';
65
+ const HEARTBEAT_METHOD = 'heartbeat';
66
+ const INVALID_PARAMS = -32602;
67
+ const REASON_NO_CONNECTION = 'no_connection';
68
+ const MIB = 1024 * 1024;
69
+ /** A burst within any budget. */
70
+ const FITS = { count: 3, size: 8 };
71
+ /**
72
+ * A burst of 3 MiB in megabyte frames: charged a little over 3 MiB with its
73
+ * envelopes and overhead, inside the 4 MiB default budget.
74
+ */
75
+ const LARGE_FITS = { count: 3, size: MIB };
76
+ /** A burst of 32 MiB — eight times the default budget. */
77
+ const OVERFLOWS = { count: 32, size: MIB };
78
+ /** One mebibyte past the action's `count` × `size` limit. */
79
+ const REFUSED = { count: 65, size: MIB };
80
+ /** How long the test waits for the megabyte burst and its response. */
81
+ const LARGE_BURST_TIMEOUT_MS = 15_000;
82
+ /** How long the test waits for the overflow close. */
83
+ const CLOSE_TIMEOUT_MS = 15_000;
84
+ /**
85
+ * Register the outbound-overflow parity suite over a real upgrade: a burst
86
+ * within the budget arrives whole, one past it closes the socket with
87
+ * `WS_CLOSE_OUTBOUND_OVERFLOW` and no response, and the account's other
88
+ * connection is unaffected.
89
+ */
90
+ export const describe_ws_outbound_overflow_cross_tests = (options) => {
91
+ const { setup_test, capabilities, base_url, ws_path, origin, rpc_path } = options;
92
+ /** A daemon-token socket the backend has admitted — the one that floods itself. */
93
+ const open_flooder = (fixture) => create_admitted_ws_transport({
94
+ base_url,
95
+ ws_path,
96
+ cookies: [],
97
+ origin: null,
98
+ headers: fixture.create_daemon_token_headers()
99
+ });
100
+ /** The `index` of every flood notification `client` received, in arrival order. */
101
+ const flood_indices = (client) => client.messages
102
+ .filter(is_notification(FLOOD_NOTIFICATION_METHOD))
103
+ .map((msg) => msg.params.index);
104
+ const assert_open_and_answering = async (client, id, message) => {
105
+ assert.deepStrictEqual(await client.request(id, HEARTBEAT_METHOD, {}), {}, message);
106
+ assert.strictEqual(client.close_code, null, message);
107
+ };
108
+ describe('websocket outbound overflow parity', { timeout: 30_000 }, () => {
109
+ test_if(capabilities.ws, 'a burst within the budget arrives in order, ahead of its response', async () => {
110
+ const fixture = await setup_test();
111
+ const client = await open_flooder(fixture);
112
+ try {
113
+ const result = await client.request('fits', FLOOD_METHOD, FITS);
114
+ assert.deepStrictEqual(result, { queued: FITS.count });
115
+ assert.deepStrictEqual(flood_indices(client), [0, 1, 2]);
116
+ const frames = client.messages.filter(is_notification(FLOOD_NOTIFICATION_METHOD));
117
+ for (const frame of frames) {
118
+ assert.strictEqual(frame.params.payload, 'x'.repeat(FITS.size));
119
+ }
120
+ // the whole burst is ahead of the response
121
+ const response_at = client.messages.findIndex(is_response_for('fits'));
122
+ const last_flood_at = client.messages.lastIndexOf(frames.at(-1));
123
+ assert.ok(last_flood_at < response_at, 'the burst precedes its response');
124
+ await assert_open_and_answering(client, 'after_fits', 'the socket stays open');
125
+ }
126
+ finally {
127
+ await client.close().catch(() => { });
128
+ }
129
+ });
130
+ test_if(capabilities.ws, 'a multi-megabyte burst within the budget arrives whole and in order, the response last', async () => {
131
+ const fixture = await setup_test();
132
+ const client = await open_flooder(fixture);
133
+ try {
134
+ const result = await client.request('large_fits', FLOOD_METHOD, LARGE_FITS, LARGE_BURST_TIMEOUT_MS);
135
+ assert.deepStrictEqual(result, { queued: LARGE_FITS.count });
136
+ assert.deepStrictEqual(flood_indices(client), [0, 1, 2]);
137
+ const frames = client.messages.filter(is_notification(FLOOD_NOTIFICATION_METHOD));
138
+ const payload = 'x'.repeat(LARGE_FITS.size);
139
+ for (const frame of frames) {
140
+ // compared by length first: a mismatch would otherwise print a mebibyte
141
+ assert.strictEqual(frame.params.payload.length, LARGE_FITS.size);
142
+ assert.ok(frame.params.payload === payload, 'the payload arrived intact');
143
+ }
144
+ // nothing follows the response: the whole burst is ahead of it
145
+ const response_at = client.messages.findIndex(is_response_for('large_fits'));
146
+ const last_flood_at = client.messages.lastIndexOf(frames.at(-1));
147
+ assert.ok(last_flood_at < response_at, 'the burst precedes its response');
148
+ assert.strictEqual(client.close_code, null, 'a burst within the budget closes nothing');
149
+ await assert_open_and_answering(client, 'after_large_fits', 'the socket stays open');
150
+ }
151
+ finally {
152
+ await client.close().catch(() => { });
153
+ }
154
+ });
155
+ test_if(capabilities.ws, 'a burst past the budget closes the socket with WS_CLOSE_OUTBOUND_OVERFLOW and no response', async () => {
156
+ const fixture = await setup_test();
157
+ // a second connection on the same account (the keeper's session)
158
+ const bystander = await create_admitted_ws_transport({
159
+ base_url,
160
+ ws_path,
161
+ cookies: fixture.transport.cookies(),
162
+ origin
163
+ });
164
+ const client = await open_flooder(fixture);
165
+ try {
166
+ await client.send(create_jsonrpc_request(FLOOD_METHOD, OVERFLOWS, 'overflows'));
167
+ const closed = await client.wait_for_close(CLOSE_TIMEOUT_MS);
168
+ assert.ok(closed, 'the flooded socket did not close');
169
+ assert.strictEqual(client.close_code, WS_CLOSE_OUTBOUND_OVERFLOW);
170
+ assert.strictEqual(client.close_reason, OUTBOUND_OVERFLOW_REASON);
171
+ // the response of the request whose handler overflowed is not sent
172
+ assert.strictEqual(client.messages.some(is_response_for('overflows')), false, 'no response may follow an overflow');
173
+ // frames accepted before the overflow may or may not arrive; any
174
+ // that do are a contiguous prefix of the burst, and never all of it
175
+ const indices = flood_indices(client);
176
+ assert.ok(indices.length < OVERFLOWS.count, `${indices.length} flood frames arrived`);
177
+ assert.deepStrictEqual(indices, indices.map((_, i) => i), 'the flood frames that arrived are a contiguous prefix from index 0');
178
+ // the account's other connection kept working through it
179
+ await assert_open_and_answering(bystander, 'bystander', "another connection on the account is untouched by the first one's overflow");
180
+ // not a revocation: the daemon token still upgrades
181
+ const again = await open_flooder(fixture);
182
+ await again.close();
183
+ }
184
+ finally {
185
+ await Promise.all([client.close().catch(() => { }), bystander.close().catch(() => { })]);
186
+ }
187
+ });
188
+ test_if(capabilities.ws, 'a burst past the flood action size limit is refused, and the socket stays open', async () => {
189
+ const fixture = await setup_test();
190
+ const client = await open_flooder(fixture);
191
+ try {
192
+ await client.send(create_jsonrpc_request(FLOOD_METHOD, REFUSED, 'refused'));
193
+ const frame = await client.wait_for(is_response_for('refused'));
194
+ assert.ok(frame.error, 'expected invalid_params but the call succeeded');
195
+ assert.strictEqual(frame.error.code, INVALID_PARAMS);
196
+ assert.deepStrictEqual(flood_indices(client), [], 'a refused burst queues nothing');
197
+ await assert_open_and_answering(client, 'after_refused', 'the socket stays open');
198
+ }
199
+ finally {
200
+ await client.close().catch(() => { });
201
+ }
202
+ });
203
+ test_if(rpc_path !== undefined, 'over HTTP the flood action refuses with invalid_params, reason no_connection', async () => {
204
+ const fixture = await setup_test();
205
+ const res = await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, FLOOD_METHOD, FITS, fixture.create_daemon_token_headers());
206
+ assert.strictEqual(res.ok, false, 'expected a refusal over HTTP');
207
+ assert.strictEqual(res.error?.code, INVALID_PARAMS);
208
+ assert.strictEqual(res.error?.data?.reason, REASON_NO_CONNECTION);
209
+ });
210
+ });
211
+ };