@fuzdev/fuz_app 0.147.0 → 0.148.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 (273) hide show
  1. package/dist/actions/CLAUDE.md +6 -4
  2. package/dist/actions/action_codegen.d.ts +15 -1
  3. package/dist/actions/action_codegen.d.ts.map +1 -1
  4. package/dist/actions/action_codegen.js +23 -2
  5. package/dist/actions/perform_action.js +1 -1
  6. package/dist/auth/cell_action_specs.d.ts +3 -1
  7. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  8. package/dist/auth/cell_action_specs.js +5 -5
  9. package/dist/auth/cell_actions.d.ts.map +1 -1
  10. package/dist/auth/cell_actions.js +2 -3
  11. package/dist/auth/cell_data_schema.d.ts +1 -1
  12. package/dist/auth/cell_data_schema.js +1 -1
  13. package/dist/auth/cell_field_action_specs.d.ts +4 -3
  14. package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
  15. package/dist/auth/cell_field_action_specs.js +5 -6
  16. package/dist/auth/cell_field_actions.d.ts.map +1 -1
  17. package/dist/auth/cell_field_actions.js +8 -1
  18. package/dist/auth/cell_item_action_specs.d.ts +4 -2
  19. package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
  20. package/dist/auth/cell_item_action_specs.js +5 -5
  21. package/dist/auth/cell_item_actions.d.ts.map +1 -1
  22. package/dist/auth/cell_item_actions.js +8 -1
  23. package/dist/auth/role_grant_offer_action_specs.d.ts +2 -0
  24. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  25. package/dist/auth/role_grant_offer_action_specs.js +3 -1
  26. package/dist/primitive_schemas.d.ts +1 -1
  27. package/dist/primitive_schemas.d.ts.map +1 -1
  28. package/dist/primitive_schemas.js +2 -1
  29. package/dist/schema_meta.d.ts +20 -0
  30. package/dist/schema_meta.d.ts.map +1 -1
  31. package/dist/schema_meta.js +7 -1
  32. package/dist/testing/CLAUDE.md +720 -114
  33. package/dist/testing/admin_integration.d.ts.map +1 -1
  34. package/dist/testing/admin_integration.js +73 -5
  35. package/dist/testing/adversarial_input.d.ts +0 -47
  36. package/dist/testing/adversarial_input.d.ts.map +1 -1
  37. package/dist/testing/adversarial_input.js +4 -340
  38. package/dist/testing/adversarial_input_cases.d.ts +64 -0
  39. package/dist/testing/adversarial_input_cases.d.ts.map +1 -0
  40. package/dist/testing/adversarial_input_cases.js +348 -0
  41. package/dist/testing/audit_completeness.d.ts.map +1 -1
  42. package/dist/testing/audit_completeness.js +24 -20
  43. package/dist/testing/audit_drift_guard.d.ts +27 -1
  44. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  45. package/dist/testing/audit_drift_guard.js +50 -0
  46. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  47. package/dist/testing/cross_backend/account_lifecycle.js +29 -12
  48. package/dist/testing/cross_backend/actor_lookup.d.ts.map +1 -1
  49. package/dist/testing/cross_backend/actor_lookup.js +7 -25
  50. package/dist/testing/cross_backend/app_settings.d.ts.map +1 -1
  51. package/dist/testing/cross_backend/app_settings.js +4 -6
  52. package/dist/testing/cross_backend/audit_cross_helpers.d.ts +51 -0
  53. package/dist/testing/cross_backend/audit_cross_helpers.d.ts.map +1 -0
  54. package/dist/testing/cross_backend/audit_cross_helpers.js +78 -0
  55. package/dist/testing/cross_backend/backend_config.d.ts +2 -1
  56. package/dist/testing/cross_backend/backend_config.d.ts.map +1 -1
  57. package/dist/testing/cross_backend/body_size.d.ts +6 -0
  58. package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
  59. package/dist/testing/cross_backend/body_size.js +26 -44
  60. package/dist/testing/cross_backend/body_size_smuggling.d.ts +5 -0
  61. package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
  62. package/dist/testing/cross_backend/body_size_smuggling.js +7 -14
  63. package/dist/testing/cross_backend/build_test_backend_paths.d.ts +3 -1
  64. package/dist/testing/cross_backend/build_test_backend_paths.d.ts.map +1 -1
  65. package/dist/testing/cross_backend/capabilities.d.ts +55 -59
  66. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  67. package/dist/testing/cross_backend/capabilities.js +20 -23
  68. package/dist/testing/cross_backend/cargo_binary.d.ts +70 -0
  69. package/dist/testing/cross_backend/cargo_binary.d.ts.map +1 -0
  70. package/dist/testing/cross_backend/cargo_binary.js +148 -0
  71. package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
  72. package/dist/testing/cross_backend/cell_crud.js +57 -1
  73. package/dist/testing/cross_backend/cell_gated_create.d.ts +0 -60
  74. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  75. package/dist/testing/cross_backend/cell_gated_create.js +9 -487
  76. package/dist/testing/cross_backend/cell_grant_role.d.ts +14 -2
  77. package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -1
  78. package/dist/testing/cross_backend/cell_grant_role.js +22 -12
  79. package/dist/testing/cross_backend/cell_moderate.d.ts +66 -0
  80. package/dist/testing/cross_backend/cell_moderate.d.ts.map +1 -0
  81. package/dist/testing/cross_backend/cell_moderate.js +511 -0
  82. package/dist/testing/cross_backend/cell_ref_admission.d.ts.map +1 -1
  83. package/dist/testing/cross_backend/cell_ref_admission.js +1 -1
  84. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  85. package/dist/testing/cross_backend/cell_relations.js +40 -19
  86. package/dist/testing/cross_backend/conformance_case.d.ts +43 -4
  87. package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
  88. package/dist/testing/cross_backend/conformance_case.js +29 -1
  89. package/dist/testing/cross_backend/conformance_rpc_auth.d.ts +93 -0
  90. package/dist/testing/cross_backend/conformance_rpc_auth.d.ts.map +1 -0
  91. package/dist/testing/cross_backend/conformance_rpc_auth.js +125 -0
  92. package/dist/testing/cross_backend/conformance_table.d.ts +144 -4
  93. package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
  94. package/dist/testing/cross_backend/conformance_table.js +297 -65
  95. package/dist/testing/cross_backend/cookie_attributes.d.ts.map +1 -1
  96. package/dist/testing/cross_backend/cookie_attributes.js +3 -2
  97. package/dist/testing/cross_backend/create_cross_backend_global_setup.d.ts +38 -5
  98. package/dist/testing/cross_backend/create_cross_backend_global_setup.d.ts.map +1 -1
  99. package/dist/testing/cross_backend/create_cross_backend_global_setup.js +24 -3
  100. package/dist/testing/cross_backend/create_dual_spawn_global_setup.d.ts +5 -5
  101. package/dist/testing/cross_backend/create_dual_spawn_global_setup.d.ts.map +1 -1
  102. package/dist/testing/cross_backend/credential_deadline.d.ts +10 -6
  103. package/dist/testing/cross_backend/credential_deadline.d.ts.map +1 -1
  104. package/dist/testing/cross_backend/credential_deadline.js +32 -31
  105. package/dist/testing/cross_backend/credential_header_robustness.d.ts.map +1 -1
  106. package/dist/testing/cross_backend/credential_header_robustness.js +4 -6
  107. package/dist/testing/cross_backend/{cell_cross_helpers.d.ts → cross_rpc.d.ts} +12 -2
  108. package/dist/testing/cross_backend/cross_rpc.d.ts.map +1 -0
  109. package/dist/testing/cross_backend/{cell_cross_helpers.js → cross_rpc.js} +31 -7
  110. package/dist/testing/cross_backend/default_backend_configs.d.ts +56 -61
  111. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  112. package/dist/testing/cross_backend/default_backend_configs.js +97 -91
  113. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  114. package/dist/testing/cross_backend/default_spine_surface.js +2 -2
  115. package/dist/testing/cross_backend/differential.d.ts +126 -0
  116. package/dist/testing/cross_backend/differential.d.ts.map +1 -0
  117. package/dist/testing/cross_backend/differential.js +201 -0
  118. package/dist/testing/cross_backend/differential_normalize.d.ts +150 -0
  119. package/dist/testing/cross_backend/differential_normalize.d.ts.map +1 -0
  120. package/dist/testing/cross_backend/differential_normalize.js +153 -0
  121. package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
  122. package/dist/testing/cross_backend/fact_serving.js +27 -17
  123. package/dist/testing/cross_backend/full_spine_mount.d.ts +15 -0
  124. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
  125. package/dist/testing/cross_backend/full_spine_mount.js +55 -1
  126. package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
  127. package/dist/testing/cross_backend/identity_parity.js +6 -5
  128. package/dist/testing/cross_backend/in_process_setup.d.ts +12 -2
  129. package/dist/testing/cross_backend/in_process_setup.d.ts.map +1 -1
  130. package/dist/testing/cross_backend/in_process_setup.js +10 -7
  131. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  132. package/dist/testing/cross_backend/login_security.js +13 -18
  133. package/dist/testing/cross_backend/origin.js +1 -1
  134. package/dist/testing/cross_backend/origin_conformance.js +1 -1
  135. package/dist/testing/cross_backend/origin_scope.d.ts +6 -0
  136. package/dist/testing/cross_backend/origin_scope.d.ts.map +1 -1
  137. package/dist/testing/cross_backend/origin_scope.js +13 -12
  138. package/dist/testing/cross_backend/peer_ping_ws.d.ts +2 -5
  139. package/dist/testing/cross_backend/peer_ping_ws.d.ts.map +1 -1
  140. package/dist/testing/cross_backend/peer_ping_ws.js +14 -24
  141. package/dist/testing/cross_backend/postgres_database.d.ts +85 -0
  142. package/dist/testing/cross_backend/postgres_database.d.ts.map +1 -0
  143. package/dist/testing/cross_backend/postgres_database.js +140 -0
  144. package/dist/testing/cross_backend/provided_context.d.ts +28 -0
  145. package/dist/testing/cross_backend/provided_context.d.ts.map +1 -0
  146. package/dist/testing/cross_backend/provided_context.js +35 -0
  147. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  148. package/dist/testing/cross_backend/query_shape.js +10 -12
  149. package/dist/testing/cross_backend/ready.js +2 -2
  150. package/dist/testing/cross_backend/revocation_drivers.d.ts +4 -3
  151. package/dist/testing/cross_backend/revocation_drivers.d.ts.map +1 -1
  152. package/dist/testing/cross_backend/revocation_drivers.js +1 -1
  153. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts +3 -2
  154. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts.map +1 -1
  155. package/dist/testing/cross_backend/role_grant_offer_lifecycle.d.ts +13 -0
  156. package/dist/testing/cross_backend/role_grant_offer_lifecycle.d.ts.map +1 -0
  157. package/dist/testing/cross_backend/role_grant_offer_lifecycle.js +203 -0
  158. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +2 -5
  159. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  160. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +203 -111
  161. package/dist/testing/cross_backend/role_grant_participation.d.ts +13 -0
  162. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  163. package/dist/testing/cross_backend/role_grant_participation.js +32 -21
  164. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -1
  165. package/dist/testing/cross_backend/role_grant_role_grammar.js +6 -9
  166. package/dist/testing/cross_backend/rpc_adversarial.d.ts +38 -0
  167. package/dist/testing/cross_backend/rpc_adversarial.d.ts.map +1 -0
  168. package/dist/testing/cross_backend/rpc_adversarial.js +292 -0
  169. package/dist/testing/cross_backend/rpc_success_fixtures.d.ts +24 -0
  170. package/dist/testing/cross_backend/rpc_success_fixtures.d.ts.map +1 -0
  171. package/dist/testing/cross_backend/rpc_success_fixtures.js +124 -0
  172. package/dist/testing/cross_backend/rust_spine_consumer_surface.d.ts +101 -0
  173. package/dist/testing/cross_backend/rust_spine_consumer_surface.d.ts.map +1 -0
  174. package/dist/testing/cross_backend/rust_spine_consumer_surface.js +113 -0
  175. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +10 -0
  176. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  177. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +27 -18
  178. package/dist/testing/cross_backend/session_cap.d.ts +9 -8
  179. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
  180. package/dist/testing/cross_backend/session_cap.js +9 -4
  181. package/dist/testing/cross_backend/session_cookie_codec.js +1 -1
  182. package/dist/testing/cross_backend/setup.d.ts +56 -54
  183. package/dist/testing/cross_backend/setup.d.ts.map +1 -1
  184. package/dist/testing/cross_backend/setup.js +29 -29
  185. package/dist/testing/cross_backend/simple_request.d.ts.map +1 -1
  186. package/dist/testing/cross_backend/simple_request.js +4 -4
  187. package/dist/testing/cross_backend/spawn_backend.d.ts +59 -10
  188. package/dist/testing/cross_backend/spawn_backend.d.ts.map +1 -1
  189. package/dist/testing/cross_backend/spawn_backend.js +219 -60
  190. package/dist/testing/cross_backend/spine_suites.d.ts +231 -0
  191. package/dist/testing/cross_backend/spine_suites.d.ts.map +1 -0
  192. package/dist/testing/cross_backend/spine_suites.js +626 -0
  193. package/dist/testing/cross_backend/spine_surface_constants.d.ts +29 -0
  194. package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
  195. package/dist/testing/cross_backend/spine_surface_constants.js +29 -0
  196. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  197. package/dist/testing/cross_backend/sse_round_trip.js +3 -4
  198. package/dist/testing/cross_backend/standard.d.ts +18 -1
  199. package/dist/testing/cross_backend/standard.d.ts.map +1 -1
  200. package/dist/testing/cross_backend/standard.js +2 -0
  201. package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts +24 -0
  202. package/dist/testing/cross_backend/test_cell_gated_create_authorize.d.ts.map +1 -1
  203. package/dist/testing/cross_backend/test_cell_gated_create_authorize.js +15 -0
  204. package/dist/testing/cross_backend/testing_backdoor.d.ts +2 -8
  205. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  206. package/dist/testing/cross_backend/testing_backdoor.js +7 -10
  207. package/dist/testing/cross_backend/testing_reset_actions.d.ts +3 -2
  208. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  209. package/dist/testing/cross_backend/testing_reset_actions.js +8 -4
  210. package/dist/testing/cross_backend/testing_server_bun.js +1 -1
  211. package/dist/testing/cross_backend/token_lifetime.js +1 -1
  212. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  213. package/dist/testing/cross_backend/token_scope_surface.js +9 -13
  214. package/dist/testing/cross_backend/transport_composition.d.ts +26 -2
  215. package/dist/testing/cross_backend/transport_composition.d.ts.map +1 -1
  216. package/dist/testing/cross_backend/transport_composition.js +81 -51
  217. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +8 -0
  218. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  219. package/dist/testing/cross_backend/ts_spine_backend_config.js +17 -10
  220. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts +2 -5
  221. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
  222. package/dist/testing/cross_backend/ws_action_rate_limit.js +8 -14
  223. package/dist/testing/cross_backend/ws_connection_cap.d.ts +4 -9
  224. package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -1
  225. package/dist/testing/cross_backend/ws_connection_cap.js +11 -12
  226. package/dist/testing/cross_backend/ws_endpoint.d.ts +52 -0
  227. package/dist/testing/cross_backend/ws_endpoint.d.ts.map +1 -0
  228. package/dist/testing/cross_backend/ws_endpoint.js +54 -0
  229. package/dist/testing/cross_backend/ws_inbound_dispatch.d.ts +2 -5
  230. package/dist/testing/cross_backend/ws_inbound_dispatch.d.ts.map +1 -1
  231. package/dist/testing/cross_backend/ws_inbound_dispatch.js +9 -15
  232. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +2 -7
  233. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -1
  234. package/dist/testing/cross_backend/ws_outbound_overflow.js +5 -9
  235. package/dist/testing/cross_backend/ws_role_gated.d.ts +4 -5
  236. package/dist/testing/cross_backend/ws_role_gated.d.ts.map +1 -1
  237. package/dist/testing/cross_backend/ws_role_gated.js +12 -21
  238. package/dist/testing/cross_backend/ws_round_trip.d.ts +12 -9
  239. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  240. package/dist/testing/cross_backend/ws_round_trip.js +29 -34
  241. package/dist/testing/db_entities.d.ts +16 -3
  242. package/dist/testing/db_entities.d.ts.map +1 -1
  243. package/dist/testing/db_entities.js +28 -5
  244. package/dist/testing/integration_helpers.d.ts +61 -2
  245. package/dist/testing/integration_helpers.d.ts.map +1 -1
  246. package/dist/testing/integration_helpers.js +75 -18
  247. package/dist/testing/round_trip.d.ts +17 -0
  248. package/dist/testing/round_trip.d.ts.map +1 -1
  249. package/dist/testing/round_trip.js +67 -24
  250. package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
  251. package/dist/testing/rpc_attack_surface.js +13 -138
  252. package/dist/testing/rpc_envelope_cases.d.ts +111 -0
  253. package/dist/testing/rpc_envelope_cases.d.ts.map +1 -0
  254. package/dist/testing/rpc_envelope_cases.js +240 -0
  255. package/dist/testing/rpc_helpers.d.ts +31 -0
  256. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  257. package/dist/testing/rpc_helpers.js +35 -14
  258. package/dist/testing/rpc_round_trip.d.ts +62 -6
  259. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  260. package/dist/testing/rpc_round_trip.js +151 -39
  261. package/dist/testing/schema_generators.d.ts.map +1 -1
  262. package/dist/testing/schema_generators.js +22 -2
  263. package/dist/testing/standard.d.ts +26 -0
  264. package/dist/testing/standard.d.ts.map +1 -1
  265. package/dist/testing/standard.js +4 -1
  266. package/dist/testing/transports/raw_http.d.ts +21 -0
  267. package/dist/testing/transports/raw_http.d.ts.map +1 -1
  268. package/dist/testing/transports/raw_http.js +30 -0
  269. package/package.json +6 -4
  270. package/dist/testing/cross_backend/actor_search.d.ts +0 -6
  271. package/dist/testing/cross_backend/actor_search.d.ts.map +0 -1
  272. package/dist/testing/cross_backend/actor_search.js +0 -90
  273. package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +0 -1
