@fuzdev/fuz_app 0.140.0 → 0.142.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 (311) hide show
  1. package/dist/actions/CLAUDE.md +72 -22
  2. package/dist/actions/action_rpc.d.ts +5 -1
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +9 -1
  5. package/dist/actions/connection_closer.d.ts +44 -4
  6. package/dist/actions/connection_closer.d.ts.map +1 -1
  7. package/dist/actions/connection_closer.js +33 -3
  8. package/dist/actions/perform_action.d.ts +10 -0
  9. package/dist/actions/perform_action.d.ts.map +1 -1
  10. package/dist/actions/perform_action.js +23 -9
  11. package/dist/actions/register_action_ws.d.ts +42 -6
  12. package/dist/actions/register_action_ws.d.ts.map +1 -1
  13. package/dist/actions/register_action_ws.js +87 -19
  14. package/dist/actions/register_ws_endpoint.d.ts +15 -6
  15. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  16. package/dist/actions/register_ws_endpoint.js +10 -4
  17. package/dist/actions/socket.svelte.d.ts +19 -4
  18. package/dist/actions/socket.svelte.d.ts.map +1 -1
  19. package/dist/actions/socket.svelte.js +31 -4
  20. package/dist/actions/transports.d.ts +30 -1
  21. package/dist/actions/transports.d.ts.map +1 -1
  22. package/dist/actions/transports.js +30 -1
  23. package/dist/actions/transports_ws_auth_guard.d.ts +13 -6
  24. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  25. package/dist/actions/transports_ws_auth_guard.js +24 -10
  26. package/dist/actions/transports_ws_backend.d.ts +51 -5
  27. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  28. package/dist/actions/transports_ws_backend.js +62 -9
  29. package/dist/actions/ws_endpoint_spec.d.ts +11 -10
  30. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  31. package/dist/auth/CLAUDE.md +44 -22
  32. package/dist/auth/account_actions.d.ts.map +1 -1
  33. package/dist/auth/account_actions.js +12 -1
  34. package/dist/auth/account_route_schema.d.ts +1 -0
  35. package/dist/auth/account_route_schema.d.ts.map +1 -1
  36. package/dist/auth/account_route_schema.js +12 -0
  37. package/dist/auth/account_routes.d.ts +7 -2
  38. package/dist/auth/account_routes.d.ts.map +1 -1
  39. package/dist/auth/account_routes.js +240 -187
  40. package/dist/auth/admin_action_specs.d.ts +6 -1
  41. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  42. package/dist/auth/admin_action_specs.js +6 -1
  43. package/dist/auth/admin_actions.d.ts.map +1 -1
  44. package/dist/auth/admin_actions.js +14 -2
  45. package/dist/auth/api_token.d.ts +20 -0
  46. package/dist/auth/api_token.d.ts.map +1 -1
  47. package/dist/auth/api_token.js +21 -1
  48. package/dist/auth/api_token_queries.d.ts +22 -4
  49. package/dist/auth/api_token_queries.d.ts.map +1 -1
  50. package/dist/auth/api_token_queries.js +11 -6
  51. package/dist/auth/audit_log_routes.d.ts +10 -0
  52. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  53. package/dist/auth/audit_log_routes.js +20 -0
  54. package/dist/auth/audit_log_schema.d.ts +30 -5
  55. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  56. package/dist/auth/audit_log_schema.js +30 -0
  57. package/dist/auth/bearer_auth.d.ts +4 -2
  58. package/dist/auth/bearer_auth.d.ts.map +1 -1
  59. package/dist/auth/bearer_auth.js +19 -8
  60. package/dist/auth/bootstrap_route_schema.d.ts +1 -0
  61. package/dist/auth/bootstrap_route_schema.d.ts.map +1 -1
  62. package/dist/auth/bootstrap_route_schema.js +1 -0
  63. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  64. package/dist/auth/bootstrap_routes.js +85 -76
  65. package/dist/auth/cell_action_specs.d.ts +7 -3
  66. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  67. package/dist/auth/cell_action_specs.js +9 -5
  68. package/dist/auth/cell_actions.d.ts +3 -3
  69. package/dist/auth/cell_actions.d.ts.map +1 -1
  70. package/dist/auth/cell_actions.js +67 -38
  71. package/dist/auth/cell_authorize.d.ts +69 -8
  72. package/dist/auth/cell_authorize.d.ts.map +1 -1
  73. package/dist/auth/cell_authorize.js +89 -9
  74. package/dist/auth/cell_field_action_specs.d.ts +5 -4
  75. package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
  76. package/dist/auth/cell_field_action_specs.js +7 -7
  77. package/dist/auth/cell_field_actions.d.ts +7 -5
  78. package/dist/auth/cell_field_actions.d.ts.map +1 -1
  79. package/dist/auth/cell_field_actions.js +24 -22
  80. package/dist/auth/cell_grant_action_specs.d.ts +6 -0
  81. package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
  82. package/dist/auth/cell_grant_action_specs.js +6 -0
  83. package/dist/auth/cell_grant_actions.d.ts +3 -1
  84. package/dist/auth/cell_grant_actions.d.ts.map +1 -1
  85. package/dist/auth/cell_grant_actions.js +16 -2
  86. package/dist/auth/cell_item_action_specs.d.ts +5 -4
  87. package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
  88. package/dist/auth/cell_item_action_specs.js +7 -7
  89. package/dist/auth/cell_item_actions.d.ts +6 -5
  90. package/dist/auth/cell_item_actions.d.ts.map +1 -1
  91. package/dist/auth/cell_item_actions.js +23 -22
  92. package/dist/auth/cell_relation_list_limit.d.ts +20 -0
  93. package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
  94. package/dist/auth/cell_relation_list_limit.js +27 -0
  95. package/dist/auth/cell_relation_visibility.d.ts +4 -3
  96. package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
  97. package/dist/auth/cell_relation_visibility.js +6 -5
  98. package/dist/auth/cell_steward.d.ts +45 -0
  99. package/dist/auth/cell_steward.d.ts.map +1 -0
  100. package/dist/auth/cell_steward.js +53 -0
  101. package/dist/auth/cleanup.d.ts +9 -9
  102. package/dist/auth/cleanup.js +9 -9
  103. package/dist/auth/credential_deadline.d.ts +95 -0
  104. package/dist/auth/credential_deadline.d.ts.map +1 -0
  105. package/dist/auth/credential_deadline.js +139 -0
  106. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  107. package/dist/auth/daemon_token_middleware.js +4 -1
  108. package/dist/auth/middleware.d.ts +90 -12
  109. package/dist/auth/middleware.d.ts.map +1 -1
  110. package/dist/auth/middleware.js +115 -16
  111. package/dist/auth/request_context.d.ts +8 -2
  112. package/dist/auth/request_context.d.ts.map +1 -1
  113. package/dist/auth/request_context.js +20 -4
  114. package/dist/auth/resolved_auth.d.ts +13 -2
  115. package/dist/auth/resolved_auth.d.ts.map +1 -1
  116. package/dist/auth/resolved_auth.js +5 -4
  117. package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
  118. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  119. package/dist/auth/role_grant_offer_action_specs.js +6 -1
  120. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  121. package/dist/auth/role_grant_offer_actions.js +23 -16
  122. package/dist/auth/role_grant_offer_queries.d.ts +8 -6
  123. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  124. package/dist/auth/role_grant_offer_queries.js +16 -8
  125. package/dist/auth/session_cookie.d.ts +4 -0
  126. package/dist/auth/session_cookie.d.ts.map +1 -1
  127. package/dist/auth/session_cookie.js +3 -2
  128. package/dist/auth/session_middleware.d.ts +1 -1
  129. package/dist/auth/session_middleware.d.ts.map +1 -1
  130. package/dist/auth/session_middleware.js +4 -1
  131. package/dist/auth/session_queries.d.ts +19 -2
  132. package/dist/auth/session_queries.d.ts.map +1 -1
  133. package/dist/auth/session_queries.js +8 -2
  134. package/dist/auth/signup_route_schema.d.ts.map +1 -1
  135. package/dist/auth/signup_route_schema.js +1 -0
  136. package/dist/auth/signup_routes.d.ts +4 -3
  137. package/dist/auth/signup_routes.d.ts.map +1 -1
  138. package/dist/auth/signup_routes.js +157 -134
  139. package/dist/auth/token_scope.d.ts +7 -1
  140. package/dist/auth/token_scope.d.ts.map +1 -1
  141. package/dist/auth/token_scope.js +8 -2
  142. package/dist/db/CLAUDE.md +32 -19
  143. package/dist/db/cell_ddl.d.ts +1 -2
  144. package/dist/db/cell_ddl.d.ts.map +1 -1
  145. package/dist/db/cell_ddl.js +1 -2
  146. package/dist/db/cell_field_queries.d.ts +20 -18
  147. package/dist/db/cell_field_queries.d.ts.map +1 -1
  148. package/dist/db/cell_field_queries.js +28 -22
  149. package/dist/db/cell_item_queries.d.ts +20 -14
  150. package/dist/db/cell_item_queries.d.ts.map +1 -1
  151. package/dist/db/cell_item_queries.js +28 -18
  152. package/dist/db/cell_moderation.d.ts +67 -0
  153. package/dist/db/cell_moderation.d.ts.map +1 -0
  154. package/dist/db/cell_moderation.js +66 -0
  155. package/dist/db/cell_queries.d.ts +44 -14
  156. package/dist/db/cell_queries.d.ts.map +1 -1
  157. package/dist/db/cell_queries.js +68 -45
  158. package/dist/db/cell_view_sql.d.ts +102 -0
  159. package/dist/db/cell_view_sql.d.ts.map +1 -0
  160. package/dist/db/cell_view_sql.js +112 -0
  161. package/dist/db/db_pg.d.ts +1 -1
  162. package/dist/db/db_pg.js +1 -1
  163. package/dist/hono_context.d.ts +23 -0
  164. package/dist/hono_context.d.ts.map +1 -1
  165. package/dist/hono_context.js +19 -0
  166. package/dist/http/CLAUDE.md +41 -15
  167. package/dist/http/auth_shape.d.ts +5 -2
  168. package/dist/http/auth_shape.d.ts.map +1 -1
  169. package/dist/http/auth_shape.js +5 -2
  170. package/dist/http/ip_canonical.d.ts +30 -3
  171. package/dist/http/ip_canonical.d.ts.map +1 -1
  172. package/dist/http/ip_canonical.js +50 -3
  173. package/dist/http/middleware_spec.d.ts +16 -0
  174. package/dist/http/middleware_spec.d.ts.map +1 -1
  175. package/dist/http/origin.d.ts +113 -18
  176. package/dist/http/origin.d.ts.map +1 -1
  177. package/dist/http/origin.js +247 -127
  178. package/dist/http/route_spec.d.ts +21 -0
  179. package/dist/http/route_spec.d.ts.map +1 -1
  180. package/dist/http/schema_helpers.d.ts +22 -0
  181. package/dist/http/schema_helpers.d.ts.map +1 -1
  182. package/dist/http/schema_helpers.js +24 -0
  183. package/dist/http/surface.d.ts +6 -1
  184. package/dist/http/surface.d.ts.map +1 -1
  185. package/dist/http/surface.js +12 -5
  186. package/dist/primitive_schemas.d.ts +10 -1
  187. package/dist/primitive_schemas.d.ts.map +1 -1
  188. package/dist/primitive_schemas.js +11 -2
  189. package/dist/rate_limiter.d.ts +179 -12
  190. package/dist/rate_limiter.d.ts.map +1 -1
  191. package/dist/rate_limiter.js +352 -42
  192. package/dist/realtime/sse_auth_guard.d.ts +22 -6
  193. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  194. package/dist/realtime/sse_auth_guard.js +31 -12
  195. package/dist/realtime/subscriber_registry.d.ts +10 -1
  196. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  197. package/dist/realtime/subscriber_registry.js +10 -1
  198. package/dist/server/app_server.d.ts +20 -7
  199. package/dist/server/app_server.d.ts.map +1 -1
  200. package/dist/server/app_server.js +8 -3
  201. package/dist/server/env.d.ts +2 -1
  202. package/dist/server/env.d.ts.map +1 -1
  203. package/dist/server/env.js +7 -17
  204. package/dist/server/serve_fact_route.d.ts +8 -4
  205. package/dist/server/serve_fact_route.d.ts.map +1 -1
  206. package/dist/server/serve_fact_route.js +16 -6
  207. package/dist/testing/CLAUDE.md +164 -25
  208. package/dist/testing/adversarial_headers.d.ts.map +1 -1
  209. package/dist/testing/adversarial_headers.js +13 -6
  210. package/dist/testing/connection_closer_helpers.d.ts +6 -1
  211. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  212. package/dist/testing/connection_closer_helpers.js +4 -0
  213. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  214. package/dist/testing/cross_backend/account_lifecycle.js +37 -2
  215. package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
  216. package/dist/testing/cross_backend/body_size.js +14 -65
  217. package/dist/testing/cross_backend/capabilities.d.ts +26 -2
  218. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  219. package/dist/testing/cross_backend/capabilities.js +9 -3
  220. package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
  221. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  222. package/dist/testing/cross_backend/cell_gated_create.js +401 -5
  223. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  224. package/dist/testing/cross_backend/cell_relations.js +145 -2
  225. package/dist/testing/cross_backend/conformance_case.d.ts +11 -3
  226. package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
  227. package/dist/testing/cross_backend/conformance_case.js +6 -0
  228. package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
  229. package/dist/testing/cross_backend/conformance_table.js +20 -19
  230. package/dist/testing/cross_backend/credential_deadline.d.ts +25 -0
  231. package/dist/testing/cross_backend/credential_deadline.d.ts.map +1 -0
  232. package/dist/testing/cross_backend/credential_deadline.js +185 -0
  233. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  234. package/dist/testing/cross_backend/default_backend_configs.js +13 -2
  235. package/dist/testing/cross_backend/expected_schema.json +0 -1
  236. package/dist/testing/cross_backend/full_spine_mount.d.ts +3 -0
  237. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
  238. package/dist/testing/cross_backend/full_spine_mount.js +7 -1
  239. package/dist/testing/cross_backend/login_security.d.ts +16 -0
  240. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  241. package/dist/testing/cross_backend/login_security.js +154 -0
  242. package/dist/testing/cross_backend/narrowed_api_token.d.ts +23 -0
  243. package/dist/testing/cross_backend/narrowed_api_token.d.ts.map +1 -0
  244. package/dist/testing/cross_backend/narrowed_api_token.js +40 -0
  245. package/dist/testing/cross_backend/origin.d.ts.map +1 -1
  246. package/dist/testing/cross_backend/origin.js +6 -26
  247. package/dist/testing/cross_backend/origin_conformance.d.ts +152 -0
  248. package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -0
  249. package/dist/testing/cross_backend/origin_conformance.js +136 -0
  250. package/dist/testing/cross_backend/origin_conformance.json +1679 -0
  251. package/dist/testing/cross_backend/origin_scope.d.ts +16 -0
  252. package/dist/testing/cross_backend/origin_scope.d.ts.map +1 -0
  253. package/dist/testing/cross_backend/origin_scope.js +140 -0
  254. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  255. package/dist/testing/cross_backend/query_shape.js +24 -2
  256. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts +35 -0
  257. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts.map +1 -0
  258. package/dist/testing/cross_backend/role_grant_cross_helpers.js +64 -0
  259. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +8 -0
  260. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  261. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +44 -33
  262. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  263. package/dist/testing/cross_backend/role_grant_participation.js +64 -0
  264. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  265. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +6 -4
  266. package/dist/testing/cross_backend/spine_surface_constants.d.ts +20 -1
  267. package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
  268. package/dist/testing/cross_backend/spine_surface_constants.js +23 -1
  269. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  270. package/dist/testing/cross_backend/testing_backdoor.js +20 -7
  271. package/dist/testing/cross_backend/testing_reset_actions.d.ts +180 -5
  272. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  273. package/dist/testing/cross_backend/testing_reset_actions.js +154 -5
  274. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  275. package/dist/testing/cross_backend/token_scope_surface.js +25 -21
  276. package/dist/testing/cross_backend/transport_composition.d.ts +60 -0
  277. package/dist/testing/cross_backend/transport_composition.d.ts.map +1 -0
  278. package/dist/testing/cross_backend/transport_composition.js +364 -0
  279. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  280. package/dist/testing/cross_backend/ts_spine_backend_config.js +21 -6
  281. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
  282. package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
  283. package/dist/testing/cross_backend/ws_role_gated.d.ts +38 -0
  284. package/dist/testing/cross_backend/ws_role_gated.d.ts.map +1 -0
  285. package/dist/testing/cross_backend/ws_role_gated.js +142 -0
  286. package/dist/testing/entities.d.ts +23 -0
  287. package/dist/testing/entities.d.ts.map +1 -1
  288. package/dist/testing/entities.js +37 -0
  289. package/dist/testing/integration.d.ts.map +1 -1
  290. package/dist/testing/integration.js +3 -1
  291. package/dist/testing/middleware.d.ts +10 -0
  292. package/dist/testing/middleware.d.ts.map +1 -1
  293. package/dist/testing/middleware.js +31 -7
  294. package/dist/testing/schema_introspect.js +1 -1
  295. package/dist/testing/schema_parity.d.ts +4 -3
  296. package/dist/testing/schema_parity.d.ts.map +1 -1
  297. package/dist/testing/schema_parity.js +4 -3
  298. package/dist/testing/schema_ready_fixture.d.ts +2 -4
  299. package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
  300. package/dist/testing/schema_ready_fixture.js +2 -4
  301. package/dist/testing/stubs.d.ts +16 -3
  302. package/dist/testing/stubs.d.ts.map +1 -1
  303. package/dist/testing/stubs.js +20 -7
  304. package/dist/testing/surface_invariants.js +2 -2
  305. package/dist/ui/auth_state.svelte.d.ts +10 -0
  306. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  307. package/dist/ui/auth_state.svelte.js +22 -4
  308. package/package.json +1 -1
  309. package/dist/db/cell_history_ddl.d.ts +0 -38
  310. package/dist/db/cell_history_ddl.d.ts.map +0 -1
  311. package/dist/db/cell_history_ddl.js +0 -61
