@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
@@ -4,10 +4,28 @@
4
4
  * Tracks failed attempts per key (typically IP address) using a sliding
5
5
  * time window. No external dependencies — state resets on server restart.
6
6
  *
7
+ * **Reserve, then settle.** A handler that checks the limiter, awaits work,
8
+ * and records afterwards lets every request of a concurrent burst pass the
9
+ * check before any of them records — a burst of any size gets through.
10
+ * `RateLimiter.try_reserve` closes that: it admits a request and counts it in
11
+ * flight in one synchronous step, and the attempt counts against the cap
12
+ * until its `RateLimitReservation` is settled — `record` keeps it as an
13
+ * attempt, `release` gives it back. Every handler whose outcome decides
14
+ * whether the attempt counts reserves, and releases in a `finally` so an
15
+ * early return or a throw never leaks an in-flight slot; `check` + `record`
16
+ * stays for callers that record in the same synchronous step they check.
17
+ *
18
+ * **Attempts count while in flight, whatever their outcome** — so a burst of
19
+ * *correct* concurrent attempts from one key is capped too. The password
20
+ * surfaces therefore reserve with `RateLimiter.reserve`, which waits up to
21
+ * `RESERVATION_WAIT_MAX_MS` for a slot when the cap is reached only by
22
+ * attempts in flight, and refuses at once when it is reached by recorded ones.
23
+ *
7
24
  * @module
8
25
  */
9
26
  import { LruMap } from '@fuzdev/fuz_util/lru_map.ts';
10
27
  import { ERROR_RATE_LIMIT_EXCEEDED } from './http/error_schemas.js';
28
+ import { PERIODIC_INTERVAL_MS_MAX } from './periodic.js';
11
29
  /**
12
30
  * Default tracked-key cap: bounds worst-case memory under key-enumeration
13
31
  * attacks (an attacker rotating source IPs cannot grow the backing map
@@ -90,10 +108,36 @@ export const announce_rate_limiters_disabled = () => {
90
108
  rate_limiters_disabled_announced = true;
91
109
  console.error(RATE_LIMITERS_DISABLED_BANNER);
92
110
  };
111
+ /**
112
+ * `retry_after` reported while a key is at its cap only because of attempts
113
+ * still in flight. Those settle within a request's lifetime, so the earliest
114
+ * moment worth retrying is the next second, not the oldest recorded attempt's
115
+ * expiry. Mirrors the Rust spine's `IN_FLIGHT_RETRY_AFTER_SECONDS`.
116
+ */
117
+ export const IN_FLIGHT_RETRY_AFTER_SECONDS = 1;
118
+ /**
119
+ * How long a password-surface reservation (`RateLimiter.reserve`) waits for a
120
+ * slot when the key is at its cap only because of attempts still in flight,
121
+ * before answering `429`. Mirrors the Rust spine's `RESERVATION_WAIT_MAX`.
122
+ *
123
+ * Counting attempts while they are in flight caps *successful* concurrent
124
+ * attempts from one key too — a class behind one NAT logging in together.
125
+ * Those attempts settle within a request's lifetime, so a short wait turns
126
+ * most of those refusals into a late success, while a key at its cap on
127
+ * *recorded* attempts is still refused at once.
128
+ */
129
+ export const RESERVATION_WAIT_MAX_MS = 5_000;
130
+ /** The reservation a disabled limiter hands out: settling it does nothing. */
131
+ const INERT_RESERVATION = Object.freeze({
132
+ record: () => { },
133
+ release: () => { }
134
+ });
135
+ const entry_is_idle = (entry) => entry.timestamps.length === 0 && entry.in_flight === 0;
93
136
  /**
94
137
  * In-memory sliding window rate limiter.
95
138
  *
96
- * Stores an array of timestamps per key. On `check`/`record`, timestamps
139
+ * Stores an array of timestamps per key, plus a count of attempts reserved
140
+ * (`try_reserve`) and not yet settled. On `check`/`record`, timestamps
97
141
  * outside the window are pruned. `retry_after` reports seconds until the
98
142
  * oldest active timestamp expires.
99
143
  *
@@ -110,10 +154,37 @@ export const announce_rate_limiters_disabled = () => {
110
154
  */
