@fuzdev/fuz_app 0.54.0 → 0.56.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 (348) hide show
  1. package/dist/actions/CLAUDE.md +214 -103
  2. package/dist/actions/action_bridge.d.ts +8 -5
  3. package/dist/actions/action_bridge.d.ts.map +1 -1
  4. package/dist/actions/action_bridge.js +1 -11
  5. package/dist/actions/action_codegen.d.ts +32 -0
  6. package/dist/actions/action_codegen.d.ts.map +1 -1
  7. package/dist/actions/action_codegen.js +35 -15
  8. package/dist/actions/action_registry.d.ts.map +1 -1
  9. package/dist/actions/action_registry.js +5 -2
  10. package/dist/actions/action_rpc.d.ts +141 -22
  11. package/dist/actions/action_rpc.d.ts.map +1 -1
  12. package/dist/actions/action_rpc.js +106 -187
  13. package/dist/actions/action_spec.d.ts +55 -16
  14. package/dist/actions/action_spec.d.ts.map +1 -1
  15. package/dist/actions/action_spec.js +16 -11
  16. package/dist/actions/action_types.d.ts +28 -60
  17. package/dist/actions/action_types.d.ts.map +1 -1
  18. package/dist/actions/action_types.js +13 -5
  19. package/dist/actions/broadcast_api.d.ts +2 -2
  20. package/dist/actions/broadcast_api.js +2 -2
  21. package/dist/actions/compile_action_registry.d.ts +50 -0
  22. package/dist/actions/compile_action_registry.d.ts.map +1 -0
  23. package/dist/actions/compile_action_registry.js +69 -0
  24. package/dist/actions/heartbeat.d.ts +8 -4
  25. package/dist/actions/heartbeat.d.ts.map +1 -1
  26. package/dist/actions/heartbeat.js +5 -4
  27. package/dist/actions/perform_action.d.ts +145 -0
  28. package/dist/actions/perform_action.d.ts.map +1 -0
  29. package/dist/actions/perform_action.js +258 -0
  30. package/dist/actions/register_action_ws.d.ts +46 -40
  31. package/dist/actions/register_action_ws.d.ts.map +1 -1
  32. package/dist/actions/register_action_ws.js +101 -159
  33. package/dist/actions/register_ws_endpoint.d.ts +15 -10
  34. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  35. package/dist/actions/register_ws_endpoint.js +54 -7
  36. package/dist/actions/transports.d.ts.map +1 -1
  37. package/dist/actions/transports.js +0 -4
  38. package/dist/actions/transports_ws_auth_guard.d.ts +1 -1
  39. package/dist/actions/transports_ws_auth_guard.js +1 -1
  40. package/dist/actions/transports_ws_backend.d.ts +1 -1
  41. package/dist/actions/transports_ws_backend.js +1 -1
  42. package/dist/auth/CLAUDE.md +794 -410
  43. package/dist/auth/account_action_specs.d.ts +28 -7
  44. package/dist/auth/account_action_specs.d.ts.map +1 -1
  45. package/dist/auth/account_action_specs.js +7 -7
  46. package/dist/auth/account_actions.d.ts +7 -13
  47. package/dist/auth/account_actions.d.ts.map +1 -1
  48. package/dist/auth/account_actions.js +26 -35
  49. package/dist/auth/account_queries.d.ts +52 -16
  50. package/dist/auth/account_queries.d.ts.map +1 -1
  51. package/dist/auth/account_queries.js +87 -38
  52. package/dist/auth/account_routes.d.ts +9 -11
  53. package/dist/auth/account_routes.d.ts.map +1 -1
  54. package/dist/auth/account_routes.js +118 -46
  55. package/dist/auth/account_schema.d.ts +46 -35
  56. package/dist/auth/account_schema.d.ts.map +1 -1
  57. package/dist/auth/account_schema.js +21 -28
  58. package/dist/auth/admin_action_specs.d.ts +100 -32
  59. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  60. package/dist/auth/admin_action_specs.js +64 -33
  61. package/dist/auth/admin_actions.d.ts +13 -19
  62. package/dist/auth/admin_actions.d.ts.map +1 -1
  63. package/dist/auth/admin_actions.js +37 -41
  64. package/dist/auth/audit_emitter.d.ts +160 -0
  65. package/dist/auth/audit_emitter.d.ts.map +1 -0
  66. package/dist/auth/audit_emitter.js +83 -0
  67. package/dist/auth/audit_log_queries.d.ts +17 -48
  68. package/dist/auth/audit_log_queries.d.ts.map +1 -1
  69. package/dist/auth/audit_log_queries.js +20 -56
  70. package/dist/auth/audit_log_routes.d.ts +1 -1
  71. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  72. package/dist/auth/audit_log_routes.js +7 -3
  73. package/dist/auth/audit_log_schema.d.ts +92 -32
  74. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  75. package/dist/auth/audit_log_schema.js +75 -46
  76. package/dist/auth/auth_guard_resolver.d.ts +44 -0
  77. package/dist/auth/auth_guard_resolver.d.ts.map +1 -0
  78. package/dist/auth/auth_guard_resolver.js +56 -0
  79. package/dist/auth/bearer_auth.d.ts +9 -7
  80. package/dist/auth/bearer_auth.d.ts.map +1 -1
  81. package/dist/auth/bearer_auth.js +13 -21
  82. package/dist/auth/bootstrap_account.d.ts +7 -7
  83. package/dist/auth/bootstrap_account.d.ts.map +1 -1
  84. package/dist/auth/bootstrap_account.js +7 -7
  85. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  86. package/dist/auth/bootstrap_routes.js +11 -10
  87. package/dist/auth/cleanup.d.ts +20 -26
  88. package/dist/auth/cleanup.d.ts.map +1 -1
  89. package/dist/auth/cleanup.js +33 -42
  90. package/dist/auth/credential_type_schema.d.ts +115 -0
  91. package/dist/auth/credential_type_schema.d.ts.map +1 -0
  92. package/dist/auth/credential_type_schema.js +127 -0
  93. package/dist/auth/daemon_token_middleware.d.ts +23 -11
  94. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  95. package/dist/auth/daemon_token_middleware.js +28 -22
  96. package/dist/auth/ddl.d.ts +2 -2
  97. package/dist/auth/ddl.d.ts.map +1 -1
  98. package/dist/auth/ddl.js +6 -6
  99. package/dist/auth/deps.d.ts +7 -18
  100. package/dist/auth/deps.d.ts.map +1 -1
  101. package/dist/auth/grant_path_schema.d.ts +117 -0
  102. package/dist/auth/grant_path_schema.d.ts.map +1 -0
  103. package/dist/auth/grant_path_schema.js +137 -0
  104. package/dist/auth/invite_queries.d.ts +12 -1
  105. package/dist/auth/invite_queries.d.ts.map +1 -1
  106. package/dist/auth/invite_queries.js +12 -1
  107. package/dist/auth/invite_schema.d.ts +1 -1
  108. package/dist/auth/invite_schema.d.ts.map +1 -1
  109. package/dist/auth/invite_schema.js +1 -1
  110. package/dist/auth/middleware.d.ts.map +1 -1
  111. package/dist/auth/middleware.js +9 -4
  112. package/dist/auth/migrations.d.ts +37 -14
  113. package/dist/auth/migrations.d.ts.map +1 -1
  114. package/dist/auth/migrations.js +79 -32
  115. package/dist/auth/request_context.d.ts +331 -61
  116. package/dist/auth/request_context.d.ts.map +1 -1
  117. package/dist/auth/request_context.js +378 -95
  118. package/dist/auth/{permit_offer_action_specs.d.ts → role_grant_offer_action_specs.d.ts} +163 -94
  119. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -0
  120. package/dist/auth/role_grant_offer_action_specs.js +262 -0
  121. package/dist/auth/role_grant_offer_actions.d.ts +104 -0
  122. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -0
  123. package/dist/auth/role_grant_offer_actions.js +473 -0
  124. package/dist/auth/{permit_offer_notifications.d.ts → role_grant_offer_notifications.d.ts} +90 -70
  125. package/dist/auth/role_grant_offer_notifications.d.ts.map +1 -0
  126. package/dist/auth/role_grant_offer_notifications.js +182 -0
  127. package/dist/auth/role_grant_offer_queries.d.ts +242 -0
  128. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -0
  129. package/dist/auth/role_grant_offer_queries.js +533 -0
  130. package/dist/auth/role_grant_offer_schema.d.ts +150 -0
  131. package/dist/auth/role_grant_offer_schema.d.ts.map +1 -0
  132. package/dist/auth/{permit_offer_schema.js → role_grant_offer_schema.js} +60 -36
  133. package/dist/auth/role_grant_queries.d.ts +231 -0
  134. package/dist/auth/role_grant_queries.d.ts.map +1 -0
  135. package/dist/auth/role_grant_queries.js +320 -0
  136. package/dist/auth/role_schema.d.ts +150 -40
  137. package/dist/auth/role_schema.d.ts.map +1 -1
  138. package/dist/auth/role_schema.js +144 -45
  139. package/dist/auth/scope_kind_schema.d.ts +96 -0
  140. package/dist/auth/scope_kind_schema.d.ts.map +1 -0
  141. package/dist/auth/scope_kind_schema.js +94 -0
  142. package/dist/auth/self_service_role_action_specs.d.ts +6 -1
  143. package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
  144. package/dist/auth/self_service_role_action_specs.js +3 -1
  145. package/dist/auth/self_service_role_actions.d.ts +34 -27
  146. package/dist/auth/self_service_role_actions.d.ts.map +1 -1
  147. package/dist/auth/self_service_role_actions.js +68 -48
  148. package/dist/auth/session_cookie.d.ts +43 -6
  149. package/dist/auth/session_cookie.d.ts.map +1 -1
  150. package/dist/auth/session_cookie.js +31 -5
  151. package/dist/auth/session_middleware.d.ts +37 -3
  152. package/dist/auth/session_middleware.d.ts.map +1 -1
  153. package/dist/auth/session_middleware.js +33 -7
  154. package/dist/auth/signup_routes.d.ts.map +1 -1
  155. package/dist/auth/signup_routes.js +48 -19
  156. package/dist/auth/standard_action_specs.d.ts +2 -2
  157. package/dist/auth/standard_action_specs.js +4 -4
  158. package/dist/auth/standard_rpc_actions.d.ts +23 -19
  159. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  160. package/dist/auth/standard_rpc_actions.js +12 -12
  161. package/dist/db/migrate.d.ts +12 -8
  162. package/dist/db/migrate.d.ts.map +1 -1
  163. package/dist/db/migrate.js +10 -7
  164. package/dist/dev/setup.d.ts +2 -2
  165. package/dist/dev/setup.d.ts.map +1 -1
  166. package/dist/dev/setup.js +9 -7
  167. package/dist/env/load.d.ts +1 -1
  168. package/dist/env/load.js +1 -1
  169. package/dist/hono_context.d.ts +64 -5
  170. package/dist/hono_context.d.ts.map +1 -1
  171. package/dist/hono_context.js +38 -2
  172. package/dist/http/CLAUDE.md +264 -87
  173. package/dist/http/auth_shape.d.ts +191 -0
  174. package/dist/http/auth_shape.d.ts.map +1 -0
  175. package/dist/http/auth_shape.js +237 -0
  176. package/dist/http/common_routes.js +3 -3
  177. package/dist/http/db_routes.d.ts +4 -0
  178. package/dist/http/db_routes.d.ts.map +1 -1
  179. package/dist/http/db_routes.js +44 -7
  180. package/dist/http/error_schemas.d.ts +132 -19
  181. package/dist/http/error_schemas.d.ts.map +1 -1
  182. package/dist/http/error_schemas.js +132 -40
  183. package/dist/http/jsonrpc_errors.d.ts +27 -2
  184. package/dist/http/jsonrpc_errors.d.ts.map +1 -1
  185. package/dist/http/jsonrpc_errors.js +26 -2
  186. package/dist/http/pending_effects.d.ts +71 -18
  187. package/dist/http/pending_effects.d.ts.map +1 -1
  188. package/dist/http/pending_effects.js +87 -18
  189. package/dist/http/proxy.d.ts +52 -5
  190. package/dist/http/proxy.d.ts.map +1 -1
  191. package/dist/http/proxy.js +92 -14
  192. package/dist/http/route_spec.d.ts +113 -41
  193. package/dist/http/route_spec.d.ts.map +1 -1
  194. package/dist/http/route_spec.js +130 -52
  195. package/dist/http/schema_helpers.d.ts +3 -2
  196. package/dist/http/schema_helpers.d.ts.map +1 -1
  197. package/dist/http/schema_helpers.js +9 -2
  198. package/dist/http/surface.d.ts +2 -1
  199. package/dist/http/surface.d.ts.map +1 -1
  200. package/dist/http/surface.js +1 -2
  201. package/dist/http/surface_query.d.ts +39 -35
  202. package/dist/http/surface_query.d.ts.map +1 -1
  203. package/dist/http/surface_query.js +79 -36
  204. package/dist/primitive_schemas.d.ts +39 -0
  205. package/dist/primitive_schemas.d.ts.map +1 -0
  206. package/dist/primitive_schemas.js +40 -0
  207. package/dist/realtime/sse_auth_guard.d.ts +5 -5
  208. package/dist/realtime/sse_auth_guard.js +9 -9
  209. package/dist/runtime/mock.d.ts +1 -1
  210. package/dist/runtime/mock.js +1 -1
  211. package/dist/server/app_backend.d.ts +14 -11
  212. package/dist/server/app_backend.d.ts.map +1 -1
  213. package/dist/server/app_backend.js +12 -8
  214. package/dist/server/app_server.d.ts +7 -7
  215. package/dist/server/app_server.d.ts.map +1 -1
  216. package/dist/server/app_server.js +36 -31
  217. package/dist/server/validate_nginx.d.ts +1 -1
  218. package/dist/server/validate_nginx.js +1 -1
  219. package/dist/testing/CLAUDE.md +73 -55
  220. package/dist/testing/admin_integration.d.ts +5 -6
  221. package/dist/testing/admin_integration.d.ts.map +1 -1
  222. package/dist/testing/admin_integration.js +100 -96
  223. package/dist/testing/adversarial_headers.js +1 -1
  224. package/dist/testing/app_server.d.ts +11 -14
  225. package/dist/testing/app_server.d.ts.map +1 -1
  226. package/dist/testing/app_server.js +18 -17
  227. package/dist/testing/assertions.d.ts.map +1 -1
  228. package/dist/testing/assertions.js +2 -1
  229. package/dist/testing/attack_surface.d.ts.map +1 -1
  230. package/dist/testing/attack_surface.js +15 -9
  231. package/dist/testing/audit_completeness.d.ts +2 -2
  232. package/dist/testing/audit_completeness.d.ts.map +1 -1
  233. package/dist/testing/audit_completeness.js +53 -39
  234. package/dist/testing/auth_apps.d.ts +5 -4
  235. package/dist/testing/auth_apps.d.ts.map +1 -1
  236. package/dist/testing/auth_apps.js +28 -22
  237. package/dist/testing/data_exposure.d.ts.map +1 -1
  238. package/dist/testing/data_exposure.js +5 -5
  239. package/dist/testing/db.d.ts +1 -1
  240. package/dist/testing/db.d.ts.map +1 -1
  241. package/dist/testing/db.js +4 -4
  242. package/dist/testing/db_entities.d.ts +22 -0
  243. package/dist/testing/db_entities.d.ts.map +1 -0
  244. package/dist/testing/db_entities.js +28 -0
  245. package/dist/testing/entities.d.ts +10 -8
  246. package/dist/testing/entities.d.ts.map +1 -1
  247. package/dist/testing/entities.js +22 -18
  248. package/dist/testing/integration.d.ts.map +1 -1
  249. package/dist/testing/integration.js +13 -14
  250. package/dist/testing/integration_helpers.d.ts +8 -6
  251. package/dist/testing/integration_helpers.d.ts.map +1 -1
  252. package/dist/testing/integration_helpers.js +29 -23
  253. package/dist/testing/middleware.d.ts +15 -11
  254. package/dist/testing/middleware.d.ts.map +1 -1
  255. package/dist/testing/middleware.js +75 -32
  256. package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
  257. package/dist/testing/rpc_attack_surface.js +40 -24
  258. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  259. package/dist/testing/rpc_helpers.js +3 -1
  260. package/dist/testing/rpc_round_trip.d.ts +1 -1
  261. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  262. package/dist/testing/rpc_round_trip.js +14 -13
  263. package/dist/testing/sse_round_trip.d.ts +3 -4
  264. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  265. package/dist/testing/sse_round_trip.js +7 -11
  266. package/dist/testing/standard.d.ts +1 -1
  267. package/dist/testing/stubs.d.ts +25 -0
  268. package/dist/testing/stubs.d.ts.map +1 -1
  269. package/dist/testing/stubs.js +43 -2
  270. package/dist/testing/surface_invariants.d.ts +2 -2
  271. package/dist/testing/ws_round_trip.d.ts +12 -13
  272. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  273. package/dist/testing/ws_round_trip.js +24 -12
  274. package/dist/ui/AdminAccounts.svelte +23 -20
  275. package/dist/ui/AdminOverview.svelte +15 -13
  276. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  277. package/dist/ui/{AdminPermitHistory.svelte → AdminRoleGrantHistory.svelte} +12 -12
  278. package/dist/ui/AdminRoleGrantHistory.svelte.d.ts +4 -0
  279. package/dist/ui/AdminRoleGrantHistory.svelte.d.ts.map +1 -0
  280. package/dist/ui/BootstrapForm.svelte +1 -1
  281. package/dist/ui/CLAUDE.md +65 -59
  282. package/dist/ui/{PermitOfferForm.svelte → RoleGrantOfferForm.svelte} +37 -22
  283. package/dist/ui/RoleGrantOfferForm.svelte.d.ts +20 -0
  284. package/dist/ui/RoleGrantOfferForm.svelte.d.ts.map +1 -0
  285. package/dist/ui/{PermitOfferHistory.svelte → RoleGrantOfferHistory.svelte} +12 -12
  286. package/dist/ui/{PermitOfferHistory.svelte.d.ts → RoleGrantOfferHistory.svelte.d.ts} +4 -4
  287. package/dist/ui/RoleGrantOfferHistory.svelte.d.ts.map +1 -0
  288. package/dist/ui/{PermitOfferInbox.svelte → RoleGrantOfferInbox.svelte} +14 -14
  289. package/dist/ui/{PermitOfferInbox.svelte.d.ts → RoleGrantOfferInbox.svelte.d.ts} +4 -4
  290. package/dist/ui/RoleGrantOfferInbox.svelte.d.ts.map +1 -0
  291. package/dist/ui/SignupForm.svelte +1 -1
  292. package/dist/ui/SurfaceExplorer.svelte +35 -15
  293. package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
  294. package/dist/ui/account_sessions_state.svelte.d.ts +2 -3
  295. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  296. package/dist/ui/account_sessions_state.svelte.js +2 -3
  297. package/dist/ui/admin_accounts_state.svelte.d.ts +25 -18
  298. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  299. package/dist/ui/admin_accounts_state.svelte.js +28 -17
  300. package/dist/ui/admin_rpc_adapters.d.ts +20 -20
  301. package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
  302. package/dist/ui/admin_rpc_adapters.js +17 -17
  303. package/dist/ui/admin_sessions_state.svelte.d.ts +2 -2
  304. package/dist/ui/admin_sessions_state.svelte.js +2 -2
  305. package/dist/ui/audit_log_state.svelte.d.ts +7 -7
  306. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  307. package/dist/ui/audit_log_state.svelte.js +6 -6
  308. package/dist/ui/auth_state.svelte.d.ts +3 -3
  309. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  310. package/dist/ui/auth_state.svelte.js +6 -6
  311. package/dist/ui/format_scope.d.ts +2 -2
  312. package/dist/ui/format_scope.js +2 -2
  313. package/dist/ui/{permit_offers_state.svelte.d.ts → role_grant_offers_state.svelte.d.ts} +39 -31
  314. package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -0
  315. package/dist/ui/{permit_offers_state.svelte.js → role_grant_offers_state.svelte.js} +25 -19
  316. package/dist/ui/ui_format.js +2 -2
  317. package/package.json +3 -3
  318. package/dist/auth/permit_offer_action_specs.d.ts.map +0 -1
  319. package/dist/auth/permit_offer_action_specs.js +0 -227
  320. package/dist/auth/permit_offer_actions.d.ts +0 -110
  321. package/dist/auth/permit_offer_actions.d.ts.map +0 -1
  322. package/dist/auth/permit_offer_actions.js +0 -452
  323. package/dist/auth/permit_offer_notifications.d.ts.map +0 -1
  324. package/dist/auth/permit_offer_notifications.js +0 -182
  325. package/dist/auth/permit_offer_queries.d.ts +0 -183
  326. package/dist/auth/permit_offer_queries.d.ts.map +0 -1
  327. package/dist/auth/permit_offer_queries.js +0 -408
  328. package/dist/auth/permit_offer_schema.d.ts +0 -103
  329. package/dist/auth/permit_offer_schema.d.ts.map +0 -1
  330. package/dist/auth/permit_queries.d.ts +0 -210
  331. package/dist/auth/permit_queries.d.ts.map +0 -1
  332. package/dist/auth/permit_queries.js +0 -294
  333. package/dist/auth/require_keeper.d.ts +0 -20
  334. package/dist/auth/require_keeper.d.ts.map +0 -1
  335. package/dist/auth/require_keeper.js +0 -35
  336. package/dist/auth/route_guards.d.ts +0 -21
  337. package/dist/auth/route_guards.d.ts.map +0 -1
  338. package/dist/auth/route_guards.js +0 -32
  339. package/dist/auth/session_lifecycle.d.ts +0 -37
  340. package/dist/auth/session_lifecycle.d.ts.map +0 -1
  341. package/dist/auth/session_lifecycle.js +0 -29
  342. package/dist/ui/AdminPermitHistory.svelte.d.ts +0 -4
  343. package/dist/ui/AdminPermitHistory.svelte.d.ts.map +0 -1
  344. package/dist/ui/PermitOfferForm.svelte.d.ts +0 -14
  345. package/dist/ui/PermitOfferForm.svelte.d.ts.map +0 -1
  346. package/dist/ui/PermitOfferHistory.svelte.d.ts.map +0 -1
  347. package/dist/ui/PermitOfferInbox.svelte.d.ts.map +0 -1
  348. package/dist/ui/permit_offers_state.svelte.d.ts.map +0 -1
