@fuzdev/fuz_app 0.118.0 → 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 (155) 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 +113 -32
  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 +61 -38
  44. package/dist/auth/audit_emitter.d.ts.map +1 -1
  45. package/dist/auth/audit_emitter.js +57 -14
  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/cleanup.d.ts +203 -28
  54. package/dist/auth/cleanup.d.ts.map +1 -1
  55. package/dist/auth/cleanup.js +222 -26
  56. package/dist/auth/deps.d.ts +28 -0
  57. package/dist/auth/deps.d.ts.map +1 -1
  58. package/dist/auth/resolved_auth.d.ts +105 -0
  59. package/dist/auth/resolved_auth.d.ts.map +1 -0
  60. package/dist/auth/resolved_auth.js +124 -0
  61. package/dist/auth/session_middleware.d.ts +8 -1
  62. package/dist/auth/session_middleware.d.ts.map +1 -1
  63. package/dist/auth/session_middleware.js +9 -3
  64. package/dist/auth/session_queries.d.ts +31 -6
  65. package/dist/auth/session_queries.d.ts.map +1 -1
  66. package/dist/auth/session_queries.js +31 -6
  67. package/dist/auth/standard_rpc_actions.d.ts +9 -9
  68. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  69. package/dist/auth/standard_rpc_actions.js +3 -3
  70. package/dist/db/CLAUDE.md +7 -1
  71. package/dist/db/status.d.ts +58 -1
  72. package/dist/db/status.d.ts.map +1 -1
  73. package/dist/db/status.js +205 -77
  74. package/dist/http/CLAUDE.md +7 -3
  75. package/dist/http/db_routes.d.ts +6 -2
  76. package/dist/http/db_routes.d.ts.map +1 -1
  77. package/dist/http/db_routes.js +2 -2
  78. package/dist/periodic.d.ts +80 -0
  79. package/dist/periodic.d.ts.map +1 -0
  80. package/dist/periodic.js +122 -0
  81. package/dist/realtime/sse_auth_guard.d.ts +79 -49
  82. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  83. package/dist/realtime/sse_auth_guard.js +103 -73
  84. package/dist/realtime/subscriber_registry.d.ts +119 -8
  85. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  86. package/dist/realtime/subscriber_registry.js +184 -25
  87. package/dist/server/app_backend.d.ts +12 -1
  88. package/dist/server/app_backend.d.ts.map +1 -1
  89. package/dist/server/app_backend.js +5 -2
  90. package/dist/server/app_server.d.ts +55 -15
  91. package/dist/server/app_server.d.ts.map +1 -1
  92. package/dist/server/app_server.js +41 -13
  93. package/dist/testing/CLAUDE.md +140 -48
  94. package/dist/testing/app_server.d.ts +2 -2
  95. package/dist/testing/app_server.d.ts.map +1 -1
  96. package/dist/testing/app_server.js +11 -2
  97. package/dist/testing/audit_drift_guard.d.ts +1 -48
  98. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  99. package/dist/testing/audit_drift_guard.js +0 -42
  100. package/dist/testing/connection_closer_helpers.d.ts +9 -22
  101. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  102. package/dist/testing/connection_closer_helpers.js +11 -17
  103. package/dist/testing/cross_backend/capabilities.d.ts +67 -13
  104. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  105. package/dist/testing/cross_backend/capabilities.js +27 -12
  106. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  107. package/dist/testing/cross_backend/default_backend_configs.js +21 -0
  108. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  109. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
  110. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  111. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
  112. package/dist/testing/cross_backend/session_cap.d.ts +13 -0
  113. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
  114. package/dist/testing/cross_backend/session_cap.js +55 -19
  115. package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
  116. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  117. package/dist/testing/cross_backend/sse_round_trip.js +93 -26
  118. package/dist/testing/cross_backend/testing_server_bun.js +4 -4
  119. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  120. package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
  121. package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
  122. package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
  123. package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
  124. package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
  125. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  126. package/dist/testing/cross_backend/ws_round_trip.js +156 -19
  127. package/dist/testing/sse_round_trip.d.ts +6 -4
  128. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  129. package/dist/testing/stubs.d.ts +1 -1
  130. package/dist/testing/stubs.d.ts.map +1 -1
  131. package/dist/testing/stubs.js +6 -4
  132. package/dist/testing/transports/sse_transport.d.ts +1 -1
  133. package/dist/testing/transports/ws_client.d.ts +7 -0
  134. package/dist/testing/transports/ws_client.d.ts.map +1 -1
  135. package/dist/testing/transports/ws_raw_client.d.ts +51 -0
  136. package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
  137. package/dist/testing/transports/ws_raw_client.js +156 -0
  138. package/dist/testing/transports/ws_transport.d.ts +28 -0
  139. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  140. package/dist/testing/transports/ws_transport.js +45 -1
  141. package/dist/testing/ws_round_trip.d.ts +8 -3
  142. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  143. package/dist/testing/ws_round_trip.js +19 -4
  144. package/dist/ui/AdminAccounts.svelte +4 -3
  145. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  146. package/dist/ui/AdminOverview.svelte +3 -1
  147. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  148. package/dist/ui/CLAUDE.md +22 -6
  149. package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
  150. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  151. package/dist/ui/admin_accounts_state.svelte.js +16 -0
  152. package/dist/ui/auth_state.svelte.d.ts +25 -1
  153. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  154. package/dist/ui/auth_state.svelte.js +28 -1
  155. package/package.json +1 -1