@@ -0,0 +1,364 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend front-door contract: what every transport answers before its
4
+ * request reaches per-action dispatch.
5
+ *
6
+ * Each spine composes its front door differently. The TS spine mounts one
7
+ * stack at `/api/*` — body limit, then origin, then session and bearer — and
8
+ * each surface adds its own gates behind it. The Rust spine stacks global
9
+ * layers (body limit, then origin) and has each handler resolve its credential
10
+ * and run its own gates. Two compositions can agree on every per-surface suite
11
+ * and still disagree on which refusal answers when a request trips several, so
12
+ * this suite pins the answer per transport as a table:
13
+ *
14
+ * | surface | foreign `Origin` | unreadable `Origin` | duplicated `Origin` | over cap + foreign `Origin` | no credential | narrowed token |
15
+ * | --- | --- | --- | --- | --- | --- | --- |
16
+ * | REST `POST` (`/logout`) | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | 413 `payload_too_large` | 401 `authentication_required` | 403 `credential_type_required` |
17
+ * | REST `POST` (`/password`) | — | — | — | — | 401 `authentication_required` | — |
18
+ * | RPC `POST` | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | 413 `payload_too_large` | 401 `authentication_required` | 403 `token_scope_required` (`rpc:<method>`) |
19
+ * | RPC `GET` | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | — | 401 `authentication_required` | 403 `token_scope_required` (`rpc:<method>`) |
20
+ * | WS upgrade | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | — | 401 `authentication_required` | 403 `token_scope_required` (`surface:ws_upgrade`) |
21
+ * | SSE (audit stream) | 403 `forbidden_origin` | 403 `forbidden_origin` | 403 `forbidden_origin` | — | 401 `authentication_required` | 403 `credential_type_required` |
22
+ *
23
+ * - **foreign `Origin`** carries the keeper's session cookie, so only the
24
+ * origin check can refuse it — a front door missing that check answers the
25
+ * surface's success instead.
26
+ * - **unreadable `Origin`** is the allowed origin with a byte past ASCII
27
+ * (`0xE9`), with the keeper's session — a check that reads such a value as
28
+ * absent lets it through.
29
+ * - **duplicated `Origin`** sends the allowed origin on two header lines, with
30
+ * the keeper's session — refused whether a backend sees the two lines (the
31
+ * Rust spine) or one value joined from them (the TS runtimes), so a check that
32
+ * reads only the first lets it through.
33
+ * - **over cap + foreign `Origin`** declares a `Content-Length` one byte over
34
+ * the shared 1 MiB cap and carries no credential: the cap answers on the
35
+ * header, ahead of both the origin check and auth. Only the surfaces that
36
+ * take a body have the column.
37
+ * - **no credential** sends an allowed `Origin` and nothing else.
38
+ * - **narrowed token** sends a bearer minted with a method-scoped scope, and no
39
+ * `Origin` (a bearer in a browser context is discarded, not refused). The RPC
40
+ * probe calls a method the scope doesn't name. A narrowed token reaches no
41
+ * non-RPC surface, but on a session-only surface the credential gate answers
42
+ * first — the REST probe is session-only `/logout`, and the audit stream is
43
+ * session-only on both spines.
44
+ *
45
+ * Each case asserts the status and the refusal body: a flat `{error}` (plus
46
+ * its named fields) or, past the RPC front door, the JSON-RPC error envelope
47
+ * with the reason on `error.data.reason`. A WebSocket refusal's body is
48
+ * required where the backend declares `capabilities.ws_refusal_body`; where it
49
+ * doesn't (Node's `@hono/node-ws` drops a refused upgrade's body), an empty
50
+ * body passes on its status and a body that is sent is still asserted.
51
+ *
52
+ * **Transport.** Given a `base_url`, every probe is written to the socket
53
+ * (`raw_http_exchange`): an upgrade has to be, and an over-cap request sends
54
+ * its head alone, as `body_size.ts` explains — the 413 must arrive without a
55
+ * body byte. With `base_url: null` the probes go through the fixture's
56
+ * in-process transport, whole bodies included, and the WebSocket rows skip
57
+ * (there is no socket to upgrade).
58
+ *
59
+ * Wire it for any backend that serves the spine's account routes, RPC
60
+ * endpoint, WebSocket endpoint and audit stream; the WS and SSE rows gate on
61
+ * `capabilities.ws` and `capabilities.sse`.
62
+ *
63
+ * @module
64
+ */
65
+ import { assert, describe } from 'vitest';
66
+ import { randomBytes } from 'node:crypto';
67
+ import { account_verify_action_spec } from '../../auth/account_action_specs.js';
68
+ import { ERROR_AUTHENTICATION_REQUIRED, ERROR_CREDENTIAL_TYPE_REQUIRED, ERROR_FORBIDDEN_ORIGIN, ERROR_PAYLOAD_TOO_LARGE, ERROR_TOKEN_SCOPE_REQUIRED } from '../../http/error_schemas.js';
69
+ import { create_rpc_get_url } from '../rpc_helpers.js';
70
+ import { raw_http_exchange, raw_http_parse_response } from '../transports/raw_http.js';
71
+ import { test_if } from './capabilities.js';
72
+ import { mint_narrowed_api_token } from './narrowed_api_token.js';
73
+ import { SPINE_RPC_PATH, SPINE_SSE_PATH } from './spine_surface_constants.js';
74
+ /** The RPC method the narrowed token admits. */
75
+ const NARROWED_ADMITTED_METHOD = account_verify_action_spec.method;
76
+ /**
77
+ * The RPC method the narrowed-token probe calls — one its scope doesn't name.
78
+ * A read with no credential-type or role gate, so the scope gate is the only
79
+ * one that can refuse it, over `GET` as over `POST`.
80
+ */
81
+ const NARROWED_REFUSED_METHOD = 'account_session_list';
82
+ const FORBIDDEN_ORIGIN = { status: 403, error: ERROR_FORBIDDEN_ORIGIN };
83
+ const PAYLOAD_TOO_LARGE = {
84
+ status: 413,
85
+ error: ERROR_PAYLOAD_TOO_LARGE
86
+ };
87
+ const SESSION_ONLY = {
88
+ status: 403,
89
+ error: ERROR_CREDENTIAL_TYPE_REQUIRED,
90
+ fields: { required_credential_types: ['session'] }
91
+ };
92
+ const RPC_UNAUTHENTICATED = {
93
+ status: 401,
94
+ error: ERROR_AUTHENTICATION_REQUIRED,
95
+ envelope: true
96
+ };
97
+ const RPC_SCOPE_REFUSED = {
98
+ status: 403,
99
+ error: ERROR_TOKEN_SCOPE_REQUIRED,
100
+ fields: { required_scope: `rpc:${NARROWED_REFUSED_METHOD}` },
101
+ envelope: true
102
+ };
103
+ const UNAUTHENTICATED = {
104
+ status: 401,
105
+ error: ERROR_AUTHENTICATION_REQUIRED
106
+ };
107
+ /** The front-door table — one case per cell of the module doc's matrix. */
108
+ export const transport_composition_cases = [
109
+ { surface: 'rest_post', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN },
110
+ { surface: 'rest_post', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN },
111
+ { surface: 'rest_post', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN },
112
+ { surface: 'rest_post', probe: 'over_cap_foreign_origin', expect: PAYLOAD_TOO_LARGE },
113
+ { surface: 'rest_post', probe: 'no_credential', expect: UNAUTHENTICATED },
114
+ { surface: 'rest_post', probe: 'narrowed_token', expect: SESSION_ONLY },
115
+ // the other session-only REST route that resolves its own credential on the
116
+ // Rust spine, so its 401 is pinned beside `/logout`'s
117
+ { surface: 'rest_password', probe: 'no_credential', expect: UNAUTHENTICATED },
118
+ { surface: 'rpc_post', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN },
119
+ { surface: 'rpc_post', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN },
120
+ { surface: 'rpc_post', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN },
121
+ { surface: 'rpc_post', probe: 'over_cap_foreign_origin', expect: PAYLOAD_TOO_LARGE },
122
+ { surface: 'rpc_post', probe: 'no_credential', expect: RPC_UNAUTHENTICATED },
123
+ { surface: 'rpc_post', probe: 'narrowed_token', expect: RPC_SCOPE_REFUSED },
124
+ { surface: 'rpc_get', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN },
125
+ { surface: 'rpc_get', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN },
126
+ { surface: 'rpc_get', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN },
127
+ { surface: 'rpc_get', probe: 'no_credential', expect: RPC_UNAUTHENTICATED },
128
+ { surface: 'rpc_get', probe: 'narrowed_token', expect: RPC_SCOPE_REFUSED },
129
+ { surface: 'ws_upgrade', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN, gate: 'ws' },
130
+ { surface: 'ws_upgrade', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN, gate: 'ws' },
131
+ { surface: 'ws_upgrade', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN, gate: 'ws' },
132
+ { surface: 'ws_upgrade', probe: 'no_credential', expect: UNAUTHENTICATED, gate: 'ws' },
133
+ {
134
+ surface: 'ws_upgrade',
135
+ probe: 'narrowed_token',
136
+ expect: {
137
+ status: 403,
138
+ error: ERROR_TOKEN_SCOPE_REQUIRED,
139
+ fields: { required_scope: 'surface:ws_upgrade' }
140
+ },
141
+ gate: 'ws'
142
+ },
143
+ { surface: 'sse', probe: 'foreign_origin', expect: FORBIDDEN_ORIGIN, gate: 'sse' },
144
+ { surface: 'sse', probe: 'unreadable_origin', expect: FORBIDDEN_ORIGIN, gate: 'sse' },
145
+ { surface: 'sse', probe: 'duplicated_origin', expect: FORBIDDEN_ORIGIN, gate: 'sse' },
146
+ { surface: 'sse', probe: 'no_credential', expect: UNAUTHENTICATED, gate: 'sse' },
147
+ { surface: 'sse', probe: 'narrowed_token', expect: SESSION_ONLY, gate: 'sse' }
148
+ ];
149
+ /** The shared 1 MiB default cap both spines ship, as in `body_size.ts`. */
150
+ const BODY_LIMIT_DEFAULT_BYTES = 1024 * 1024;
151
+ /** An `Origin` no backend's allowlist admits. */
152
+ const DISALLOWED_ORIGIN = 'http://evil.com';
153
+ /** How long a raw exchange waits for the refusal. */
154
+ const RAW_READ_TIMEOUT_MS = 2000;
155
+ const rpc_envelope = (method, id) => JSON.stringify({ jsonrpc: '2.0', method, id });
156
+ /** A JSON object body of exactly `bytes` ASCII bytes. */
157
+ const sized_json = (bytes) => {
158
+ const base = JSON.stringify({ pad: '' });
159
+ return JSON.stringify({ pad: 'x'.repeat(Math.max(0, bytes - base.length)) });
160
+ };
161
+ const to_header_lines = (headers) => Object.entries(headers)
162
+ .map(([name, value]) => `${name}: ${value}\r\n`)
163
+ .join('');
164
+ const parse_json = (text) => {
165
+ try {
166
+ return JSON.parse(text);
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ };
172
+ const case_name = (c) => `${c.surface} × ${c.probe} → ${c.expect.status} ${c.expect.error}`;
173
+ /**
174
+ * Read the refusal reason and fields out of a body, by the shape the case
175
+ * expects. An envelope expectation fails on a flat body and the other way
176
+ * round, so a spine answering from the wrong layer fails here.
177
+ */
178
+ const assert_refusal_body = (c, body, text) => {
179
+ const label = case_name(c);
180
+ assert.ok(body !== null && typeof body === 'object', `${label}: expected a JSON body, got ${JSON.stringify(text.slice(0, 200))}`);
181
+ let refusal;
182
+ if (c.expect.envelope) {
183
+ const envelope = body;
184
+ assert.strictEqual(envelope.jsonrpc, '2.0', `${label}: expected a JSON-RPC envelope: ${text}`);
185
+ const data = envelope.error?.data;
186
+ assert.ok(data !== null && typeof data === 'object', `${label}: expected error.data on the envelope: ${text}`);
187
+ refusal = data;
188
+ assert.strictEqual(refusal.reason, c.expect.error, `${label}: error.data.reason`);
189
+ }
190
+ else {
191
+ refusal = body;
192
+ assert.ok(!('jsonrpc' in refusal), `${label}: expected a flat body, got an envelope: ${text}`);
193
+ assert.strictEqual(refusal.error, c.expect.error, `${label}: body.error`);
194
+ }
195
+ for (const [key, expected] of Object.entries(c.expect.fields ?? {})) {
196
+ assert.deepStrictEqual(refusal[key], expected, `${label}: ${key}`);
197
+ }
198
+ };
199
+ /**
200
+ * Register the front-door table's cases — one test per cell, each on a fresh
201
+ * fixture.
202
+ *
203
+ * This is the front-door contract: a backend that wires it answers each
204
+ * transport's probes as the table says — when one request trips several
205
+ * refusals, the one the table names answers.
206
+ */
207
+ export const describe_transport_composition_cross_tests = (options) => {
208
+ const { setup_test, capabilities, base_url } = options;
209
+ const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
210
+ const rest_path = options.rest_path ?? '/api/account/logout';
211
+ const password_path = options.password_path ?? '/api/account/password';
212
+ const ws_path = options.ws_path ?? '/api/ws';
213
+ const sse_path = options.sse_path ?? SPINE_SSE_PATH;
214
+ const allowed_origin = options.origin ?? (base_url === null ? 'http://localhost:5173' : new URL(base_url).origin);
215
+ /** The request line, headers and body a case's probe sends. */
216
+ const build_probe = async (c, fixture) => {
217
+ const over_cap = c.probe === 'over_cap_foreign_origin';
218
+ let credential;
219
+ let repeated_headers;
220
+ switch (c.probe) {
221
+ case 'foreign_origin':
222
+ credential = { ...fixture.create_session_headers(), origin: DISALLOWED_ORIGIN };
223
+ break;
224
+ case 'unreadable_origin':
225
+ // latin1 on the socket — the byte 0xE9
226
+ credential = { ...fixture.create_session_headers(), origin: `${allowed_origin}\u00e9` };
227
+ break;
228
+ case 'duplicated_origin':
229
+ credential = { ...fixture.create_session_headers(), origin: allowed_origin };
230
+ repeated_headers = [['origin', allowed_origin]];
231
+ break;
232
+ case 'over_cap_foreign_origin':
233
+ // no credential either: the cap answers ahead of the origin check and auth
234
+ credential = { origin: DISALLOWED_ORIGIN };
235
+ break;
236
+ case 'no_credential':
237
+ credential = { origin: allowed_origin };
238
+ break;
239
+ case 'narrowed_token': {
240
+ const token = await mint_narrowed_api_token({
241
+ app: fixture.transport,
242
+ rpc_path,
243
+ session_headers: fixture.create_session_headers(),
244
+ methods: [NARROWED_ADMITTED_METHOD]
245
+ });
246
+ // no `Origin` — a bearer in a browser context is discarded
247
+ credential = { authorization: `Bearer ${token}` };
248
+ break;
249
+ }
250
+ }
251
+ const rpc_method = c.probe === 'narrowed_token' ? NARROWED_REFUSED_METHOD : account_verify_action_spec.method;
252
+ const id = `${c.surface}-${c.probe}`;
253
+ switch (c.surface) {
254
+ case 'rest_post':
255
+ case 'rest_password':
256
+ return {
257
+ method: 'POST',
258
+ path: c.surface === 'rest_post' ? rest_path : password_path,
259
+ headers: { ...credential, 'content-type': 'application/json' },
260
+ body: over_cap ? sized_json(BODY_LIMIT_DEFAULT_BYTES + 1) : '{}',
261
+ over_cap,
262
+ repeated_headers
263
+ };
264
+ case 'rpc_post': {
265
+ return {
266
+ method: 'POST',
267
+ path: rpc_path,
268
+ headers: { ...credential, 'content-type': 'application/json' },
269
+ body: over_cap ? sized_json(BODY_LIMIT_DEFAULT_BYTES + 1) : rpc_envelope(rpc_method, id),
270
+ over_cap,
271
+ repeated_headers
272
+ };
273
+ }
274
+ case 'rpc_get':
275
+ return {
276
+ method: 'GET',
277
+ path: create_rpc_get_url(rpc_path, rpc_method, undefined, id),
278
+ headers: credential,
279
+ body: null,
280
+ over_cap,
281
+ repeated_headers
282
+ };
283
+ case 'ws_upgrade':
284
+ return {
285
+ method: 'GET',
286
+ path: ws_path,
287
+ headers: {
288
+ ...credential,
289
+ upgrade: 'websocket',
290
+ connection: 'Upgrade',
291
+ 'sec-websocket-key': randomBytes(16).toString('base64'),
292
+ 'sec-websocket-version': '13'
293
+ },
294
+ body: null,
295
+ over_cap,
296
+ repeated_headers
297
+ };
298
+ case 'sse':
299
+ return {
300
+ method: 'GET',
301
+ path: sse_path,
302
+ headers: { ...credential, accept: 'text/event-stream' },
303
+ body: null,
304
+ over_cap,
305
+ repeated_headers
306
+ };
307
+ }
308
+ };
309
+ /** Put a probe on the socket, or through the in-process transport. */
310
+ const send_probe = async (fixture, probe) => {
311
+ if (base_url === null) {
312
+ const headers = new Headers(probe.headers);
313
+ // in-process there is no wire: a repeated header joins, as the runtimes join it
314
+ for (const [name, value] of probe.repeated_headers ?? [])
315
+ headers.append(name, value);
316
+ const res = await fixture.fresh_transport()(probe.path, {
317
+ method: probe.method,
318
+ headers,
319
+ ...(probe.body === null ? {} : { body: probe.body })
320
+ });
321
+ return { status: res.status, text: await res.text() };
322
+ }
323
+ const url = new URL(base_url);
324
+ const body = probe.over_cap ? '' : (probe.body ?? '');
325
+ const length_header = probe.over_cap
326
+ ? { 'content-length': String(BODY_LIMIT_DEFAULT_BYTES + 1) }
327
+ : probe.body === null
328
+ ? {}
329
+ : { 'content-length': String(body.length) };
330
+ const request = `${probe.method} ${probe.path} HTTP/1.1\r\n` +
331
+ to_header_lines({ host: url.host, ...probe.headers, ...length_header }) +
332
+ (probe.repeated_headers ?? []).map(([name, value]) => `${name}: ${value}\r\n`).join('') +
333
+ `\r\n` +
334
+ body;
335
+ const { received, closed_by_server } = await raw_http_exchange(base_url, request, {
336
+ read_timeout_ms: RAW_READ_TIMEOUT_MS,
337
+ until: 'response'
338
+ });
339
+ const response = raw_http_parse_response(received);
340
+ assert.ok(response, `${probe.method} ${probe.path}: expected a whole response; ` +
341
+ (closed_by_server
342
+ ? 'the server closed the connection first'
343
+ : `none arrived within ${RAW_READ_TIMEOUT_MS}ms`) +
344
+ `. Received: ${received ? received.slice(0, 200) : 'nothing'}`);
345
+ return { status: response.status, text: response.body };
346
+ };
347
+ describe('transport composition (front door)', () => {
348
+ for (const c of transport_composition_cases) {
349
+ const gated = c.gate === undefined || capabilities[c.gate];
350
+ // an upgrade needs a socket; in-process there is none
351
+ const reachable = c.surface !== 'ws_upgrade' || base_url !== null;
352
+ test_if(gated && reachable, case_name(c), async () => {
353
+ const fixture = await setup_test();
354
+ const res = await send_probe(fixture, await build_probe(c, fixture));
355
+ assert.strictEqual(res.status, c.expect.status, `${case_name(c)}: status (body: ${res.text.slice(0, 200)})`);
356
+ // a backend that declares no WS refusal body (Node's `@hono/node-ws`)
357
+ // may answer with an empty one; a body it does send is still asserted
358
+ if (c.surface === 'ws_upgrade' && !capabilities.ws_refusal_body && res.text === '')
359
+ return;
360
+ assert_refusal_body(c, parse_json(res.text), res.text);
361
+ });
362
+ }
363
+ });
364
+ };
@@ -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;AAiDzD,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"}
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;AAiEzD,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"}
@@ -9,8 +9,11 @@ import { LOGIN_RATE_LIMIT_ENABLED_ENV, SPINE_SSE_PATH, TS_SPINE_DIR_ENV } from '
9
9
  * WS transport's `request_connection` path drives server→client `peer/ping`),
10
10
  * `ws_account_actions` (the binary mounts its full action set on `/api/ws`,
11
11
  * the account actions among them),
12
- * and `cell_gated_create` (the binary's `full_spine_mount` mounts the
13
- * `test_cell_gated_create_authorize` policy).
12
+ * `cell_gated_create` (the binary's `full_spine_mount` mounts the
13
+ * `test_cell_gated_create_authorize` policy), and `ws_role_gated` (the binary
14
+ * mounts the role-gated WS endpoint at `SPINE_ROLE_GATED_WS_PATH`). The Node
15
+ * binary's preset; `ws_refusal_body` stays `false`, as `@hono/node-ws` drops a
16
+ * refused upgrade's body.
14
17
  */
15
18
  const ts_spine_capabilities = Object.freeze({
16
19
  ...ts_default_capabilities,
@@ -18,11 +21,22 @@ const ts_spine_capabilities = Object.freeze({
18
21
  ready: true,
19
22
  peer_request: true,
20
23
  ws_account_actions: true,
21
- cell_gated_create: true
24
+ cell_gated_create: true,
25
+ ws_role_gated: true
26
+ });
27
+ /**
28
+ * Capabilities for the **Deno** spine binary — `ts_spine_capabilities` with
29
+ * `ws_refusal_body: true` (`Deno.upgradeWebSocket` leaves a refused upgrade's
30
+ * response to Hono, body included).
31
+ */
32
+ const ts_spine_deno_capabilities = Object.freeze({
33
+ ...ts_spine_capabilities,
34
+ ws_refusal_body: true
22
35
  });
23
36
  /**
24
37
  * Capabilities for the **Bun** spine binary — `ts_spine_capabilities` with
25
- * `ws_handshake_pipelining: false`.
38
+ * `ws_handshake_pipelining: false` and `ws_refusal_body: true` (`Bun.serve`
39
+ * sends a refused upgrade's response as built).
26
40
  *
27
41
  * `Bun.serve` answers `400` to an upgrade request that carries WebSocket
28
42
  * frames in the same TCP write when the handler upgrades after an await that
@@ -34,7 +48,8 @@ const ts_spine_capabilities = Object.freeze({
34
48
  */
35
49
  const ts_spine_bun_capabilities = Object.freeze({
36
50
  ...ts_spine_capabilities,
37
- ws_handshake_pipelining: false
51
+ ws_handshake_pipelining: false,
52
+ ws_refusal_body: true
38
53
  });
39
54
  /** Default port for the Node TS spine binary — slots beside the Rust `spine_stub` (1177). */
40
55
  export const TS_SPINE_NODE_DEFAULT_PORT = 1178;
@@ -148,7 +163,7 @@ export const ts_spine_deno_backend_config = (options = {}) => {
148
163
  ...login_rate_limit_env(enable_login_rate_limit),
149
164
  ...action_rate_limit_env(options)
150
165
  },
151
- capabilities: ts_spine_capabilities
166
+ capabilities: ts_spine_deno_capabilities
152
167
  }),
153
168
  sse_path: SPINE_SSE_PATH
154
169
  };
