@fuzdev/fuz_app 0.117.1 → 0.119.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 (179) hide show
  1. package/dist/actions/CLAUDE.md +301 -73
  2. package/dist/actions/action_rpc.d.ts.map +1 -1
  3. package/dist/actions/action_rpc.js +16 -1
  4. package/dist/actions/connection_closer.d.ts +136 -51
  5. package/dist/actions/connection_closer.d.ts.map +1 -1
  6. package/dist/actions/connection_closer.js +133 -34
  7. package/dist/actions/register_action_ws.d.ts +176 -24
  8. package/dist/actions/register_action_ws.d.ts.map +1 -1
  9. package/dist/actions/register_action_ws.js +581 -241
  10. package/dist/actions/register_ws_endpoint.d.ts +1 -0
  11. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  12. package/dist/actions/register_ws_endpoint.js +1 -0
  13. package/dist/actions/socket.svelte.d.ts +43 -9
  14. package/dist/actions/socket.svelte.d.ts.map +1 -1
  15. package/dist/actions/socket.svelte.js +59 -8
  16. package/dist/actions/transports.d.ts +55 -1
  17. package/dist/actions/transports.d.ts.map +1 -1
  18. package/dist/actions/transports.js +55 -1
  19. package/dist/actions/transports_ws_auth_guard.d.ts +53 -67
  20. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  21. package/dist/actions/transports_ws_auth_guard.js +87 -111
  22. package/dist/actions/transports_ws_backend.d.ts +212 -30
  23. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  24. package/dist/actions/transports_ws_backend.js +327 -70
  25. package/dist/actions/ws_endpoint_spec.d.ts +26 -13
  26. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  27. package/dist/auth/CLAUDE.md +125 -31
  28. package/dist/auth/account_actions.d.ts +12 -18
  29. package/dist/auth/account_actions.d.ts.map +1 -1
  30. package/dist/auth/account_actions.js +47 -33
  31. package/dist/auth/account_routes.d.ts +5 -11
  32. package/dist/auth/account_routes.d.ts.map +1 -1
  33. package/dist/auth/account_routes.js +40 -36
  34. package/dist/auth/account_schema.d.ts +27 -0
  35. package/dist/auth/account_schema.d.ts.map +1 -1
  36. package/dist/auth/account_schema.js +22 -0
  37. package/dist/auth/admin_actions.d.ts +8 -17
  38. package/dist/auth/admin_actions.d.ts.map +1 -1
  39. package/dist/auth/admin_actions.js +33 -21
  40. package/dist/auth/api_token_queries.d.ts +23 -2
  41. package/dist/auth/api_token_queries.d.ts.map +1 -1
  42. package/dist/auth/api_token_queries.js +39 -3
  43. package/dist/auth/audit_emitter.d.ts +72 -44
  44. package/dist/auth/audit_emitter.d.ts.map +1 -1
  45. package/dist/auth/audit_emitter.js +60 -16
  46. package/dist/auth/audit_log_route_schema.js +7 -7
  47. package/dist/auth/audit_log_routes.d.ts +31 -6
  48. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  49. package/dist/auth/audit_log_routes.js +85 -11
  50. package/dist/auth/audit_log_schema.d.ts +48 -0
  51. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  52. package/dist/auth/audit_log_schema.js +58 -0
  53. package/dist/auth/bootstrap_account.d.ts +3 -2
  54. package/dist/auth/bootstrap_account.d.ts.map +1 -1
  55. package/dist/auth/bootstrap_account.js +3 -3
  56. package/dist/auth/cleanup.d.ts +219 -30
  57. package/dist/auth/cleanup.d.ts.map +1 -1
  58. package/dist/auth/cleanup.js +260 -45
  59. package/dist/auth/deps.d.ts +29 -1
  60. package/dist/auth/deps.d.ts.map +1 -1
  61. package/dist/auth/migrations.d.ts.map +1 -1
  62. package/dist/auth/migrations.js +27 -0
  63. package/dist/auth/resolved_auth.d.ts +105 -0
  64. package/dist/auth/resolved_auth.d.ts.map +1 -0
  65. package/dist/auth/resolved_auth.js +124 -0
  66. package/dist/auth/role_grant_offer_ddl.d.ts +4 -2
  67. package/dist/auth/role_grant_offer_ddl.d.ts.map +1 -1
  68. package/dist/auth/role_grant_offer_ddl.js +10 -3
  69. package/dist/auth/role_grant_offer_queries.d.ts +16 -7
  70. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  71. package/dist/auth/role_grant_offer_queries.js +33 -15
  72. package/dist/auth/role_grant_offer_schema.d.ts +9 -0
  73. package/dist/auth/role_grant_offer_schema.d.ts.map +1 -1
  74. package/dist/auth/session_middleware.d.ts +8 -1
  75. package/dist/auth/session_middleware.d.ts.map +1 -1
  76. package/dist/auth/session_middleware.js +9 -3
  77. package/dist/auth/session_queries.d.ts +31 -6
  78. package/dist/auth/session_queries.d.ts.map +1 -1
  79. package/dist/auth/session_queries.js +31 -6
  80. package/dist/auth/standard_rpc_actions.d.ts +9 -9
  81. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  82. package/dist/auth/standard_rpc_actions.js +3 -3
  83. package/dist/db/CLAUDE.md +7 -1
  84. package/dist/db/migrate.d.ts +8 -13
  85. package/dist/db/migrate.d.ts.map +1 -1
  86. package/dist/db/migrate.js +9 -38
  87. package/dist/db/status.d.ts +58 -7
  88. package/dist/db/status.d.ts.map +1 -1
  89. package/dist/db/status.js +205 -97
  90. package/dist/http/CLAUDE.md +7 -3
  91. package/dist/http/db_routes.d.ts +6 -2
  92. package/dist/http/db_routes.d.ts.map +1 -1
  93. package/dist/http/db_routes.js +2 -2
  94. package/dist/periodic.d.ts +80 -0
  95. package/dist/periodic.d.ts.map +1 -0
  96. package/dist/periodic.js +122 -0
  97. package/dist/realtime/sse_auth_guard.d.ts +79 -49
  98. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  99. package/dist/realtime/sse_auth_guard.js +103 -73
  100. package/dist/realtime/subscriber_registry.d.ts +119 -8
  101. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  102. package/dist/realtime/subscriber_registry.js +184 -25
  103. package/dist/runtime/deno.d.ts.map +1 -1
  104. package/dist/runtime/deno.js +9 -6
  105. package/dist/runtime/deps.d.ts +3 -2
  106. package/dist/runtime/deps.d.ts.map +1 -1
  107. package/dist/runtime/secure_file.d.ts +23 -9
  108. package/dist/runtime/secure_file.d.ts.map +1 -1
  109. package/dist/runtime/secure_file.js +29 -10
  110. package/dist/server/app_backend.d.ts +13 -2
  111. package/dist/server/app_backend.d.ts.map +1 -1
  112. package/dist/server/app_backend.js +5 -2
  113. package/dist/server/app_server.d.ts +55 -15
  114. package/dist/server/app_server.d.ts.map +1 -1
  115. package/dist/server/app_server.js +41 -13
  116. package/dist/testing/CLAUDE.md +140 -48
  117. package/dist/testing/app_server.d.ts +2 -2
  118. package/dist/testing/app_server.d.ts.map +1 -1
  119. package/dist/testing/app_server.js +11 -2
  120. package/dist/testing/audit_drift_guard.d.ts +3 -52
  121. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  122. package/dist/testing/audit_drift_guard.js +2 -46
  123. package/dist/testing/connection_closer_helpers.d.ts +9 -22
  124. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  125. package/dist/testing/connection_closer_helpers.js +11 -17
  126. package/dist/testing/cross_backend/capabilities.d.ts +67 -13
  127. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  128. package/dist/testing/cross_backend/capabilities.js +27 -12
  129. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  130. package/dist/testing/cross_backend/default_backend_configs.js +21 -0
  131. package/dist/testing/cross_backend/expected_schema.json +1 -0
  132. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  133. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
  134. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  135. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
  136. package/dist/testing/cross_backend/session_cap.d.ts +13 -0
  137. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
  138. package/dist/testing/cross_backend/session_cap.js +55 -19
  139. package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
  140. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  141. package/dist/testing/cross_backend/sse_round_trip.js +93 -26
  142. package/dist/testing/cross_backend/testing_server_bun.js +4 -4
  143. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  144. package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
  145. package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
  146. package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
  147. package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
  148. package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
  149. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  150. package/dist/testing/cross_backend/ws_round_trip.js +156 -19
  151. package/dist/testing/sse_round_trip.d.ts +6 -4
  152. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  153. package/dist/testing/stubs.d.ts +1 -1
  154. package/dist/testing/stubs.d.ts.map +1 -1
  155. package/dist/testing/stubs.js +6 -4
  156. package/dist/testing/transports/sse_transport.d.ts +1 -1
  157. package/dist/testing/transports/ws_client.d.ts +7 -0
  158. package/dist/testing/transports/ws_client.d.ts.map +1 -1
  159. package/dist/testing/transports/ws_raw_client.d.ts +51 -0
  160. package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
  161. package/dist/testing/transports/ws_raw_client.js +156 -0
  162. package/dist/testing/transports/ws_transport.d.ts +28 -0
  163. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  164. package/dist/testing/transports/ws_transport.js +45 -1
  165. package/dist/testing/ws_round_trip.d.ts +8 -3
  166. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  167. package/dist/testing/ws_round_trip.js +19 -4
  168. package/dist/ui/AdminAccounts.svelte +4 -3
  169. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  170. package/dist/ui/AdminOverview.svelte +3 -1
  171. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  172. package/dist/ui/CLAUDE.md +22 -6
  173. package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
  174. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  175. package/dist/ui/admin_accounts_state.svelte.js +16 -0
  176. package/dist/ui/auth_state.svelte.d.ts +25 -1
  177. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  178. package/dist/ui/auth_state.svelte.js +28 -1
  179. package/package.json +2 -2