@@ -1,65 +1,150 @@
1
1
  /**
2
- * Narrow structural capability for closing live WebSocket connections
3
- * tied to a session token hash, API token id, or account id.
4
- *
5
- * **Why this exists.** Per-message authorization phase on WebSocket
6
- * (`actions/perform_action.ts`) reloads role_grants from the DB on every
7
- * message but does NOT re-query session / token validity — that
8
- * trade-off keeps chatty connections fast. The cost: revocation
9
- * doesn't actually disconnect open sockets unless something closes
10
- * them. `actions/transports_ws_auth_guard.ts` is the listener-based seam
11
- * (audit-event → close), but it only fires after the audit INSERT
12
- * succeeds — if the INSERT fails (DB error, pool exhausted, handler
13
- * dies mid-flight) the listener never runs and the live socket keeps
14
- * working with a stale `RequestContext` until disconnect.
15
- *
16
- * Used by self-service revocation handlers (`account_session_revoke` /
17
- * `_revoke_all`, `account_token_revoke`, `logout`, `password`) and the
18
- * admin revoke-all handlers (`admin_session_revoke_all`,
19
- * `admin_token_revoke_all`) to eagerly drop affected sockets BEFORE
20
- * emitting the corresponding audit event. The audit listener stays as
21
- * a fail-safe for out-of-band emit sites (admin tools, scheduled
22
- * jobs, SSE-driven flows). `close_sockets_for_*` is idempotent so the
23
- * second pass is a no-op.
24
- *
25
- * Mirrors `zzz_server`'s `close_sockets_for_*` calls in
26
- * `account.rs::logout_inner` / `_password_inner` /
27
- * `handlers/account.rs::handle_account_session_revoke[_all]` /
28
- * `_token_revoke` (landed 2026-05-16).
29
- *
30
- * `BackendWebsocketTransport` satisfies this interface structurally,
31
- * so consumers pass their transport instance directly (same shape as
32
- * `NotificationSender`). The interface stays local so handlers don't
33
- * couple to the concrete transport, and tests can inject a capturing
34
- * stub with no WS machinery.
2
+ * Closing live connections — WebSocket and SSE — when the credential behind
3
+ * them is revoked.
4
+ *
5
+ * **Why this exists.** A WebSocket and an SSE stream are authorized once, at
6
+ * open. Per-message WebSocket dispatch (`actions/perform_action.ts`) reloads
7
+ * role grants, never the session or token, and a one-way stream re-reads
8
+ * nothing. So a revocation takes effect on an open connection only when
9
+ * something closes it. `ConnectionCloser` is that capability, and every
10
+ * revocation handler holds one: the session and token revokes, the revoke-alls,
11
+ * account delete and purge, `/logout`, `/password`, and the session and token
12
+ * caps for what they evict. The auth cleanup holds one too
13
+ * (`AuthCleanupDeps.connection_closer`): its session sweep closes the
14
+ * connections of each expired session it deletes, after that delete commits.
15
+ *
16
+ * ## Closing after commit
17
+ *
18
+ * A revocation runs inside a request transaction, and it must not close a
19
+ * connection until that transaction commits. `queue_connection_close` defers
20
+ * the close onto the request's post-commit queue (`emit_after_commit`): it
21
+ * runs after the commit and is dropped on rollback, so a revocation that never
22
+ * happened closes nothing.
23
+ *
24
+ * Two things rest on that order. A client told "revoked" rechecks its session
25
+ * straight away, and before the commit the row it probes is still there. And
26
+ * connection admission registers a connection and then re-reads its
27
+ * credential (`revalidate_resolved_auth`): a close that missed the
28
+ * registration must belong to a revocation the re-read can see, which holds
29
+ * only if the close came after the commit.
30
+ *
31
+ * ## Why the handlers close directly
32
+ *
33
+ * The audit listeners (`create_ws_auth_guard`, `create_sse_auth_guard`) close
34
+ * connections too, but only once the audit row is written, and that write is
35
+ * pool-routed and fail-open. The handler's own close is the one that runs
36
+ * whatever happens to the audit write, so the closer it holds must reach every
37
+ * transport whose connections outlive their credential. `RealtimeCloser` is
38
+ * that fan-out, and `create_app_backend` puts one on `AppDeps`.
39
+ *
40
+ * Twin of the Rust spine's `SocketRevoker`, `SocketCloseTarget`,
41
+ * `queue_socket_close`, and `RealtimeRevoker`.
35
42
  *
36
43
  * @module
37
44
  */