@@ -26,7 +26,13 @@ import '../assert_dev_env.js';
26
26
  * drives the lifecycle RPC over HTTP, then asserts the frame lands on that
27
27
  * socket and strict-parses against the canonical wire schema — the guard
28
28
  * against serialization drift (field / null / datetime / the flat revoke shape /
29
- * the supersede `reason` + `cause_id`).
29
+ * the supersede `reason` + `cause_id`). Every case also holds a third account's
30
+ * admitted socket open — a bystander, party to nothing the case drives — and,
31
+ * once the targeted frame has landed, asserts the bystander received nothing
32
+ * but the answers to its own requests, behind a `heartbeat` barrier on its
33
+ * socket (`assert_bystander_heard_nothing` says why the barrier makes the
34
+ * silence meaningful). A fan-out that reached past its target account fails
35
+ * there.
30
36
  *
31
37
  * Sends are queued on the post-commit drain (handler-emit, not audit-derived),
32
38
  * so a frame may land a beat after the RPC resolves — `WsClient.wait_for` polls
@@ -53,15 +59,17 @@ import '../assert_dev_env.js';
53
59
  */
54
60
  import { assert, describe } from 'vitest';
55
61
  import { rpc_call } from '../rpc_helpers.js';
56
- import { create_admitted_ws_transport } from '../transports/ws_transport.js';
57
- import { is_notification_with } from '../transports/ws_client.js';
62
+ import { WS_ADMISSION_PROBE_ID } from '../transports/ws_transport.js';
63
+ import { is_notification_with, is_response_for } from '../transports/ws_client.js';
58
64
  import { ROLE_ADMIN } from '../../auth/role_schema.js';