@@ -1,33 +1,86 @@
1
1
  /**
2
- * Shared post-commit side-effect helper.
2
+ * Two-queue side-effect machinery for request handlers.
3
3
  *
4
- * WS sends and `on_audit_event` SSE broadcasts must never fire mid-transaction —
5
- * a rollback would leak state that never existed. Anything pushed onto
6
- * `pending_effects` runs after the response is sent (see the request-context
7
- * middleware), so this helper is the canonical home for post-commit fan-out.
4
+ * Handlers register fire-and-forget work in one of two queues, distinguished
5
+ * by their timing contract:
8
6
  *
9
- * Satisfied by both `RouteContext` (HTTP routes) and `ActionContext` (RPC
10
- * actions) they share `{log, pending_effects}` by convention, so this
11
- * module stays in `http/` (both depend on it).
7
+ * - `pending_effects: Array<Promise<void>>` eager. Producers push pool
8
+ * writes that are already in flight (audit emits, session-touch UPDATE,
9
+ * api-token usage tracking). The pool write is rollback-resilient by
10
+ * virtue of running outside the request transaction; pushing the
11
+ * in-flight handle lets test mode (`await_pending_effects: true`) await
12
+ * it.
13
+ * - `post_commit_effects: Array<() => void | Promise<void>>` — deferred.
14
+ * Producers go through `emit_after_commit(ctx, fn)`; the flush
15
+ * middleware is the only site that ever invokes the thunk, and it does
16
+ * so after the request handler (and its wrapping `db.transaction`)
17
+ * returns. Used for WS sends and any work that must observe a committed
18
+ * transaction.
19
+ *
20
+ * The split exists because the two shapes encode different contracts:
21
+ * eager pushers are saying "wait for this work that's already started";
22
+ * thunk pushers are saying "run this after the handler returns." Burying
23
+ * both behind one `Array<PendingEffect>` made `c.var.pending_effects.push(x)`
24
+ * ambiguous at the call site. With separate queues, the field name is
25
+ * the contract.
26
+ *
27
+ * Both `RouteContext` (HTTP routes) and `ActionContext` (RPC + WS
28
+ * actions) carry both queues by convention, so this module stays in
29
+ * `http/` (every transport depends on it).
12
30
  *
13
31
  * @module
14
32
  */