@@ -16,9 +16,9 @@ documents the cross-cutting invariants that don't fit on any single symbol.
16
16
 
17
17
  ## AppDeps split
18
18
 
19
- - **Capabilities** — `AppDeps` — stateless, injectable per env: `read_secure_file` (hardened bootstrap-token read), `delete_file`, `keyring`, `password`, `db`, `log`, `audit`.
19
+ - **Capabilities** — `AppDeps` — stateless, injectable per env: `read_secure_file` (hardened bootstrap-token read), `delete_file`, `keyring`, `password`, `db`, `log`, `audit`, `connection_closer` (the `RealtimeCloser` every revocation handler closes live connections through — `actions/CLAUDE.md` §Connection closer).
20
20
  - **Route caps** — `RouteFactoryDeps` — `Omit<AppDeps, 'db'>`; handlers get `db` via `RouteContext`.
21
- - **Action caps** — `ActionFactoryDeps` (`auth/deps.ts`) — the `{log, audit}` shape action factories take; `RouteFactoryDeps`/`AppDeps` satisfy it structurally (role-grant-offer adds `notification_sender?`). The two pure-read actor factories (`actor_lookup`/`actor_search`) take only `{log: Logger}`.
21
+ - **Action caps** — `ActionFactoryDeps` (`auth/deps.ts`) — the `{log, audit}` shape action factories take; `RouteFactoryDeps`/`AppDeps` satisfy it structurally (role-grant-offer adds `notification_sender?`). The factories whose handlers end credentials (`create_account_actions`, `create_admin_actions`, and the standard bundle) take `RevokingActionFactoryDeps` — `ActionFactoryDeps` plus a required `connection_closer`. The two pure-read actor factories (`actor_lookup`/`actor_search`) take only `{log: Logger}`.
22
22
  - **Parameters** — `*Options` — static startup values, per-factory.
23
23
  - **Runtime state** — inline ref — mutable values: `bootstrap_status`, `DaemonTokenState`. NOT in deps or options.
24
24
 
@@ -56,7 +56,7 @@ Cross-cutting notes that don't live on any single symbol:
56
56
 
57
57
  Convention — `*_schema.ts` is Zod-only; `*_ddl.ts` holds DDL strings.
58
58
 
59
- - `auth/account_schema.ts` — `Account`, `Actor`, `RoleGrant`, `AuthSession`, `ApiToken` + client-safe JSON shapes.
59
+ - `auth/account_schema.ts` — `Account`, `Actor`, `RoleGrant`, `AuthSession`, `ApiToken` + client-safe JSON shapes. Grant predicates over wire fields: `is_role_grant_active` (not revoked, not expired) and `has_global_role(role_grants, role, now?)` (an active grant of the role with `scope_id === null` — the bare-list form of `has_scoped_role(ctx, role, null)`, used client-side by `AuthState`).
60
60
  - `auth/role_schema.ts` — `RoleName`, `RoleSpec`, `ROLE_KEEPER`, `ROLE_ADMIN`, `is_builtin_role`, `create_role_schema`, `builtin_role_specs_by_name`, `role_has_grant_path`, `list_roles_with_grant_path`.
61
61
  - `auth/scope_kind_schema.ts` — `ScopeKindName`, `create_scope_kind_schema` (open registry, no builtins).
62
62
  - `auth/credential_type_schema.ts` — `CredentialTypeName`, `CREDENTIAL_TYPE_SESSION` / `_API_TOKEN` / `_DAEMON_TOKEN`, `create_credential_type_schema`.
