@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,33 +1,166 @@
1
1
  /**
2
2
  * Backend WebSocket transport — manages server-side WebSocket connections
3
- * with session tracking and revocation support.
3
+ * with session tracking, revocation support, a per-account connection cap,
4
+ * and two-phase registration.
5
+ *
6
+ * ## Two-phase registration
7
+ *
8
+ * An upgrade resolves its credential before the socket exists, and every
9
+ * revocation closes the connections *registered* under the revoked
10
+ * credential. A one-step registration therefore has a window: a revocation
11
+ * landing between the credential check and the insert closes nothing, and the
12
+ * upgrade is admitted on a dead credential — for the socket's lifetime, since
13
+ * per-message dispatch never re-reads the session or token.
14
+ *
15
+ * Registration is split to close it:
16
+ *
17
+ * 1. `BackendWebsocketTransport.register_pending` inserts the entry
18
+ * **un-admitted**. It is closeable from that instant — `close_sockets_for_*`
19
+ * match pending entries deliberately — and is otherwise inert: it evicts
20
+ * nothing, the cap does not count it, no broadcast / `send_to_account` /
21
+ * `request_connection` reaches it, and the count accessors and
22
+ * `is_registered` do not see it (`get_pending_connection_count` does).
23
+ * 2. The caller re-checks the credential against the database
24
+ * (`revalidate_resolved_auth` in `auth/resolved_auth.ts`).
25
+ * 3. `BackendWebsocketTransport.admit` flips the entry to admitted — or
26
+ * refuses when a revocation closed it in the meantime, evicting nothing.
27
+ * The cap's eviction happens here, so a connection that is never admitted
28
+ * never closes a live one, and any number of pending registrations on one
29
+ * account leave its admitted connections alone.
30
+ *
31
+ * A revocation whose close ran **after** step 1 finds the entry, pending or
32
+ * admitted, and closes it; one that **committed before** step 2 is seen by the
33
+ * re-read, which refuses. Nothing is delivered to a pending entry rather than
34
+ * queued for it, so a refusal never has to promise that a queue a revoked
35
+ * credential would have read is dropped unread, and a broadcast's returned
36
+ * count names only connections that can receive it. The cost is a slightly
37
+ * wider blind spot at connect: a server-initiated message sent between the
38
+ * handshake and admission — one credential re-check later — is not delivered,
39
+ * just as one sent before the handshake is not.
40
+ *
41
+ * `add_connection` stays as the one-step form (register and admit together)
42
+ * for a connection with no revocable credential behind it — tests and
43
+ * harnesses that register a socket directly. An upgrade that authenticated a session or token
44
+ * uses the two-phase form. The twin of the Rust `fuz_realtime`
45
+ * `ConnectionRegistry`.
4
46
  *
5
47
  * @module
6
48
  */
7
49
  import { to_error_message } from '@fuzdev/fuz_util/error.ts';
50
+ import { Logger } from '@fuzdev/fuz_util/log.ts';
8
51
  import { create_uuid } from '@fuzdev/fuz_util/id.ts';