111
155
  export class RateLimiter {
112
156
  options;
113
- /** Key → array of attempt timestamps. */
157
+ /** Key → recorded attempt timestamps + attempts in flight. */
114
158
  #attempts;
115
159
  #cleanup_timer = null;
160
+ /**
161
+ * `reserve` calls parked on a key at its in-flight cap, per key, with the
162
+ * wakers that key's settles, records, and resets run so each re-decides.
163
+ * Held apart from the attempts map, so evicting a key's entry strands none
164
+ * of its waiters; a key's slot is removed when its last waiter leaves. Per
165
+ * key, so a settle wakes only the waiters it can admit or refuse — never
166
+ * every waiter on the limiter.
167
+ */
168
+ #waiters = new Map();
169
+ /**
170
+ * @param options - the limiter's bounds
171
+ * @throws {RangeError} when `max_attempts`, `window_ms`, or `cleanup_interval_ms` is not a safe integer ≥ 0, `cleanup_interval_ms` exceeds `PERIODIC_INTERVAL_MS_MAX`, or `max_keys` is neither `null` nor a positive safe integer
172
+ */
116
173
  constructor(options) {
174
+ for (const field of ['max_attempts', 'window_ms', 'cleanup_interval_ms']) {
175
+ const value = options[field];
176
+ if (!Number.isSafeInteger(value) || value < 0) {
177
+ throw new RangeError(`RateLimiterOptions.${field} must be a safe integer ≥ 0: ${value}`);
178
+ }
179
+ }
180
+ if (options.cleanup_interval_ms > PERIODIC_INTERVAL_MS_MAX) {
181
+ throw new RangeError(`RateLimiterOptions.cleanup_interval_ms must be at most ${PERIODIC_INTERVAL_MS_MAX}: ${options.cleanup_interval_ms}`);
182
+ }
183
+ if (options.max_keys !== undefined &&
184
+ options.max_keys !== null &&
185
+ !(Number.isSafeInteger(options.max_keys) && options.max_keys > 0)) {
186
+ throw new RangeError(`RateLimiterOptions.max_keys must be null or a positive safe integer: ${options.max_keys}`);
187
+ }
117
188
  this.options = options;
118
189
  const max_keys = options.max_keys === undefined ? DEFAULT_RATE_LIMITER_MAX_KEYS : options.max_keys;
119
190
  this.#attempts = max_keys === null ? new Map() : new LruMap(max_keys);
@@ -129,6 +200,17 @@ export class RateLimiter {
129
200
  get size() {
130
201
  return this.#attempts.size;
131
202
  }
203
+ /**
204
+ * Keys holding a parked-waiter slot right now (see `reserve`): each slot is
205
+ * removed with its key's last waiter. For tests pinning that a waiter
206
+ * leaves nothing behind; the twin of the Rust spine's test-only
207
+ * `waiter_keys`.
208
+ *
209
+ * @internal
210
+ */
211
+ get waiter_keys() {
212
+ return this.#waiters.size;
213
+ }
132
214
  /**
133
215
  * Custom inspect output that exposes only `options` and `size`, never the
134
216
  * tracked keys. The key set is sensitive (usernames / IP addresses) and
@@ -141,38 +223,59 @@ export class RateLimiter {
141
223
  return { options: this.options, size: this.size };
142
224
  }
143
225
  /**
144
- * Check whether `key` is allowed without recording an attempt.
226
+ * Check whether `key` is allowed without recording an attempt, counting
227
+ * recorded attempts and reserved ones still in flight.
145
228
  *
146
229
  * Prunes timestamps that fell outside the window as a side effect (and
147
- * removes the key entirely when none remain), so the backing map stays
148
- * bounded even under read-only traffic.
230
+ * removes the key entirely when none remain and none are in flight), so
231
+ * the backing map stays bounded even under read-only traffic.
232
+ *
233
+ * A check followed by an awaited operation and then a `record` lets a
234
+ * concurrent burst past the cap; reserve with `try_reserve` instead.
149
235
  *
150
236
  * @param key - rate limit key (e.g. IP address)
151
237
  * @param now - current timestamp in ms (defaults to `Date.now()`)
152
238
  * @mutates internal map - prunes expired timestamps for `key`
153
239
  */
154
240
  check(key, now = Date.now()) {
241
+ const load = this.#load(key, now);
242
+ switch (load.kind) {
243
+ case 'free':
244
+ return { allowed: true, remaining: this.options.max_attempts - load.used, retry_after: 0 };
245
+ case 'recorded':
246
+ return { allowed: false, remaining: 0, retry_after: load.retry_after };
247
+ case 'in_flight':
248
+ return { allowed: false, remaining: 0, retry_after: IN_FLIGHT_RETRY_AFTER_SECONDS };
249
+ }
250
+ }
251
+ /** Where `key` stands against the cap, pruning expired timestamps first. */
252
+ #load(key, now) {
155
253
  const { max_attempts, window_ms } = this.options;
156
- const cutoff = now - window_ms;
157
- const timestamps = this.#attempts.get(key);
158
- if (!timestamps) {
159
- return { allowed: true, remaining: max_attempts, retry_after: 0 };
254
+ // A cap of zero refuses before reading the key: no entry is created or
255
+ // promoted, and no reservation parks. The wait it names is the whole
256
+ // window, at least a second.
257
+ if (max_attempts === 0) {
258
+ return { kind: 'recorded', retry_after: Math.max(1, Math.ceil(window_ms / 1000)) };
160
259
  }
161
- const active = timestamps.filter((t) => t > cutoff);
162
- if (active.length !== timestamps.length) {
163
- if (active.length === 0) {
164
- this.#attempts.delete(key);
165
- }
166
- else {
167
- this.#attempts.set(key, active);
168
- }
260
+ const entry = this.#attempts.get(key);
261
+ if (!entry)
262
+ return { kind: 'free', used: 0 };
263
+ const cutoff = now - window_ms;
264
+ const active = entry.timestamps.filter((t) => t > cutoff);
265
+ entry.timestamps = active;
266
+ // A key with attempts in flight stays even with no live timestamp:
267
+ // deleting it would drop the count it carries.
268
+ if (entry_is_idle(entry)) {
269
+ this.#attempts.delete(key);
270
+ return { kind: 'free', used: 0 };
169
271
  }
170
- if (active.length < max_attempts) {
171
- return { allowed: true, remaining: max_attempts - active.length, retry_after: 0 };
272
+ // At the cap on recorded attempts alone, wait for the oldest; at the
273
+ // cap only with the in-flight attempts counted, a settle frees a slot.
274
+ if (active.length >= max_attempts) {
275
+ return { kind: 'recorded', retry_after: Math.ceil((active[0] + window_ms - now) / 1000) };
172
276
  }
173
- const oldest = active[0];
174
- const retry_after = Math.ceil((oldest + window_ms - now) / 1000);
175
- return { allowed: false, remaining: 0, retry_after };
277
+ const used = active.length + entry.in_flight;
278
+ return used < max_attempts ? { kind: 'free', used } : { kind: 'in_flight' };
176
279
  }
177
280
  /**
178
281
  * Record a failed attempt for `key` and return the updated result.
@@ -184,19 +287,21 @@ export class RateLimiter {
184
287
  record(key, now = Date.now()) {
185
288
  const { max_attempts, window_ms } = this.options;
186
289
  const cutoff = now - window_ms;
187
- let timestamps = this.#attempts.get(key);
188
- if (timestamps) {
290
+ let entry = this.#attempts.get(key);
291
+ if (entry) {
189
292
  // Prune expired entries in place.
190
- const active = timestamps.filter((t) => t > cutoff);
191
- active.push(now);
192
- this.#attempts.set(key, active);
193
- timestamps = active;
293
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
294
+ entry.timestamps.push(now);
194
295
  }
195
296
  else {
196
- timestamps = [now];
197
- this.#attempts.set(key, timestamps);
297
+ entry = { timestamps: [now], in_flight: 0 };
298
+ this.#attempts.set(key, entry);
198
299
  }
199
- const count = timestamps.length;
300
+ // A parked reservation re-decides: the key may now be at its cap on
301
+ // recorded attempts, which refuses it at once.
302
+ this.#wake(key);
303
+ const timestamps = entry.timestamps;
304
+ const count = timestamps.length + entry.in_flight;
200
305
  if (count <= max_attempts) {
201
306
  return { allowed: true, remaining: max_attempts - count, retry_after: 0 };
202
307
  }
@@ -204,6 +309,171 @@ export class RateLimiter {
204
309
  const retry_after = Math.ceil((oldest + window_ms - now) / 1000);
205
310
  return { allowed: false, remaining: 0, retry_after };
206
311
  }
312
+ /**
313
+ * Admit an attempt for `key` and count it in flight, in one synchronous
314
+ * step.
315
+ *
316
+ * The attempt counts against the cap — for this request and every
317
+ * concurrent one — until the returned reservation is settled: recorded as
318
+ * an attempt, or released. So no burst of concurrent requests gets more
319
+ * than `max_attempts` past the limiter between them. Release in a
320
+ * `finally`: nothing settles a reservation for you, so one left unsettled
321
+ * holds its slot for the life of the process.
322
+ *
323
+ * @param key - rate limit key (e.g. IP address)
324
+ * @param now - current timestamp in ms (defaults to `Date.now()`)
325
+ * @returns the reservation, or `{ok: false, retry_after}` when recorded plus in-flight attempts already reach the cap (nothing is reserved then)
326
+ * @mutates internal map - increments `key`'s in-flight count, creating its entry when absent
327
+ */
328
+ try_reserve(key, now = Date.now()) {
329
+ const checked = this.check(key, now);
330
+ if (!checked.allowed)
331
+ return { ok: false, retry_after: checked.retry_after };
332
+ return { ok: true, reservation: this.#admit(key) };
333
+ }
334
+ /**
335
+ * `try_reserve`, waiting up to `max_wait_ms` for a slot when the key is at
336
+ * its cap only because of attempts still in flight.
337
+ *
338
+ * Each settle (and record and reset) on the key wakes its waiters to
339
+ * re-decide — that key's only, so a settle costs nothing for waiters parked
340
+ * elsewhere: a freed slot is reserved; recorded attempts reaching the cap
341
+ * refuse at once with their real `retry_after`; reaching `max_wait_ms` (or
342
+ * `signal` aborting) refuses with `IN_FLIGHT_RETRY_AFTER_SECONDS`. At most
343
+ * `max_attempts` reservations wait on one key — one more is refused at
344
+ * once — so a flood can't park requests without bound. A waiter that
345
+ * leaves, by any path, leaves the key's waiter count as it found it. A
346
+ * waiter holds no place in a queue: each wake, every waiter re-decides, so
347
+ * one can lose every race for a freed slot until `max_wait_ms` refuses it.
348
+ *
349
+ * @param key - rate limit key (e.g. IP address)
350
+ * @param options - the wait bound and an abort signal
351
+ * @returns the reservation, or `{ok: false, retry_after}` as described (nothing is reserved then)
352
+ * @mutates internal map - increments `key`'s in-flight count on admission; counts the call in `key`'s waiters while it waits
353
+ */
354
+ async reserve(key, options = {}) {
355
+ const { max_wait_ms = RESERVATION_WAIT_MAX_MS, signal } = options;
356
+ const deadline = Date.now() + max_wait_ms;
357
+ let parked = false;
358
+ try {
359
+ for (;;) {
360
+ const load = this.#load(key, Date.now());
361
+ if (load.kind === 'free') {
362
+ this.#leave(key, parked);
363
+ parked = false;
364
+ return { ok: true, reservation: this.#admit(key) };
365
+ }
366
+ if (load.kind === 'recorded')
367
+ return { ok: false, retry_after: load.retry_after };
368
+ if (!parked) {
369
+ let waiters = this.#waiters.get(key);
370
+ if (waiters && waiters.count >= this.options.max_attempts) {
371
+ return { ok: false, retry_after: IN_FLIGHT_RETRY_AFTER_SECONDS };
372
+ }
373
+ if (!waiters) {
374
+ waiters = { count: 0, wakers: new Set() };
375
+ this.#waiters.set(key, waiters);
376
+ }
377
+ waiters.count++;
378
+ parked = true;
379
+ }
380
+ const remaining_ms = deadline - Date.now();
381
+ if (remaining_ms <= 0 || !(await this.#wait_for_wake(key, remaining_ms, signal))) {
382
+ return { ok: false, retry_after: IN_FLIGHT_RETRY_AFTER_SECONDS };
383
+ }
384
+ }
385
+ }
386
+ finally {
387
+ this.#leave(key, parked);
388
+ }
389
+ }
390
+ /** Leave `key`'s waiters, if parked there; the last one out removes the key's slot. */
391
+ #leave(key, parked) {
392
+ if (!parked)
393
+ return;
394
+ const waiters = this.#waiters.get(key);
395
+ if (!waiters)
396
+ return;
397
+ waiters.count--;
398
+ if (waiters.count <= 0)
399
+ this.#waiters.delete(key);
400
+ }
401
+ /** Resolves `true` on `key`'s next wake, `false` on timeout or abort; cleans up either way. */
402
+ #wait_for_wake(key, timeout_ms, signal) {
403
+ return new Promise((resolve) => {
404
+ const wakers = this.#waiters.get(key)?.wakers;
405
+ if (signal?.aborted || !wakers) {
406
+ resolve(false);
407
+ return;
408
+ }
409
+ const done = (woke) => {
410
+ wakers.delete(waker);
411
+ clearTimeout(timer);
412
+ signal?.removeEventListener('abort', on_abort);
413
+ resolve(woke);
414
+ };
415
+ const waker = () => done(true);
416
+ const on_abort = () => done(false);
417
+ const timer = setTimeout(() => done(false), timeout_ms);
418
+ wakers.add(waker);
419
+ signal?.addEventListener('abort', on_abort, { once: true });
420
+ });
421
+ }
422
+ /** Run the wakers of the reservations parked on `key`. */
423
+ #wake(key) {
424
+ const wakers = this.#waiters.get(key)?.wakers;
425
+ if (!wakers)
426
+ return;
427
+ for (const waker of [...wakers])
428
+ waker();
429
+ }
430
+ /** Count one more attempt in flight on `key`, already found free. */
431
+ #admit(key) {
432
+ let entry = this.#attempts.get(key);
433
+ if (!entry) {
434
+ // A fresh key past `max_keys` evicts the least-recently-used entry,
435
+ // in-flight attempts and all; their reservations settle as no-ops
436
+ // (release) or into a fresh entry (record).
437
+ entry = { timestamps: [], in_flight: 0 };
438
+ this.#attempts.set(key, entry);
439
+ }
440
+ entry.in_flight++;
441
+ const reserved = entry;
442
+ let settled = false;
443
+ const settle = (record, settle_now) => {
444
+ if (settled)
445
+ return;
446
+ settled = true;
447
+ this.#settle(key, reserved, record, settle_now);
448
+ };
449
+ return {
450
+ record: (record_now = Date.now()) => settle(true, record_now),
451
+ release: () => settle(false, Date.now())
452
+ };
453
+ }
454
+ #settle(key, reserved, record, now) {
455
+ const current = this.#attempts.get(key);
456
+ if (current !== reserved) {
457
+ // The reserved entry is gone — evicted, or reset with nothing in
458
+ // flight but this — so there is no in-flight count to give back. A
459
+ // record still counts, against whatever the key holds now.
460
+ if (record)
461
+ this.record(key, now);
462
+ else
463
+ this.#wake(key);
464
+ return;
465
+ }
466
+ reserved.in_flight = Math.max(0, reserved.in_flight - 1);
467
+ if (record) {
468
+ const cutoff = now - this.options.window_ms;
469
+ reserved.timestamps = reserved.timestamps.filter((t) => t > cutoff);
470
+ reserved.timestamps.push(now);
471
+ }
472
+ else if (entry_is_idle(reserved)) {
473
+ this.#attempts.delete(key);
474
+ }
475
+ this.#wake(key);
476
+ }
207
477
  /**
208
478
  * Clear all attempts for `key`.
209
479
  *
@@ -218,8 +488,9 @@ export class RateLimiter {
218
488
  * otherwise an attacker interleaves their own logins (or, under open signup,
219
489
  * throwaway account creations needing no credential at all) with guesses
220
490
  * against arbitrary victims and sprays indefinitely from one address.
221
- * Nothing records on the IP bucket on success on any path, so with no reset
222
- * it is a pure monotone-within-window failure counter that only time clears.
491
+ * The only success that records on an IP bucket is an open signup (charged
492
+ * like a failure); no success clears one, so with no reset each is a
493
+ * monotone-within-window counter that only time clears.
223
494
  *
224
495
  * Because they are monotone, the IP buckets are **one instance per auth
225
496
  * surface** (`login_ip_rate_limiter`, `signup_ip_rate_limiter`,
@@ -229,13 +500,24 @@ export class RateLimiter {
229
500
  * four routes at once — a cost that used to be masked by the reset this
230
501
  * doc forbids.
231
502
  *
232
- * @mutates internal map - removes the entry for `key`
503
+ * Attempts still in flight on `key` (reserved by concurrent requests) are
504
+ * kept: a reset forgives what was recorded, not what is still running.
505
+ *
506
+ * @mutates internal map - removes the entry for `key`, or clears its timestamps while attempts are in flight
233
507
  */
234
508
  reset(key) {
235
- this.#attempts.delete(key);
509
+ const entry = this.#attempts.get(key);
510
+ if (entry && entry.in_flight > 0) {
511
+ entry.timestamps = [];
512
+ }
513
+ else {
514
+ this.#attempts.delete(key);
515
+ }
516
+ this.#wake(key);
236
517
  }
237
518
  /**
238
- * Remove entries whose timestamps are all outside the window.
519
+ * Remove entries whose timestamps are all outside the window and that
520
+ * have no attempt in flight.
239
521
  *
240
522
  * @param now - current timestamp in ms (defaults to `Date.now()`)
241
523
  * @mutates internal map - prunes expired timestamps and deletes empty keys
@@ -246,14 +528,11 @@ export class RateLimiter {
246
528
  // to the MRU end during iteration and causes re-visit in the same pass.
247
529
  // The `Map` path is unaffected but the snapshot is cheap on both.
248
530
  const entries = [...this.#attempts];
249
- for (const [key, timestamps] of entries) {
250
- const active = timestamps.filter((t) => t > cutoff);
251
- if (active.length === 0) {
531
+ for (const [key, entry] of entries) {
532
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
533
+ if (entry_is_idle(entry)) {
252
534
  this.#attempts.delete(key);
253
535
  }
254
- else {
255
- this.#attempts.set(key, active);
256
- }
257
536
  }
258
537
  }
259
538
  /**
@@ -268,6 +547,37 @@ export class RateLimiter {
268
547
  }
269
548
  }
270
549
  }
550
+ /**
551
+ * `RateLimiter.try_reserve` against a limiter that may be absent.
552
+ *
553
+ * An inert reservation when `limiter` is `null` (disabled) or `key` is `null`
554
+ * (no resolvable key — e.g. no client IP), so a handler reserves and settles
555
+ * the same way whether or not the surface is limited. Twin of the Rust
556
+ * spine's `try_reserve_optional`.
557
+ *
558
+ * @param limiter - the limiter, or `null` when disabled
559
+ * @param key - rate limit key, or `null` to skip
560
+ * @param now - current timestamp in ms (defaults to `Date.now()`)
561
+ */
562
+ export const try_reserve_optional = (limiter, key, now = Date.now()) => {
563
+ if (!limiter || key === null)
564
+ return { ok: true, reservation: INERT_RESERVATION };
565
+ return limiter.try_reserve(key, now);
566
+ };
567
+ /**
568
+ * `RateLimiter.reserve` against a limiter that may be absent — an inert
569
+ * reservation when `limiter` or `key` is `null`. Twin of the Rust spine's
570
+ * `reserve_optional`.
571
+ *
572
+ * @param limiter - the limiter, or `null` when disabled
573
+ * @param key - rate limit key, or `null` to skip
574
+ * @param options - the wait bound and an abort signal
575
+ */
576
+ export const reserve_optional = async (limiter, key, options) => {
577
+ if (!limiter || key === null)
578
+ return { ok: true, reservation: INERT_RESERVATION };
579
+ return limiter.reserve(key, options);
580
+ };
271
581
  /**
272
582
  * Create a `RateLimiter` with sensible defaults for per-IP login protection.
273
583
  *
@@ -35,12 +35,20 @@ export declare const AUDIT_LOG_CHANNEL = "audit_log";
35
35
  * their `groups` — pending registrations included. The audit stream route
36
36
  * registers `scope = session hash` and `groups = [account id]`; a route that
37
37
  * admits bearer tokens registers the API token id too, and
38
- * `close_sockets_for_token` then reaches it. `close_all_sockets` is
39
- * `SubscriberRegistry.close_all` — every stream, for shutdown.
38
+ * `close_sockets_for_token` then reaches it. `close_sockets_for_role` closes
39
+ * the account's streams when `role` is the one the registry's streams are
40
+ * gated on — the registry keeps one gating role for all its streams, not one
41
+ * per stream. `close_all_sockets` is `SubscriberRegistry.close_all` — every
42
+ * stream, for shutdown.
40
43
  *
41
44
  * Twin of the Rust spine's `SocketRevoker` impl on `SseRegistry`.
45
+ *
46
+ * @param registry - the registry to close streams on
47
+ * @param required_role - the role every stream of `registry` was gated on,
48
+ * or `null` when they are not role-gated (`close_sockets_for_role` then
49
+ * closes nothing)
42
50
  */
43
- export declare const create_sse_connection_closer: <T>(registry: SubscriberRegistry<T>) => ConnectionCloser;
51
+ export declare const create_sse_connection_closer: <T>(registry: SubscriberRegistry<T>, required_role: string | null) => ConnectionCloser;
44
52
  /**
45
53
  * Create an audit event handler that closes SSE streams on a successful
46
54
  * revocation row, dispatching on the event's `RevocationScope`
@@ -56,9 +64,12 @@ export declare const create_sse_connection_closer: <T>(registry: SubscriberRegis
56
64
  * registers the token id and is closed here.
57
65
  * - `account` → every stream of `target_account_id ?? account_id`.
58
66
  * - `role` → every stream of the target account, when the revoked role
59
- * (`metadata.role`) is `required_role`. The WebSocket guard does not act on
60
- * this scope — per-message dispatch re-authorizes there — but a one-way
61
- * stream has no later check, so it must close here.
67
+ * (`metadata.role`) is `required_role` and the revoked grant was global
68
+ * (`to_global_role_revocation`): the stream's role gate admits on global
69
+ * grants only, so revoking a scoped grant of the same role takes away
70
+ * nothing it admitted on. A stream is authorized once at open and has no
71
+ * later check; the WebSocket guard closes its role-gated sockets on the
72
+ * same rows.
62
73
  * - `none` → nothing.
63
74
  *
64
75
  * Over-closing a one-way feed is cheap: the client reconnects if it is still
@@ -94,6 +105,11 @@ export interface AuditLogSse {
94
105
  log: Logger;
95
106
  /** Combined broadcast + guard callback. Wired by `create_app_server`'s `audit_log_sse` option, or compose inside the consumer's `audit_factory` body. */
96
107
  on_audit_event: (event: AuditLogEvent) => void;
108
+ /**
109
+ * The role the stream requires — what a caller adapting `registry` passes
110
+ * `create_sse_connection_closer`, so its role close matches the guard's.
111
+ */
112
+ required_role: string;
97
113
  }
98
114
  /**
99
115
  * SSE event specs for audit log events.
@@ -1 +1 @@
1
- {"version":3,"file":"sse_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,EAIN,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACxF,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE3D,2DAA2D;AAC3D,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAE7C;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,4BAA4B,GAAI,CAAC,EAC7C,UAAU,kBAAkB,CAAC,CAAC,CAAC,KAC7B,gBAKD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,EACtC,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,GAAG,IAAI,EAC5B,KAAK,MAAM,KACT,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CA4DjC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B;;;;;OAKG;IACH,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;IAC9C,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC;IACZ,yJAAyJ;IACzJ,cAAc,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;CAC/C;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,SAAS,CAOlD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,eAAO,MAAM,oBAAoB,GAAI,SAAS;IAC7C,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;;OASG;IACH,iBAAiB,EAAE,cAAc,GAAG,IAAI,CAAC;IACzC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B,KAAG,WAgBH,CAAC"}
1
+ {"version":3,"file":"sse_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,EAKN,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACxF,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE3D,2DAA2D;AAC3D,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,4BAA4B,GAAI,CAAC,EAC7C,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,GAAG,IAAI,KAC1B,gBAOD,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,EACtC,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,GAAG,IAAI,EAC5B,KAAK,MAAM,KACT,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAoEjC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B;;;;;OAKG;IACH,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;IAC9C,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC;IACZ,yJAAyJ;IACzJ,cAAc,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC/C;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,SAAS,CAOlD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,eAAO,MAAM,oBAAoB,GAAI,SAAS;IAC7C,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;;OASG;IACH,iBAAiB,EAAE,cAAc,GAAG,IAAI,CAAC;IACzC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B,KAAG,WAiBH,CAAC"}
@@ -20,7 +20,7 @@
20
20
  * @module
21
21
  */
22
22
  import { UnreachableError } from '@fuzdev/fuz_util/error.ts';
23
- import { AUDIT_EVENT_TYPES, AuditLogEventJson, to_revocation_scope } from '../auth/audit_log_schema.js';
23
+ import { AUDIT_EVENT_TYPES, AuditLogEventJson, to_global_role_revocation, to_revocation_scope } from '../auth/audit_log_schema.js';
24
24
  import { SubscriberRegistry } from './subscriber_registry.js';
25
25
  /** SSE channel the audit-log stream route publishes on. */
26
26
  export const AUDIT_LOG_CHANNEL = 'audit_log';
@@ -33,15 +33,24 @@ export const AUDIT_LOG_CHANNEL = 'audit_log';
33
33
  * their `groups` — pending registrations included. The audit stream route
34
34
  * registers `scope = session hash` and `groups = [account id]`; a route that
35
35
  * admits bearer tokens registers the API token id too, and
36
- * `close_sockets_for_token` then reaches it. `close_all_sockets` is
37
- * `SubscriberRegistry.close_all` — every stream, for shutdown.
36
+ * `close_sockets_for_token` then reaches it. `close_sockets_for_role` closes
37
+ * the account's streams when `role` is the one the registry's streams are
38
+ * gated on — the registry keeps one gating role for all its streams, not one
39
+ * per stream. `close_all_sockets` is `SubscriberRegistry.close_all` — every
40
+ * stream, for shutdown.
38
41
  *
39
42
  * Twin of the Rust spine's `SocketRevoker` impl on `SseRegistry`.
43
+ *
44
+ * @param registry - the registry to close streams on
45
+ * @param required_role - the role every stream of `registry` was gated on,
46
+ * or `null` when they are not role-gated (`close_sockets_for_role` then
47
+ * closes nothing)
40
48
  */
41
- export const create_sse_connection_closer = (registry) => ({
49
+ export const create_sse_connection_closer = (registry, required_role) => ({
42
50
  close_sockets_for_session: (session_token_hash) => registry.close_by_identity(session_token_hash),
43
51
  close_sockets_for_token: (api_token_id) => registry.close_by_identity(api_token_id),
44
52
  close_sockets_for_account: (account_id) => registry.close_by_identity(account_id),
53
+ close_sockets_for_role: (account_id, role) => required_role !== null && role === required_role ? registry.close_by_identity(account_id) : 0,
45
54
  close_all_sockets: () => registry.close_all()
46
55
  });
47
56
  /**
@@ -59,9 +68,12 @@ export const create_sse_connection_closer = (registry) => ({
59
68
  * registers the token id and is closed here.
60
69
  * - `account` → every stream of `target_account_id ?? account_id`.
61
70
  * - `role` → every stream of the target account, when the revoked role
62
- * (`metadata.role`) is `required_role`. The WebSocket guard does not act on
63
- * this scope — per-message dispatch re-authorizes there — but a one-way
64
- * stream has no later check, so it must close here.
71
+ * (`metadata.role`) is `required_role` and the revoked grant was global
72
+ * (`to_global_role_revocation`): the stream's role gate admits on global
73
+ * grants only, so revoking a scoped grant of the same role takes away
74
+ * nothing it admitted on. A stream is authorized once at open and has no
75
+ * later check; the WebSocket guard closes its role-gated sockets on the
76
+ * same rows.
65
77
  * - `none` → nothing.
66
78
  *
67
79
  * Over-closing a one-way feed is cheap: the client reconnects if it is still
@@ -111,20 +123,26 @@ export const create_sse_auth_guard = (registry, required_role, log) => {
111
123
  }
112
124
  return;
113
125
  }
114
- case 'role':
126
+ case 'role': {
115
127
  // `null` means the stream isn't gated by a specific role_grant, so
116
128
  // a role revocation closes nothing.
117
129
  if (required_role === null)
118
130
  return;
119
- if (event.metadata?.role !== required_role)
131
+ // global grants only — the gate never admitted on a scoped one
132
+ const revoked = to_global_role_revocation(event);
133
+ if (!revoked || revoked.role !== required_role)
120
134
  return;
121
- break;
135
+ const closed = registry.close_by_identity(revoked.account_id);
136
+ if (closed > 0) {
137
+ log.info(`SSE auth guard: closed ${closed} stream(s) for account ${revoked.account_id} (${event.event_type})`);
138
+ }
139
+ return;
140
+ }
122
141
  case 'account':
123
142
  break;
124
143
  default:
125
144
  throw new UnreachableError(scope);
126
145
  }
127
- // `role` and `account` both close the affected account's streams —
128
146
  // admin actions set target_account_id, self-service actions only set
129
147
  // account_id
130
148
  const target = event.target_account_id ?? event.account_id;
@@ -207,9 +225,10 @@ export const create_audit_log_sse = (options) => {
207
225
  const max_per_scope = options.max_per_scope === undefined ? AUDIT_LOG_SSE_MAX_PER_SCOPE : options.max_per_scope;
208
226
  const registry = new SubscriberRegistry({ max_per_scope, log: options.log });
209
227
  const guard = create_sse_auth_guard(registry, role, options.log);
210
- options.connection_closer?.add(create_sse_connection_closer(registry));
228
+ options.connection_closer?.add(create_sse_connection_closer(registry, role));
211
229
  return {
212
230
  log: options.log,
231
+ required_role: role,
213
232
  on_audit_event: (event) => {
214
233
  registry.broadcast(AUDIT_LOG_CHANNEL, { method: event.event_type, params: event });
215
234
  guard(event);