@@ -81,8 +81,8 @@ All take `deps: QueryDeps = {db}` first; `query_validate_api_token` adds `log`.
81
81
  - `auth/actor_search_queries.ts` — case-insensitive prefix search on `actor.name`, scope-filtered when not admin.
82
82
  - `auth/role_grant_queries.ts` — idempotent create, IDOR-guarded revoke (with in-tx supersede), scope-aware lookup, role/account predicates, `query_role_grant_revoke_for_scope` parent-scope cascade.
83
83
  - `auth/role_grant_offer_queries.ts` — offer create/decline/retract/list/history/sweep, atomic `query_accept_offer` with sibling supersede; error classes `RoleGrantOfferSelfTargetError` / `_AlreadyTerminalError` / `_ExpiredError` / `_NotFoundError` / `_ActorAccountMismatchError` / `_ActorMismatchError`.
84
- - `auth/session_queries.ts` — server-side sessions (blake3-hashed), `query_session_revoke_by_hash_unscoped` (logout only), `query_session_enforce_limit` (transaction-required). No touch/renewal query — `expires_at` is an absolute cap set at mint (`AUTH_SESSION_LIFETIME_MS`).
85
- - `auth/api_token_queries.ts` — token validation with fire-and-forget usage tracking, IDOR-guarded revoke, `query_api_token_enforce_limit` (transaction-required).
84
+ - `auth/session_queries.ts` — server-side sessions (blake3-hashed), `query_session_revoke_by_hash_unscoped` (logout only), `query_session_enforce_limit` (transaction-required; returns the evicted sessions' ids — their token hashes — so the caller closes their connections after the commit), `query_session_cleanup_expired` (returns the swept sessions' ids for the same close — `cleanup_expired_sessions` is its caller). No touch/renewal query — `expires_at` is an absolute cap set at mint (`AUTH_SESSION_LIFETIME_MS`).
85
+ - `auth/api_token_queries.ts` — token validation with fire-and-forget usage tracking, `query_api_token_live_account` (the by-id, no-touch re-check read; shares the `API_TOKEN_IS_LIVE` predicate with the validate query so the two can't drift), IDOR-guarded revoke, `query_api_token_enforce_limit` (transaction-required; returns the evicted tokens' ids for the same post-commit close).
86
86
  - `auth/invite_queries.ts` — invite create/find/claim/list/delete; `query_invite_claim_unscoped` (scoping enforced upstream by `_find_unclaimed_match_for_update`, which runs inside the signup tx with `FOR UPDATE` so find + claim are atomic).
87
87
  - `auth/app_settings_queries.ts` — load/update for the single-row settings table.
88
88
  - `auth/audit_log_queries.ts` — `query_audit_log` (in-tx insert), `_list` / `_list_with_usernames` / `_list_role_grant_history` / `_cleanup_before`, drift counters (`get_audit_metadata_validation_failures` / `get_audit_unknown_event_type_failures`).