@@ -1 +1 @@
1
- {"version":3,"file":"ws_action_rate_limit.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_action_rate_limit.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAgE9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAyC5C,+DAA+D;AAC/D,MAAM,WAAW,iCAAiC;IACjD,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gDAAgD;IAChD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC9B;AAkBD;;;;GAIG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,iCAAiC,KACxC,IAqRF,CAAC"}
1
+ {"version":3,"file":"ws_action_rate_limit.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_action_rate_limit.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAkE9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAgD5C,+DAA+D;AAC/D,MAAM,WAAW,iCAAiC;IACjD,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gDAAgD;IAChD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC9B;AAkBD;;;;GAIG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,iCAAiC,KACxC,IAwSF,CAAC"}
@@ -23,7 +23,9 @@ import '../assert_dev_env.js';
23
23
  * 'ip'`, public) with malformed params, from one `X-Forwarded-For` address
24
24
  * over anonymous HTTP and over a WS whose upgrade carried the same header:
25
25
  * the call past the cap is refused on both, while a second address keeps its
26
- * own budget on both.
26
+ * own budget on both. Over HTTP, IPv6 addresses bucket by their `/64`:
27
+ * another address in the same `/64` is refused once one has spent the
28
+ * budget, while one in the next `/64` keeps its own.
27
29
  * - **account axis at the dispatchers** — `cell_create` (`rate_limit:
28
30
  * 'account'`, on both spines' RPC and WS mounts) with malformed params: the
29
31
  * call past the cap answers `429` over HTTP and `rate_limited` over WS, calls
@@ -84,6 +86,13 @@ const IP_LIMITED_BAD_PARAMS = { nonce: 'not_a_number' };
84
86
  */
85
87
  const XFF_IP_EXHAUSTED = '198.51.100.1';
86
88
  const XFF_IP_FRESH = '198.51.100.2';
89
+ /**
90
+ * RFC 3849 documentation addresses: two in one `/64` — the second differs past
91
+ * bit 64 only — and one in the next `/64`. Distinct from `login_security.ts`'s.
92
+ */
93
+ const XFF_V6_EXHAUSTED = '2001:db8:e3a:1::1';
94
+ const XFF_V6_SAME_64 = '2001:db8:e3a:1:8000::2';
95
+ const XFF_V6_OTHER_64 = '2001:db8:e3a:2::1';
87
96
  /** Generous per-response wait — the cases assert the server's answer, not a timeout. */
88
97
  const RESPONSE_TIMEOUT_MS = 5000;
89
98
  const outcome_from_frame = (status, frame) => ({
@@ -254,5 +263,16 @@ export const describe_ws_action_rate_limit_cross_tests = (options) => {
254
263
  await ws_fresh.close();
255
264
  }
256
265
  });
266
+ test('an IP-limited action buckets IPv6 clients by /64', async () => {
267
+ const fixture = await setup_test();
268
+ const call = (forwarded_for) => call_http(fixture, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS, {
269
+ 'x-forwarded-for': forwarded_for
270
+ });
271
+ for (let i = 1; i <= max_attempts; i++) {
272
+ assert_charged_not_limited(await call(XFF_V6_EXHAUSTED), `call ${i} from ${XFF_V6_EXHAUSTED}`);
273
+ }
274
+ assert_rate_limited(await call(XFF_V6_SAME_64), `a call from ${XFF_V6_SAME_64}, same /64`);
275
+ assert_charged_not_limited(await call(XFF_V6_OTHER_64), `a call from ${XFF_V6_OTHER_64}, the next /64`);
276
+ });
257
277
  });
258
278
  };
@@ -0,0 +1,38 @@
1
+ import '../assert_dev_env.js';
2
+ import { type BackendCapabilities } from './capabilities.js';
3
+ import type { SetupTest } from './setup.js';
4
+ /** Configuration for `describe_ws_role_gated_cross_tests`. */
5
+ export interface WsRoleGatedCrossTestOptions {
6
+ /** Per-test fixture producer (`default_cross_process_setup(handle)`). */
7
+ readonly setup_test: SetupTest;
8
+ /** Backend capability flags; every case gates on `ws` and `ws_role_gated`. */
9
+ readonly capabilities: BackendCapabilities;
10
+ /** Base URL the backend is reachable at. */
11
+ readonly base_url: string;
12
+ /** The ungated WebSocket endpoint path (e.g. `/api/ws`). */
13
+ readonly ws_path: string;
14
+ /** The role-gated WebSocket endpoint path. */
15
+ readonly gated_ws_path: string;
16
+ /**
17
+ * The roles `gated_ws_path` requires: `ROLE_ADMIN` and `scoped_role`
18
+ * among them.
19
+ */
20
+ readonly gated_roles: ReadonlyArray<string>;
21
+ /** A role in `gated_roles` an admin can grant both globally and scoped. */
22
+ readonly scoped_role: string;
23
+ /** JSON-RPC endpoint path. Default `SPINE_RPC_PATH`. */
24
+ readonly rpc_path?: string;
25
+ }
26
+ /**
27
+ * Register the role-gated WebSocket suite: a revoked global grant of a gating
28
+ * role closes the account's gated socket with `WS_CLOSE_PERMISSION_REVOKED`,
29
+ * its ungated socket stays open and still receives the account's pushes, and
30
+ * a re-upgrade is refused at the gate; a scoped revoke of a gating role
31
+ * closes nothing.
32
+ *
33
+ * That the closed registration is sent nothing more is beyond what a client
34
+ * can observe once it has read the close frame, so the suite does not claim
35
+ * it: each spine's own transport tests pin it.
36
+ */
37
+ export declare const describe_ws_role_gated_cross_tests: (options: WsRoleGatedCrossTestOptions) => void;
38
+ //# sourceMappingURL=ws_role_gated.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws_role_gated.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_role_gated.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwC9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAKtE,OAAO,KAAK,EAAE,SAAS,EAAmC,MAAM,YAAY,CAAC;AAM7E,8DAA8D;AAC9D,MAAM,WAAW,2BAA2B;IAC3C,yEAAyE;IACzE,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,4CAA4C;IAC5C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,4DAA4D;IAC5D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,8CAA8C;IAC9C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC5C,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kCAAkC,GAAI,SAAS,2BAA2B,KAAG,IA2IzF,CAAC"}