45
+ import type { Uuid } from '@fuzdev/fuz_util/id.ts';
46
+ import { type EmitAfterCommitContext } from '../http/pending_effects.js';
38
47
  /**
39
- * Narrow capability — three idempotent socket-close methods, each
40
- * returning the number of sockets actually closed (zero when none
41
- * matched). Callers typically ignore the return value (used by
42
- * telemetry / tests).
48
+ * Close live connections by identity.
49
+ *
50
+ * Each method returns how many connections it closed. Closing is idempotent:
51
+ * a connection already gone isn't counted, so a handler's close and an audit
52
+ * listener's close on the same revocation compose. Implementations are
53
+ * synchronous.
54
+ *
55
+ * One connection that fails to close must not spare the rest: an
56
+ * implementation attempts every matching connection, then throws the first
57
+ * error it met.
58
+ *
59
+ * `BackendWebsocketTransport` satisfies this structurally, and
60
+ * `create_sse_connection_closer` adapts a `SubscriberRegistry`.
43
61
  */
44
62
  export interface ConnectionCloser {
45
- /**
46
- * Close every connection authenticated with a session whose blake3
47
- * hash matches `session_token_hash`. Idempotent — calling on an
48
- * already-closed session is a no-op.
49
- */
63
+ /** Close every connection authenticated by the session whose blake3 hash is `session_token_hash`. */
50
64
  close_sockets_for_session: (session_token_hash: string) => number;
65
+ /** Close every connection authenticated by the API token `api_token_id`. */
66
+ close_sockets_for_token: (api_token_id: string) => number;
51
67
  /**
52
- * Close every connection authenticated with the given API token id.
53
- * Idempotent — calling on an already-revoked token is a no-op.
68
+ * Close every connection of `account_id`, whatever credential opened it —
69
+ * the close for a revocation that invalidates all of an account's
70
+ * credentials.
54
71
  */
55
- close_sockets_for_token: (api_token_id: string) => number;
72
+ close_sockets_for_account: (account_id: Uuid) => number;
73
+ }
74
+ /**
75
+ * Which connections a revocation closes — the argument of
76
+ * `queue_connection_close`, one variant per `ConnectionCloser` method.
77
+ */
78
+ export type ConnectionCloseTarget = {
79
+ kind: 'session';
80
+ session_token_hash: string;
81
+ } | {
82
+ kind: 'token';
83
+ api_token_id: string;
84
+ } | {
85
+ kind: 'account';
86
+ account_id: Uuid;
87
+ };
88
+ /**
89
+ * Close the connections `target` names, now.
90
+ *
91
+ * A revocation handler calls `queue_connection_close` instead, which runs this
92
+ * after the commit.
93
+ *
94
+ * @returns how many connections closed
95
+ */
96
+ export declare const close_connections: (closer: ConnectionCloser, target: ConnectionCloseTarget) => number;
97
+ /**
98
+ * Queue a connection close on `ctx`'s post-commit queue, so it runs only after
99
+ * the request's transaction commits and never when the handler throws.
100
+ *
101
+ * The one way a revocation handler closes connections — see the module doc for
102
+ * why an in-transaction revocation must not close inline.
103
+ *
104
+ * In a handler with no wrapping transaction a throw still drops the queued
105
+ * close while an autocommit write stays; close with `close_connections`
106
+ * straight after the write there.
107
+ *
108
+ * @param ctx - the route or action context of the request doing the revoking
109
+ * @param closer - the closer to run, `deps.connection_closer` in a handler
110
+ * @param target - the connections to close
111
+ * @mutates `ctx.post_commit_effects` - appends the close
112
+ */
113
+ export declare const queue_connection_close: (ctx: EmitAfterCommitContext, closer: ConnectionCloser, target: ConnectionCloseTarget) => void;
114
+ /**
115
+ * A `ConnectionCloser` that closes nothing — for a backend with no
116
+ * live-connection surface, where a revocation has nothing to close.
117
+ */
118
+ export declare const noop_connection_closer: ConnectionCloser;
119
+ /**
120
+ * One `ConnectionCloser` over every live-connection transport of a backend.
121
+ *
122
+ * Each close fans out to every member and returns the sum of what each
123
+ * closed. A revocation handler holds this rather than one transport, so its
124
+ * close reaches the WebSocket transports and the SSE registries alike.
125
+ *
126
+ * A member that throws does not stop the fan-out: every member is still
127
+ * called, and the first error is thrown once they all have been — so the
128
+ * caller (the post-commit flush, an audit listener) logs it.
129
+ */
130
+ export interface RealtimeCloser extends ConnectionCloser {
56
131
  /**
57
- * Close every connection bound to `account_id`, regardless of
58
- * credential type (session / api_token / daemon_token). Coarse
59
- * closure used when every credential on an account is invalidated
60
- * — password change, session-revoke-all, token-revoke-all, logout.
61
- * Idempotent.
132
+ * Add a transport's closer. Idempotent by reference, so two endpoints
133
+ * sharing one transport add it once.
134
+ *
135
+ * Members must be the same instances connections are registered on — a
136
+ * closer over a fresh registry closes nothing.
62
137
  */
63
- close_sockets_for_account: (account_id: string) => number;
138
+ add: (closer: ConnectionCloser) => void;
64
139
  }