9
52
  import { jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
10
53
  import { create_jsonrpc_error_response, create_jsonrpc_request, to_jsonrpc_message_id, is_jsonrpc_request } from '../http/jsonrpc_helpers.js';
11
- import { WS_CLOSE_SESSION_REVOKED } from './transports.js';
54
+ import { WS_CLOSE_CONNECTION_LIMIT, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON } from './transports.js';
12
55
  import { PendingPeerRequests } from './peer_request.js';
13
56
  /** Type guard for `FilterableBroadcastTransport`. */
14
57
  export const is_filterable_broadcast_transport = (transport) => 'broadcast_filtered' in transport &&
15
58
  typeof transport.broadcast_filtered === 'function';
59
+ /**
60
+ * Default per-account WebSocket connection cap, applied by
61
+ * `BackendWebsocketTransport` unless overridden via
62
+ * `BackendWebsocketTransportOptions.max_connections_per_account`.
63
+ *
64
+ * Matches the account-wide ceiling the audit SSE cap implies (the default
65
+ * session cap, `DEFAULT_MAX_SESSIONS`, times `AUDIT_LOG_SSE_MAX_PER_SCOPE`
66
+ * streams per session) — generous enough that a user's tabs and tools never
67
+ * meet it; only an abusive or leaking client does. The twin of the Rust
68
+ * spine's `DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT`.
69
+ */
70
+ export const DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT = 50;
16
71
  export class BackendWebsocketTransport {
17
72
  transport_name = 'backend_websocket_rpc';
18
- // Map connection IDs to WebSocket contexts
73
+ // Per-account connection cap; `null` = uncapped.
74
+ #max_connections_per_account;
75
+ #log;
76
+ // One entry per registered connection, pending or admitted, in
77
+ // registration order (Map iteration is insertion order — the cap's
78
+ // "oldest"). Adding a new identity scope (e.g. `device_id`) means adding a
79
+ // field to `ConnectionIdentity`, not a parallel map.
19
80
  #connections = new Map();
20
- // Reverse map to find connection ID by socket
21
- #connection_ids = new WeakMap();
22
- // Auth identity per connection. Adding a new identity scope (e.g.
23
- // `device_id`) means adding a field here, not a new parallel map.
24
- #connection_identities = new Map();
25
81
  // Server→client request correlation (ActionPeer). The transport owns the
26
82
  // sockets + the send; the registry owns the pending map, id allocation,
27
83
  // deadlines, and the per-connection in-flight cap (see `peer_request.ts`).
28
84
  #pending = new PendingPeerRequests();
29
85
  /**
30
- * Add a new WebSocket connection with auth info.
86
+ * @throws Error when `max_connections_per_account` is neither `null` nor a
87
+ * positive integer
88
+ */
89
+ constructor(options) {
90
+ const max = options?.max_connections_per_account === undefined
91
+ ? DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT
92
+ : options.max_connections_per_account;
93
+ if (max !== null && (!Number.isInteger(max) || max < 1)) {
94
+ throw new Error(`BackendWebsocketTransport: max_connections_per_account must be a positive integer or null, got ${max}`);
95
+ }
96
+ this.#max_connections_per_account = max;
97
+ this.#log = options?.log === undefined ? new Logger('[ws]') : options.log;
98
+ }
99
+ /**
100
+ * Register a connection **un-admitted** — phase one of the two-phase
101
+ * registration an upgrade runs (module doc).
102
+ *
103
+ * The entry is closeable at once: a `close_sockets_for_*` matching
104
+ * `token_hash` / `account_id` / `api_token_id` removes it, aborts
105
+ * `abort_controller`, and closes `ws` with `WS_CLOSE_SESSION_REVOKED`; the
106
+ * later `admit` then refuses. Until admitted it evicts nothing, is not
107
+ * counted toward the per-account cap, and receives no message.
108
+ *
109
+ * The caller owns the entry until it is admitted: every refusal path calls
110
+ * `remove_connection` with the returned id.
111
+ *
112
+ * @param ws - the socket to close on revocation
113
+ * @param token_hash - blake3 session token hash, or `null` for non-session credentials
114
+ * @param account_id - the authenticated account
115
+ * @param api_token_id - `api_token.id` for bearer connections, else `null`
116
+ * @param abort_controller - the socket-scoped controller to abort when the
117
+ * transport closes this connection (revocation or cap eviction)
118
+ * @returns the freshly assigned `connection_id` (branded `Uuid`)
119
+ * @mutates this - inserts an un-admitted entry into `#connections`
120
+ */
121
+ register_pending(ws, token_hash, account_id, api_token_id = null, abort_controller = null) {
122
+ return this.#insert(ws, token_hash, account_id, api_token_id, abort_controller, false);
123
+ }
124
+ /**
125
+ * Admit a pending connection — phase two (module doc).
126
+ *
127
+ * If the entry is gone — a revocation closed it while it was pending, or it
128
+ * was removed — nothing is evicted and the connection is refused. Otherwise
129
+ * the per-account cap is enforced exactly as in `add_connection`, counting
130
+ * only **admitted** connections, and the entry becomes admitted: it now
131
+ * receives messages, is counted, and can itself be superseded. Admitting an
132
+ * already-admitted connection is a no-op that answers `true`.
133
+ *
134
+ * @param connection_id - the id `register_pending` returned
135
+ * @returns `false` when the connection is no longer registered — the caller
136
+ * closes the socket as revoked
137
+ * @mutates this - marks the entry admitted; past the cap, first removes the
138
+ * account's oldest admitted connections and closes their `WSContext`
139
+ */
140
+ admit(connection_id) {
141
+ const entry = this.#connections.get(connection_id);
142
+ if (!entry)
143
+ return false;
144
+ if (entry.admitted)
145
+ return true;
146
+ // the entry itself is un-admitted, so the eviction neither counts nor
147
+ // removes it
148
+ if (this.#max_connections_per_account !== null) {
149
+ this.#evict_oldest_for_account(entry.identity.account_id, this.#max_connections_per_account);
150
+ }
151
+ entry.admitted = true;
152
+ return true;
153
+ }
154
+ /**
155
+ * Register a connection, admitted at once — the one-step form of
156
+ * `register_pending` + `admit`.
157
+ *
158
+ * **Not for an upgrade that authenticated a revocable credential**: a
159
+ * revocation landing between that upgrade's credential check and this call
160
+ * closes nothing, and the connection would be admitted on a dead credential
161
+ * (module doc, "Two-phase registration"). This is for a connection with
162
+ * nothing to revoke behind it.
163
+ *
31
164
  * Session connections pass a token hash for targeted revocation.
32
165
  * Bearer token connections (`api_token`) pass the `api_token.id` so the
33
166
  * socket can be closed when that specific token is revoked without
@@ -35,93 +168,179 @@ export class BackendWebsocketTransport {
35
168
  * pass `null` for both — they're only reachable via
36
169
  * `close_sockets_for_account`.
37
170
  *
171
+ * Enforces the per-account cap (`max_connections_per_account`): when
172
+ * `account_id` already holds the maximum, its oldest admitted connections are
173
+ * removed and sent a `WS_CLOSE_CONNECTION_LIMIT` close before the new one is
174
+ * inserted. A removed connection gets no further broadcast or peer request,
175
+ * stops counting toward the cap at once, and has its `abort_controller`
176
+ * aborted; its socket is closed when the close handshake completes. The new
177
+ * connection is always admitted.
178
+ *
38
179
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
39
- * @mutates this - inserts into `#connections`, `#connection_ids`, and
40
- * `#connection_identities`
180
+ * @mutates this - inserts an admitted entry into `#connections`; past the
181
+ * cap, first removes the account's oldest admitted connections and closes
182
+ * their `WSContext`
41
183
  */
42
- add_connection(ws, token_hash, account_id, api_token_id = null) {
184
+ add_connection(ws, token_hash, account_id, api_token_id = null, abort_controller = null) {
185
+ if (this.#max_connections_per_account !== null) {
186
+ this.#evict_oldest_for_account(account_id, this.#max_connections_per_account);
187
+ }
188
+ return this.#insert(ws, token_hash, account_id, api_token_id, abort_controller, true);
189
+ }
190
+ #insert(ws, token_hash, account_id, api_token_id, abort_controller, admitted) {
43
191
  const connection_id = create_uuid();
44
- this.#connections.set(connection_id, ws);
45
- this.#connection_ids.set(ws, connection_id);
46
- this.#connection_identities.set(connection_id, { token_hash, account_id, api_token_id });
192
+ this.#connections.set(connection_id, {
193
+ ws,
194
+ identity: { token_hash, account_id, api_token_id },
195
+ admitted,
196
+ abort_controller
197
+ });
47
198
  return connection_id;
48
199
  }
