@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
@@ -1 +1 @@
1
- {"version":3,"file":"account_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,OAAO,EAAsC,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAsB9F,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,WAAW,CAAC;AAyB3D,4CAA4C;AAC5C,MAAM,WAAW,oBAAoB;IACpC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,yBAAyB,EAC/B,UAAS,oBAAyB,KAChC,KAAK,CAAC,SAAS,CA8KjB,CAAC"}
1
+ {"version":3,"file":"account_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAIH,OAAO,EAAsC,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAwB9F,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,WAAW,CAAC;AAyB3D,4CAA4C;AAC5C,MAAM,WAAW,oBAAoB;IACpC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,yBAAyB,EAC/B,UAAS,oBAAyB,KAChC,KAAK,CAAC,SAAS,CAsLjB,CAAC"}
@@ -27,12 +27,14 @@
27
27
  *
28
28
  * @module
29
29
  */
30
+ import { to_error_message } from '@fuzdev/fuz_util/error.ts';
30
31
  import { rpc_action } from '../actions/action_rpc.js';
32
+ import { jsonrpc_errors } from '../http/jsonrpc_errors.js';
31
33
  import { queue_connection_close } from '../actions/connection_closer.js';
32
34
  import { to_session_account } from './account_schema.js';
33
35
  import { query_session_list_for_account, query_session_revoke_for_account, query_session_revoke_all_for_account } from './session_queries.js';
34
36
  import { query_api_token_enforce_limit, query_api_token_list_for_account, query_create_api_token, query_revoke_api_token_for_account } from './api_token_queries.js';
35
- import { parse_stored_token_scope, token_scope_from_input, token_scope_label } from './token_scope.js';
37
+ import { parse_stored_token_scope, serialize_token_scope, token_scope_from_input, token_scope_label } from './token_scope.js';
36
38
  import { token_lifetime_to_expires_at } from './token_lifetime.js';
37
39
  import { generate_api_token } from './api_token.js';
38
40
  import { DEFAULT_MAX_TOKENS } from './account_route_schema.js';