140
+ /**
141
+ * Create an empty `RealtimeCloser`.
142
+ *
143
+ * `create_app_backend` creates the one on `AppDeps.connection_closer`, and
144
+ * `create_app_server` adds each WebSocket transport it mounts and its audit
145
+ * stream registry. A consumer that mounts a transport by hand adds it through
146
+ * the `connection_closer` option of `register_ws_endpoint` /
147
+ * `create_audit_log_sse`, or with `add`.
148
+ */
149
+ export declare const create_realtime_closer: () => RealtimeCloser;
65
150
  //# sourceMappingURL=connection_closer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"connection_closer.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/connection_closer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAChC;;;;OAIG;IACH,yBAAyB,EAAE,CAAC,kBAAkB,EAAE,MAAM,KAAK,MAAM,CAAC;IAClE;;;OAGG;IACH,uBAAuB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1D;;;;;;OAMG;IACH,yBAAyB,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;CAC1D"}
1
+ {"version":3,"file":"connection_closer.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/connection_closer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAGnD,OAAO,EAAqB,KAAK,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAE5F;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,gBAAgB;IAChC,qGAAqG;IACrG,yBAAyB,EAAE,CAAC,kBAAkB,EAAE,MAAM,KAAK,MAAM,CAAC;IAClE,4EAA4E;IAC5E,uBAAuB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1D;;;;OAIG;IACH,yBAAyB,EAAE,CAAC,UAAU,EAAE,IAAI,KAAK,MAAM,CAAC;CACxD;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,GAC9B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,kBAAkB,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,UAAU,EAAE,IAAI,CAAA;CAAE,CAAC;AAEzC;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,GAC7B,QAAQ,gBAAgB,EACxB,QAAQ,qBAAqB,KAC3B,MAWF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,sBAAsB,GAClC,KAAK,sBAAsB,EAC3B,QAAQ,gBAAgB,EACxB,QAAQ,qBAAqB,KAC3B,IAIF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,sBAAsB,EAAE,gBAInC,CAAC;AAEH;;;;;;;;;;GAUG;AACH,MAAM,WAAW,cAAe,SAAQ,gBAAgB;IACvD;;;;;;OAMG;IACH,GAAG,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,CAAC;CACxC;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,QAAO,cAgCzC,CAAC"}
@@ -1,38 +1,137 @@
1
1
  /**
2
- * Narrow structural capability for closing live WebSocket connections
3
- * tied to a session token hash, API token id, or account id.
4
- *
5
- * **Why this exists.** Per-message authorization phase on WebSocket
6
- * (`actions/perform_action.ts`) reloads role_grants from the DB on every
7
- * message but does NOT re-query session / token validity — that
8
- * trade-off keeps chatty connections fast. The cost: revocation
9
- * doesn't actually disconnect open sockets unless something closes
10
- * them. `actions/transports_ws_auth_guard.ts` is the listener-based seam
11
- * (audit-event → close), but it only fires after the audit INSERT
12
- * succeeds — if the INSERT fails (DB error, pool exhausted, handler
13
- * dies mid-flight) the listener never runs and the live socket keeps
14
- * working with a stale `RequestContext` until disconnect.
15
- *
16
- * Used by self-service revocation handlers (`account_session_revoke` /
17
- * `_revoke_all`, `account_token_revoke`, `logout`, `password`) and the
18
- * admin revoke-all handlers (`admin_session_revoke_all`,
19
- * `admin_token_revoke_all`) to eagerly drop affected sockets BEFORE
20
- * emitting the corresponding audit event. The audit listener stays as
21
- * a fail-safe for out-of-band emit sites (admin tools, scheduled
22
- * jobs, SSE-driven flows). `close_sockets_for_*` is idempotent so the
23
- * second pass is a no-op.
24
- *
25
- * Mirrors `zzz_server`'s `close_sockets_for_*` calls in
26
- * `account.rs::logout_inner` / `_password_inner` /
27
- * `handlers/account.rs::handle_account_session_revoke[_all]` /
28
- * `_token_revoke` (landed 2026-05-16).
29
- *
30
- * `BackendWebsocketTransport` satisfies this interface structurally,
31
- * so consumers pass their transport instance directly (same shape as
32
- * `NotificationSender`). The interface stays local so handlers don't
33
- * couple to the concrete transport, and tests can inject a capturing
34
- * stub with no WS machinery.
2
+ * Closing live connections — WebSocket and SSE — when the credential behind
3
+ * them is revoked.
4
+ *
5
+ * **Why this exists.** A WebSocket and an SSE stream are authorized once, at
6
+ * open. Per-message WebSocket dispatch (`actions/perform_action.ts`) reloads
7
+ * role grants, never the session or token, and a one-way stream re-reads
8
+ * nothing. So a revocation takes effect on an open connection only when
9
+ * something closes it. `ConnectionCloser` is that capability, and every
10
+ * revocation handler holds one: the session and token revokes, the revoke-alls,
11
+ * account delete and purge, `/logout`, `/password`, and the session and token
12
+ * caps for what they evict. The auth cleanup holds one too
13
+ * (`AuthCleanupDeps.connection_closer`): its session sweep closes the
14
+ * connections of each expired session it deletes, after that delete commits.
15
+ *
16
+ * ## Closing after commit
17
+ *
18
+ * A revocation runs inside a request transaction, and it must not close a
19
+ * connection until that transaction commits. `queue_connection_close` defers
20
+ * the close onto the request's post-commit queue (`emit_after_commit`): it
21
+ * runs after the commit and is dropped on rollback, so a revocation that never
22
+ * happened closes nothing.
23
+ *
24
+ * Two things rest on that order. A client told "revoked" rechecks its session
25
+ * straight away, and before the commit the row it probes is still there. And
26
+ * connection admission registers a connection and then re-reads its
27
+ * credential (`revalidate_resolved_auth`): a close that missed the
28
+ * registration must belong to a revocation the re-read can see, which holds
29
+ * only if the close came after the commit.
30
+ *
31
+ * ## Why the handlers close directly
32
+ *
33
+ * The audit listeners (`create_ws_auth_guard`, `create_sse_auth_guard`) close
34
+ * connections too, but only once the audit row is written, and that write is
35
+ * pool-routed and fail-open. The handler's own close is the one that runs
36
+ * whatever happens to the audit write, so the closer it holds must reach every
37
+ * transport whose connections outlive their credential. `RealtimeCloser` is
38
+ * that fan-out, and `create_app_backend` puts one on `AppDeps`.
39
+ *
40
+ * Twin of the Rust spine's `SocketRevoker`, `SocketCloseTarget`,
41
+ * `queue_socket_close`, and `RealtimeRevoker`.
35
42
  *
36
43
  * @module
37
44
  */