49
200
  /**
50
- * Remove a WebSocket connection and its auth tracking data.
51
- * Idempotent — safe to call after revocation has already cleaned up.
201
+ * Remove a connection and its auth tracking data, pending or admitted, by
202
+ * the id `register_pending` / `add_connection` returned. Idempotent — safe
203
+ * to call after revocation or a cap eviction has already cleaned up, and a
204
+ * no-op for an unknown id.
205
+ *
206
+ * Keyed by id rather than by `WSContext` because a runtime adapter may hand
207
+ * each socket event its own context object (`hono/bun` does), so the one a
208
+ * close event carries can't identify the connection.
209
+ *
210
+ * Removal only: the socket is not closed and the registered
211
+ * `abort_controller` is not aborted — the caller that ends a socket does
212
+ * both.
52
213
  *
53
- * @mutates this - deletes the connection's entries from `#connections`,
54
- * `#connection_ids`, and `#connection_identities`
214
+ * @mutates this - deletes the connection's entry from `#connections` and
215
+ * settles its pending peer requests as `connection_gone`
55
216
  */
56
- remove_connection(ws) {
57
- const connection_id = this.#connection_ids.get(ws);
58
- if (connection_id) {
59
- this.#cleanup_connection(connection_id, ws);
60
- }
217
+ remove_connection(connection_id) {
218
+ this.#cleanup_connection(connection_id);
61
219
  }
62
220
  /**
63
- * Close every connection whose identity matches the predicate.
221
+ * Close every connection whose identity matches the predicate — pending or
222
+ * admitted. Never filter on admission here: reaching a registration in
223
+ * flight is the point of the pending phase, and `admit` then refuses it.
224
+ *
225
+ * A socket whose `close` throws does not stop the loop: every match is
226
+ * removed and closed, and the first error is thrown afterward. The failing
227
+ * socket's connection is already removed and its handlers aborted, so
228
+ * nothing more is dispatched or delivered on it.
64
229
  *
65
230
  * @returns the number of sockets closed
231
+ * @throws the first error a socket's `close` threw, after every match was attempted
66
232
  */
67
233
  #close_where(predicate) {
68
234
  let count = 0;
69
- for (const [connection_id, identity] of this.#connection_identities) {
70
- if (predicate(identity)) {
71
- const ws = this.#connections.get(connection_id);
72
- if (ws) {
73
- this.#revoke_connection(connection_id, ws);
235
+ let failed = false;
236
+ let first_error;
237
+ // deleting the current entry during Map iteration is safe
238
+ for (const [connection_id, entry] of this.#connections) {
239
+ if (predicate(entry.identity)) {
240
+ try {
241
+ this.#revoke_connection(connection_id, entry);
74
242
  count++;
75
243
  }
244
+ catch (error) {
245
+ if (!failed) {
246
+ failed = true;
247
+ first_error = error;
248
+ }
249
+ }
76
250
  }
77
251
  }
252
+ if (failed)
253
+ throw first_error;
78
254
  return count;
79
255
  }
80
256
  /**
81
- * Close all sockets associated with a specific session token hash.
257
+ * Close all sockets associated with a specific session token hash,
258
+ * including a registration still pending admission.
82
259
  *
83
260
  * @returns the number of sockets closed
84
- * @mutates this - removes matching connections from internal maps and
85
- * closes their underlying `WSContext` with `WS_CLOSE_SESSION_REVOKED`
261
+ * @mutates this - removes matching connections, aborts their registered
262
+ * controllers, and closes their underlying `WSContext` with
263
+ * `WS_CLOSE_SESSION_REVOKED`
86
264
  */
87
265
  close_sockets_for_session(token_hash) {
88
266
  return this.#close_where((id) => id.token_hash === token_hash);
89
267
  }
90
268
  /**
91
- * Close all sockets associated with a specific account.
269
+ * Close all sockets associated with a specific account, including a
270
+ * registration still pending admission.
92
271
  *
93
272
  * @returns the number of sockets closed
94
- * @mutates this - removes matching connections from internal maps and
95
- * closes their underlying `WSContext` with `WS_CLOSE_SESSION_REVOKED`
273
+ * @mutates this - removes matching connections, aborts their registered
274
+ * controllers, and closes their underlying `WSContext` with
275
+ * `WS_CLOSE_SESSION_REVOKED`
96
276
  */
97
277
  close_sockets_for_account(account_id) {
98
278
  return this.#close_where((id) => id.account_id === account_id);
99
279
  }
100
280
  /**
101
- * Close all sockets associated with a specific API token.
281
+ * Close all sockets associated with a specific API token, including a
282
+ * registration still pending admission.
102
283
  *
103
- * Used on `token_revoke` audit events so revoking one token doesn't
104
- * tear down the account's session-authenticated sockets or other
105
- * tokens' sockets.
284
+ * Closes just this token's sockets, so revoking one token — or evicting
285
+ * one past the token cap — doesn't tear down the account's
286
+ * session-authenticated sockets or other tokens' sockets.
106
287
  *
107
288
  * @returns the number of sockets closed
108
- * @mutates this - removes matching connections from internal maps and
109
- * closes their underlying `WSContext` with `WS_CLOSE_SESSION_REVOKED`
289
+ * @mutates this - removes matching connections, aborts their registered
290
+ * controllers, and closes their underlying `WSContext` with
291
+ * `WS_CLOSE_SESSION_REVOKED`
110
292
  */
111
293
  close_sockets_for_token(api_token_id) {
112
294
  return this.#close_where((id) => id.api_token_id === api_token_id);
113
295
  }
114
- #cleanup_connection(connection_id, ws) {
296
+ #cleanup_connection(connection_id) {
115
297
  this.#connections.delete(connection_id);
116
- this.#connection_ids.delete(ws);
117
- this.#connection_identities.delete(connection_id);
118
298
  // Wake any handler still awaiting a reply on this socket — the peer is
119
299
  // gone, so the request can never complete.
120
300
  this.#pending.drain(connection_id);
121
301
  }
122
- #revoke_connection(connection_id, ws) {
123
- this.#cleanup_connection(connection_id, ws);
124
- ws.close(WS_CLOSE_SESSION_REVOKED, 'Session revoked');
302
+ #revoke_connection(connection_id, entry) {
303
+ this.#cleanup_connection(connection_id);
304
+ // abort before the close so a handler observing `ctx.signal` bails out
305
+ // without waiting for the close handshake
306
+ entry.abort_controller?.abort();
307
+ entry.ws.close(WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON);
308
+ }
309
+ /**
310
+ * Make room for one more admitted connection on `account_id`: when the
311
+ * account already holds `max`, close its oldest so that `max - 1` remain.
312
+ * Pending registrations are neither counted nor closed — a connection that
313
+ * may yet be refused must not cost a live one its slot.
314
+ */
315
+ #evict_oldest_for_account(account_id, max) {
316
+ // Map iteration is insertion order, so `owned` is oldest-first
317
+ const owned = [];
318
+ for (const [connection_id, entry] of this.#connections) {
319
+ if (entry.admitted && entry.identity.account_id === account_id) {
320
+ owned.push([connection_id, entry]);
321
+ }
322
+ }
323
+ const excess = owned.length + 1 - max;
324
+ if (excess <= 0)
325
+ return;
326
+ for (let i = 0; i < excess; i++) {
327
+ const [connection_id, entry] = owned[i];
328
+ this.#supersede_connection(connection_id, entry);
329
+ }
330
+ // same line as the Rust spine's registry, so one grep finds either
331
+ this.#log?.info('ws: connection cap closed oldest', { account_id, closed: excess, max });
332
+ }
333
+ #supersede_connection(connection_id, entry) {
334
+ this.#cleanup_connection(connection_id);
335
+ try {
336
+ entry.abort_controller?.abort();
337
+ entry.ws.close(WS_CLOSE_CONNECTION_LIMIT, 'connection limit');
338
+ }
339
+ catch (error) {
340
+ // the evicted socket is another connection's — a failure closing it
341
+ // must not fail the connection being admitted
342
+ this.#log?.error('error closing superseded client:', error);
343
+ }
125
344
  }