59
65
  import { WS_CLOSE_PERMISSION_REVOKED } from '../../actions/transports.js';
60
66
  import { ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_ACCEPTED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_DECLINED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_RETRACTED_NOTIFICATION_METHOD, ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, ROLE_GRANT_REVOKE_NOTIFICATION_METHOD, RoleGrantOfferReceivedParams, RoleGrantOfferAcceptedParams, RoleGrantOfferDeclinedParams, RoleGrantOfferRetractedParams, RoleGrantOfferSupersedeParams, RoleGrantRevokeParams } from '../../auth/role_grant_offer_notifications.js';
61
67
  import { test_if } from './capabilities.js';
62
68
  import { create_active_role_grant, create_pending_role_grant_offer } from './role_grant_cross_helpers.js';
63
- /** JSON-RPC endpoint path — matches the spine's `/api/rpc` (and the forge's). */
64
- const RPC_PATH = '/api/rpc';
69
+ import { SPINE_RPC_PATH } from './spine_surface_constants.js';
70
+ import { open_session_ws } from './ws_endpoint.js';
71
+ /** The request id the bystander's barrier `heartbeat` goes out under. */
72
+ const BYSTANDER_BARRIER_ID = 'bystander_barrier';
65
73
  /**
66
74
  * Register the role-grant-offer WS notification suite — seven cases over a real
67
75
  * upgrade, one per server-initiated notification (received / accepted /
@@ -71,28 +79,62 @@ const RPC_PATH = '/api/rpc';
71
79
  * params schema. Gated on `capabilities.ws`.
72
80
  */