15
33
  import type { Logger } from '@fuzdev/fuz_util/log.js';
16
- /** Minimal structural context required by `emit_after_commit`. */
17
- export interface PendingEffectsContext {
34
+ /**
35
+ * Minimal structural context required by `emit_after_commit`. Both
36
+ * `RouteContext` and `ActionContext` satisfy this — they each carry
37
+ * `log` and `post_commit_effects`.
38
+ */
39
+ export interface EmitAfterCommitContext {
18
40
  log: Logger;
19
- pending_effects: Array<Promise<void>>;
41
+ post_commit_effects: Array<() => void | Promise<void>>;
20
42
  }
21
43
  /**
22
44
  * Defer a side effect until after the handler's transaction commits.
23
45
  *
24
- * Exceptions thrown by `fn` are caught and logged via `ctx.log.error`, so one
25
- * failed send cannot corrupt the already-committed response or starve other
26
- * queued effects in the same tick.
46
+ * Pushes a raw thunk onto `ctx.post_commit_effects` the flush
47
+ * middleware (in `server/app_server.ts` and the per-message WS dispatcher)
48
+ * is the only site that ever invokes `fn`. This is load-bearing: a
49
+ * previous implementation queued `Promise.resolve().then(fn)`, which
50
+ * JavaScript's microtask scheduler drains before the wrapping
51
+ * `await db.query('COMMIT')` resumes — `fn` fired mid-transaction and a
52
+ * rollback would leak a notification for state that never landed.
53
+ *
54
+ * The thunk shape closes that gap by deferring the work to flush time.
55
+ * The flush owns the per-thunk `try/catch` + `log.error` so any
56
+ * directly-pushed thunk (tests included) cannot escape the safety net.
57
+ *
58
+ * @param ctx - context carrying `log` and the `post_commit_effects` queue
59
+ * @param fn - side effect to run after commit; may return `void` or `Promise<void>`
60
+ * @mutates `ctx.post_commit_effects` - appends `fn` verbatim
61
+ */
62
+ export declare const emit_after_commit: (ctx: EmitAfterCommitContext, fn: () => void | Promise<void>) => void;
63
+ /**
64
+ * Drain an eager `pending_effects` queue: `Promise.allSettled` the
65
+ * in-flight handles, route every rejection through `log.error`, and
66
+ * fan out to `on_rejection` when supplied (production wires this to
67
+ * `on_effect_error` for monitoring).
68
+ *
69
+ * Returned promise resolves once every effect has settled. Never
70
+ * rejects. No-op when `effects` is empty (common on read-only
71
+ * requests).
72
+ *
73
+ * Symmetric with `flush_post_commit_effects` for the deferred queue.
74
+ */
75
+ export declare const flush_pending_effects: (effects: ReadonlyArray<Promise<void>>, log: Logger, on_rejection?: (reason: unknown) => void) => Promise<void>;
76
+ /**
77
+ * Drain a `post_commit_effects` queue: invoke each thunk under
78
+ * `try/catch`, collect any returned promises, and `Promise.allSettled`
79
+ * them. Synchronous throws and async rejections are routed through
80
+ * `log.error` so one failing effect cannot starve siblings.
27
81
  *
28
- * @param ctx - context carrying `log` and the `pending_effects` queue
29
- * @param fn - synchronous side effect to run after commit
30
- * @mutates `ctx.pending_effects` - appends a never-rejecting promise wrapping `fn`
82
+ * Returned promise resolves once every thunk has finished. Never
83
+ * rejects.
31
84
  */
32
- export declare const emit_after_commit: (ctx: PendingEffectsContext, fn: () => void) => void;
85
+ export declare const flush_post_commit_effects: (effects: ReadonlyArray<() => void | Promise<void>>, log: Logger) => Promise<void>;
33
86
  //# sourceMappingURL=pending_effects.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pending_effects.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/pending_effects.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,kEAAkE;AAClE,MAAM,WAAW,qBAAqB;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;CACtC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,qBAAqB,EAAE,IAAI,MAAM,IAAI,KAAG,IAU9E,CAAC"}
1
+ {"version":3,"file":"pending_effects.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/pending_effects.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,mBAAmB,EAAE,KAAK,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;CACvD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,iBAAiB,GAC7B,KAAK,sBAAsB,EAC3B,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAC5B,IAEF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,SAAS,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,EACrC,KAAK,MAAM,EACX,eAAe,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,KACtC,OAAO,CAAC,IAAI,CASd,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,GACrC,SAAS,aAAa,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAClD,KAAK,MAAM,KACT,OAAO,CAAC,IAAI,CAiBd,CAAC"}
@@ -1,35 +1,104 @@
1
1
  /**
2
- * Shared post-commit side-effect helper.
2
+ * Two-queue side-effect machinery for request handlers.
3
3
  *
4
- * WS sends and `on_audit_event` SSE broadcasts must never fire mid-transaction —
5
- * a rollback would leak state that never existed. Anything pushed onto
6
- * `pending_effects` runs after the response is sent (see the request-context
7
- * middleware), so this helper is the canonical home for post-commit fan-out.
4
+ * Handlers register fire-and-forget work in one of two queues, distinguished
5
+ * by their timing contract:
8
6
  *
9
- * Satisfied by both `RouteContext` (HTTP routes) and `ActionContext` (RPC
10
- * actions) they share `{log, pending_effects}` by convention, so this
11
- * module stays in `http/` (both depend on it).
7
+ * - `pending_effects: Array<Promise<void>>` eager. Producers push pool
8
+ * writes that are already in flight (audit emits, session-touch UPDATE,
9
+ * api-token usage tracking). The pool write is rollback-resilient by
10
+ * virtue of running outside the request transaction; pushing the
11
+ * in-flight handle lets test mode (`await_pending_effects: true`) await
12
+ * it.
13
+ * - `post_commit_effects: Array<() => void | Promise<void>>` — deferred.
14
+ * Producers go through `emit_after_commit(ctx, fn)`; the flush
15
+ * middleware is the only site that ever invokes the thunk, and it does
16
+ * so after the request handler (and its wrapping `db.transaction`)
17
+ * returns. Used for WS sends and any work that must observe a committed
18
+ * transaction.
19
+ *
20
+ * The split exists because the two shapes encode different contracts:
21
+ * eager pushers are saying "wait for this work that's already started";
22
+ * thunk pushers are saying "run this after the handler returns." Burying
23
+ * both behind one `Array<PendingEffect>` made `c.var.pending_effects.push(x)`
24
+ * ambiguous at the call site. With separate queues, the field name is
25
+ * the contract.
26
+ *
27
+ * Both `RouteContext` (HTTP routes) and `ActionContext` (RPC + WS
28
+ * actions) carry both queues by convention, so this module stays in
29
+ * `http/` (every transport depends on it).
12
30
  *
13
31
  * @module
14
32
  */
15
33
  /**
16
34
  * Defer a side effect until after the handler's transaction commits.
17
35
  *
18
- * Exceptions thrown by `fn` are caught and logged via `ctx.log.error`, so one
19
- * failed send cannot corrupt the already-committed response or starve other
20
- * queued effects in the same tick.
36
+ * Pushes a raw thunk onto `ctx.post_commit_effects` the flush
37
+ * middleware (in `server/app_server.ts` and the per-message WS dispatcher)
38
+ * is the only site that ever invokes `fn`. This is load-bearing: a
39
+ * previous implementation queued `Promise.resolve().then(fn)`, which
40
+ * JavaScript's microtask scheduler drains before the wrapping
41
+ * `await db.query('COMMIT')` resumes — `fn` fired mid-transaction and a
42
+ * rollback would leak a notification for state that never landed.
21
43
  *
22
- * @param ctx - context carrying `log` and the `pending_effects` queue
23
- * @param fn - synchronous side effect to run after commit
24
- * @mutates `ctx.pending_effects` - appends a never-rejecting promise wrapping `fn`
44
+ * The thunk shape closes that gap by deferring the work to flush time.
45
+ * The flush owns the per-thunk `try/catch` + `log.error` so any
46
+ * directly-pushed thunk (tests included) cannot escape the safety net.
47
+ *
48
+ * @param ctx - context carrying `log` and the `post_commit_effects` queue
49
+ * @param fn - side effect to run after commit; may return `void` or `Promise<void>`
50
+ * @mutates `ctx.post_commit_effects` - appends `fn` verbatim
25
51
  */
26
52
  export const emit_after_commit = (ctx, fn) => {
27
- ctx.pending_effects.push(Promise.resolve().then(() => {
53
+ ctx.post_commit_effects.push(fn);
54
+ };
55
+ /**
56
+ * Drain an eager `pending_effects` queue: `Promise.allSettled` the
57
+ * in-flight handles, route every rejection through `log.error`, and
58
+ * fan out to `on_rejection` when supplied (production wires this to
59
+ * `on_effect_error` for monitoring).
60
+ *
61
+ * Returned promise resolves once every effect has settled. Never
62
+ * rejects. No-op when `effects` is empty (common on read-only
63
+ * requests).
64
+ *
65
+ * Symmetric with `flush_post_commit_effects` for the deferred queue.
66
+ */
67
+ export const flush_pending_effects = async (effects, log, on_rejection) => {
68
+ if (effects.length === 0)
69
+ return;
70
+ const results = await Promise.allSettled(effects);
71
+ for (const result of results) {
72
+ if (result.status === 'rejected') {
73
+ log.error('pending effect rejected:', result.reason);
74
+ on_rejection?.(result.reason);
75
+ }
76
+ }
77
+ };
78
+ /**
79
+ * Drain a `post_commit_effects` queue: invoke each thunk under
80
+ * `try/catch`, collect any returned promises, and `Promise.allSettled`
81
+ * them. Synchronous throws and async rejections are routed through
82
+ * `log.error` so one failing effect cannot starve siblings.
83
+ *
84
+ * Returned promise resolves once every thunk has finished. Never
85
+ * rejects.
86
+ */
87
+ export const flush_post_commit_effects = async (effects, log) => {
88
+ const promises = [];
89
+ for (const fn of effects) {
28
90
  try {
29
- fn();
91
+ const result = fn();
92
+ if (result instanceof Promise) {
93
+ promises.push(result.catch((err) => {
94
+ log.error('post-commit side effect failed:', err);
95
+ }));
96
+ }
30
97
  }
31
98
  catch (err) {
32
- ctx.log.error('post-commit side effect failed:', err);
99
+ log.error('post-commit side effect failed:', err);
33
100
  }
34
- }));
101
+ }
102
+ if (promises.length)
103
+ await Promise.allSettled(promises);
35
104
  };
@@ -54,23 +54,70 @@ export type ParsedProxy = {
54
54
  * @throws Error on invalid IP, invalid CIDR network, or NaN/negative/over-range prefix
55
55
  */
56
56
  export declare const parse_proxy_entry: (entry: string) => ParsedProxy;
57
+ /**
58
+ * Strict IP validity check.
59
+ *
60
+ * Defense in depth around Hono's `hono/utils/ipaddr` helpers, which are
61
+ * lax in two ways:
62
+ *
63
+ * 1. `distinctRemoteAddr` classifies anything-with-a-colon as `'IPv6'`,
64
+ * including `'host:port'`, `'attacker:controlled'`, `'203.0.113.1:8080'`.
65
+ * 2. `convertIPv6ToBinary` silently accepts malformed forms like
66
+ * `'[::1]:8080'` and `'::1\n'`, parsing them as inconsistent binary
67
+ * values that would still serve as distinct rate-limit keys for an
68
+ * attacker rotating the suffix.
69
+ *
70
+ * Strict validation here is two-layered: a character-set pre-filter
71
+ * (`IP_LITERAL_CHARS`), then a round-trip through `convertIPv*ToBinary`
72
+ * to confirm the input parses cleanly. Either layer alone has holes;
73
+ * together they reject every input form we've seen Hono mis-handle.
74
+ *
75
+ * Used as the security primitive for any code path that takes an IP
76
+ * string from an untrusted source (XFF, query params) and uses it as a
77
+ * key (rate limiting, audit subject) or compares it against trusted
78
+ * proxies via CIDR (where the latent throw would otherwise bubble out).
79
+ *
80
+ * @returns the address family on success, `undefined` if the string is
81
+ * not a strictly-valid IP
82
+ */
83
+ export declare const validate_ip_strict: (ip: string) => "IPv4" | "IPv6" | undefined;
57
84
  /**
58
85
  * Check whether `ip` matches any entry in the trusted proxy list.
59
86
  *
60
87
  * Normalizes `ip` before matching (lowercase, IPv4-mapped IPv6 stripped).
88
+ * Uses `validate_ip_strict` to reject malformed input — without strict
89
+ * validation, Hono's lax `distinctRemoteAddr` would let an entry like
90
+ * `'203.0.113.1:8080'` (false-positive `'IPv6'`) reach
91
+ * `convertIPv6ToBinary` in the CIDR-match branch and throw.
61
92
  */
62
93
  export declare const is_trusted_ip: (ip: string, proxies: Array<ParsedProxy>) => boolean;
63
94
  /**
64
95
  * Resolve the real client IP from an `X-Forwarded-For` header value.
65
96
  *
66
- * Walks right-to-left, skipping trusted proxy entries. The first
67
- * non-trusted entry is the client IP. If all entries are trusted,
68
- * returns the leftmost entry. All entries are normalized before
69
- * matching and in the returned value.
97
+ * Walks right-to-left, skipping trusted proxy entries AND any entry
98
+ * that fails strict IP validation (`validate_ip_strict`). The first
99
+ * untrusted, strictly-valid entry is the client IP. If every walked
100
+ * entry is trusted or malformed, returns the leftmost strictly-valid
101
+ * (trusted) entry (likely-misconfigured all-trusted case) or
102
+ * `undefined` (everything was malformed — middleware falls back to
103
+ * the connection IP). All entries are normalized before matching and
104
+ * in the returned value.
105
+ *
106
+ * Skipping malformed entries is the rate-limit-key fix for the
107
+ * "attacker controls XFF and the proxy passes it through" surface —
108
+ * without the skip, an attacker could rotate arbitrary strings (incl.
109
+ * `'attacker:controlled'`, which Hono's lax `distinctRemoteAddr`
110
+ * misclassifies as IPv6) as XFF values to get fresh per-IP rate-limit
111
+ * buckets. Tradeoff: legitimate non-standard proxies that include
112
+ * ports in XFF entries (e.g. `203.0.113.1:8080`) also fail strict
113
+ * validation, so those entries get skipped and the rate-limit bucket
114
+ * collapses to the proxy's connection IP (one bucket for everyone
115
+ * behind that proxy). Standard proxies (nginx, cloud LBs) don't
116
+ * include ports.
70
117
  *
71
118
  * @param forwarded_for - the `X-Forwarded-For` header value
72
119
  * @param proxies - parsed trusted proxy entries
73
- * @returns the normalized client IP, or `undefined` if the header is empty
120
+ * @returns the normalized client IP, or `undefined` if the header is empty / all entries malformed
74
121
  */
75
122
  export declare const resolve_client_ip: (forwarded_for: string, proxies: Array<ParsedProxy>) => string | undefined;
76
123
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AAErD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,sBAAsB,CAAC;AAEzD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,GAAI,IAAI,MAAM,KAAG,MAQzC,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,sFAAsF;IACtF,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,+DAA+D;IAC/D,iBAAiB,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;IACtD,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,GACpB;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAC,GAC7B;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAA;CAAC,CAAC;AAElF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,KAAG,WA6CjD,CAAC;AAiBF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,IAAI,MAAM,EAAE,SAAS,KAAK,CAAC,WAAW,CAAC,KAAG,OAqBvE,CAAC;AAQF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,GAC7B,eAAe,MAAM,EACrB,SAAS,KAAK,CAAC,WAAW,CAAC,KACzB,MAAM,GAAG,SAiBX,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,uBAAuB,GAAI,SAAS,YAAY,KAAG,iBAyC/D,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GAAI,SAAS,YAAY,KAAG,cAInE,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GAAI,GAAG,OAAO,KAAG,MAAyC,CAAC"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AAErD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,sBAAsB,CAAC;AAEzD;;;;;;;;GAQG;AACH,eAAO,MAAM,YAAY,GAAI,IAAI,MAAM,KAAG,MAQzC,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,YAAY;IAC5B,sFAAsF;IACtF,eAAe,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC/B,+DAA+D;IAC/D,iBAAiB,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;IACtD,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,GACpB;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAC,GAC7B;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAAA;CAAC,CAAC;AAElF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,KAAG,WA6CjD,CAAC;AA2BF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,kBAAkB,GAAI,IAAI,MAAM,KAAG,MAAM,GAAG,MAAM,GAAG,SAWjE,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,GAAI,IAAI,MAAM,EAAE,SAAS,KAAK,CAAC,WAAW,CAAC,KAAG,OAqBvE,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,iBAAiB,GAC7B,eAAe,MAAM,EACrB,SAAS,KAAK,CAAC,WAAW,CAAC,KACzB,MAAM,GAAG,SA0BX,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,uBAAuB,GAAI,SAAS,YAAY,KAAG,iBAyC/D,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GAAI,SAAS,YAAY,KAAG,cAInE,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GAAI,GAAG,OAAO,KAAG,MAAyC,CAAC"}
@@ -91,14 +91,70 @@ const cidr_contains = (ip_binary, network, prefix, total_bits) => {
91
91
  const shift = BigInt(total_bits - prefix);
92
92
  return ip_binary >> shift === network >> shift;
93
93
  };
94
+ /**
95
+ * Allowed character set for a bare IP literal.
96
+ *
97
+ * Covers the union of IPv4 (digits + `.`), IPv6 (hex digits + `:`), and
98
+ * IPv4-mapped IPv6 forms (`::ffff:127.0.0.1`). Anything outside this
99
+ * set — brackets, whitespace, control bytes, letters g-z — disqualifies
100
+ * the input regardless of what Hono's parser does with it.
101
+ */
102
+ const IP_LITERAL_CHARS = /^[0-9a-fA-F.:]+$/;
103
+ /**
104
+ * Strict IP validity check.
105
+ *
106
+ * Defense in depth around Hono's `hono/utils/ipaddr` helpers, which are
107
+ * lax in two ways:
108
+ *
109
+ * 1. `distinctRemoteAddr` classifies anything-with-a-colon as `'IPv6'`,
110
+ * including `'host:port'`, `'attacker:controlled'`, `'203.0.113.1:8080'`.
111
+ * 2. `convertIPv6ToBinary` silently accepts malformed forms like
112
+ * `'[::1]:8080'` and `'::1\n'`, parsing them as inconsistent binary
113
+ * values that would still serve as distinct rate-limit keys for an
114
+ * attacker rotating the suffix.
115
+ *
116
+ * Strict validation here is two-layered: a character-set pre-filter
117
+ * (`IP_LITERAL_CHARS`), then a round-trip through `convertIPv*ToBinary`
118
+ * to confirm the input parses cleanly. Either layer alone has holes;
119
+ * together they reject every input form we've seen Hono mis-handle.
120
+ *
121
+ * Used as the security primitive for any code path that takes an IP
122
+ * string from an untrusted source (XFF, query params) and uses it as a
123
+ * key (rate limiting, audit subject) or compares it against trusted
124
+ * proxies via CIDR (where the latent throw would otherwise bubble out).
125
+ *
126
+ * @returns the address family on success, `undefined` if the string is
127
+ * not a strictly-valid IP
128
+ */
129
+ export const validate_ip_strict = (ip) => {
130
+ if (!IP_LITERAL_CHARS.test(ip))
131
+ return undefined;
132
+ const type = distinctRemoteAddr(ip);
133
+ if (!type)
134
+ return undefined;
135
+ try {
136
+ if (type === 'IPv4')
137
+ convertIPv4ToBinary(ip);
138
+ else
139
+ convertIPv6ToBinary(ip);
140
+ return type;
141
+ }
142
+ catch {
143
+ return undefined;
144
+ }
145
+ };
94
146
  /**
95
147
  * Check whether `ip` matches any entry in the trusted proxy list.
96
148
  *
97
149
  * Normalizes `ip` before matching (lowercase, IPv4-mapped IPv6 stripped).
150
+ * Uses `validate_ip_strict` to reject malformed input — without strict
151
+ * validation, Hono's lax `distinctRemoteAddr` would let an entry like
152
+ * `'203.0.113.1:8080'` (false-positive `'IPv6'`) reach
153
+ * `convertIPv6ToBinary` in the CIDR-match branch and throw.
98
154
  */
99
155
  export const is_trusted_ip = (ip, proxies) => {
100
156
  const normalized = normalize_ip(ip);
101
- const address_type = distinctRemoteAddr(normalized);
157
+ const address_type = validate_ip_strict(normalized);
102
158
  if (!address_type)
103
159
  return false;
104
160
  for (const proxy of proxies) {
@@ -121,22 +177,33 @@ export const is_trusted_ip = (ip, proxies) => {
121
177
  }
122
178
  return false;
123
179
  };
124
- // NOTE: some non-standard proxies include ports in XFF entries (e.g.
125
- // 203.0.113.1:8080). The entry fails distinctRemoteAddr and is treated as
126
- // untrusted (safe default), but rate limiting keys on the port-suffixed
127
- // string instead of the bare IP. Low risk — nginx and cloud LBs don't
128
- // include ports.
129
180
  /**
130
181
  * Resolve the real client IP from an `X-Forwarded-For` header value.
131
182
  *
132
- * Walks right-to-left, skipping trusted proxy entries. The first
133
- * non-trusted entry is the client IP. If all entries are trusted,
134
- * returns the leftmost entry. All entries are normalized before
135
- * matching and in the returned value.
183
+ * Walks right-to-left, skipping trusted proxy entries AND any entry
184
+ * that fails strict IP validation (`validate_ip_strict`). The first
185
+ * untrusted, strictly-valid entry is the client IP. If every walked
186
+ * entry is trusted or malformed, returns the leftmost strictly-valid
187
+ * (trusted) entry (likely-misconfigured all-trusted case) or
188
+ * `undefined` (everything was malformed — middleware falls back to
189
+ * the connection IP). All entries are normalized before matching and
190
+ * in the returned value.
191
+ *
192
+ * Skipping malformed entries is the rate-limit-key fix for the
193
+ * "attacker controls XFF and the proxy passes it through" surface —
194
+ * without the skip, an attacker could rotate arbitrary strings (incl.
195
+ * `'attacker:controlled'`, which Hono's lax `distinctRemoteAddr`
196
+ * misclassifies as IPv6) as XFF values to get fresh per-IP rate-limit
197
+ * buckets. Tradeoff: legitimate non-standard proxies that include
198
+ * ports in XFF entries (e.g. `203.0.113.1:8080`) also fail strict
199
+ * validation, so those entries get skipped and the rate-limit bucket
200
+ * collapses to the proxy's connection IP (one bucket for everyone
201
+ * behind that proxy). Standard proxies (nginx, cloud LBs) don't
202
+ * include ports.
136
203
  *
137
204
  * @param forwarded_for - the `X-Forwarded-For` header value
138
205
  * @param proxies - parsed trusted proxy entries
139
- * @returns the normalized client IP, or `undefined` if the header is empty
206
+ * @returns the normalized client IP, or `undefined` if the header is empty / all entries malformed
140
207
  */
141
208
  export const resolve_client_ip = (forwarded_for, proxies) => {
142
209
  const entries = [];
@@ -147,15 +214,26 @@ export const resolve_client_ip = (forwarded_for, proxies) => {
147
214
  }
148
215
  if (entries.length === 0)
149
216
  return undefined;
150
- // Walk from right to left, skip trusted proxies
217
+ // Walk from right to left, skip trusted proxies and malformed entries.
218
+ // Returning a malformed entry as the client IP would let an attacker
219
+ // who controls XFF poison the per-IP rate-limit key.
151
220
  for (let i = entries.length - 1; i >= 0; i--) {
152
221
  const entry = entries[i];
222
+ if (!validate_ip_strict(entry))
223
+ continue;
153
224
  if (!is_trusted_ip(entry, proxies)) {
154
225
  return entry;
155
226
  }
156
227
  }
157
- // All entries are trusted return leftmost (edge case, likely misconfigured)
158
- return entries[0];
228
+ // Every entry was trusted or malformed. Prefer the leftmost
229
+ // strictly-valid (trusted) entry — the misconfiguration warn in
230
+ // the middleware fires on it. If none, fall through to undefined
231
+ // and let the middleware fall back to the connection IP.
232
+ for (const entry of entries) {
233
+ if (validate_ip_strict(entry))
234
+ return entry;
235
+ }
236
+ return undefined;
159
237
  };
160
238
  /**
161
239
  * Create a Hono middleware that resolves the client IP from trusted proxies.