38
- export {};
45
+ import { UnreachableError } from '@fuzdev/fuz_util/error.ts';
46
+ import { emit_after_commit } from '../http/pending_effects.js';
47
+ /**
48
+ * Close the connections `target` names, now.
49
+ *
50
+ * A revocation handler calls `queue_connection_close` instead, which runs this
51
+ * after the commit.
52
+ *
53
+ * @returns how many connections closed
54
+ */
55
+ export const close_connections = (closer, target) => {
56
+ switch (target.kind) {
57
+ case 'session':
58
+ return closer.close_sockets_for_session(target.session_token_hash);
59
+ case 'token':
60
+ return closer.close_sockets_for_token(target.api_token_id);
61
+ case 'account':
62
+ return closer.close_sockets_for_account(target.account_id);
63
+ default:
64
+ throw new UnreachableError(target);
65
+ }
66
+ };
67
+ /**
68
+ * Queue a connection close on `ctx`'s post-commit queue, so it runs only after
69
+ * the request's transaction commits and never when the handler throws.
70
+ *
71
+ * The one way a revocation handler closes connections — see the module doc for
72
+ * why an in-transaction revocation must not close inline.
73
+ *
74
+ * In a handler with no wrapping transaction a throw still drops the queued
75
+ * close while an autocommit write stays; close with `close_connections`
76
+ * straight after the write there.
77
+ *
78
+ * @param ctx - the route or action context of the request doing the revoking
79
+ * @param closer - the closer to run, `deps.connection_closer` in a handler
80
+ * @param target - the connections to close
81
+ * @mutates `ctx.post_commit_effects` - appends the close
82
+ */
83
+ export const queue_connection_close = (ctx, closer, target) => {
84
+ emit_after_commit(ctx, () => {
85
+ close_connections(closer, target);
86
+ });
87
+ };
88
+ /**
89
+ * A `ConnectionCloser` that closes nothing — for a backend with no
90
+ * live-connection surface, where a revocation has nothing to close.
91
+ */
92
+ export const noop_connection_closer = Object.freeze({
93
+ close_sockets_for_session: () => 0,
94
+ close_sockets_for_token: () => 0,
95
+ close_sockets_for_account: () => 0
96
+ });
97
+ /**
98
+ * Create an empty `RealtimeCloser`.
99
+ *
100
+ * `create_app_backend` creates the one on `AppDeps.connection_closer`, and
101
+ * `create_app_server` adds each WebSocket transport it mounts and its audit
102
+ * stream registry. A consumer that mounts a transport by hand adds it through
103
+ * the `connection_closer` option of `register_ws_endpoint` /
104
+ * `create_audit_log_sse`, or with `add`.
105
+ */
106
+ export const create_realtime_closer = () => {
107
+ const members = new Set();
108
+ const close_all = (close) => {
109
+ let count = 0;
110
+ let failed = false;
111
+ let first_error;
112
+ for (const member of members) {
113
+ try {
114
+ count += close(member);
115
+ }
116
+ catch (error) {
117
+ // keep going — one transport's failure must not leave another's
118
+ // connections open on a revoked credential
119
+ if (!failed) {
120
+ failed = true;
121
+ first_error = error;
122
+ }
123
+ }
124
+ }
125
+ if (failed)
126
+ throw first_error;
127
+ return count;
128
+ };
129
+ return Object.freeze({
130
+ add: (closer) => {
131
+ members.add(closer);
132
+ },
133
+ close_sockets_for_session: (session_token_hash) => close_all((member) => member.close_sockets_for_session(session_token_hash)),
134
+ close_sockets_for_token: (api_token_id) => close_all((member) => member.close_sockets_for_token(api_token_id)),
135
+ close_sockets_for_account: (account_id) => close_all((member) => member.close_sockets_for_account(account_id))
136
+ });
137
+ };