73
81
  export const describe_role_grant_offer_notification_ws_tests = (options) => {
74
- const { setup_test, capabilities, base_url, ws_path, ws_required_roles = [] } = options;
82
+ const { setup_test, capabilities, ws_required_roles = [] } = options;
75
83
  const revoke_closes_the_socket = ws_required_roles.includes(ROLE_ADMIN);
76
84
  // -- shared helpers -------------------------------------------------------
77
85
  /**
78
- * Open a WS transport for a single session cookie (`<name>=<value>`),
79
- * admitted before it resolves: a targeted notification is delivered only to
80
- * an admitted connection, and the handshake alone doesn't say it is one.
86
+ * Open a WS transport on an account's session, admitted before it resolves:
87
+ * a targeted notification is delivered only to an admitted connection, and
88
+ * the handshake alone doesn't say it is one.
81
89
  */
82
- const open_ws = (cookie) => {
83
- assert.ok(cookie, 'expected a session cookie for the WS upgrade');
84
- return create_admitted_ws_transport({
85
- base_url,
86
- ws_path,
87
- cookies: [cookie],
88
- origin: base_url
89
- });
90
+ const open_ws = (account) => open_session_ws(options, account, { admitted: true });
91
+ /**
92
+ * A third account's admitted socket — party to nothing a case drives. It
93
+ * holds `ROLE_ADMIN`, as every account here does, so it opens a socket
94
+ * gated on that role too. Opened before the lifecycle call, after the
95
+ * account's own offer and accept have run.
96
+ */
97
+ const open_bystander = async (fixture) => {
98
+ const bystander = await fixture.create_account({ roles: [ROLE_ADMIN] });
99
+ return open_ws(bystander);
100
+ };
101
+ /**
102
+ * Assert the bystander's socket received nothing but the answers to its
103
+ * own requests. Called once the targeted party holds its frame, and
104
+ * meaningful only behind the barrier it sends first: a `heartbeat` round
105
+ * trip on the bystander's socket.
106
+ *
107
+ * Why the barrier makes the silence a finding: both spines queue one
108
+ * fan-out to every recipient in a single pass with no await in it (the TS
109
+ * transport's `broadcast_filtered` loop, the Rust registry's
110
+ * `deliver_all`), and neither writes a response ahead of a notification
111
+ * already queued for the same connection. On the TS spine responses and
112
+ * notifications share one outbound queue. On the Rust spine a response is
113
+ * written directly, but the loop's completion arm first writes every
114
+ * frame already queued for the connection (`flush_queued_frames` in
115
+ * `fuz_realtime`'s `ws_endpoint.rs`). So a frame the pass addressed to the
116
+ * bystander reaches it ahead of the barrier's response, and reading that
117
+ * response first means the pass sent it nothing. On the TS spine this is
118
+ * strict: a fan-out is sent in one synchronous closure. On the Rust spine
119
+ * `deliver_all` pushes to recipients one after another on one worker
120
+ * while the target's connection loop runs on another, so the target's
121
+ * frame can be written while the pass is still pushing — a window the
122
+ * barrier's round trip outlasts in practice. Either way the check covers
123
+ * every pass of the drain, not just the one that reached the target: TS
124
+ * runs them in one closure, and `PostCommitEffects::drain_all` joins
125
+ * effects whose notify futures push synchronously, so every pass
126
+ * completes in the drain's first poll.
127
+ */
128
+ const assert_bystander_heard_nothing = async (bystander_ws) => {
129
+ await bystander_ws.request(BYSTANDER_BARRIER_ID, 'heartbeat', {}, 5000);
130
+ const strays = bystander_ws.messages.filter((msg) => !is_response_for(WS_ADMISSION_PROBE_ID)(msg) && !is_response_for(BYSTANDER_BARRIER_ID)(msg));
131
+ assert.deepStrictEqual(strays, [], 'a bystander socket received frames addressed to others');
90
132
  };
91
133
  /** Drive a JSON-RPC call over the fixture's HTTP transport. */
92
- const rpc = (fixture, method, params, headers) => rpc_call({ app: fixture.transport, path: RPC_PATH, method, params, headers });
134
+ const rpc = (fixture, method, params, headers) => rpc_call({ app: fixture.transport, path: SPINE_RPC_PATH, method, params, headers });
93
135
  /** A fresh pending `ROLE_ADMIN` offer, from the keeper unless a grantor is given. */
94
136
  const create_pending_offer = (fixture, to_account_id, grantor_headers) => create_pending_role_grant_offer(fixture, to_account_id, grantor_headers, {
95
- rpc_path: RPC_PATH
137
+ rpc_path: SPINE_RPC_PATH
96
138
  });
97
139
  // -- tests ----------------------------------------------------------------
98
140
  describe('role_grant_offer WS notifications (cross-process)', () => {
@@ -104,77 +146,105 @@ export const describe_role_grant_offer_notification_ws_tests = (options) => {
104
146
  // earlier notification lands before the socket opens, so it's not
105
147
  // the one we observe).
106
148
  const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
107
- const recipient_ws = await open_ws(recipient.create_session_headers().cookie);
149
+ const bystander_ws = await open_bystander(fixture);
108
150
  try {
109
- // The keeper (grantor, holds ROLE_ADMIN) offers the recipient a
110
- // role over RPC — fresh pending offer, emits the notification
111
- // post-commit.
112
- const created = await rpc(fixture, 'role_grant_offer_create', { to_account_id: recipient.account.id, role: ROLE_ADMIN }, fixture.create_session_headers());
113
- assert.isTrue(created.ok, `offer create failed: ${JSON.stringify(created)}`);
114
- const frame = await recipient_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD, (p) => p.offer.to_account_id === recipient.account.id), 5000);
115
- // Params strict-parse against the canonical wire schema — guards
116
- // the serialization against field/null/datetime drift.
117
- const params = RoleGrantOfferReceivedParams.parse(frame.params);
118
- assert.strictEqual(params.offer.to_account_id, recipient.account.id);
119
- assert.strictEqual(params.offer.role, ROLE_ADMIN);
151
+ const recipient_ws = await open_ws(recipient);
152
+ try {
153
+ // The keeper (grantor, holds ROLE_ADMIN) offers the recipient a
154
+ // role over RPC — fresh pending offer, emits the notification
155
+ // post-commit.
156
+ const created = await rpc(fixture, 'role_grant_offer_create', { to_account_id: recipient.account.id, role: ROLE_ADMIN }, fixture.create_session_headers());
157
+ assert.isTrue(created.ok, `offer create failed: ${JSON.stringify(created)}`);
158
+ const frame = await recipient_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD, (p) => p.offer.to_account_id === recipient.account.id), 5000);
159
+ // Params strict-parse against the canonical wire schema — guards
160
+ // the serialization against field/null/datetime drift.
161
+ const params = RoleGrantOfferReceivedParams.parse(frame.params);
162
+ assert.strictEqual(params.offer.to_account_id, recipient.account.id);
163
+ assert.strictEqual(params.offer.role, ROLE_ADMIN);
164
+ await assert_bystander_heard_nothing(bystander_ws);
165
+ }
166
+ finally {
167
+ await recipient_ws.close();
168
+ }
120
169
  }
121
170
  finally {
122
- await recipient_ws.close();
171
+ await bystander_ws.close();
123
172
  }
124
173
  });
