@fuzdev/fuz_app 0.141.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 (147) hide show
  1. package/dist/actions/CLAUDE.md +10 -5
  2. package/dist/actions/connection_closer.d.ts +19 -2
  3. package/dist/actions/connection_closer.d.ts.map +1 -1
  4. package/dist/actions/connection_closer.js +25 -2
  5. package/dist/actions/perform_action.d.ts +10 -0
  6. package/dist/actions/perform_action.d.ts.map +1 -1
  7. package/dist/actions/perform_action.js +23 -9
  8. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  9. package/dist/actions/register_ws_endpoint.js +5 -2
  10. package/dist/auth/CLAUDE.md +33 -13
  11. package/dist/auth/account_actions.d.ts.map +1 -1
  12. package/dist/auth/account_actions.js +12 -1
  13. package/dist/auth/account_route_schema.d.ts.map +1 -1
  14. package/dist/auth/account_route_schema.js +8 -0
  15. package/dist/auth/account_routes.d.ts +7 -2
  16. package/dist/auth/account_routes.d.ts.map +1 -1
  17. package/dist/auth/account_routes.js +240 -187
  18. package/dist/auth/admin_action_specs.d.ts +6 -1
  19. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  20. package/dist/auth/admin_action_specs.js +6 -1
  21. package/dist/auth/admin_actions.d.ts.map +1 -1
  22. package/dist/auth/admin_actions.js +14 -2
  23. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  24. package/dist/auth/bootstrap_routes.js +85 -76
  25. package/dist/auth/cell_action_specs.d.ts +7 -3
  26. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  27. package/dist/auth/cell_action_specs.js +9 -5
  28. package/dist/auth/cell_actions.d.ts +3 -3
  29. package/dist/auth/cell_actions.d.ts.map +1 -1
  30. package/dist/auth/cell_actions.js +67 -38
  31. package/dist/auth/cell_authorize.d.ts +69 -8
  32. package/dist/auth/cell_authorize.d.ts.map +1 -1
  33. package/dist/auth/cell_authorize.js +89 -9
  34. package/dist/auth/cell_field_action_specs.d.ts +5 -4
  35. package/dist/auth/cell_field_action_specs.d.ts.map +1 -1
  36. package/dist/auth/cell_field_action_specs.js +7 -7
  37. package/dist/auth/cell_field_actions.d.ts +7 -5
  38. package/dist/auth/cell_field_actions.d.ts.map +1 -1
  39. package/dist/auth/cell_field_actions.js +24 -22
  40. package/dist/auth/cell_grant_action_specs.d.ts +6 -0
  41. package/dist/auth/cell_grant_action_specs.d.ts.map +1 -1
  42. package/dist/auth/cell_grant_action_specs.js +6 -0
  43. package/dist/auth/cell_grant_actions.d.ts +3 -1
  44. package/dist/auth/cell_grant_actions.d.ts.map +1 -1
  45. package/dist/auth/cell_grant_actions.js +16 -2
  46. package/dist/auth/cell_item_action_specs.d.ts +5 -4
  47. package/dist/auth/cell_item_action_specs.d.ts.map +1 -1
  48. package/dist/auth/cell_item_action_specs.js +7 -7
  49. package/dist/auth/cell_item_actions.d.ts +6 -5
  50. package/dist/auth/cell_item_actions.d.ts.map +1 -1
  51. package/dist/auth/cell_item_actions.js +23 -22
  52. package/dist/auth/cell_relation_list_limit.d.ts +20 -0
  53. package/dist/auth/cell_relation_list_limit.d.ts.map +1 -0
  54. package/dist/auth/cell_relation_list_limit.js +27 -0
  55. package/dist/auth/cell_relation_visibility.d.ts +4 -3
  56. package/dist/auth/cell_relation_visibility.d.ts.map +1 -1
  57. package/dist/auth/cell_relation_visibility.js +6 -5
  58. package/dist/auth/cell_steward.d.ts +45 -0
  59. package/dist/auth/cell_steward.d.ts.map +1 -0
  60. package/dist/auth/cell_steward.js +53 -0
  61. package/dist/auth/request_context.d.ts +4 -0
  62. package/dist/auth/request_context.d.ts.map +1 -1
  63. package/dist/auth/request_context.js +6 -1
  64. package/dist/auth/role_grant_offer_action_specs.d.ts +5 -0
  65. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -1
  66. package/dist/auth/role_grant_offer_action_specs.js +6 -1
  67. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  68. package/dist/auth/role_grant_offer_actions.js +23 -16
  69. package/dist/auth/role_grant_offer_queries.d.ts +8 -6
  70. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  71. package/dist/auth/role_grant_offer_queries.js +16 -8
  72. package/dist/auth/signup_routes.d.ts +4 -3
  73. package/dist/auth/signup_routes.d.ts.map +1 -1
  74. package/dist/auth/signup_routes.js +157 -134
  75. package/dist/auth/token_scope.d.ts +7 -1
  76. package/dist/auth/token_scope.d.ts.map +1 -1
  77. package/dist/auth/token_scope.js +8 -2
  78. package/dist/db/CLAUDE.md +32 -19
  79. package/dist/db/cell_ddl.d.ts +1 -2
  80. package/dist/db/cell_ddl.d.ts.map +1 -1
  81. package/dist/db/cell_ddl.js +1 -2
  82. package/dist/db/cell_field_queries.d.ts +20 -18
  83. package/dist/db/cell_field_queries.d.ts.map +1 -1
  84. package/dist/db/cell_field_queries.js +28 -22
  85. package/dist/db/cell_item_queries.d.ts +20 -14
  86. package/dist/db/cell_item_queries.d.ts.map +1 -1
  87. package/dist/db/cell_item_queries.js +28 -18
  88. package/dist/db/cell_moderation.d.ts +67 -0
  89. package/dist/db/cell_moderation.d.ts.map +1 -0
  90. package/dist/db/cell_moderation.js +66 -0
  91. package/dist/db/cell_queries.d.ts +44 -14
  92. package/dist/db/cell_queries.d.ts.map +1 -1
  93. package/dist/db/cell_queries.js +68 -45
  94. package/dist/db/cell_view_sql.d.ts +102 -0
  95. package/dist/db/cell_view_sql.d.ts.map +1 -0
  96. package/dist/db/cell_view_sql.js +112 -0
  97. package/dist/db/db_pg.d.ts +1 -1
  98. package/dist/db/db_pg.js +1 -1
  99. package/dist/http/auth_shape.d.ts +5 -2
  100. package/dist/http/auth_shape.d.ts.map +1 -1
  101. package/dist/http/auth_shape.js +5 -2
  102. package/dist/http/ip_canonical.d.ts +30 -3
  103. package/dist/http/ip_canonical.d.ts.map +1 -1
  104. package/dist/http/ip_canonical.js +50 -3
  105. package/dist/primitive_schemas.d.ts +10 -1
  106. package/dist/primitive_schemas.d.ts.map +1 -1
  107. package/dist/primitive_schemas.js +11 -2
  108. package/dist/rate_limiter.d.ts +179 -12
  109. package/dist/rate_limiter.d.ts.map +1 -1
  110. package/dist/rate_limiter.js +352 -42
  111. package/dist/server/serve_fact_route.d.ts +8 -4
  112. package/dist/server/serve_fact_route.d.ts.map +1 -1
  113. package/dist/server/serve_fact_route.js +15 -6
  114. package/dist/testing/CLAUDE.md +10 -3
  115. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  116. package/dist/testing/cross_backend/account_lifecycle.js +37 -2
  117. package/dist/testing/cross_backend/cell_gated_create.d.ts +38 -0
  118. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  119. package/dist/testing/cross_backend/cell_gated_create.js +401 -5
  120. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  121. package/dist/testing/cross_backend/cell_relations.js +145 -2
  122. package/dist/testing/cross_backend/expected_schema.json +0 -1
  123. package/dist/testing/cross_backend/login_security.d.ts +16 -0
  124. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  125. package/dist/testing/cross_backend/login_security.js +154 -0
  126. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -1
  127. package/dist/testing/cross_backend/query_shape.js +24 -2
  128. package/dist/testing/cross_backend/role_grant_participation.d.ts.map +1 -1
  129. package/dist/testing/cross_backend/role_grant_participation.js +64 -0
  130. package/dist/testing/cross_backend/spine_surface_constants.d.ts +1 -1
  131. package/dist/testing/cross_backend/spine_surface_constants.js +1 -1
  132. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -1
  133. package/dist/testing/cross_backend/ws_action_rate_limit.js +21 -1
  134. package/dist/testing/schema_introspect.js +1 -1
  135. package/dist/testing/schema_parity.d.ts +4 -3
  136. package/dist/testing/schema_parity.d.ts.map +1 -1
  137. package/dist/testing/schema_parity.js +4 -3
  138. package/dist/testing/schema_ready_fixture.d.ts +2 -4
  139. package/dist/testing/schema_ready_fixture.d.ts.map +1 -1
  140. package/dist/testing/schema_ready_fixture.js +2 -4
  141. package/dist/ui/auth_state.svelte.d.ts +10 -0
  142. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  143. package/dist/ui/auth_state.svelte.js +22 -4
  144. package/package.json +1 -1
  145. package/dist/db/cell_history_ddl.d.ts +0 -38
  146. package/dist/db/cell_history_ddl.d.ts.map +0 -1
  147. package/dist/db/cell_history_ddl.js +0 -61