@@ -123,20 +123,43 @@ upstream in `auth/signup_routes.ts` via `query_invite_find_unclaimed_match_for_u
123
123
  `audit_factory` callback over `create_audit_emitter`; closes over the pool +
124
124
  its registered listeners + optional `AuditLogConfig`. Six methods:
125
125
 
126
- - `emit(ctx, input)` — fire-and-forget pool write, pushes to `ctx.pending_effects`
126
+ - `emit(ctx, input)` — fire-and-forget pool write, pushes to `ctx.pending_effects`; a success row's listener fan-out goes on `ctx.post_commit_effects`
127
127
  - `emit_role_grant_target(ctx, auth, input)` — lifts `actor_id` / `account_id` / `ip` boilerplate for role-grant-shape events
128
- - `emit_pool(input)` — awaitable pool write for code paths without `pending_effects` (cleanup sweeps)
128
+ - `emit_pool(input)` — awaitable pool write for code paths without a request context (ad-hoc maintenance scripts; not for success audits paired with a mutation — those write in-tx and `notify` post-commit). Writes, then notifies, whatever the outcome
129
129
  - `notify(event)` — fan out an already-written row to listeners (used by in-tx audit batches like `query_accept_offer.audit_events`)
130
130
  - `add_listener(listener)` — append-only listener registration (twin of Rust `fuz_auth` `AuditEmitter::add_listener`)
131
131
  - `listener_count()` — registered-listener count, for tests / diagnostics
132
132
 
133
133
  Listeners are closure-private and append-only. `create_app_server` registers
134
- the audit-log SSE listener and per-endpoint WS auth guards / logout closers via
134
+ the audit-log SSE listener and per-endpoint WS auth guards via
135
135
  `add_listener` so SSE + WS fan-out compose on top of the consumer's
136
136
  `on_audit_event` callback without shallow-copying `AppDeps`. `notify` iterates a
137
137
  snapshot so a listener registered mid-fan-out fires only on the next event
138
138
  (converges with the Rust twin's cloned vec).
139
139
 
140
+ **When listeners hear about a row.** `emit` always writes at once, on the pool
141
+ (the row survives a rollback). The fan-out depends on the outcome: a
142
+ **failure** row is announced as soon as it is written — the attempt happened
143
+ whatever the transaction does; a **success** row is announced only after the
144
+ request's transaction commits (a thunk on `ctx.post_commit_effects` that awaits
145
+ the write) and never when the handler throws. The listeners act on a success
146
+ row — the revocation guards close connections, the audit stream broadcasts it
147
+ — so announcing one before its commit would close while the revoked credential
148
+ still reads as valid, and a rolled-back revocation would close anyway. The
149
+ Rust spine gets the same order by writing a success row in the transaction and
150
+ queueing its fan-out behind the commit. `AuditEmitterContext` is therefore
151
+ `{pending_effects, post_commit_effects}` — `RouteContext` and `ActionContext`
152
+ both satisfy it.
153
+
154
+ **Revocation scopes** (`auth/audit_log_schema.ts`) —
155
+ `audit_event_revocation_scopes` declares, per builtin event type, what a
156
+ successful row invalidates (`RevocationScope`: `none` / `session` / `token` /
157
+ `account` / `role`), and `to_revocation_scope(event_type)` reads it (an unknown
158
+ event type is `none`). Typed over `AuditEventType`, so a new event type does not
159
+ compile until it declares its scope. Both revocation listeners
160
+ (`create_ws_auth_guard`, `create_sse_auth_guard`) dispatch on it. Twin of the
161
+ `revocation_scope` column on the Rust `AUDIT_EVENT_SPECS`.
162
+
140
163
  **Drift counters** (`auth/audit_log_queries.ts`) — `audit_metadata_validation_failures`
141
164
  and `audit_unknown_event_type_failures` are process-wide, fail-open
142
165
  (write the row anyway). Independent in implementation; under the factory
@@ -147,7 +170,7 @@ they track the same config. Sample via `get_*`; `reset_*` are test-only.
147
170
  - `auth/account_routes.ts` — `POST /login` / `/logout` / `/password`, `GET /verify` (nginx `auth_request` shim), `GET /api/account/status`. Constants: `DEFAULT_MAX_SESSIONS = 5`, `DEFAULT_MAX_TOKENS = 10`, `DEFAULT_LOGIN_FAIL_FLOOR_MS = 250`, `DEFAULT_LOGIN_FAIL_JITTER_MS = 25`.
148
171
  - `auth/bootstrap_routes.ts` — `POST /bootstrap` + `check_bootstrap_status`; `BootstrapStatus` runtime ref.
149
172
  - `auth/signup_routes.ts` — `POST /signup` (open or invite-gated).
150
- - `auth/audit_log_routes.ts` — optional `GET /audit/stream` (SSE); list/history are on the RPC surface.
173
+ - `auth/audit_log_routes.ts` — optional `GET /audit/stream` (SSE); list/history are on the RPC surface. Takes `stream: {registry, log}` (an `AuditLogSse` satisfies it). The handler admits the stream in two phases — register pending on the `SubscriberRegistry`, re-read the credential (`revalidate_resolved_auth`) and the acting actor's role grants, admit — so a revocation racing the request opens no stream: 401 / 403 from the re-reads, or a 200 whose body is the connect comment alone when the pending registration was closed.
151
174
  - `auth/auth_guard_resolver.ts` — `fuz_auth_guard_resolver` injected into `apply_route_specs` so the framework stays auth-agnostic. Maps each `RouteAuth` axis to a guard and is the single site every route spec traverses, so it also carries the two invariants the schema can't (a `required_scope` that isn't a well-formed capability, and one on an unrestricted route).
152
175
 
153
176
  **Hono-free route shapes.** Each cookie/SSE-coupled route module has a sibling
@@ -176,6 +199,12 @@ tokens (force re-auth everywhere), then clears the session cookie. Declares
176
199
  `credential_types: ['session']` (see ../../../docs/security.md
177
200
  §Credential-channel gating).
178
201
 
202
+ **The routes close what they revoke, after the commit.** `/logout` and
203
+ `/password` queue an account-wide close, and `/login` queues a session close
204
+ for each session its cap evicted (`queue_connection_close` on
205
+ `deps.connection_closer`). The queue runs once the route's transaction has
206
+ committed and is dropped if the handler throws.
207
+
179
208
  REST-only post RPC migration: `/login`, `/logout`, `/password`, `/signup`,
180
209
  `/bootstrap`, `/verify` (empty-body shim), optional `/audit/stream`.
181
210
  Everything else listed under §RPC action surfaces.
@@ -184,7 +213,8 @@ Everything else listed under §RPC action surfaces.
184
213
 
185
214
  - `auth/middleware.ts` — `create_auth_middleware_specs(deps, options)` assembles `[origin, session, request_context, bearer_auth]` + optional `daemon_token`.
186
215
  - `auth/request_context.ts` — `RequestContext`, `resolve_acting_actor`, `build_request_context`, predicates (`has_role`, `has_scoped_role`, `has_any_scoped_role` — the dispatcher's role gate and every builtin-role check read the **global** grant via `has_scoped_role(_, role, null)` / `has_any_scoped_role(_, roles, null)`; scope-blind `has_role` is never the gate for a builtin role), guards (`require_auth`, `require_role`, `require_credential_types`, `require_token_scope`), `token_scope_surface_denial` (the direct-call form, for surfaces that aren't route specs), `refresh_role_grants`.
187
- - `auth/session_middleware.ts` — `process_session_cookie` integration, `create_session_and_set_cookie` (shared by login / signup / bootstrap).
216
+ - `auth/session_middleware.ts` — `process_session_cookie` integration, `create_session_and_set_cookie` (shared by login / signup / bootstrap; returns the token hashes of the sessions the cap evicted, which the caller closes after its commit — only login passes a cap).
217
+ - `auth/resolved_auth.ts` — `ResolvedAuth` (the resolved credential as one value: `account_id`, `credential_type`, `token_hash`, `api_token_id`), `get_resolved_auth(c)` (gathers it from the context keys the auth middleware set), and `revalidate_resolved_auth(deps, resolved)`, the no-touch re-read a long-lived connection runs once it is registered: session row / token row (by id) still live and still on the account, account not soft-deleted; a daemon token re-reads its account only. Reuses the middleware's own queries and predicates. Called by the WS upgrade (`actions/register_action_ws.ts`) and the audit SSE route between registering a connection pending and admitting it. Twin of the Rust `revalidate_resolved_auth`.
188
218
  - `auth/bearer_auth.ts` — soft-fail bearer middleware; rejects when `Origin` or `Referer` present (browser context).
189
219
  - `auth/daemon_token_middleware.ts` — `create_daemon_token_middleware(state, deps, log)` — the credential **consumer** only (soft-fail validation, keeper account resolution). The producer (`write_daemon_token` / `start_daemon_token_rotation`) lives in `testing/daemon_token_rotation.ts` behind the dev-env guard — no production assembly mints daemon tokens, mirroring the Rust spine's `fuz_testing`-confined producer. Soft-fails — discards the credential (pass-through, no own 401/503) on **every** non-success path: browser context (`Origin`/`Referer` present), malformed/invalid token, and valid-token-but-no-keeper all `next()` through to the dispatcher's `credential_type_required` (403) gate, mirroring the bearer guard and the Rust spine's `resolve.rs`. Daemon tokens are loopback-only, so browser context never arises in practice — the discard is defense-in-depth.
190
220
 
@@ -586,11 +616,13 @@ Closure state:
586
616
  directly (`query_app_settings_load`/`query_app_settings_update`);
587
617
  `auth/signup_routes.ts` reads the current value fresh per request. There is no
588
618
  `options.app_settings` ref.
589
- - `options.connection_closer?` — handler-side eager WS close on
590
- `admin_session_revoke_all` / `admin_token_revoke_all` BEFORE the audit
591
- emit so revocation lands even on audit INSERT failure. Listener-based
592
- close (`transports_ws_auth_guard`) stays as a fail-safe. Failure outcomes
593
- skip the eager close.
619
+ - `deps.connection_closer` (required, `RevokingActionFactoryDeps`) —
620
+ `admin_session_revoke_all` / `admin_token_revoke_all` / `account_delete` /
621
+ `account_purge` queue a close of the target account's live connections
622
+ (`queue_connection_close`), run after the handler's transaction commits and
623
+ dropped if it throws. The close does not depend on the audit write; the
624
+ audit listeners repeat it on the announced row. Failure outcomes queue no
625
+ close.
594
626
 
595
627
  Failure-outcome audit rows: `admin_session_revoke_all` and `_token_revoke_all`
596
628
  emit an `outcome: 'failure'` row on `ERROR_ACCOUNT_NOT_FOUND` for forensic
@@ -751,11 +783,14 @@ gated event also records `credential_type` in metadata (mirrors REST
751
783
  `password_change`).
752
784
 
753
785
  Options: `max_tokens?: number | null` (defaults to `DEFAULT_MAX_TOKENS`;
754
- `null` disables), `connection_closer?: ConnectionCloser | null`. Each handler
755
- fires `close_sockets_for_*` synchronously BEFORE the audit emit. Failure
756
- outcomes (`revoked: false`) skip the eager close — mirrors the listener's
786
+ `null` disables). Deps: `RevokingActionFactoryDeps` — `log`, `audit`, and the
787
+ required `connection_closer`. Each revoking handler queues its close
788
+ (`queue_connection_close`: one session, one token, or the account), which runs
789
+ after the handler's transaction commits and is dropped if it throws;
790
+ `account_token_create` queues a token close for each token its cap evicts.
791
+ Failure outcomes (`revoked: false`) queue no close — mirrors the listeners'
757
792
  `outcome === 'failure'` guard so attacker-guessable ids can't target
758
- arbitrary sockets.
793
+ arbitrary connections.
759
794
 
760
795
  ### Standard RPC bundle
761
796
 
@@ -768,8 +803,8 @@ app-settings methods are always wired). Frontend mirror is
768
803
 
769
804
  Option routing — `roles` is shared between admin + role-grant-offer;
770
805
  `default_ttl_ms` + `authorize` → role-grant-offer
771
- only; `max_tokens` → account only; `connection_closer` → admin + account;
772
- `notification_sender` → role-grant-offer only.
806
+ only; `max_tokens` → account only. On `deps`: `notification_sender` →
807
+ role-grant-offer only; `connection_closer` (required) → admin + account.
773
808
 
774
809
  Pair with `create_app_server`'s `rpc_endpoints` factory form
775
810
  `(ctx) => Array<RpcEndpointSpec>` so the combined action list gets
@@ -841,13 +876,72 @@ drops the hand-maintained method-name mappings:
841
876
 
842
877
  ## Cleanup
843
878
 
844
- `auth/cleanup.ts` — `run_auth_cleanup(deps)` runs every sweep (expired
845
- sessions + expired offers) and returns counts. Re-throws sweep errors so the
846
- caller's scheduler can log/alert. Idempotency: audit log has no tombstone on
847
- `role_grant_offer_expire`, so concurrent runs double-audit — deploy a single
848
- scheduled invocation per instance. Expired offer rows are preserved (audit
849
- value for the history view).
850
-
851
- `AuthCleanupDeps` requires `audit: AuditEmitter` — production wiring always
852
- has a bound emitter; tests pass `create_test_audit_emitter()` from
853
- `testing/stubs.ts`.
879
+ `auth/cleanup.ts` — twin of the Rust `fuz_auth::auth_cleanup` module.
880
+
881
+ - `run_auth_cleanup(deps)` — one pass: `cleanup_expired_sessions`, then
882
+ `cleanup_expired_role_grant_offers`; returns `AuthCleanupResult`
883
+ (`{expired_sessions, expired_offers}`, the same shape on both spines).
884
+ Re-throws the first sweep's error. Expired offer rows are preserved (audit
885
+ value for the history view).
886
+ - `start_auth_cleanup(deps, {interval_ms?})` → `{stop}` — the scheduler: a
887
+ pass at once, then one per interval (`DEFAULT_AUTH_CLEANUP_INTERVAL_MS`, ten
888
+ minutes), until `stop()`. `create_app_server`'s `auth_cleanup` option calls
889
+ it with `backend.deps` and chains `stop` into `AppServer.close`. **Off by
890
+ default** there; a production server turns it on, and an in-process test
891
+ harness leaves it off (a background pass deletes rows and writes audit rows
892
+ mid-test).
893
+
894
+ `AuthCleanupDeps` is `{db, log, audit, connection_closer}`, all required —
895
+ `AppDeps` satisfies it. Tests pass `create_test_audit_emitter()` from
896
+ `testing/stubs.ts` and, where nothing is to be closed, `noop_connection_closer`.
897
+ The sweep belongs in the process that holds the connections: run from another
898
+ process against a live server's database, it deletes the rows that server's
899
+ own pass would have closed for.
900
+
901
+ **The session sweep closes what it sweeps.** A connection never re-reads its
902
+ session, and once the sweep deletes an expired row `account_session_revoke`
903
+ has nothing left to close for — so `cleanup_expired_sessions` closes each
904
+ swept session's connections itself, by session id
905
+ (`connection_closer.close_sockets_for_session`), which is why
906
+ `query_session_cleanup_expired` returns the deleted ids. With the cleanup
907
+ scheduled, a connection outlives its **session's** expiry by at most one
908
+ interval. An expired API token's row is not swept and its connection is not
909
+ closed (`account_token_revoke` still finds the row).
910
+
911
+ - The delete runs in its own transaction and the closes follow the commit —
912
+ the rule every revocation close keeps (`actions/CLAUDE.md` §Connection
913
+ closer).
914
+ - A transaction that fails once the delete has answered still closes: the
915
+ sessions are expired either way, the error is thrown after the closes, and
916
+ the next pass deletes the rows the commit left.
917
+ - A closer that throws for one session is logged and the rest still close.
918
+ - No audit row is written for a swept session, so no listener repeats the
919
+ close.
920
+
921
+ **The schedule** is `start_periodic` (`periodic.ts`, twin of the Rust
922
+ `fuz_sys::periodic::spawn_periodic`), which owns the loop's rules:
923
+ `start_auth_cleanup` is that loop over `run_auth_cleanup` plus the swept-counts
924
+ log. Passes never overlap, and a pass that overruns its interval delays the
925
+ next one (which starts when the slow one ends, the one after a full interval
926
+ later) rather than bursting. A failed pass is logged at `warn` with its error
927
+ and retried on the next interval. The timer is unref'd. An interval that is
928
+ not a positive finite number, or exceeds `PERIODIC_INTERVAL_MS_MAX` (the
929
+ largest delay a timer honors), throws at the call. **Shutdown differs from
930
+ Rust**: its task drops a pass in progress; `stop()` ends the schedule at once
931
+ and its promise resolves when the pass in progress has finished — await it
932
+ before closing the database. The wait has no bound of its own.
933
+
934
+ **Offer idempotency.** `cleanup_expired_role_grant_offers` runs one transaction:
935
+ `query_role_grant_offer_sweep_expired` claims the expired pending offers not
936
+ yet audited by stamping `role_grant_offer.expire_audited_at` (`UPDATE …
937
+ RETURNING`), and each claimed row's `role_grant_offer_expire` audit row is
938
+ inserted in the same transaction; events fan out via `audit.notify` after the
939
+ commit. Each expiry is audited exactly once — a second run finds nothing, and
940
+ concurrent runs claim disjoint rows (a row another sweep holds blocks on its
941
+ row lock, then fails the re-checked stamp). A failed audit insert rolls the
942
+ stamps back and the next run retries, so the offer sweep can throw. The stamp
943
+ is not a lifecycle state (outside the terminal CHECK and the pending
944
+ predicate) and stays off the wire (`to_role_grant_offer_json` drops it); the
945
+ create upsert's re-offer clears it, so a refreshed offer's own expiry is
946
+ audited too. The `role_grant_offer_expire_sweep` partial index serves the
947
+ claim.
@@ -17,14 +17,18 @@
17
17
  * so passing another account's session or token id returns `revoked: false`
18
18
  * rather than revealing whether the id exists.
19
19
  *
20
+ * Every handler that ends a credential closes the connections it opened,
21
+ * after the handler's transaction commits (`queue_connection_close` on
22
+ * `deps.connection_closer`): the two session revokes, the token revoke, and
23
+ * `account_token_create` for the tokens its cap evicts.
24
+ *
20
25
  * Counterpart to `auth/account_routes.ts`, which keeps the cookie-lifecycle flows
21
26
  * (`login`, `logout`, `password`, `signup`, `bootstrap`) on REST.
22
27
  *
23
28
  * @module
24
29
  */
25
30
  import { type RpcAction } from '../actions/action_rpc.js';
26
- import type { ConnectionCloser } from '../actions/connection_closer.js';
27
- import type { ActionFactoryDeps } from './deps.js';
31
+ import type { RevokingActionFactoryDeps } from './deps.js';
28
32
  /** Options for `create_account_actions`. */
29
33
  export interface AccountActionOptions {
30
34
  /**
@@ -34,27 +38,17 @@ export interface AccountActionOptions {
34
38
  * `DEFAULT_MAX_TOKENS`; pass `null` to disable the cap.
35
39
  */
36
40
  max_tokens?: number | null;
37
- /**
38
- * Live-connection closer — when set, `account_session_revoke` /
39
- * `_session_revoke_all` / `account_token_revoke` handlers eagerly close
40
- * affected WebSocket sockets BEFORE emitting the corresponding audit
41
- * event. Closes the audit-failure-leaks-WS surface: the listener-based
42
- * close (`transports_ws_auth_guard`) only fires after the audit INSERT
43
- * succeeds, so a DB error would leave live sockets stale. `BackendWebsocketTransport`
44
- * satisfies this interface structurally; consumers pass their transport
45
- * instance directly. When absent, only the listener-based close runs.
46
- * Mirrors `zzz_server`'s handler-side `close_sockets_for_*` calls.
47
- */
48
- connection_closer?: ConnectionCloser | null;
49
41
  }
50
42
  /**
51
43
  * Create the self-service account RPC actions.
52
44
  *
53
- * @param deps - `ActionFactoryDeps` (`log`, `audit`). `audit.emit` writes
54
- * audit rows via the captured pool; the bound emitter encapsulates
55
- * `on_audit_event` fan-out and the optional `AuditLogConfig`.
45
+ * @param deps - `RevokingActionFactoryDeps` (`log`, `audit`,
46
+ * `connection_closer`). `audit.emit` writes audit rows via the captured
47
+ * pool; the bound emitter encapsulates `on_audit_event` fan-out and the
48
+ * optional `AuditLogConfig`. `connection_closer` closes a revoked
49
+ * credential's live connections after the revocation commits.
56
50
  * @param options - per-factory configuration
57
51
  * @returns the `RpcAction` array to spread into a `create_rpc_endpoint` call
58
52
  */
59
- export declare const create_account_actions: (deps: ActionFactoryDeps, options?: AccountActionOptions) => Array<RpcAction>;
53
+ export declare const create_account_actions: (deps: RevokingActionFactoryDeps, options?: AccountActionOptions) => Array<RpcAction>;
60
54
  //# sourceMappingURL=account_actions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"account_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAsC,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC9F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAqBxE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAyBnD,4CAA4C;AAC5C,MAAM,WAAW,oBAAoB;IACpC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B;;;;;;;;;;OAUG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;CAC5C;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,GAClC,MAAM,iBAAiB,EACvB,UAAS,oBAAyB,KAChC,KAAK,CAAC,SAAS,CAwKjB,CAAC"}
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"}
@@ -17,12 +17,18 @@
17
17
  * so passing another account's session or token id returns `revoked: false`
18
18
  * rather than revealing whether the id exists.
19
19
  *
20
+ * Every handler that ends a credential closes the connections it opened,
21
+ * after the handler's transaction commits (`queue_connection_close` on
22
+ * `deps.connection_closer`): the two session revokes, the token revoke, and
23
+ * `account_token_create` for the tokens its cap evicts.
24
+ *
20
25
  * Counterpart to `auth/account_routes.ts`, which keeps the cookie-lifecycle flows
21
26
  * (`login`, `logout`, `password`, `signup`, `bootstrap`) on REST.
22
27
  *
23
28
  * @module
24
29
  */
25
30
  import { rpc_action } from '../actions/action_rpc.js';
31
+ import { queue_connection_close } from '../actions/connection_closer.js';
26
32
  import { to_session_account } from './account_schema.js';
27
33
  import { query_session_list_for_account, query_session_revoke_for_account, query_session_revoke_all_for_account } from './session_queries.js';
28
34
  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,14 +41,17 @@ import { account_verify_action_spec, account_session_list_action_spec, account_s
35
41
  /**
36
42
  * Create the self-service account RPC actions.
37
43
  *
38
- * @param deps - `ActionFactoryDeps` (`log`, `audit`). `audit.emit` writes
39
- * audit rows via the captured pool; the bound emitter encapsulates
40
- * `on_audit_event` fan-out and the optional `AuditLogConfig`.
44
+ * @param deps - `RevokingActionFactoryDeps` (`log`, `audit`,
45
+ * `connection_closer`). `audit.emit` writes audit rows via the captured
46
+ * pool; the bound emitter encapsulates `on_audit_event` fan-out and the
47
+ * optional `AuditLogConfig`. `connection_closer` closes a revoked
48
+ * credential's live connections after the revocation commits.
41
49
  * @param options - per-factory configuration
42
50
  * @returns the `RpcAction` array to spread into a `create_rpc_endpoint` call
43
51
  */
44
52
  export const create_account_actions = (deps, options = {}) => {
45
- const { max_tokens = DEFAULT_MAX_TOKENS, connection_closer = null } = options;
53
+ const { max_tokens = DEFAULT_MAX_TOKENS } = options;
54
+ const { connection_closer } = deps;
46
55
  const verify_handler = (_input, ctx) => {
47
56
  return to_session_account(ctx.auth.account);
48
57
  };
@@ -52,20 +61,18 @@ export const create_account_actions = (deps, options = {}) => {
52
61
  };
53
62
  const session_revoke_handler = async (input, ctx) => {
54
63
  const revoked = await query_session_revoke_for_account(ctx, input.session_id, ctx.auth.account.id);
55
- // Handler-side belt+suspenders: close the live WS socket bound to this
56
- // session BEFORE the audit emit, so revocation lands even if the audit
57
- // INSERT fails. The real ordering invariant is "before the transaction
58
- // commits": this handler runs inside the dispatcher's transaction
59
- // (side_effects: true), so any throw between this close and the return
60
- // would roll back the DB revoke while leaving the socket severed. That
61
- // is benign — the session is still valid, the client reconnects — but
62
- // don't introduce a throw here without acknowledging the trade.
63
- // Only fire on success — failure carries an attacker-guessable
64
- // session_id and the listener-based close already ignores failure
65
- // outcomes for the same reason. Idempotent — the audit listener runs a
66
- // second close on success but matches no sockets the second time.
67
- if (revoked && connection_closer) {
68
- connection_closer.close_sockets_for_session(input.session_id);
64
+ // Close the connections this session opened, once the revoke has
65
+ // committed — queued, so it never runs before the commit or for a
66
+ // revoke that rolled back, and it does not depend on the audit write
67
+ // below. Only on success: a failed revoke carries a caller-supplied
68
+ // session_id, and closing on it would let any account close another's
69
+ // connections by guessing a hash. The audit listener repeats the close
70
+ // on the announced row; closing is idempotent.
71
+ if (revoked) {
72
+ queue_connection_close(ctx, connection_closer, {
73
+ kind: 'session',
74
+ session_token_hash: input.session_id
75
+ });
69
76
  }
70
77
  deps.audit.emit(ctx, {
71
78
  event_type: 'session_revoke',
@@ -79,17 +86,14 @@ export const create_account_actions = (deps, options = {}) => {
79
86
  };
80
87
  const session_revoke_all_handler = async (_input, ctx) => {
81
88
  const count = await query_session_revoke_all_for_account(ctx, ctx.auth.account.id);
82
- // Handler-side belt+suspenders — see session_revoke_handler comment.
83
- // Close fires regardless of `count` (today `count >= 1` always — the
84
- // caller is using the session they're revoking; future bearer / daemon-
85
- // token-credentialed callers may hit `count: 0`). Symmetric with the
86
- // admin revoke-all handlers in `admin_actions.ts`, where `count: 0` is
87
- // a real outcome (target account had no live sessions/tokens) and the
88
- // eager close still fires to scrub sockets that the audit listener
89
- // would otherwise miss when the INSERT fails. Idempotent at all counts.
90
- if (connection_closer) {
91
- connection_closer.close_sockets_for_account(ctx.auth.account.id);
92
- }
89
+ // Post-commit close — see session_revoke_handler. Queued regardless of
90
+ // `count` (today `count >= 1` always — the caller is using a session
91
+ // it is revoking), symmetric with the admin revoke-all handlers in
92
+ // `admin_actions.ts`, where `count: 0` is a real outcome.
93
+ queue_connection_close(ctx, connection_closer, {
94
+ kind: 'account',
95
+ account_id: ctx.auth.account.id
96
+ });
93
97
  deps.audit.emit(ctx, {
94
98
  event_type: 'session_revoke_all',
95
99
  account_id: ctx.auth.account.id,
@@ -109,7 +113,14 @@ export const create_account_actions = (deps, options = {}) => {
109
113
  const expires_at = token_lifetime_to_expires_at(input.lifetime);
110
114
  await query_create_api_token(ctx, id, ctx.auth.account.id, input.name, token_hash, scope, expires_at);
111
115
  if (max_tokens != null) {
112
- await query_api_token_enforce_limit(ctx, ctx.auth.account.id, max_tokens);
116
+ const evicted = await query_api_token_enforce_limit(ctx, ctx.auth.account.id, max_tokens);
117
+ // The cap deleted the evicted rows; the connections they opened end
118
+ // only when closed, and only after the commit — the same post-commit
119
+ // close the revocations use. No audit row: there is no eviction
120
+ // event, and `token_create` records the mint that caused it.
121
+ for (const api_token_id of evicted) {
122
+ queue_connection_close(ctx, connection_closer, { kind: 'token', api_token_id });
123
+ }
113
124
  }
114
125
  deps.audit.emit(ctx, {
115
126
  event_type: 'token_create',
@@ -145,9 +156,12 @@ export const create_account_actions = (deps, options = {}) => {
145
156
  };
146
157
  const token_revoke_handler = async (input, ctx) => {
147
158
  const revoked = await query_revoke_api_token_for_account(ctx, input.token_id, ctx.auth.account.id);
148
- // Handler-side belt+suspenders — see session_revoke_handler comment.
149
- if (revoked && connection_closer) {
150
- connection_closer.close_sockets_for_token(input.token_id);
159
+ // Post-commit close, on success only — see session_revoke_handler.
160
+ if (revoked) {
161
+ queue_connection_close(ctx, connection_closer, {
162
+ kind: 'token',
163
+ api_token_id: input.token_id
164
+ });
151
165
  }
152
166
  deps.audit.emit(ctx, {
153
167
  event_type: 'token_revoke',
@@ -25,7 +25,6 @@ import type { SessionOptions } from './session_cookie.js';
25
25
  import { type RouteSpec } from '../http/route_spec.js';
26
26
  import { type RateLimiter } from '../rate_limiter.js';
27
27
  import type { RouteFactoryDeps } from './deps.js';
28
- import type { ConnectionCloser } from '../actions/connection_closer.js';
29
28
  /**
30
29
  * Create the account status route spec.
31
30
  *
@@ -112,15 +111,6 @@ export interface AccountRouteOptions extends AuthSessionRouteOptions {
112
111
  * jitter while keeping the floor. Default `DEFAULT_LOGIN_FAIL_JITTER_MS`.
113
112
  */
114
113
  login_fail_jitter_ms?: number;
115
- /**
116
- * Live-connection closer — when set, the `logout` and `password` handlers
117
- * eagerly close affected WebSocket sockets for the account BEFORE
118
- * emitting the corresponding audit event. Mirrors the self-service
119
- * action surface (see `AccountActionOptions.connection_closer`). When
120
- * absent, only the listener-based close (`transports_ws_auth_guard`
121
- * registered via `audit.add_listener`) runs.
122
- */
123
- connection_closer?: ConnectionCloser | null;
124
114
  /**
125
115
  * Runtime bootstrap status for the bundled `GET /status` route — when
126
116
  * `available`, its unauthenticated 401 carries `bootstrap_available: true`
@@ -143,7 +133,11 @@ export interface AccountRouteOptions extends AuthSessionRouteOptions {
143
133
  * every account surface serves it, matching the Rust `account_router`.
144
134
  * Self-service session/token management is on `auth/account_actions.ts`.
145
135
  *
146
- * @param deps - stateless capabilities (keyring, password, log)
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`).
139
+ *
140
+ * @param deps - stateless capabilities (keyring, password, log, audit, connection_closer)
147
141
  * @param options - per-factory configuration (session_options, login_ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
148
142
  * @returns route specs (not yet applied to Hono)
149
143
  */
@@ -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;AAClD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAGxE;;;;;;;;;;;;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,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC5C;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAID;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,gBAAgB,EACtB,SAAS,mBAAmB,KAC1B,KAAK,CAAC,SAAS,CA6RjB,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;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"}
@@ -32,6 +32,7 @@ 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
34
  import { rate_limit_exceeded_response } from '../rate_limiter.js';
35
+ import { queue_connection_close } from '../actions/connection_closer.js';
35
36
  import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INVALID_CREDENTIALS } from '../http/error_schemas.js';