125
174
  test_if(capabilities.ws, 'accept delivers role_grant_offer_accepted to the grantor WS', async () => {
126
175
  const fixture = await setup_test();
127
176
  const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
128
177
  // Grantor = keeper; open its socket BEFORE the recipient accepts.
129
- const grantor = await open_ws(fixture.create_session_headers().cookie);
178
+ const bystander_ws = await open_bystander(fixture);
130
179
  try {
131
- const offer = await create_pending_offer(fixture, recipient.account.id);
132
- const accepted = await rpc(fixture, 'role_grant_offer_accept', { offer_id: offer.id }, recipient.create_session_headers());
133
- assert.isTrue(accepted.ok, `accept failed: ${JSON.stringify(accepted)}`);
134
- const frame = await grantor.wait_for(is_notification_with(ROLE_GRANT_OFFER_ACCEPTED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
135
- const params = RoleGrantOfferAcceptedParams.parse(frame.params);
136
- assert.strictEqual(params.offer.id, offer.id);
137
- assert.isNotNull(params.offer.accepted_at);
180
+ const grantor = await open_ws(fixture);
181
+ try {
182
+ const offer = await create_pending_offer(fixture, recipient.account.id);
183
+ const accepted = await rpc(fixture, 'role_grant_offer_accept', { offer_id: offer.id }, recipient.create_session_headers());
184
+ assert.isTrue(accepted.ok, `accept failed: ${JSON.stringify(accepted)}`);
185
+ const frame = await grantor.wait_for(is_notification_with(ROLE_GRANT_OFFER_ACCEPTED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
186
+ const params = RoleGrantOfferAcceptedParams.parse(frame.params);
187
+ assert.strictEqual(params.offer.id, offer.id);
188
+ assert.isNotNull(params.offer.accepted_at);
189
+ await assert_bystander_heard_nothing(bystander_ws);
190
+ }
191
+ finally {
192
+ await grantor.close();
193
+ }
138
194
  }
139
195
  finally {
140
- await grantor.close();
196
+ await bystander_ws.close();
141
197
  }
142
198
  });
143
199
  test_if(capabilities.ws, 'decline delivers role_grant_offer_declined to the grantor WS', async () => {
144
200
  const fixture = await setup_test();
145
201
  const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
146
- const grantor = await open_ws(fixture.create_session_headers().cookie);
202
+ const bystander_ws = await open_bystander(fixture);
147
203
  try {
148
- const offer = await create_pending_offer(fixture, recipient.account.id);
149
- const declined = await rpc(fixture, 'role_grant_offer_decline', { offer_id: offer.id, reason: 'no thanks' }, recipient.create_session_headers());
150
- assert.isTrue(declined.ok, `decline failed: ${JSON.stringify(declined)}`);
151
- const frame = await grantor.wait_for(is_notification_with(ROLE_GRANT_OFFER_DECLINED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
152
- const params = RoleGrantOfferDeclinedParams.parse(frame.params);
153
- assert.strictEqual(params.offer.id, offer.id);
154
- // Decline reason rides inside the offer row, not a sibling field.
155
- assert.strictEqual(params.offer.decline_reason, 'no thanks');
204
+ const grantor = await open_ws(fixture);
205
+ try {
206
+ const offer = await create_pending_offer(fixture, recipient.account.id);
207
+ const declined = await rpc(fixture, 'role_grant_offer_decline', { offer_id: offer.id, reason: 'no thanks' }, recipient.create_session_headers());
208
+ assert.isTrue(declined.ok, `decline failed: ${JSON.stringify(declined)}`);
209
+ const frame = await grantor.wait_for(is_notification_with(ROLE_GRANT_OFFER_DECLINED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
210
+ const params = RoleGrantOfferDeclinedParams.parse(frame.params);
211
+ assert.strictEqual(params.offer.id, offer.id);
212
+ // Decline reason rides inside the offer row, not a sibling field.
213
+ assert.strictEqual(params.offer.decline_reason, 'no thanks');
214
+ await assert_bystander_heard_nothing(bystander_ws);
215
+ }
216
+ finally {
217
+ await grantor.close();
218
+ }
156
219
  }
157
220
  finally {
158
- await grantor.close();
221
+ await bystander_ws.close();
159
222
  }
160
223
  });
161
224
  test_if(capabilities.ws, 'retract delivers role_grant_offer_retracted to the recipient WS', async () => {
162
225
  const fixture = await setup_test();
163
226
  const recipient = await fixture.create_account({ roles: [ROLE_ADMIN] });
164
227
  // Recipient learns their pending offer was pulled — open its socket.
165
- const recipient_ws = await open_ws(recipient.create_session_headers().cookie);
228
+ const bystander_ws = await open_bystander(fixture);
166
229
  try {
167
- const offer = await create_pending_offer(fixture, recipient.account.id);
168
- const retracted = await rpc(fixture, 'role_grant_offer_retract', { offer_id: offer.id }, fixture.create_session_headers());
169
- assert.isTrue(retracted.ok, `retract failed: ${JSON.stringify(retracted)}`);
170
- const frame = await recipient_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_RETRACTED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
171
- const params = RoleGrantOfferRetractedParams.parse(frame.params);
172
- assert.strictEqual(params.offer.id, offer.id);
173
- assert.strictEqual(params.offer.to_account_id, recipient.account.id);
174
- assert.isNotNull(params.offer.retracted_at);
230
+ const recipient_ws = await open_ws(recipient);
231
+ try {
232
+ const offer = await create_pending_offer(fixture, recipient.account.id);
233
+ const retracted = await rpc(fixture, 'role_grant_offer_retract', { offer_id: offer.id }, fixture.create_session_headers());
234
+ assert.isTrue(retracted.ok, `retract failed: ${JSON.stringify(retracted)}`);
235
+ const frame = await recipient_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_RETRACTED_NOTIFICATION_METHOD, (p) => p.offer.id === offer.id), 5000);
236
+ const params = RoleGrantOfferRetractedParams.parse(frame.params);
237
+ assert.strictEqual(params.offer.id, offer.id);
238
+ assert.strictEqual(params.offer.to_account_id, recipient.account.id);
239
+ assert.isNotNull(params.offer.retracted_at);
240
+ await assert_bystander_heard_nothing(bystander_ws);
241
+ }
242
+ finally {
243
+ await recipient_ws.close();
244
+ }
175
245
  }
176
246
  finally {
177
- await recipient_ws.close();
247
+ await bystander_ws.close();
178
248
  }
179
249
  });
180
250
  test_if(capabilities.ws, revoke_closes_the_socket
@@ -183,38 +253,46 @@ export const describe_role_grant_offer_notification_ws_tests = (options) => {
183
253
  const fixture = await setup_test();
184
254
  const revokee = await fixture.create_account({ roles: [ROLE_ADMIN] });
185
255
  const role_grant_id = await create_active_role_grant(fixture, revokee, {
186
- rpc_path: RPC_PATH
256
+ rpc_path: SPINE_RPC_PATH
187
257
  });
188
- const revokee_ws = await open_ws(revokee.create_session_headers().cookie);
258
+ const bystander_ws = await open_bystander(fixture);
189
259
  try {
190
- const revoked = await rpc(fixture, 'role_grant_revoke', { actor_id: revokee.actor.id, role_grant_id, reason: 'cleanup' }, fixture.create_session_headers());
191
- assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
192
- const is_revoke_frame = is_notification_with(ROLE_GRANT_REVOKE_NOTIFICATION_METHOD, (p) => p.role_grant_id === role_grant_id);
193
- // Flat params — guards the no-`offer`-wrapper, `revoked_by`-omitted
194
- // shape against drift.
195
- const assert_flat_revoke = (frame) => {
196
- const params = RoleGrantRevokeParams.parse(frame.params);
197
- assert.strictEqual(params.role_grant_id, role_grant_id);
198
- assert.strictEqual(params.role, ROLE_ADMIN);
199
- assert.strictEqual(params.reason, 'cleanup');
200
- assert.notProperty(frame.params, 'revoked_by');
201
- };
202
- if (revoke_closes_the_socket) {
203
- // the socket lost the role its endpoint requires; the revoke
204
- // notification may land just ahead of the close or not at all,
205
- // and is held to the same shape when it does
206
- const closed = await revokee_ws.wait_for_close(5000);
207
- assert.ok(closed, 'the gated socket did not close after the revoke');
208
- assert.strictEqual(revokee_ws.close_code, WS_CLOSE_PERMISSION_REVOKED);
209
- const early = revokee_ws.messages.find(is_revoke_frame);
210
- if (early)
211
- assert_flat_revoke(early);
212
- return;
260
+ const revokee_ws = await open_ws(revokee);
261
+ try {
262
+ const revoked = await rpc(fixture, 'role_grant_revoke', { actor_id: revokee.actor.id, role_grant_id, reason: 'cleanup' }, fixture.create_session_headers());
263
+ assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
264
+ const is_revoke_frame = is_notification_with(ROLE_GRANT_REVOKE_NOTIFICATION_METHOD, (p) => p.role_grant_id === role_grant_id);
265
+ // Flat params — guards the no-`offer`-wrapper, `revoked_by`-omitted
266
+ // shape against drift.
267
+ const assert_flat_revoke = (frame) => {
268
+ const params = RoleGrantRevokeParams.parse(frame.params);
269
+ assert.strictEqual(params.role_grant_id, role_grant_id);
270
+ assert.strictEqual(params.role, ROLE_ADMIN);
271
+ assert.strictEqual(params.reason, 'cleanup');
272
+ assert.notProperty(frame.params, 'revoked_by');
273
+ };
274
+ if (revoke_closes_the_socket) {
275
+ // the socket lost the role its endpoint requires; the revoke
276
+ // notification may land just ahead of the close or not at all,
277
+ // and is held to the same shape when it does
278
+ const closed = await revokee_ws.wait_for_close(5000);
279
+ assert.ok(closed, 'the gated socket did not close after the revoke');
280
+ assert.strictEqual(revokee_ws.close_code, WS_CLOSE_PERMISSION_REVOKED);
281
+ const early = revokee_ws.messages.find(is_revoke_frame);
282
+ if (early)
283
+ assert_flat_revoke(early);
284
+ await assert_bystander_heard_nothing(bystander_ws);
285
+ return;
286
+ }
287
+ assert_flat_revoke(await revokee_ws.wait_for(is_revoke_frame, 5000));
288
+ await assert_bystander_heard_nothing(bystander_ws);
289
+ }
290
+ finally {
291
+ await revokee_ws.close();
213
292
  }
214
- assert_flat_revoke(await revokee_ws.wait_for(is_revoke_frame, 5000));
215
293
  }
216
294
  finally {
217
- await revokee_ws.close();
295
+ await bystander_ws.close();
218
296
  }
219
297
  });
220
298
  test_if(capabilities.ws, 'accept of a sibling delivers role_grant_offer_supersede to the other grantor WS', async () => {
@@ -229,20 +307,27 @@ export const describe_role_grant_offer_notification_ws_tests = (options) => {
229
307
  const grantor2_offer = await create_pending_offer(fixture, recipient.account.id, grantor2.create_session_headers());
230
308
  // Open grantor 1 (keeper) — its offer is the one that gets superseded
231
309
  // when the recipient accepts grantor 2's offer.
232
- const grantor1_ws = await open_ws(fixture.create_session_headers().cookie);
310
+ const bystander_ws = await open_bystander(fixture);
233
311
  try {
234
- const accepted = await rpc(fixture, 'role_grant_offer_accept', { offer_id: grantor2_offer.id }, recipient.create_session_headers());
235
- assert.isTrue(accepted.ok, `accept failed: ${JSON.stringify(accepted)}`);
236
- const frame = await grantor1_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, (p) => p.offer.id === keeper_offer.id), 5000);
237
- const params = RoleGrantOfferSupersedeParams.parse(frame.params);
238
- assert.strictEqual(params.offer.id, keeper_offer.id);
239
- assert.strictEqual(params.reason, 'sibling_accepted');
240
- // cause_id points at the accepted sibling (grantor 2's offer).
241
- assert.strictEqual(params.cause_id, grantor2_offer.id);
242
- assert.isNotNull(params.offer.superseded_at);
312
+ const grantor1_ws = await open_ws(fixture);
313
+ try {
314
+ const accepted = await rpc(fixture, 'role_grant_offer_accept', { offer_id: grantor2_offer.id }, recipient.create_session_headers());
315
+ assert.isTrue(accepted.ok, `accept failed: ${JSON.stringify(accepted)}`);
316
+ const frame = await grantor1_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, (p) => p.offer.id === keeper_offer.id), 5000);
317
+ const params = RoleGrantOfferSupersedeParams.parse(frame.params);
318
+ assert.strictEqual(params.offer.id, keeper_offer.id);
319
+ assert.strictEqual(params.reason, 'sibling_accepted');
320
+ // cause_id points at the accepted sibling (grantor 2's offer).
321
+ assert.strictEqual(params.cause_id, grantor2_offer.id);
322
+ assert.isNotNull(params.offer.superseded_at);
323
+ await assert_bystander_heard_nothing(bystander_ws);
324
+ }
325
+ finally {
326
+ await grantor1_ws.close();
327
+ }
243
328
  }
244
329
  finally {
245
- await grantor1_ws.close();
330
+ await bystander_ws.close();
246
331
  }
247
332
  });
248
333
  test_if(capabilities.ws, 'revoke supersedes a pending sibling offer and notifies its grantor WS', async () => {
@@ -250,26 +335,33 @@ export const describe_role_grant_offer_notification_ws_tests = (options) => {
250
335
  const grantor = await fixture.create_account({ roles: [ROLE_ADMIN] });
251
336
  const revokee = await fixture.create_account({ roles: [ROLE_ADMIN] });
252
337
  const role_grant_id = await create_active_role_grant(fixture, revokee, {
253
- rpc_path: RPC_PATH
338
+ rpc_path: SPINE_RPC_PATH
254
339
  });
255
340
  // A *pending* sibling offer for the same (account, role) — this is what
256
341
  // the revoke cascade supersedes.
257
342
  const sibling_offer = await create_pending_offer(fixture, revokee.account.id, grantor.create_session_headers());
258
343
  // Watch the sibling grantor's socket for the supersede push.
259
- const grantor_ws = await open_ws(grantor.create_session_headers().cookie);
344
+ const bystander_ws = await open_bystander(fixture);
260
345
  try {
261
- const revoked = await rpc(fixture, 'role_grant_revoke', { actor_id: revokee.actor.id, role_grant_id }, fixture.create_session_headers());
262
- assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
263
- const frame = await grantor_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, (p) => p.offer.id === sibling_offer.id), 5000);
264
- const params = RoleGrantOfferSupersedeParams.parse(frame.params);
265
- assert.strictEqual(params.offer.id, sibling_offer.id);
266
- assert.strictEqual(params.reason, 'role_grant_revoked');
267
- // cause_id points at the revoked role_grant.
268
- assert.strictEqual(params.cause_id, role_grant_id);
269
- assert.isNotNull(params.offer.superseded_at);
346
+ const grantor_ws = await open_ws(grantor);
347
+ try {
348
+ const revoked = await rpc(fixture, 'role_grant_revoke', { actor_id: revokee.actor.id, role_grant_id }, fixture.create_session_headers());
349
+ assert.isTrue(revoked.ok, `revoke failed: ${JSON.stringify(revoked)}`);
350
+ const frame = await grantor_ws.wait_for(is_notification_with(ROLE_GRANT_OFFER_SUPERSEDE_NOTIFICATION_METHOD, (p) => p.offer.id === sibling_offer.id), 5000);
351
+ const params = RoleGrantOfferSupersedeParams.parse(frame.params);
352
+ assert.strictEqual(params.offer.id, sibling_offer.id);
353
+ assert.strictEqual(params.reason, 'role_grant_revoked');
354
+ // cause_id points at the revoked role_grant.
355
+ assert.strictEqual(params.cause_id, role_grant_id);
356
+ assert.isNotNull(params.offer.superseded_at);
357
+ await assert_bystander_heard_nothing(bystander_ws);
358
+ }
359
+ finally {
360
+ await grantor_ws.close();
361
+ }
270
362
  }
271
363
  finally {
272
- await grantor_ws.close();
364
+ await bystander_ws.close();
273
365
  }
274
366
  });
275
367
  });
@@ -6,6 +6,19 @@ export interface RoleGrantParticipationCrossTestOptions {
6
6
  readonly setup_test: SetupTest;
7
7
  /** RPC endpoint path. Default `/api/rpc`. */
8
8
  readonly rpc_path?: string;
9
+ /**
10
+ * The role conferred — one the backend's registry holds with the `admin`
11
+ * grant path. Default `SPINE_PARTICIPANT_ROLE`, the spine's own; a backend
12
+ * with only the builtin roles passes `ROLE_ADMIN`.
13
+ */
14
+ readonly role?: string;
15
+ /**
16
+ * A scope kind `role` applies to, for the scoped-assign case; `null` skips
17
+ * that case. Default `'space'` (the spine participant's), or `null` for a
18
+ * builtin role, which is global-only (a scoped builtin is refused
19
+ * `role_grant_builtin_scoped`).
20
+ */
21
+ readonly scope_kind?: string | null;
9
22
  }
10
23
  export declare const describe_role_grant_participation_cross_tests: (options: RoleGrantParticipationCrossTestOptions) => void;
11
24
  //# sourceMappingURL=role_grant_participation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"role_grant_participation.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/role_grant_participation.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAqD9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,0EAA0E;AAC1E,MAAM,WAAW,sCAAsC;IACtD,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,6CAA6C;IAC7C,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,eAAO,MAAM,6CAA6C,GACzD,SAAS,sCAAsC,KAC7C,IAuQF,CAAC"}
1
+ {"version":3,"file":"role_grant_participation.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/role_grant_participation.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA2D9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,0EAA0E;AAC1E,MAAM,WAAW,sCAAsC;IACtD,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,6CAA6C;IAC7C,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,eAAO,MAAM,6CAA6C,GACzD,SAAS,sCAAsC,KAC7C,IA6QF,CAAC"}
@@ -36,50 +36,61 @@ import '../assert_dev_env.js';
36
36
  * The `participant` role is registered admin-grantable on both spines
37
37
  * (`spine_roles` / the Rust stub's `RoleRegistry`), so the suite is ungated;
38
38
  * every spine mounts the standard RPC surface it drives.
39
+ * The role is the `role` option, `participant` by default: a backend without
40
+ * it passes another admin-grantable role (`ROLE_ADMIN` for one with only the
41
+ * builtins, which skips the scoped case — builtins are global-only).
39
42
  *
40
43
  * @module
41
44
  */
42
45
  import { describe, test, assert } from 'vitest';
46
+ import { is_builtin_role } from '../../auth/role_schema.js';
43
47
  import { rpc_call } from '../rpc_helpers.js';
44
- import { AuditLogListOutput } from '../../auth/admin_action_specs.js';
45
48
  import { ERROR_ROLE_GRANT_OFFER_SELF_TARGET, ERROR_ROLE_GRANT_OFFER_TERMINAL } from '../../auth/role_grant_offer_action_specs.js';
46
49
  import { JSONRPC_INVALID_PARAMS, JSONRPC_INVALID_REQUEST } from '../../http/jsonrpc.js';
50
+ import { list_audit_events } from './audit_cross_helpers.js';
51
+ import { test_if } from './capabilities.js';
47
52
  import { SPINE_PARTICIPANT_ROLE, SPINE_RPC_PATH } from './spine_surface_constants.js';
48
53
  export const describe_role_grant_participation_cross_tests = (options) => {
49
54
  const { setup_test } = options;
50
55
  const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
56
+ const role = options.role ?? SPINE_PARTICIPANT_ROLE;
57
+ const scope_kind = options.scope_kind === undefined
58
+ ? is_builtin_role(role)
59
+ ? null
60
+ : 'space'
61
+ : options.scope_kind;
51
62
  const rpc = (transport, method, params, headers) => rpc_call({ app: transport, path: rpc_path, method, params, headers });
52
63
  describe('role-gated participation conferral (success paths)', () => {
53
- test('admin assigns the participant app-role to a fresh account (idempotent)', async () => {
64
+ test(`admin assigns the ${role} app-role to a fresh account (idempotent)`, async () => {
54
65
  const fixture = await setup_test();
55
66
  const recipient = await fixture.create_account({ username: 'participation_assignee' });
56
67
  // The keeper holds ROLE_ADMIN (seeded via extra_keeper_roles), so it
57
68
  // clears the dispatcher admin gate; `participant` is admin-grantable, so
58
69
  // it clears the grant-path gate; the recipient is single-actor, so the
59
70
  // target actor resolves without `to_actor_id`.
60
- const first = await rpc(fixture.transport, 'role_grant_assign', { to_account_id: recipient.account.id, role: SPINE_PARTICIPANT_ROLE }, fixture.create_session_headers());
71
+ const first = await rpc(fixture.transport, 'role_grant_assign', { to_account_id: recipient.account.id, role }, fixture.create_session_headers());
61
72
  assert.ok(first.ok, `assign must succeed: ${JSON.stringify(first)}`);
62
73
  const assigned = first.result;
63
74
  assert.strictEqual(assigned.ok, true);
64
75
  assert.ok(typeof assigned.role_grant_id === 'string' && assigned.role_grant_id.length > 0, 'assign returns the new role_grant id');
65
76
  // Idempotent — re-assigning the active grant returns the existing id.
66
- const second = await rpc(fixture.transport, 'role_grant_assign', { to_account_id: recipient.account.id, role: SPINE_PARTICIPANT_ROLE }, fixture.create_session_headers());
77
+ const second = await rpc(fixture.transport, 'role_grant_assign', { to_account_id: recipient.account.id, role }, fixture.create_session_headers());
67
78
  assert.ok(second.ok, `re-assign must succeed: ${JSON.stringify(second)}`);
68
79
  assert.strictEqual(second.result.role_grant_id, assigned.role_grant_id, 're-assigning an active grant returns the existing role_grant id');
69
80
  });
70
- test('admin assigns a scoped participant grant that round-trips scope_kind', async () => {
81
+ test_if(scope_kind !== null, `admin assigns a scoped ${role} grant that round-trips scope_kind`, async () => {
71
82
  const fixture = await setup_test();
72
83
  const recipient = await fixture.create_account({ username: 'participation_scoped' });
73
84
  const scope_id = '33333333-3333-4333-8333-333333333333';
74
85
  const scoped = await rpc(fixture.transport, 'role_grant_assign', {
75
86
  to_account_id: recipient.account.id,
76
- role: SPINE_PARTICIPANT_ROLE,
77
- scope_kind: 'space',
87
+ role,
88
+ scope_kind,
78
89
  scope_id
79
90
  }, fixture.create_session_headers());
80
91
  assert.ok(scoped.ok, `scoped assign must succeed: ${JSON.stringify(scoped)}`);
81
92
  const scoped_id = scoped.result.role_grant_id;
82
- const global = await rpc(fixture.transport, 'role_grant_assign', { to_account_id: recipient.account.id, role: SPINE_PARTICIPANT_ROLE }, fixture.create_session_headers());
93
+ const global = await rpc(fixture.transport, 'role_grant_assign', { to_account_id: recipient.account.id, role }, fixture.create_session_headers());
83
94
  assert.ok(global.ok, `global assign must succeed: ${JSON.stringify(global)}`);
84
95
  const global_id = global.result.role_grant_id;
85
96
  assert.notStrictEqual(global_id, scoped_id, 'the global grant is a distinct row');
@@ -90,21 +101,21 @@ export const describe_role_grant_participation_cross_tests = (options) => {
90
101
  const by_id = new Map(entry.role_grants.map((g) => [g.id, g]));
91
102
  const scoped_row = by_id.get(scoped_id);
92
103
  assert.ok(scoped_row, 'the scoped grant is listed');
93
- assert.strictEqual(scoped_row.role, SPINE_PARTICIPANT_ROLE);
94
- assert.strictEqual(scoped_row.scope_kind, 'space', 'scope_kind round-trips');
104
+ assert.strictEqual(scoped_row.role, role);
105
+ assert.strictEqual(scoped_row.scope_kind, scope_kind, 'scope_kind round-trips');
95
106
  assert.strictEqual(scoped_row.scope_id, scope_id, 'scope_id round-trips');
96
107
  const global_row = by_id.get(global_id);
97
108
  assert.ok(global_row, 'the global grant is listed');
98
109
  assert.strictEqual(global_row.scope_kind, null);
99
110
  assert.strictEqual(global_row.scope_id, null);
100
111
  });
101
- test('admin offers the participant app-role and the recipient accepts', async () => {
112
+ test(`admin offers the ${role} app-role and the recipient accepts`, async () => {
102
113
  const fixture = await setup_test();
103
114
  const recipient = await fixture.create_account({ username: 'participation_offeree' });
104
115
  // Admin-only conferral via the consent flow: the keeper (admin) offers
105
116
  // the app-role, proving the widened grant-path gate admits it through
106
117
  // the offer path too.
107
- const created = await rpc(fixture.transport, 'role_grant_offer_create', { to_account_id: recipient.account.id, role: SPINE_PARTICIPANT_ROLE }, fixture.create_session_headers());
118
+ const created = await rpc(fixture.transport, 'role_grant_offer_create', { to_account_id: recipient.account.id, role }, fixture.create_session_headers());
108
119
  assert.ok(created.ok, `offer create must succeed: ${JSON.stringify(created)}`);
109
120
  const offer_id = created.result.offer.id;
110
121
  // The recipient accepts → a role_grant lands.
@@ -112,9 +123,9 @@ export const describe_role_grant_participation_cross_tests = (options) => {
112
123
  assert.ok(accepted.ok, `accept must succeed: ${JSON.stringify(accepted)}`);
113
124
  assert.ok(typeof accepted.result.role_grant_id === 'string', 'accept lands a role_grant for the app-role');
114
125
  });
115
- /** Offer `participant` to `recipient` as the keeper and accept it; returns the ids. */
126
+ /** Offer `role` to `recipient` as the keeper and accept it; returns the ids. */
116
127
  const offer_and_accept = async (fixture, recipient) => {
117
- const created = await rpc(fixture.transport, 'role_grant_offer_create', { to_account_id: recipient.account.id, role: SPINE_PARTICIPANT_ROLE }, fixture.create_session_headers());
128
+ const created = await rpc(fixture.transport, 'role_grant_offer_create', { to_account_id: recipient.account.id, role }, fixture.create_session_headers());
118
129
  assert.ok(created.ok, `offer create must succeed: ${JSON.stringify(created)}`);
119
130
  const offer_id = created.result.offer.id;
120
131
  const accepted = await rpc(fixture.fresh_transport(), 'role_grant_offer_accept', { offer_id }, recipient.create_session_headers());
@@ -122,12 +133,12 @@ export const describe_role_grant_participation_cross_tests = (options) => {
122
133
  const { role_grant_id } = accepted.result;
123
134
  return { offer_id, role_grant_id };
124
135
  };
125
- /** How many `event_type` audit rows the keeper can list. */
126
- const count_audit_rows = async (fixture, event_type) => {
127
- const listed = await rpc(fixture.transport, 'audit_log_list', { event_type }, fixture.create_session_headers());
128
- assert.ok(listed.ok, `audit_log_list must succeed: ${JSON.stringify(listed)}`);
129
- return AuditLogListOutput.parse(listed.result).events.length;
130
- };
136
+ /**
137
+ * How many `event_type` audit rows the keeper can list, behind the drain
138
+ * barrier — without it a replay's late row could land after the count,
139
+ * and the no-new-row assertions would pass vacuously.
140
+ */
141
+ const count_audit_rows = async (fixture, event_type) => (await list_audit_events(fixture, { event_type }, rpc_path)).length;
131
142
  test('re-accepting an accepted offer answers the same grant and writes no audit row', async () => {
132
143
  const fixture = await setup_test();
133
144
  const recipient = await fixture.create_account({ username: 'participation_replayer' });
@@ -157,7 +168,7 @@ export const describe_role_grant_participation_cross_tests = (options) => {
157
168
  const fixture = await setup_test();
158
169
  const own_id = fixture.account.id;
159
170
  for (const to_account_id of [own_id, own_id.toUpperCase()]) {
160
- const res = await rpc(fixture.transport, 'role_grant_offer_create', { to_account_id, role: SPINE_PARTICIPANT_ROLE }, fixture.create_session_headers());
171
+ const res = await rpc(fixture.transport, 'role_grant_offer_create', { to_account_id, role }, fixture.create_session_headers());
161
172
  assert.ok(!res.ok, `a self-offer must be refused (${to_account_id}): ${JSON.stringify(res)}`);
162
173
  assert.strictEqual(res.status, 400, to_account_id);
163
174
  assert.strictEqual(res.error.code, JSONRPC_INVALID_PARAMS, to_account_id);
@@ -1 +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"}
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;AAuC9B,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,IAyFF,CAAC"}