126
345
  async send(message, _options) {
127
346
  if (is_jsonrpc_request(message)) {
@@ -138,19 +357,23 @@ export class BackendWebsocketTransport {
138
357
  }
139
358
  #broadcast(message) {
140
359
  const serialized = JSON.stringify(message);
141
- for (const ws of this.#connections.values()) {
360
+ for (const entry of this.#connections.values()) {
361
+ // a registration pending admission is not a connection yet
362
+ if (!entry.admitted)
363
+ continue;
142
364
  try {
143
- ws.send(serialized);
365
+ entry.ws.send(serialized);
144
366
  }
145
367
  catch (error) {
146
- console.error('[backend websocket transport] Error broadcasting to client:', error);
368
+ this.#log?.error('error broadcasting to client:', error);
147
369
  }
148
370
  }
149
371
  // TODO hack - remove if not ever needed, I assume this will need to be async so let's hold that assumption
150
372
  return Promise.resolve();
151
373
  }
152
374
  /**
153
- * Broadcast to connections whose identity satisfies a predicate.
375
+ * Broadcast to admitted connections whose identity satisfies a predicate. A
376
+ * registration pending admission is skipped before the predicate runs.
154
377
  *
155
378
  * Used by the broadcast API when a consumer supplies a subscription ACL hook
156
379
  * (e.g. zap's `zap_run_created` only reaches the account that owns the run).
@@ -162,24 +385,21 @@ export class BackendWebsocketTransport {
162
385
  broadcast_filtered(message, predicate) {
163
386
  const serialized = JSON.stringify(message);
164
387
  let count = 0;
165
- for (const [connection_id, identity] of this.#connection_identities) {
166
- if (!predicate(identity))
167
- continue;
168
- const ws = this.#connections.get(connection_id);
169
- if (!ws)
388
+ for (const entry of this.#connections.values()) {
389
+ if (!entry.admitted || !predicate(entry.identity))
170
390
  continue;
171
391
  try {
172
- ws.send(serialized);
392
+ entry.ws.send(serialized);
173
393
  count++;
174
394
  }
175
395
  catch (error) {
176
- console.error('[backend websocket transport] Error broadcasting filtered to client:', error);
396
+ this.#log?.error('error broadcasting filtered to client:', error);
177
397
  }
178
398
  }
179
399
  return count;
180
400
  }
181
401
  /**
182
- * Send a message to every socket bound to a specific account.
402
+ * Send a message to every admitted socket bound to a specific account.
183
403
  *
184
404
  * Targeted per-account fan-out for any flow where the delivery target
185
405
  * is a single known account. Prefer this over `broadcast_filtered` when
@@ -204,7 +424,9 @@ export class BackendWebsocketTransport {
204
424
  * connection. Resolves when the client's matching reply arrives (routed in
205
425
  * via `resolve_peer_response`), the deadline elapses (`timeout`), the
206
426
  * per-connection cap is hit (`too_many_in_flight`), or the socket closes
207
- * (`connection_gone`). Never throws — every failure is a `PeerRequestError`.
427
+ * (`connection_gone`). A connection still pending admission answers
428
+ * `connection_gone` like an unknown one. Never throws — every failure is a
429
+ * `PeerRequestError`.
208
430
  *
209
431
  * Delegates correlation to `#pending` (id allocation, deadline, cap, drain);
210
432
  * this method owns only the socket lookup + the send. Server-issued ids are
@@ -215,9 +437,11 @@ export class BackendWebsocketTransport {
215
437
  * @mutates this - registers then clears an entry in `#pending`
216
438
  */
217
439
  request_connection(connection_id, method, params, options) {
218
- const ws = this.#connections.get(connection_id);
219
- if (!ws)
440
+ const entry = this.#connections.get(connection_id);
441
+ if (!entry?.admitted) {
220
442
  return Promise.resolve({ ok: false, error: { kind: 'connection_gone' } });
443
+ }
444
+ const { ws } = entry;
221
445
  const registered = this.#pending.register(connection_id, options?.timeout_ms);
222
446
  if (!registered)
223
447
  return Promise.resolve({ ok: false, error: { kind: 'too_many_in_flight' } });
@@ -246,18 +470,51 @@ export class BackendWebsocketTransport {
246
470
  resolve_peer_response(connection_id, response) {
247
471
  return this.#pending.resolve(connection_id, response);
248
472
  }
473
+ /**
474
+ * Whether a connection is still registered — and admitted: a registration
475
+ * pending admission answers `false`, like every other reader of the
476
+ * transport.
477
+ *
478
+ * `register_action_ws` checks it before dispatching each inbound message, so
479
+ * a socket the server has closed — revoked, superseded by the cap, or
480
+ * refused at admission — dispatches nothing more while its close handshake
481
+ * is still in flight. The twin of the Rust `ConnectionRegistry::is_registered`.
482
+ */
483
+ is_registered(connection_id) {
484
+ return this.#connections.get(connection_id)?.admitted === true;
485
+ }
249
486
  is_ready() {
250
- return this.#connections.size > 0;
487
+ return this.get_connection_count() > 0;
251
488
  }
252
489
  /**
253
- * Number of currently tracked WebSocket connections.
490
+ * Number of currently **admitted** WebSocket connections.
254
491
  *
255
- * Read-only counter intended for telemetry, logging, and tests.
256
- * Counts every entry in the connection map — including connections
257
- * that have been closed by the peer but not yet removed by the WS
258
- * adapter's `onClose` callback.
492
+ * Read-only counter intended for telemetry, logging, and tests. A
493
+ * registration still pending admission is not a connection yet and is
494
+ * counted by `get_pending_connection_count` instead. Includes connections
495
+ * that have been closed by the peer but not yet removed by the WS adapter's
496
+ * `onClose` callback.
259
497
  */
260
498
  get_connection_count() {
261
- return this.#connections.size;
499
+ let count = 0;
500
+ for (const entry of this.#connections.values()) {
501
+ if (entry.admitted)
502
+ count++;
503
+ }
504
+ return count;
505
+ }
506
+ /**
507
+ * Number of registrations awaiting admission — between `register_pending`
508
+ * and `admit` (or their refusal). Each lives for one credential re-check, so
509
+ * a count that stays high means upgrades are stalling there. For telemetry
510
+ * and tests.
511
+ */
512
+ get_pending_connection_count() {
513
+ let count = 0;
514
+ for (const entry of this.#connections.values()) {
515
+ if (!entry.admitted)
516
+ count++;
517
+ }
518
+ return count;
262
519
  }
263
520
  }
@@ -52,6 +52,14 @@ export interface WsEndpointSpec {
52
52
  * `AppServer.ws_endpoints[path]` for broadcast / fan-out.
53
53
  */
54
54
  transport?: BackendWebsocketTransport;
55
+ /**
56
+ * Per-account connection cap for the auto-created transport. See
57
+ * `RegisterActionWsOptions.max_connections_per_account` — rejected
58
+ * alongside `transport`, which carries its own cap.
59
+ *
60
+ * @default DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT
61
+ */
62
+ max_connections_per_account?: number | null;
55
63
  /**
56
64
  * Server-side heartbeat policy. Default-on (60s receive-silence
57
65
  * timeout). Set `false` only when an upstream stack (TCP keepalive,
@@ -68,23 +76,29 @@ export interface WsEndpointSpec {
68
76
  */
69
77
  max_message_bytes?: number;
70
78
  /**
71
- * Called once per socket after `transport.add_connection` but before
72
- * the first message dispatches. See
73
- * `RegisterActionWsOptions.on_socket_open`.
79
+ * Called once per socket after the connection is admitted — registered
80
+ * and its credential re-checked — and before the first message
81
+ * dispatches. See `RegisterActionWsOptions.on_socket_open`.
74
82
  */
75
83
  on_socket_open?: (ctx: SocketOpenContext) => void | Promise<void>;
76
84
  /**
77
- * Called once per socket on close, before `transport.remove_connection`.
78
- * See `RegisterActionWsOptions.on_socket_close`.
85
+ * Called once per admitted socket on close, after
86
+ * `transport.remove_connection`. See
87
+ * `RegisterActionWsOptions.on_socket_close`.
79
88
  */
80
89
  on_socket_close?: (ctx: SocketCloseContext) => void | Promise<void>;
81
90
  /**
82
- * Default `true` — auto-composes `create_ws_auth_guard` +
83
- * `create_ws_logout_closer` against this endpoint's transport and
84
- * registers them via `deps.audit.add_listener`. Wiring is deduped by
91
+ * Default `true` — registers `create_ws_auth_guard` against this
92
+ * endpoint's transport via `deps.audit.add_listener`, so a revocation's
93
+ * audit row closes its sockets. Wiring is deduped by
85
94
  * transport **reference identity** (`WeakSet<BackendWebsocketTransport>`),
86
95
  * so two `WsEndpointSpec`s sharing the exact same instance get a
87
- * single pair of listeners.
96
+ * single listener.
97
+ *
98
+ * The guard repeats what the revocation handlers already do: the
99
+ * transport is always in `deps.connection_closer`, whatever this flag
100
+ * says, and the handlers close through it after their commit. Turning the
101
+ * guard off leaves those closes in place.
88
102
  *
89
103
  * **Shared-transport OR-semantics.** When multiple `WsEndpointSpec`s
90
104
  * share one transport, the guard is wired iff **any** of those specs
@@ -96,11 +110,10 @@ export interface WsEndpointSpec {
96
110
  * Reference-identity dedupe means **wrapped or proxied transports
97
111
  * dedupe as separate entries** — a consumer threading every
98
112
  * transport through a tracing / DI / metrics shim will get a fresh
99
- * pair of listeners per shimmed reference, even when the underlying
113
+ * listener per shimmed reference, even when the underlying
100
114
  * transport is the same. If you wrap or proxy, set `auth_guard:
101
115
  * false` on the duplicate `WsEndpointSpec`s and compose
102
- * `create_ws_auth_guard` / `create_ws_logout_closer` against the
103
- * underlying transport once.
116
+ * `create_ws_auth_guard` against the underlying transport once.
104
117
  *
105
118
  * Set `false` when a consumer needs to compose their own callback
106
119
  * from scratch — or to opt out of the auto-wiring entirely.
@@ -115,7 +128,7 @@ export interface WsEndpointSpec {
115
128
  /**
116
129
  * Extra audit-event handlers registered via `deps.audit.add_listener`
117
130
  * AFTER the standard `auth_guard` wiring (when enabled). By the time
118
- * these run, the standard guards may have already closed sockets. Use
131
+ * these run, the standard guard may have already closed sockets. Use
119
132
  * for role-revoke disconnection, custom analytics, etc.
120
133
  *
121
134
  * Never deduped — consumer-owned; pass the same handler twice and it
@@ -1 +1 @@
1
- {"version":3,"file":"ws_endpoint_spec.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/ws_endpoint_spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,KAAK,EACX,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC9B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;OAKG;IACH,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACzC;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAC;IAC7C,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAC;CACxD"}
1
+ {"version":3,"file":"ws_endpoint_spec.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/ws_endpoint_spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,KAAK,EACX,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC9B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;OAKG;IACH,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACzC;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAC;IAC7C,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAC;CACxD"}