36
37
  /**
37
38
  * Create the account status route spec.
@@ -154,13 +155,17 @@ const login_fail_delay = (floor_ms, jitter_ms) => {
154
155
  * every account surface serves it, matching the Rust `account_router`.
155
156
  * Self-service session/token management is on `auth/account_actions.ts`.
156
157
  *
157
- * @param deps - stateless capabilities (keyring, password, log)
158
+ * `/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`).
161
+ *
162
+ * @param deps - stateless capabilities (keyring, password, log, audit, connection_closer)
158
163
  * @param options - per-factory configuration (session_options, login_ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
159
164
  * @returns route specs (not yet applied to Hono)
160
165
  */
161
166
  export const create_account_route_specs = (deps, options) => {
162
- const { keyring, password } = deps;
163
- const { session_options, login_ip_rate_limiter, login_account_rate_limiter, max_sessions = DEFAULT_MAX_SESSIONS, login_fail_floor_ms = DEFAULT_LOGIN_FAIL_FLOOR_MS, login_fail_jitter_ms = DEFAULT_LOGIN_FAIL_JITTER_MS, connection_closer = null, bootstrap_status } = options;
167
+ const { keyring, password, connection_closer } = deps;
168
+ const { session_options, login_ip_rate_limiter, login_account_rate_limiter, max_sessions = DEFAULT_MAX_SESSIONS, login_fail_floor_ms = DEFAULT_LOGIN_FAIL_FLOOR_MS, login_fail_jitter_ms = DEFAULT_LOGIN_FAIL_JITTER_MS, bootstrap_status } = options;
164
169
  const [verify_shape, login_shape, logout_shape, password_shape] = create_account_route_shapes({
165
170
  login_account_rate_limited: login_account_rate_limiter !== null
166
171
  });
@@ -250,7 +255,7 @@ export const create_account_route_specs = (deps, options) => {
250
255
  // that address. See `RateLimiter.reset`.
251
256
  if (login_account_rate_limiter)
252
257
  login_account_rate_limiter.reset(account_rate_key);
253
- await create_session_and_set_cookie({
258
+ const evicted = await create_session_and_set_cookie({
254
259
  keyring,
255
260
  deps: route,
256
261
  c,
@@ -258,6 +263,15 @@ export const create_account_route_specs = (deps, options) => {
258
263
  session_options,
259
264
  max_sessions
260
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 });
274
+ }
261
275
  deps.audit.emit(route, {
262
276
  event_type: 'login',
263
277
  account_id: account.id,
@@ -274,22 +288,18 @@ export const create_account_route_specs = (deps, options) => {
274
288
  if (session_token) {
275
289
  const token_hash = hash_session_token(session_token);
276
290
  await query_session_revoke_by_hash_unscoped(route, token_hash);
277
- // Handler-side belt+suspenders: eagerly close this account's
278
- // live WS connections BEFORE the audit emit so revocation
279
- // lands even if the audit INSERT fails. Account-wide (not
280
- // session-targeted) to match the Rust `account_logout` handler
281
- // and the sibling `/password` handler — logout is a
282
- // self-initiated account-grain operation, and the audit
283
- // listener (`create_ws_logout_closer`) runs the same
284
- // account-wide close on the logout event afterward, so both
285
- // layers converge (idempotent). Same transaction-commit trade
286
- // as `password` / RPC `session_revoke`: a throw between this
287
- // close and the response rolls back the DB revoke while
288
- // leaving sockets severed; benign (client reconnects), but
289
- // don't introduce a throw here without acknowledging the trade.
290
- if (connection_closer) {
291
- connection_closer.close_sockets_for_account(ctx.account.id);
292
- }
291
+ // Close this account's live connections once the revoke has
292
+ // committed — queued, so it never runs before the commit or on
293
+ // rollback, and it does not depend on the fail-open audit write
294
+ // below. Account-wide (not session-targeted) to match the Rust
295
+ // `account_logout` handler and the sibling `/password` handler
296
+ // — logout is a self-initiated account-grain operation. The
297
+ // audit listeners repeat the same close on the announced
298
+ // `logout` row; closing is idempotent.
299
+ queue_connection_close(route, connection_closer, {
300
+ kind: 'account',
301
+ account_id: ctx.account.id
302
+ });
293
303
  }
294
304
  clear_session_cookie(c, session_options);
295
305
  // Account-grain operation — no `actor_id` (which actor was
@@ -389,22 +399,16 @@ export const create_account_route_specs = (deps, options) => {
389
399
  // revoke all sessions and API tokens (force re-auth everywhere)
390
400
  const sessions_revoked = await query_session_revoke_all_for_account(route, ctx.account.id);
391
401
  const tokens_revoked = await query_revoke_all_api_tokens_for_account(route, ctx.account.id);
392
- // Handler-side belt+suspenders — close every live WS socket on
393
- // this account BEFORE the audit emit so the revoke-all cascade
394
- // lands even if the audit INSERT fails. The real ordering
395
- // invariant is "before the transaction commits": this route
396
- // runs with the default `transaction: true`, so a throw between
397
- // this close and the response would roll back the password
398
- // update + session/token revokes while leaving sockets severed.
399
- // Benign — affected clients reconnect with their still-valid
400
- // session — but don't introduce a throw here without
401
- // acknowledging the trade. Listener-based close
402
- // (`transports_ws_auth_guard` on the `password_change` event)
403
- // runs the same close afterward; idempotent on the second pass.
404
- // Mirrors `zzz_server::account::password_inner`.
405
- if (connection_closer) {
406
- connection_closer.close_sockets_for_account(ctx.account.id);
407
- }
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
+ });
408
412
  clear_session_cookie(c, session_options);
409
413
  // Account-grain operation — no `actor_id`. The password is
410
414
  // account-level state; which per-request actor was resolved