@@ -108,6 +110,15 @@ export const create_account_actions = (deps, options = {}) => {
108
110
  // There is deliberately no permissive fallback here: an omitted scope is
109
111
  // a validation error upstream, not a full-authority token.
110
112
  const scope = token_scope_from_input(input.scope);
113
+ // Over the stored-document cap is a malformed request, refused before
114
+ // any write (the insert would otherwise throw it as a 500). Twin of the
115
+ // Rust spine's `invalid_params` on `TokenScopeError::TooLarge`.
116
+ try {
117
+ serialize_token_scope(scope);
118
+ }
119
+ catch (err) {
120
+ throw jsonrpc_errors.invalid_params(`invalid scope: ${to_error_message(err)}`);
121
+ }
111
122
  // `input.lifetime` is required for the same reason — an omitted lifetime
112
123
  // is a validation error, never an eternal token.
113
124
  const expires_at = token_lifetime_to_expires_at(input.lifetime);
@@ -111,6 +111,7 @@ export declare const account_status_route_shape: {
111
111
  account: "none";
112
112
  actor: "none";
113
113
  };
114
+ reads_credential: true;
114
115
  description: string;
115
116
  input: z.ZodNull;
116
117
  output: z.ZodObject<{
@@ -1 +1 @@
1
- {"version":3,"file":"account_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAQvD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAUD,CAAC;AAEvC,8EAA8E;AAC9E,MAAM,WAAW,wBAAwB;IACxC,8FAA8F;IAC9F,0BAA0B,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,wBAAwB,KAC/B,CACF,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAoD1B,CAAC"}
1
+ {"version":3,"file":"account_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAQvD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAaD,CAAC;AAEvC,8EAA8E;AAC9E,MAAM,WAAW,wBAAwB;IACxC,8FAA8F;IAC9F,0BAA0B,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,wBAAwB,KAC/B,CACF,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CA6D1B,CAAC"}
@@ -85,6 +85,9 @@ export const account_status_route_shape = {
85
85
  method: 'GET',
86
86
  path: '/status',
87
87
  auth: { account: 'none', actor: 'none' },
88
+ // no gate — the handler answers an anonymous caller with its own 401 — but
89
+ // it reads the account the auth middleware resolved
90
+ reads_credential: true,
88
91
  description: 'Current account info (unauthenticated: 401 with bootstrap status)',
89
92
  input: AccountStatusInput,
90
93
  output: AccountStatusOutput,
@@ -115,10 +118,15 @@ export const create_account_route_shapes = (options) => [
115
118
  method: 'POST',
116
119
  path: '/login',
117
120
  auth: { account: 'none', actor: 'none' },
121
+ mints_credential: true,
118
122
  description: 'Exchange credentials for session',
119
123
  input: LoginInput,
120
124
  output: LoginOutput,
121
125
  rate_limit: 'both',
126
+ // The handler opens its own transaction around just the session writes,
127
+ // so a reservation waiting for a slot, and the Argon2 verify, hold no
128
+ // database connection.
129
+ transaction: false,
122
130
  errors: {
123
131
  400: z.looseObject({
124
132
  error: z.enum([ERROR_INVALID_JSON_BODY, ERROR_INVALID_REQUEST_BODY])
@@ -147,6 +155,10 @@ export const create_account_route_shapes = (options) => [
147
155
  input: PasswordChangeInput,
148
156
  output: PasswordChangeOutput,
149
157
  rate_limit: options.login_account_rate_limited ? 'both' : 'ip',
158
+ // The handler opens its own transaction around just the password update
159
+ // and the revocations, so a reservation waiting for a slot, and the
160
+ // Argon2 verify and hash, hold no database connection.
161
+ transaction: false,
150
162
  errors: {
151
163
  400: z.looseObject({
152
164
  error: z.enum([ERROR_INVALID_JSON_BODY, ERROR_INVALID_REQUEST_BODY])
@@ -134,8 +134,13 @@ export interface AccountRouteOptions extends AuthSessionRouteOptions {
134
134
  * Self-service session/token management is on `auth/account_actions.ts`.
135
135
  *
136
136
  * `/login` closes the connections of the sessions its cap evicts, and
137
- * `/logout` and `/password` close the account's, each after the route's
138
- * transaction commits (`queue_connection_close` on `deps.connection_closer`).
137
+ * `/logout` and `/password` close the account's, each once its revocation has
138
+ * committed: `/logout` through the route transaction's post-commit queue
139
+ * (`queue_connection_close`), `/login` and `/password` — which open their own
140
+ * transaction around just their writes — straight after it commits
141
+ * (`close_connections_after_commit`, which logs a failing close rather than
142
+ * failing the committed request), so a later throw can't drop a close a
143
+ * committed revocation owes. All on `deps.connection_closer`.
139
144
  *
140
145
  * @param deps - stateless capabilities (keyring, password, log, audit, connection_closer)
141
146
  * @param options - per-factory configuration (session_options, login_ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
@@ -1 +1 @@
1
- {"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AA4B1D,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAIlD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAAI,UAAU,oBAAoB,KAAG,SA4EhF,CAAC;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAQ/C;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,uBAAuB;IACvC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;CACxC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE;;;;;;;OAOG;IACH,qBAAqB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC1C,4FAA4F;IAC5F,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,2FAA2F;IAC3F,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAID;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,gBAAgB,EACtB,SAAS,mBAAmB,KAC1B,KAAK,CAAC,SAAS,CA2RjB,CAAC"}
1
+ {"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AA4B1D,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAIxE,OAAO,EAGN,KAAK,WAAW,EAEhB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAOlD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAAI,UAAU,oBAAoB,KAAG,SAkFhF,CAAC;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAQ/C;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,uBAAuB;IACvC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;CACxC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE;;;;;;;OAOG;IACH,qBAAqB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC1C,4FAA4F;IAC5F,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,2FAA2F;IAC3F,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAID;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,gBAAgB,EACtB,SAAS,mBAAmB,KAC1B,KAAK,CAAC,SAAS,CA+VjB,CAAC"}
@@ -31,8 +31,10 @@ import { build_account_context, build_request_context, get_request_context, requ
31
31
  import { ACCOUNT_ID_KEY, CREDENTIAL_TYPE_KEY } from '../hono_context.js';
32
32
  import { get_route_input } from '../http/route_spec.js';
33
33
  import { get_client_ip } from '../http/client_ip.js';
34
- import { rate_limit_exceeded_response } from '../rate_limiter.js';
35
- import { queue_connection_close } from '../actions/connection_closer.js';
34
+ import { rate_limit_ip_key } from '../http/ip_canonical.js';
35
+ import { parse_acting } from '../http/auth_shape.js';
36
+ import { rate_limit_exceeded_response, reserve_optional } from '../rate_limiter.js';
37
+ import { close_connections_after_commit, queue_connection_close } from '../actions/connection_closer.js';
36
38
  import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INVALID_CREDENTIALS } from '../http/error_schemas.js';
37
39
  /**
38
40
  * Create the account status route spec.
@@ -82,10 +84,15 @@ export const create_account_status_route_spec = (options) => ({
82
84
  // Resolve actor + role_grants when the caller is unambiguous (single-actor
83
85
  // account, or supplied `?acting=<uuid>`). On multi-actor accounts
84
86
  // without `acting`, fall back to account-only so the frontend can
85
- // surface a persona picker.
86
- const acting = c.req.query('acting') ?? undefined;
87
- const acting_result = await resolve_acting_actor(route, account_id, acting);
88
- if (acting_result.ok) {
87
+ // surface a persona picker. The selector goes through the `ActingActor`
88
+ // schema, lowercased; a malformed value is a missing persona —
89
+ // account-only, never a default-actor resolve (as on the Rust spine).
90
+ const acting_raw = c.req.query('acting');
91
+ const acting = parse_acting(acting_raw);
92
+ const acting_result = acting_raw !== undefined && acting === undefined
93
+ ? null
94
+ : await resolve_acting_actor(route, account_id, acting);
95
+ if (acting_result?.ok) {
89
96
  const ctx = await build_request_context(route, account_id, acting_result.actor_id);
90
97
  if (ctx) {
91
98
  const role_grants = ctx.role_grants.map((p) => ({
@@ -156,8 +163,13 @@ const login_fail_delay = (floor_ms, jitter_ms) => {
156
163
  * Self-service session/token management is on `auth/account_actions.ts`.
157
164
  *
158
165
  * `/login` closes the connections of the sessions its cap evicts, and
159
- * `/logout` and `/password` close the account's, each after the route's
160
- * transaction commits (`queue_connection_close` on `deps.connection_closer`).
166
+ * `/logout` and `/password` close the account's, each once its revocation has
167
+ * committed: `/logout` through the route transaction's post-commit queue
168
+ * (`queue_connection_close`), `/login` and `/password` — which open their own
169
+ * transaction around just their writes — straight after it commits
170
+ * (`close_connections_after_commit`, which logs a failing close rather than
171
+ * failing the committed request), so a later throw can't drop a close a
172
+ * committed revocation owes. All on `deps.connection_closer`.
161
173
  *
162
174
  * @param deps - stateless capabilities (keyring, password, log, audit, connection_closer)
163
175
  * @param options - per-factory configuration (session_options, login_ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
@@ -185,99 +197,122 @@ export const create_account_route_specs = (deps, options) => {
185
197
  {
186
198
  ...login_shape,
187
199
  handler: async (c, route) => {
188
- // Per-IP rate limit check (before any DB/password work)
189
- const ip = login_ip_rate_limiter ? get_client_ip(c) : null;
190
- if (login_ip_rate_limiter && ip) {
191
- const check = login_ip_rate_limiter.check(ip);
192
- if (!check.allowed) {
193
- return rate_limit_exceeded_response(c, check.retry_after);
194
- }
200
+ // Per-IP rate limit (before any DB/password work). Reserved, not
201
+ // checked: the attempt counts against the cap from here until it
202
+ // settles, so a concurrent burst can't all pass before any of it
203
+ // records. A key at its cap only because of attempts in flight
204
+ // waits briefly for a slot (`RESERVATION_WAIT_MAX_MS`) rather than
205
+ // refusing a burst of correct logins outright. Order: IP, then the
206
+ // lookup, then the account (its key is the looked-up id). A wait on
207
+ // the account bucket keeps the IP slot: it belongs to the same
208
+ // address, the wait is bounded, and giving it up would let that
209
+ // address exceed its cap meanwhile. The `finally` releases whatever
210
+ // no branch recorded.
211
+ // keyed by the address's rate-limit key (an IPv6 client's /64)
212
+ const ip_key = login_ip_rate_limiter ? rate_limit_ip_key(get_client_ip(c)) : null;
213
+ const ip_reserved = await reserve_optional(login_ip_rate_limiter, ip_key, {
214
+ signal: c.req.raw.signal
215
+ });
216
+ if (!ip_reserved.ok) {
217
+ return rate_limit_exceeded_response(c, ip_reserved.retry_after);
195
218
  }
196
- // `UsernameProvided` canonicalizes via `.trim().toLowerCase()` at
197
- // parse time — the validated value lands canonical in
198
- // `c.var.validated_input`, so the per-account rate-limit key,
199
- // DB lookup, and audit metadata see one form. See
200
- // `primitive_schemas.ts` for the schema-layer canonicalization.
201
- const { username, password: pw } = get_route_input(c);
202
- // DB lookup first so we can key the per-account rate limit by a canonical value
203
- // (account.id) rather than the submitted identifier. Otherwise an attacker could
204
- // alternate between username and email to double the per-account bucket.
205
- const account = await query_account_by_username_or_email(route, username);
206
- const account_rate_key = account ? account.id : username;
207
- // Per-account rate limit check (after DB lookup so the key is canonical)
208
- if (login_account_rate_limiter) {
209
- const check = login_account_rate_limiter.check(account_rate_key);
210
- if (!check.allowed) {
211
- return rate_limit_exceeded_response(c, check.retry_after);
219
+ let account_reservation = null;
220
+ try {
221
+ // `UsernameProvided` canonicalizes via `.trim().toLowerCase()` at
222
+ // parse time — the validated value lands canonical in
223
+ // `c.var.validated_input`, so the per-account rate-limit key,
224
+ // DB lookup, and audit metadata see one form. See
225
+ // `primitive_schemas.ts` for the schema-layer canonicalization.
226
+ const { username, password: pw } = get_route_input(c);
227
+ // DB lookup first so we can key the per-account rate limit by a canonical value
228
+ // (account.id) rather than the submitted identifier. Otherwise an attacker could
229
+ // alternate between username and email to double the per-account bucket.
230
+ const account = await query_account_by_username_or_email(route, username);
231
+ const account_rate_key = account ? account.id : username;
232
+ // Per-account rate limit (after DB lookup so the key is canonical)
233
+ const account_reserved = await reserve_optional(login_account_rate_limiter, account_rate_key, { signal: c.req.raw.signal });
234
+ if (!account_reserved.ok) {
235
+ return rate_limit_exceeded_response(c, account_reserved.retry_after);
212
236
  }
213
- }
214
- // Start the minimum-delay timer concurrently with failure work.
215
- // Observed response time is max(work, delay) so all 401 paths
216
- // (found-wrong-pw, not-found) return in similar time.
217
- const delay = login_fail_delay(login_fail_floor_ms, login_fail_jitter_ms);
218
- if (!account) {
219
- // enumeration prevention: verify_dummy equalizes Argon2id timing;
220
- // login_fail_delay equalizes every other path difference.
221
- await password.verify_dummy(pw);
222
- if (login_ip_rate_limiter && ip)
223
- login_ip_rate_limiter.record(ip);
224
- if (login_account_rate_limiter)
225
- login_account_rate_limiter.record(account_rate_key);
226
- deps.audit.emit(route, {
227
- event_type: 'login',
228
- outcome: 'failure',
229
- ip: get_client_ip(c),
230
- metadata: { username }
231
- });
232
- await delay;
233
- return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
234
- }
235
- const valid = await password.verify_password(pw, account.password_hash);
236
- if (!valid) {
237
- if (login_ip_rate_limiter && ip)
238
- login_ip_rate_limiter.record(ip);
237
+ account_reservation = account_reserved.reservation;
238
+ // Start the minimum-delay timer concurrently with failure work.
239
+ // Observed response time is max(work, delay) so all 401 paths
240
+ // (found-wrong-pw, not-found) return in similar time.
241
+ const delay = login_fail_delay(login_fail_floor_ms, login_fail_jitter_ms);
242
+ if (!account) {
243
+ // enumeration prevention: verify_dummy equalizes Argon2id timing;
244
+ // login_fail_delay equalizes every other path difference.
245
+ await password.verify_dummy(pw);
246
+ ip_reserved.reservation.record();
247
+ account_reservation?.record();
248
+ deps.audit.emit(route, {
249
+ event_type: 'login',
250
+ outcome: 'failure',
251
+ ip: get_client_ip(c),
252
+ metadata: { username }
253
+ });
254
+ await delay;
255
+ return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
256
+ }
257
+ const valid = await password.verify_password(pw, account.password_hash);
258
+ if (!valid) {
259
+ ip_reserved.reservation.record();
260
+ account_reservation?.record();
261
+ deps.audit.emit(route, {
262
+ event_type: 'login',
263
+ outcome: 'failure',
264
+ account_id: account.id,
265
+ ip: get_client_ip(c),
266
+ metadata: { username }
267
+ });
268
+ await delay;
269
+ return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
270
+ }
271
+ // Successful login — clear the account-grain failure history only.
272
+ // The IP aggregate is deliberately left standing: it is the
273
+ // distributed-spray backstop, so a success on one account must not
274
+ // refund the budget shared by every other account reachable from
275
+ // that address. See `RateLimiter.reset`. A success records nothing:
276
+ // both reservations are released.
277
+ ip_reserved.reservation.release();
278
+ account_reservation?.release();
239
279
  if (login_account_rate_limiter)
240
- login_account_rate_limiter.record(account_rate_key);
280
+ login_account_rate_limiter.reset(account_rate_key);
281
+ // The session INSERT and the cap eviction are the route's only
282
+ // writes, and only atomic together: one transaction around just
283
+ // them (the route is `transaction: false`, so the reservation
284
+ // waits above and the Argon2 verify held no connection). Twin of
285
+ // the Rust login's transaction around `create_capped_session`.
286
+ const evicted = await route.db.transaction((tx) => create_session_and_set_cookie({
287
+ keyring,
288
+ deps: { db: tx },
289
+ c,
290
+ account_id: account.id,
291
+ session_options,
292
+ max_sessions
293
+ }));
294
+ // The sessions the cap evicted are gone from the table, but not
295
+ // from the connections they opened — a WebSocket keeps the
296
+ // authority it resolved at upgrade, and an SSE stream never
297
+ // rechecks. Close them now the eviction has committed — inline, not
298
+ // queued: with no route transaction, a throw later in the handler
299
+ // would drop a queued close while the eviction stands. No audit
300
+ // row: there is no eviction event, and the `login` row records
301
+ // the cause.
302
+ for (const session_token_hash of evicted) {
303
+ close_connections_after_commit(connection_closer, { kind: 'session', session_token_hash }, deps.log);
304
+ }
241
305
  deps.audit.emit(route, {
242
306
  event_type: 'login',
243
- outcome: 'failure',
244
307
  account_id: account.id,
245
- ip: get_client_ip(c),
246
- metadata: { username }
308
+ ip: get_client_ip(c)
247
309
  });
248
- await delay;
249
- return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
310
+ return c.json({ ok: true });
250
311
  }
251
- // Successful login — clear the account-grain failure history only.
252
- // The IP aggregate is deliberately left standing: it is the
253
- // distributed-spray backstop, so a success on one account must not
254
- // refund the budget shared by every other account reachable from
255
- // that address. See `RateLimiter.reset`.
256
- if (login_account_rate_limiter)
257
- login_account_rate_limiter.reset(account_rate_key);
258
- const evicted = await create_session_and_set_cookie({
259
- keyring,
260
- deps: route,
261
- c,
262
- account_id: account.id,
263
- session_options,
264
- max_sessions
265
- });
266
- // The sessions the cap evicted are gone from the table, but not
267
- // from the connections they opened — a WebSocket keeps the
268
- // authority it resolved at upgrade, and an SSE stream never
269
- // rechecks. Close them once the eviction has committed. No audit
270
- // row: there is no eviction event, and the `login` row records
271
- // the cause.
272
- for (const session_token_hash of evicted) {
273
- queue_connection_close(route, connection_closer, { kind: 'session', session_token_hash });
312
+ finally {
313
+ ip_reserved.reservation.release();
314
+ account_reservation?.release();
274
315
  }
275
- deps.audit.emit(route, {
276
- event_type: 'login',
277
- account_id: account.id,
278
- ip: get_client_ip(c)
279
- });
280
- return c.json({ ok: true });
281
316
  }
282
317
  },
283
318
  {
@@ -316,111 +351,129 @@ export const create_account_route_specs = (deps, options) => {
316
351
  {
317
352
  ...password_shape,
318
353
  handler: async (c, route) => {
319
- // per-IP rate limit check (before argon2 work)
320
- const ip = login_ip_rate_limiter ? get_client_ip(c) : null;
321
- if (login_ip_rate_limiter && ip) {
322
- const check = login_ip_rate_limiter.check(ip);
323
- if (!check.allowed) {
324
- return rate_limit_exceeded_response(c, check.retry_after);
325
- }
354
+ // Per-IP rate limit (before argon2 work). Reserved, not checked:
355
+ // the attempt counts against the shared login limiters from here
356
+ // until it settles, so a concurrent burst of guesses at
357
+ // `current_password` can't all pass before any of it records. The
358
+ // `finally` releases whatever no branch recorded.
359
+ // keyed by the address's rate-limit key (an IPv6 client's /64)
360
+ const ip_key = login_ip_rate_limiter ? rate_limit_ip_key(get_client_ip(c)) : null;
361
+ const ip_reserved = await reserve_optional(login_ip_rate_limiter, ip_key, {
362
+ signal: c.req.raw.signal
363
+ });
364
+ if (!ip_reserved.ok) {
365
+ return rate_limit_exceeded_response(c, ip_reserved.retry_after);
326
366
  }
327
- const ctx = require_request_context(c);
328
- const { current_password, new_password } = get_route_input(c);
329
- // Defense in depth — see `docs/security.md` §Credential-channel gating.
330
- const credential_type = c.get(CREDENTIAL_TYPE_KEY) ?? undefined;
331
- // per-account rate limit check (after context resolution, before argon2 work)
332
- if (login_account_rate_limiter) {
333
- const check = login_account_rate_limiter.check(ctx.account.id);
334
- if (!check.allowed) {
335
- return rate_limit_exceeded_response(c, check.retry_after);
367
+ let account_reservation = null;
368
+ try {
369
+ const ctx = require_request_context(c);
370
+ const { current_password, new_password } = get_route_input(c);
371
+ // Defense in depth — see `docs/security.md` §Credential-channel gating.
372
+ const credential_type = c.get(CREDENTIAL_TYPE_KEY) ?? undefined;
373
+ // per-account rate limit (after context resolution, before argon2 work)
374
+ const account_reserved = await reserve_optional(login_account_rate_limiter, ctx.account.id, { signal: c.req.raw.signal });
375
+ if (!account_reserved.ok) {
376
+ return rate_limit_exceeded_response(c, account_reserved.retry_after);
336
377
  }
337
- }
338
- const valid = await password.verify_password(current_password, ctx.account.password_hash);
339
- if (!valid) {
340
- if (login_ip_rate_limiter && ip)
341
- login_ip_rate_limiter.record(ip);
342
- if (login_account_rate_limiter)
343
- login_account_rate_limiter.record(ctx.account.id);
344
- deps.audit.emit(route, {
345
- event_type: 'password_change',
346
- outcome: 'failure',
347
- account_id: ctx.account.id,
348
- ip: get_client_ip(c),
349
- metadata: { credential_type }
378
+ account_reservation = account_reserved.reservation;
379
+ const valid = await password.verify_password(current_password, ctx.account.password_hash);
380
+ if (!valid) {
381
+ ip_reserved.reservation.record();
382
+ account_reservation?.record();
383
+ deps.audit.emit(route, {
384
+ event_type: 'password_change',
385
+ outcome: 'failure',
386
+ account_id: ctx.account.id,
387
+ ip: get_client_ip(c),
388
+ metadata: { credential_type }
389
+ });
390
+ return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
391
+ }
392
+ // Verify succeeded — do the throw-y operations FIRST so a fault
393
+ // (Argon2 OOM, native binding error, DB outage on the UPDATE)
394
+ // can't wipe the rate-limit history of a caller observing 500s.
395
+ // Resets happen below, after both calls have settled.
396
+ const new_hash = await password.hash_password(new_password);
397
+ // Conditional UPDATE keyed on the verified hash: closes the
398
+ // verify-write race with a concurrent password change that
399
+ // already committed against the same starting hash. Account-grain
400
+ // operation — `updated_by` stays null (the per-request actor is
401
+ // incidental; password is account-level state).
402
+ //
403
+ // The UPDATE and the two revocations are the route's writes, and
404
+ // commit together or not at all: one transaction around just them
405
+ // (the route is `transaction: false`, so the reservation waits
406
+ // above and the Argon2 verify and hash held no connection). Twin of
407
+ // the Rust spine's `change_password`. `null` when the UPDATE matched
408
+ // nothing — a concurrent change committed first.
409
+ const change = await route.db.transaction(async (tx) => {
410
+ const tx_deps = { db: tx };
411
+ const updated = await query_update_account_password(tx_deps, ctx.account.id, new_hash, null, ctx.account.password_hash);
412
+ if (!updated)
413
+ return null;
414
+ // revoke all sessions and API tokens (force re-auth everywhere)
415
+ const sessions_revoked = await query_session_revoke_all_for_account(tx_deps, ctx.account.id);
416
+ const tokens_revoked = await query_revoke_all_api_tokens_for_account(tx_deps, ctx.account.id);
417
+ return { sessions_revoked, tokens_revoked };
350
418
  });
351
- return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
352
- }
353
- // Verify succeeded — do the throw-y operations FIRST so a fault
354
- // (Argon2 OOM, native binding error, DB outage on the UPDATE)
355
- // can't wipe the rate-limit history of a caller observing 500s.
356
- // Resets happen below, after both calls have settled.
357
- const new_hash = await password.hash_password(new_password);
358
- // Conditional UPDATE keyed on the verified hash: closes the
359
- // verify-write race with a concurrent password change that
360
- // already committed against the same starting hash. Account-grain
361
- // operation — `updated_by` stays null (the per-request actor is
362
- // incidental; password is account-level state).
363
- const updated = await query_update_account_password(route, ctx.account.id, new_hash, null, ctx.account.password_hash);
364
- // Verify-success contract — the caller proved knowledge, so wipe
365
- // their account-grain failure history. The race-loser branch below
366
- // re-records one on top of the wiped slate so net cost stays 1 on
367
- // that bucket. Deferring from "after verify" to "after UPDATE
368
- // settled" is what closes the S1 bypass — a throw between reset
369
- // and the UPDATE could have wiped an attacker's budget.
370
- //
371
- // The IP aggregate is deliberately not cleared: it is the same
372
- // instance login uses, so refunding it here reopens the spray path
373
- // through a route that only needs the caller's *own* password. Its
374
- // record on the race-loser branch below therefore accumulates
375
- // rather than netting out, which is the intent.
376
- if (login_account_rate_limiter)
377
- login_account_rate_limiter.reset(ctx.account.id);
378
- if (!updated) {
379
- // A concurrent password change committed first — our
380
- // `current_password` was correct at read-time but the row's
381
- // `password_hash` no longer matches. Mirrors the wrong-password
382
- // 401 shape; tag the failure metadata so admins reading the
383
- // audit log can distinguish "user typoed" from "two clients
384
- // raced." Sessions/tokens were already revoked by the winner;
385
- // no cookie clear here either.
386
- if (login_ip_rate_limiter && ip)
387
- login_ip_rate_limiter.record(ip);
419
+ // Verify-success contract — the caller proved knowledge, so wipe
420
+ // their account-grain failure history. The race-loser branch below
421
+ // re-records one on top of the wiped slate so net cost stays 1 on
422
+ // that bucket. Deferring from "after verify" to "after UPDATE
423
+ // settled" is what closes the S1 bypass — a throw between reset
424
+ // and the UPDATE could have wiped an attacker's budget.
425
+ //
426
+ // The IP aggregate is deliberately not cleared: it is the same
427
+ // instance login uses, so refunding it here reopens the spray path
428
+ // through a route that only needs the caller's *own* password. Its
429
+ // record on the race-loser branch below therefore accumulates
430
+ // rather than netting out, which is the intent.
388
431
  if (login_account_rate_limiter)
389
- login_account_rate_limiter.record(ctx.account.id);
432
+ login_account_rate_limiter.reset(ctx.account.id);
433
+ if (!change) {
434
+ // A concurrent password change committed first — our
435
+ // `current_password` was correct at read-time but the row's
436
+ // `password_hash` no longer matches. Mirrors the wrong-password
437
+ // 401 shape; tag the failure metadata so admins reading the
438
+ // audit log can distinguish "user typoed" from "two clients
439
+ // raced." Sessions/tokens were already revoked by the winner;
440
+ // no cookie clear here either.
441
+ ip_reserved.reservation.record();
442
+ account_reservation?.record();
443
+ deps.audit.emit(route, {
444
+ event_type: 'password_change',
445
+ outcome: 'failure',
446
+ account_id: ctx.account.id,
447
+ ip: get_client_ip(c),
448
+ metadata: { reason: 'concurrent_change', credential_type }
449
+ });
450
+ return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
451
+ }
452
+ const { sessions_revoked, tokens_revoked } = change;
453
+ // Close every live connection of this account now the password
454
+ // update and the revoke-all cascade have committed — inline, not
455
+ // queued: with no route transaction, a throw later in the handler
456
+ // would drop a queued close while the revocation stands. It does
457
+ // not depend on the fail-open audit write below; the audit
458
+ // listeners repeat it on the announced `password_change` row.
459
+ close_connections_after_commit(connection_closer, { kind: 'account', account_id: ctx.account.id }, deps.log);
460
+ clear_session_cookie(c, session_options);
461
+ // Account-grain operation — no `actor_id`. The password is
462
+ // account-level state; which per-request actor was resolved
463
+ // has no semantic bearing on "this account changed its
464
+ // password". Mirrors `login`/`logout`.
390
465
  deps.audit.emit(route, {
391
466
  event_type: 'password_change',
392
- outcome: 'failure',
393
467
  account_id: ctx.account.id,
394
468
  ip: get_client_ip(c),
395
- metadata: { reason: 'concurrent_change', credential_type }
469
+ metadata: { sessions_revoked, tokens_revoked, credential_type }
396
470
  });
397
- return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
471
+ return c.json({ ok: true, sessions_revoked, tokens_revoked });
472
+ }
473
+ finally {
474
+ ip_reserved.reservation.release();
475
+ account_reservation?.release();
398
476
  }
399
- // revoke all sessions and API tokens (force re-auth everywhere)
400
- const sessions_revoked = await query_session_revoke_all_for_account(route, ctx.account.id);
401
- const tokens_revoked = await query_revoke_all_api_tokens_for_account(route, ctx.account.id);
402
- // Close every live connection of this account once the password
403
- // update and the revoke-all cascade have committed — this route
404
- // runs with the default `transaction: true`, and the queued close
405
- // never runs before that commit or on rollback. It does not
406
- // depend on the fail-open audit write below; the audit listeners
407
- // repeat it on the announced `password_change` row.
408
- queue_connection_close(route, connection_closer, {
409
- kind: 'account',
410
- account_id: ctx.account.id
411
- });
412
- clear_session_cookie(c, session_options);
413
- // Account-grain operation — no `actor_id`. The password is
414
- // account-level state; which per-request actor was resolved
415
- // has no semantic bearing on "this account changed its
416
- // password". Mirrors `login`/`logout`.
417
- deps.audit.emit(route, {
418
- event_type: 'password_change',
419
- account_id: ctx.account.id,
420
- ip: get_client_ip(c),
421
- metadata: { sessions_revoked, tokens_revoked, credential_type }
422
- });
423
- return c.json({ ok: true, sessions_revoked, tokens_revoked });
424
477
  }
425
478
  }
426
479
  ];