@@ -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
  ];
@@ -668,7 +668,12 @@ export declare const invite_delete_action_spec: {
668
668
  /**
669
669
  * Soft-delete an account (reversible tombstone). Self-or-admin: the
670
670
  * caller may delete their own account; deleting another requires the
671
- * admin role (handler-enforced elevation). No `admin_` prefix — the
671
+ * admin role (handler-enforced elevation). The self path is session-only,
672
+ * also handler-enforced — the spec declares no `credential_types` so an
673
+ * admin deleting another account keeps any credential, and a self-delete
674
+ * over an API or daemon token gets the dispatcher's own 403
675
+ * `credential_type_required` (`required_credential_types: ['session']`),
676
+ * ahead of the keeper / last-admin guards. No `admin_` prefix — the
672
677
  * privilege lives in the auth check, not the name, so self-service
673
678
  * deletion stays open (`delete` = soft, `purge` = hard).
674
679
  */
@@ -1 +1 @@
1
- {"version":3,"file":"admin_action_specs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/admin_action_specs.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"admin_action_specs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/admin_action_specs.ts"],"names":[],"mappings":"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"}
@@ -412,7 +412,12 @@ export const invite_delete_action_spec = {
412
412
  /**
413
413
  * Soft-delete an account (reversible tombstone). Self-or-admin: the
414
414
  * caller may delete their own account; deleting another requires the
415
- * admin role (handler-enforced elevation). No `admin_` prefix — the
415
+ * admin role (handler-enforced elevation). The self path is session-only,
416
+ * also handler-enforced — the spec declares no `credential_types` so an
417
+ * admin deleting another account keeps any credential, and a self-delete
418
+ * over an API or daemon token gets the dispatcher's own 403
419
+ * `credential_type_required` (`required_credential_types: ['session']`),
420
+ * ahead of the keeper / last-admin guards. No `admin_` prefix — the
416
421
  * privilege lives in the auth check, not the name, so self-service
417
422
  * deletion stays open (`delete` = soft, `purge` = hard).
418
423
  */
@@ -1 +1 @@
1
- {"version":3,"file":"admin_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/admin_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAG/F,OAAO,EAKN,KAAK,gBAAgB,EACrB,MAAM,kBAAkB,CAAC;AAyC1B,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,WAAW,CAAC;AA0D3D,0CAA0C;AAC1C,MAAM,WAAW,kBAAkB;IAClC;;;;;OAKG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,yBAAyB,EAC/B,UAAS,kBAAuB,KAC9B,KAAK,CAAC,SAAS,CAogBjB,CAAC"}
1
+ {"version":3,"file":"admin_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/admin_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAI/F,OAAO,EAKN,KAAK,gBAAgB,EACrB,MAAM,kBAAkB,CAAC;AAyC1B,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,WAAW,CAAC;AA0D3D,0CAA0C;AAC1C,MAAM,WAAW,kBAAkB;IAClC;;;;;OAKG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,yBAAyB,EAC/B,UAAS,kBAAuB,KAC9B,KAAK,CAAC,SAAS,CA+gBjB,CAAC"}
@@ -30,7 +30,8 @@
30
30
  */
31
31
  import { rpc_action } from '../actions/action_rpc.js';
32
32
  import { queue_connection_close } from '../actions/connection_closer.js';
33
- import { jsonrpc_errors } from '../http/jsonrpc_errors.js';
33
+ import { credential_type_required_error } from '../actions/perform_action.js';
34
+ import { jsonrpc_errors, ThrownJsonrpcError } from '../http/jsonrpc_errors.js';
34
35
  import { builtin_role_specs_by_name, list_roles_with_grant_path, ROLE_ADMIN, ROLE_KEEPER } from './role_schema.js';
35
36
  import { has_scoped_role } from './request_context.js';
36
37
  import { query_account_has_global_role, query_account_has_active_global_role, query_count_active_accounts_with_global_role } from './role_grant_queries.js';
@@ -279,13 +280,24 @@ export const create_admin_actions = (deps, options = {}) => {
279
280
  }
280
281
  };
281
282
  // Soft-delete an account (reversible tombstone). Self-or-admin:
282
- // deleting another account requires the admin role. Tombstones the
283
+ // deleting another account requires the admin role. A self-delete is
284
+ // session-only — enforced here, not on the spec, whose gate stays open so
285
+ // an admin deleting another account keeps any credential. Tombstones the
283
286
  // account + its actor(s), revokes sessions/tokens, closes sockets, and
284
287
  // emits `account_delete` + one `actor_delete` per soft-deleted actor —
285
288
  // each carrying its identity snapshot. `delete` = soft.
286
289
  const account_delete_handler = async (input, ctx) => {
287
290
  const auth = ctx.auth;
291
+ // `Uuid` outputs lowercase, the form `auth.account.id` is in, so an
292
+ // uppercased own id still reads as self
288
293
  const target_account_id = input.account_id ?? auth.account.id;
294
+ // Self path: a session only (API and daemon tokens alike), with the
295
+ // dispatcher's own credential-type refusal, before the guards below say
296
+ // anything about the account — a leaked token can't end its own account.
297
+ if (target_account_id === auth.account.id && ctx.credential_type !== 'session') {
298
+ const refusal = credential_type_required_error(['session']);
299
+ throw new ThrownJsonrpcError(refusal.code, refusal.message, refusal.data);
300
+ }
289
301
  // Self-or-admin elevation: deleting someone else needs the **global**
290
302
  // admin grant — a scoped `admin` row confers no cross-account authority.
291
303
  if (target_account_id !== auth.account.id && !has_scoped_role(auth, ROLE_ADMIN, null)) {
@@ -1 +1 @@
1
- {"version":3,"file":"bootstrap_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/bootstrap_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE1D,OAAO,EAAqB,KAAK,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAEzF,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACtC,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;AAGlD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC/B,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACrC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,8EAA8E;IAC9E,gBAAgB,EAAE,eAAe,CAAC;IAClC;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,uBAAuB,EAAE,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9E;;;;;;;;;;OAUG;IACH,yBAAyB,EAAE,WAAW,GAAG,IAAI,CAAC;CAC9C;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACxC,kFAAkF;IAClF,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACxD,+EAA+E;IAC/E,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC;IAC1B,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,wBAAwB,EAC9B,SAAS;IAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,KACpC,OAAO,CAAC,eAAe,CAyBzB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GACxC,MAAM,gBAAgB,EACtB,SAAS,qBAAqB,KAC5B,KAAK,CAAC,SAAS,CAmHjB,CAAC"}
1
+ {"version":3,"file":"bootstrap_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/bootstrap_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE1D,OAAO,EAAqB,KAAK,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAEzF,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAGxE,OAAO,EAGN,KAAK,WAAW,EAChB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD;;GAEG;AACH,MAAM,WAAW,eAAe;IAC/B,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACrC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,8EAA8E;IAC9E,gBAAgB,EAAE,eAAe,CAAC;IAClC;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,uBAAuB,EAAE,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9E;;;;;;;;;;OAUG;IACH,yBAAyB,EAAE,WAAW,GAAG,IAAI,CAAC;CAC9C;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACxC,kFAAkF;IAClF,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACxD,+EAA+E;IAC/E,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC;IAC1B,GAAG,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,wBAAwB,EAC9B,SAAS;IAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,KACpC,OAAO,CAAC,eAAe,CAyBzB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GACxC,MAAM,gBAAgB,EACtB,SAAS,qBAAqB,KAC5B,KAAK,CAAC,SAAS,CA0HjB,CAAC"}