@fuzdev/fuz_app 0.118.0 → 0.120.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 (169) 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 +31 -3
  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 +19 -8
  71. package/dist/db/fact_disk_storage.d.ts +10 -8
  72. package/dist/db/fact_disk_storage.d.ts.map +1 -1
  73. package/dist/db/fact_disk_storage.js +13 -17
  74. package/dist/db/fact_store.d.ts +39 -38
  75. package/dist/db/fact_store.d.ts.map +1 -1
  76. package/dist/db/fact_store.js +48 -86
  77. package/dist/db/file_fact_url.d.ts +6 -6
  78. package/dist/db/file_fact_url.js +6 -6
  79. package/dist/db/status.d.ts +58 -1
  80. package/dist/db/status.d.ts.map +1 -1
  81. package/dist/db/status.js +205 -77
  82. package/dist/http/CLAUDE.md +7 -3
  83. package/dist/http/db_routes.d.ts +6 -2
  84. package/dist/http/db_routes.d.ts.map +1 -1
  85. package/dist/http/db_routes.js +2 -2
  86. package/dist/periodic.d.ts +80 -0
  87. package/dist/periodic.d.ts.map +1 -0
  88. package/dist/periodic.js +122 -0
  89. package/dist/realtime/sse_auth_guard.d.ts +79 -49
  90. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  91. package/dist/realtime/sse_auth_guard.js +103 -73
  92. package/dist/realtime/subscriber_registry.d.ts +119 -8
  93. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  94. package/dist/realtime/subscriber_registry.js +184 -25
  95. package/dist/server/app_backend.d.ts +12 -1
  96. package/dist/server/app_backend.d.ts.map +1 -1
  97. package/dist/server/app_backend.js +5 -2
  98. package/dist/server/app_server.d.ts +55 -15
  99. package/dist/server/app_server.d.ts.map +1 -1
  100. package/dist/server/app_server.js +41 -13
  101. package/dist/server/serve_fact_route.d.ts +10 -7
  102. package/dist/server/serve_fact_route.d.ts.map +1 -1
  103. package/dist/server/serve_fact_route.js +11 -8
  104. package/dist/testing/CLAUDE.md +140 -48
  105. package/dist/testing/app_server.d.ts +2 -2
  106. package/dist/testing/app_server.d.ts.map +1 -1
  107. package/dist/testing/app_server.js +11 -2
  108. package/dist/testing/audit_drift_guard.d.ts +1 -48
  109. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  110. package/dist/testing/audit_drift_guard.js +0 -42
  111. package/dist/testing/connection_closer_helpers.d.ts +9 -22
  112. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  113. package/dist/testing/connection_closer_helpers.js +11 -17
  114. package/dist/testing/cross_backend/capabilities.d.ts +67 -13
  115. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  116. package/dist/testing/cross_backend/capabilities.js +27 -12
  117. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  118. package/dist/testing/cross_backend/default_backend_configs.js +21 -0
  119. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  120. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
  121. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  122. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
  123. package/dist/testing/cross_backend/session_cap.d.ts +13 -0
  124. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
  125. package/dist/testing/cross_backend/session_cap.js +55 -19
  126. package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
  127. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  128. package/dist/testing/cross_backend/sse_round_trip.js +93 -26
  129. package/dist/testing/cross_backend/testing_server_bun.js +4 -4
  130. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  131. package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
  132. package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
  133. package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
  134. package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
  135. package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
  136. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  137. package/dist/testing/cross_backend/ws_round_trip.js +156 -19
  138. package/dist/testing/sse_round_trip.d.ts +6 -4
  139. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  140. package/dist/testing/stubs.d.ts +1 -1
  141. package/dist/testing/stubs.d.ts.map +1 -1
  142. package/dist/testing/stubs.js +6 -4
  143. package/dist/testing/transports/sse_transport.d.ts +1 -1
  144. package/dist/testing/transports/ws_client.d.ts +7 -0
  145. package/dist/testing/transports/ws_client.d.ts.map +1 -1
  146. package/dist/testing/transports/ws_raw_client.d.ts +51 -0
  147. package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
  148. package/dist/testing/transports/ws_raw_client.js +156 -0
  149. package/dist/testing/transports/ws_transport.d.ts +28 -0
  150. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  151. package/dist/testing/transports/ws_transport.js +45 -1
  152. package/dist/testing/ws_round_trip.d.ts +8 -3
  153. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  154. package/dist/testing/ws_round_trip.js +19 -4
  155. package/dist/ui/AdminAccounts.svelte +4 -3
  156. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  157. package/dist/ui/AdminOverview.svelte +3 -1
  158. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  159. package/dist/ui/CLAUDE.md +22 -6
  160. package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
  161. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  162. package/dist/ui/admin_accounts_state.svelte.js +16 -0
  163. package/dist/ui/auth_state.svelte.d.ts +25 -1
  164. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  165. package/dist/ui/auth_state.svelte.js +28 -1
  166. package/package.json +2 -2
  167. package/dist/server/file_fact_fetcher.d.ts +0 -42
  168. package/dist/server/file_fact_fetcher.d.ts.map +0 -1
  169. package/dist/server/file_fact_fetcher.js +0 -60
@@ -24,27 +24,125 @@
24
24
  * runs inside `perform_action` on every message via the same gates HTTP
25
25
  * RPC uses.
26
26
  *
27
+ * ## Admission: the revocation race
28
+ *
29
+ * The auth middleware resolves the credential before the handshake, and the
30
+ * socket exists only once `onOpen` fires. Every revocation (session revoke,
31
+ * logout, password change, token revoke, account delete) closes the sockets
32
+ * *registered* under what it revoked, so one landing in that gap would close
33
+ * nothing, and the socket would then be admitted on the dead credential and
34
+ * keep it: per-message dispatch re-authorizes the *actor*, never the session
35
+ * or token — and its admission could evict a live socket of the same account
36
+ * past the cap.
37
+ *
38
+ * `onOpen` closes the gap in three steps: register the connection **pending**
39
+ * (`BackendWebsocketTransport.register_pending` — closeable by every
40
+ * revocation, inert otherwise), re-read the credential
41
+ * (`revalidate_resolved_auth`), then `BackendWebsocketTransport.admit`. A
42
+ * revocation whose close ran after the registration found the pending entry,
43
+ * so `admit` refuses; one that committed before the re-read is seen by it. The
44
+ * cap is enforced only by `admit`, so a refused upgrade evicts nothing, and
45
+ * pending registrations never count toward it.
46
+ *
47
+ * That argument needs every revocation to close only after its commit —
48
+ * otherwise a close could miss the registration while the re-read still sees
49
+ * the credential alive. Every one does: the revocation handlers queue their
50
+ * close behind the commit (`queue_connection_close`), and the audit emitter
51
+ * announces a success row to the listeners behind it too. So no interleaving
52
+ * admits a revoked credential; `docs/security.md` §Connection Admission says
53
+ * what that rests on.
54
+ *
55
+ * A refused upgrade is closed with `WS_CLOSE_SESSION_REVOKED` (4001, the
56
+ * reason a revoked socket gets), or `WS_CLOSE_INTERNAL_ERROR` (1011) when the
57
+ * re-read itself failed — fail closed, never admitted unchecked. The handshake
58
+ * has already answered `101`, so a refusal can only be a close frame.
59
+ *
60
+ * Frames that arrive before admission are queued, not dispatched: nothing runs
61
+ * on a credential that has not been re-read. They dispatch in arrival order
62
+ * once the socket is admitted and `on_socket_open` has completed, and are
63
+ * dropped unread on a refusal. The queue holds at most
64
+ * `MAX_PRE_ADMISSION_FRAMES` frames and
65
+ * `PRE_ADMISSION_QUEUE_BYTES_FACTOR` × `max_message_bytes` bytes; past either
66
+ * the socket is closed.
67
+ *
68
+ * The upgrade's role gate is *not* re-read: a role revocation closes no
69
+ * WebSocket in the first place (per-message dispatch re-reads role grants).
70
+ *
71
+ * ## A server-closed socket dispatches nothing
72
+ *
73
+ * A runtime adapter can keep delivering inbound frames after the server calls
74
+ * `ws.close(…)`, until the close handshake completes (`@hono/node-ws` does —
75
+ * up to the `ws` close timeout when the client withholds its close frame).
76
+ * So every server-side close — revocation, cap eviction, heartbeat timeout,
77
+ * an oversized message, a refused admission — removes the connection from the
78
+ * transport and aborts the socket's signal *at close time*, and `onMessage`
79
+ * drops any frame on a socket that has ended. A connection the transport no
80
+ * longer holds though nothing closed its socket (a bare `remove_connection`)
81
+ * is ended on its next frame (`BackendWebsocketTransport.is_registered`), with
82
+ * `WS_CLOSE_INTERNAL_ERROR` — it must not linger as a socket that reads
83
+ * everything and answers nothing.
84
+ *
27
85
  * @module
28
86
  */
29
87
  import { wait } from '@fuzdev/fuz_util/async.ts';
30
88
  import { Logger } from '@fuzdev/fuz_util/log.ts';
31
89
  import { get_request_context, require_request_context, token_scope_surface_denial } from '../auth/request_context.js';
32
- import { hash_session_token } from '../auth/session_queries.js';
90
+ import { get_resolved_auth, revalidate_resolved_auth } from '../auth/resolved_auth.js';
33
91
  import { get_client_ip } from '../http/client_ip.js';
34
92
  import { flush_pending_effects, flush_post_commit_effects } from '../http/pending_effects.js';
35
93
  import { JsonrpcRequest } from '../http/jsonrpc.js';
36
94
  import { dev_only, jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
37
95
  import { create_jsonrpc_error_response, create_jsonrpc_notification, to_jsonrpc_envelope_id, to_jsonrpc_message_id, to_jsonrpc_params, is_jsonrpc_object, is_jsonrpc_request, is_jsonrpc_request_id } from '../http/jsonrpc_helpers.js';
38
- import { CREDENTIAL_TYPE_KEY, TOKEN_SCOPE_KEY, AUTH_API_TOKEN_ID_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
96
+ import { TOKEN_SCOPE_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
39
97
  import {} from './action_types.js';
40
98
  import { compile_action_registry } from './compile_action_registry.js';
41
99
  import { cancel_action_spec, CancelNotificationParams } from './cancel.js';
42
- import { DEFAULT_WS_MAX_MESSAGE_BYTES, WS_CLOSE_MESSAGE_TOO_BIG, WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT, utf8_length_over } from './transports.js';
100
+ import { DEFAULT_WS_MAX_MESSAGE_BYTES, WS_CLOSE_INTERNAL_ERROR, WS_CLOSE_MESSAGE_TOO_BIG, WS_CLOSE_POLICY_VIOLATION, WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON, utf8_length, utf8_length_over } from './transports.js';
43
101
  import { BackendWebsocketTransport } from './transports_ws_backend.js';
44
102
  import { audit_unmatched_peer_response } from './peer_request.js';
45
103
  import { perform_action, perform_action_result_to_envelope } from './perform_action.js';
46
104
  /** Default inactivity window before the server closes a silent socket. */
47
105
  export const DEFAULT_SERVER_HEARTBEAT_TIMEOUT = 60_000;
106
+ /**
107
+ * Max inbound frames one socket may have queued while it is still opening —
108
+ * between the handshake and the end of the credential re-check plus
109
+ * `on_socket_open`. One more closes the socket with
110
+ * `WS_CLOSE_POLICY_VIOLATION`.
111
+ *
112
+ * Frames sent before the socket is open wait rather than dispatch (nothing
113
+ * runs on a credential that has not been re-read), and a runtime adapter hands
114
+ * them over with no backpressure, so the wait needs a bound. This is the count
115
+ * half; `PRE_ADMISSION_QUEUE_BYTES_FACTOR` is the byte half.
116
+ *
117
+ * Sized so a client flushing its durable queue on reopen never meets it at the
118
+ * default `DEFAULT_QUEUE_MAX_SIZE` (`actions/socket.svelte.ts`). A consumer
119
+ * that raises the client's queue above this cap makes a full flush overflow
120
+ * it: the socket closes with `WS_CLOSE_POLICY_VIOLATION`, the flushed requests
121
+ * fail, and the client reconnects.
122
+ */
123
+ export const MAX_PRE_ADMISSION_FRAMES = 256;
124
+ /**
125
+ * The byte budget of one socket's pre-admission queue, as a multiple of the
126
+ * endpoint's `max_message_bytes`: the frames queued while the socket is still
127
+ * opening may total at most this many maximum-size messages. The frame that
128
+ * would pass it closes the socket with `WS_CLOSE_POLICY_VIOLATION`.
129
+ *
130
+ * The count cap alone (`MAX_PRE_ADMISSION_FRAMES`) would let each pending
131
+ * socket hold that many full-size messages, and pending sockets are not
132
+ * capped per account — a registration that is never admitted must not be able
133
+ * to pin that much memory. Small requests, which is what a client flushes on
134
+ * reopen, meet the count cap long before this one.
135
+ *
136
+ * A client whose queued requests total more than this budget and flushes
137
+ * them all on reopen overflows it: the socket closes, the flushed requests
138
+ * fail, and the client reconnects.
139
+ */
140
+ export const PRE_ADMISSION_QUEUE_BYTES_FACTOR = 4;
141
+ /**
142
+ * The close reason sent with `WS_CLOSE_POLICY_VIOLATION` when a socket
143
+ * overflows its pre-admission queue, by count or by bytes.
144
+ */
145
+ export const WS_CLOSE_PRE_ADMISSION_OVERFLOW_REASON = 'too much sent before the socket opened';
48
146
  /** Send one JSON-RPC error response frame on `ws`. */
49
147
  const send_error_response = (ws, id, error) => {
50
148
  ws.send(JSON.stringify(create_jsonrpc_error_response(id, error)));
@@ -93,17 +191,38 @@ const is_peer_response = (json) => {
93
191
  * wrap iff `spec.side_effects: true`) → DEV output validation.
94
192
  * - Authorization phase runs **per message** — role_grant changes during a
95
193
  * connection lifetime are picked up on the next message without any
96
- * in-place refresh. Authentication invalidation closes the socket via
97
- * `create_ws_auth_guard`.
194
+ * in-place refresh. Authentication invalidation closes the socket: the
195
+ * revocation handlers through `connection_closer`, and `create_ws_auth_guard`
196
+ * on the audit row.
197
+ * - Admission re-reads the credential once, after the handshake: a socket
198
+ * whose session or token was revoked while it was upgrading closes with
199
+ * `WS_CLOSE_SESSION_REVOKED`, and one whose re-check failed with
200
+ * `WS_CLOSE_INTERNAL_ERROR`. Frames sent before admission wait for it.
201
+ * - A socket the server has closed dispatches nothing more, whether or not
202
+ * its client answers the close.
98
203
  *
99
204
  * @returns the transport (supplied or freshly created) — retain it to wire
100
205
  * `create_ws_auth_guard` or broadcast on audit events.
101
206
  * @mutates options.app - registers a `GET path` route via `upgradeWebSocket`
102
- * @mutates options.transport - on every message, adds/removes connections
103
- * in the transport's internal maps via `add_connection` / `remove_connection`
207
+ * @mutates options.transport - per socket, registers, admits, and removes a
208
+ * connection via `register_pending` / `admit` / `remove_connection`
209
+ * @mutates options.connection_closer - adds the endpoint's transport
210
+ * @throws Error when `max_connections_per_account` is passed alongside
211
+ * `transport`, or is neither `null` nor a positive integer
104
212
  */
105
213
  export const register_action_ws = (options) => {
106
- const { path, app, upgradeWebSocket, actions, db, transport = new BackendWebsocketTransport(), heartbeat = true, artificial_delay = 0, max_message_bytes = DEFAULT_WS_MAX_MESSAGE_BYTES, log = new Logger('[ws]'), on_socket_open, on_socket_close, action_ip_rate_limiter = null, action_account_rate_limiter = null } = options;
214
+ if (options.transport && options.max_connections_per_account !== undefined) {
215
+ // a cap that silently did nothing would read as enforced
216
+ throw new Error(`register_action_ws: max_connections_per_account configures the transport created for ${options.path}, ` +
217
+ 'but a transport was supplied — set the cap on that BackendWebsocketTransport instead');
218
+ }
219
+ const { path, app, upgradeWebSocket, actions, db, heartbeat = true, artificial_delay = 0, max_message_bytes = DEFAULT_WS_MAX_MESSAGE_BYTES, log = new Logger('[ws]'), on_socket_open, on_socket_close, action_ip_rate_limiter = null, action_account_rate_limiter = null } = options;
220
+ const transport = options.transport ??
221
+ new BackendWebsocketTransport({
222
+ max_connections_per_account: options.max_connections_per_account,
223
+ log
224
+ });
225
+ options.connection_closer?.add(transport);
107
226
  // Build the dispatcher's per-method lookup. Only request_response
108
227
  // specs with a handler reach `action_map` — perform_action is the
109
228
  // only site that calls handlers, and it requires an `RpcAction`.
@@ -111,6 +230,7 @@ export const register_action_ws = (options) => {
111
230
  // are registry-only on WS; the cancel handler reads
112
231
  // `cancel_action_spec.method` directly.
113
232
  const { action_map } = compile_action_registry(actions, 'WS action');
233
+ const pre_admission_queue_max_bytes = PRE_ADMISSION_QUEUE_BYTES_FACTOR * max_message_bytes;
114
234
  const heartbeat_enabled = heartbeat !== false;
115
235
  const heartbeat_config = typeof heartbeat === 'object' ? heartbeat : {};
116
236
  const heartbeat_timeout = heartbeat_config.timeout ?? DEFAULT_SERVER_HEARTBEAT_TIMEOUT;
@@ -144,17 +264,35 @@ export const register_action_ws = (options) => {
144
264
  // only used by the test-preset escape hatch (perform_action runs
145
265
  // the authorization phase fresh on every message in production).
146
266
  //
147
- // Per-message dispatch reloads role_grants via the authorization
148
- // phase but does NOT re-query session / token validity — those
149
- // are checked once at upgrade. Revocation enforcement therefore
150
- // lives outside this dispatcher, in the audit-driven WS auth
151
- // guard (`transports_ws_auth_guard.ts`). Without that guard wired
152
- // into the audit chain, `session_revoke` / `token_revoke` are
153
- // no-ops for existing WS connections.
267
+ // Session / token validity is read here, by the auth middleware,
268
+ // and re-read once at admission (`onOpen` below) — never per
269
+ // message: per-message dispatch reloads role_grants via the
270
+ // authorization phase and nothing else. From admission on,
271
+ // revocation enforcement lives outside this dispatcher: the
272
+ // revocation handlers close through `deps.connection_closer`
273
+ // (which holds this endpoint's transport — see the
274
+ // `connection_closer` option), and the audit-driven WS auth guard
275
+ // (`transports_ws_auth_guard.ts`) repeats the close. With neither
276
+ // reaching this transport, `session_revoke` / `token_revoke` are
277
+ // no-ops for connections that are already open.
154
278
  const upgrade_context = require_request_context(c);
155
279
  const account_id = upgrade_context.account.id;
156
280
  const client_ip = get_client_ip(c);
157
- const credential_type = c.get(CREDENTIAL_TYPE_KEY);
281
+ // The credential the auth middleware resolved — the one derivation
282
+ // both long-lived transports share (`get_resolved_auth`), read off
283
+ // the keys the middleware set rather than re-derived here, so a
284
+ // custom session context key or a daemon token riding beside a
285
+ // session cookie can't make this path disagree with the middleware.
286
+ // Fail closed: no credential, or one on another account than the
287
+ // request context's, never reaches the upgrade.
288
+ const resolved = get_resolved_auth(c);
289
+ if (resolved === null) {
290
+ throw new Error(`register_action_ws: no resolved credential for the upgrade on ${path} — is the auth middleware wired?`);
291
+ }
292
+ if (resolved.account_id !== account_id) {
293
+ throw new Error(`register_action_ws: the credential and the request context name different accounts for the upgrade on ${path}`);
294
+ }
295
+ const { credential_type } = resolved;
158
296
  // Captured at upgrade and consulted per message. Rule 3 rejects a
159
297
  // narrowed token at the boundary below, so in practice this is always
160
298
  // `full` on a live socket — but the per-message gate runs regardless,
@@ -162,23 +300,30 @@ export const register_action_ws = (options) => {
162
300
  const token_scope = c.get(TOKEN_SCOPE_KEY) ?? null;
163
301
  // Session-based connections have a token hash for targeted revocation.
164
302
  // Bearer/daemon connections pass null — still reachable via
165
- // `close_sockets_for_account` / `close_sockets_for_token`.
166
- const token_hash = credential_type === 'session' ? hash_session_token(c.get('auth_session_id')) : null;
303
+ // `close_sockets_for_account` / `close_sockets_for_token`. A session
304
+ // credential with no hash on the context is a shape the middleware
305
+ // never builds; the admission re-read refuses it.
306
+ const { token_hash } = resolved;
167
307
  // `api_token.id` — set only for bearer connections; enables
168
308
  // `close_sockets_for_token` to tear down just this socket on
169
309
  // `token_revoke` without affecting the account's other sockets.
170
- const api_token_id = c.get(AUTH_API_TOKEN_ID_KEY);
310
+ const { api_token_id } = resolved;
171
311
  // Test escape hatch — captured once at upgrade. perform_action
172
312
  // honors it per-message so harnesses with pre-baked
173
- // `RequestContext` skip the live authorization phase.
313
+ // `RequestContext` skip the live authorization phase. It skips the
314
+ // admission re-read for the same reason: a pre-baked credential has
315
+ // no session, token, or account row to re-read.
174
316
  const upgrade_preset = c.get(TEST_CONTEXT_PRESET_KEY)
175
317
  ? { request_context: get_request_context(c) }
176
318
  : undefined;
177
- // Per-socket abort controller — fires on socket close, chained into
178
- // every in-flight handler's per-request controller via
319
+ // Per-socket abort controller — fires when the socket ends, chained
320
+ // into every in-flight handler's per-request controller via
179
321
  // `AbortSignal.any`. Keeping both signals lets the client
180
322
  // cancel-one-request-by-id (via the `cancel` notification) without
181
- // tearing down the whole socket.
323
+ // tearing down the whole socket. Registered with the transport, which
324
+ // aborts it when it closes the connection (revocation, cap eviction),
325
+ // and aborted here on every other end (`end_socket`, `onClose`) — at
326
+ // close time, not when the close handshake lands.
182
327
  const socket_abort_controller = new AbortController();
183
328
  // Per-request controllers keyed by JSON-RPC request id — lets an
184
329
  // incoming `cancel` notification abort just the matching handler.
@@ -188,14 +333,41 @@ export const register_action_ws = (options) => {
188
333
  // for unknown ids no-ops.
189
334
  const pending_controllers = new Map();
190
335
  // Identity is assembled at upgrade time so `on_socket_close` can
191
- // still read it after the audit guard tears the transport record
192
- // down; `BackendWebsocketTransport.#revoke_connection` clears the
193
- // identity map before Hono fires onClose.
336
+ // read it — by the time the hook runs the transport record is gone,
337
+ // removed in `onClose` or earlier by a revocation, a connection-cap
338
+ // eviction, or another server-side close.
194
339
  const identity = { token_hash, account_id, api_token_id };
195
340
  // Captured on open, consumed on close. Undefined before onOpen
196
341
  // fires or when a consumer never opens (e.g. immediate disconnect).
197
342
  let captured_connection_id;
198
- // Receive-silence watchdog. Seeded to open-time so the first window is
343
+ // Where the socket is in its life:
344
+ // - `opening` — from the handshake until admission and `on_socket_open`
345
+ // have both completed. Inbound frames are queued.
346
+ // - `open` — admitted; inbound frames dispatch.
347
+ // - `ended` — the client closed it, the server closed it, or it was
348
+ // refused at admission. Inbound frames are dropped: an adapter can
349
+ // keep delivering them until the close handshake lands.
350
+ let phase = 'opening';
351
+ // read through a function so a check after an `await` isn't narrowed
352
+ // to the value assigned before it
353
+ const phase_is = (expected) => phase === expected;
354
+ // Whether the connection was ever admitted — gates `on_socket_close`,
355
+ // the counterpart of an `on_socket_open` a refused socket never ran.
356
+ let was_admitted = false;
357
+ // Frames received while `opening`, in arrival order — dispatched once
358
+ // the socket is open, dropped if it ends first. Each carries the
359
+ // settlers of the promise its `onMessage` call returned, so a caller
360
+ // awaiting that call (the test harnesses do) resumes when the frame
361
+ // has been dispatched or dropped.
362
+ const queued_frames = [];
363
+ // bytes queued so far, against `pre_admission_queue_max_bytes` — only
364
+ // read while the socket is opening, so never reset
365
+ let queued_bytes = 0;
366
+ const drop_queued_frames = () => {
367
+ for (const frame of queued_frames.splice(0))
368
+ frame.resolve();
369
+ };
370
+ // Receive-silence watchdog. Seeded at admission so the first window is
199
371
  // exempt (cold-start grace — avoid killing mid-handshake sockets).
200
372
  // Bumped by onMessage. Any incoming activity counts, not just
201
373
  // heartbeats — chatty clients don't need to send extras.
@@ -207,6 +379,40 @@ export const register_action_ws = (options) => {
207
379
  heartbeat_timer = null;
208
380
  }
209
381
  };
382
+ // The one place a socket's end is recorded, whoever ended it — the
383
+ // transport (revocation, cap eviction), `end_socket`, or `onClose` all
384
+ // abort this controller.
385
+ socket_abort_controller.signal.addEventListener('abort', () => {
386
+ phase = 'ended';
387
+ stop_heartbeat_timer();
388
+ drop_queued_frames();
389
+ }, { once: true });
390
+ // End the socket from the server side: unregister it and abort its
391
+ // signal *now* — so nothing more dispatches and in-flight handlers
392
+ // see `signal.aborted` while the close handshake is still in flight —
393
+ // then send the close frame.
394
+ const end_socket = (ws, code, reason) => {
395
+ if (captured_connection_id !== undefined) {
396
+ transport.remove_connection(captured_connection_id);
397
+ }
398
+ socket_abort_controller.abort();
399
+ try {
400
+ ws.close(code, reason);
401
+ }
402
+ catch (error) {
403
+ log.error(`ws close ${code} failed:`, error);
404
+ }
405
+ };
406
+ // Refuse an upgrade at admission. A socket that already ended needs no
407
+ // close frame: either a revocation closed its pending registration (the
408
+ // transport sent the 4001) or the client went away.
409
+ const refuse_upgrade = (ws, code, reason) => {
410
+ if (captured_connection_id !== undefined) {
411
+ transport.remove_connection(captured_connection_id);
412
+ }
413
+ if (!socket_abort_controller.signal.aborted)
414
+ end_socket(ws, code, reason);
415
+ };
210
416
  // Socket-scoped notification helper — routes to this socket only,
211
417
  // matches the `ctx.notify` semantics exposed to per-message handlers.
212
418
  const notify_socket = (ws) => (notify_method, notify_params) => {
@@ -218,237 +424,362 @@ export const register_action_ws = (options) => {
218
424
  log.error('notify send failed:', notify_method, error);
219
425
  }
220
426
  };
221
- return {
222
- onOpen: async (_event, ws) => {
223
- const connection_id = transport.add_connection(ws, token_hash, account_id, api_token_id);
224
- captured_connection_id = connection_id;
225
- log.debug('ws opened', connection_id);
226
- if (heartbeat_enabled) {
227
- last_receive_time = Date.now();
228
- heartbeat_timer = setInterval(() => {
229
- const now = Date.now();
230
- const silence = now - last_receive_time;
231
- if (silence >= heartbeat_timeout) {
232
- log.info(`heartbeat timeout (${silence}ms) — closing ${WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT}`, connection_id, identity.account_id);
233
- stop_heartbeat_timer();
234
- try {
235
- ws.close(WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT, 'server heartbeat timeout');
236
- }
237
- catch (error) {
238
- log.error('heartbeat timeout close failed:', error);
239
- }
240
- }
241
- }, heartbeat_tick_interval);
427
+ // Admit the upgraded socket: register it pending, re-check its
428
+ // credential, admit it (module doc, "Admission: the revocation
429
+ // race"). Returns the connection id once admitted, or `null` after
430
+ // refusing — the socket is then already closed.
431
+ const admit_upgrade = async (ws) => {
432
+ // Pending first, synchronously: from here every revocation finds it.
433
+ const connection_id = transport.register_pending(ws, token_hash, account_id, api_token_id, socket_abort_controller);
434
+ captured_connection_id = connection_id;
435
+ if (!upgrade_preset) {
436
+ let credential_is_live;
437
+ try {
438
+ // pool-level `db`: the statements must see every revocation
439
+ // committed before they start
440
+ credential_is_live = await revalidate_resolved_auth({ db }, resolved);
441
+ }
442
+ catch (error) {
443
+ // fail closed — never admitted unchecked
444
+ log.error('ws upgrade admission: credential re-check failed', error);
445
+ refuse_upgrade(ws, WS_CLOSE_INTERNAL_ERROR, 'internal error');
446
+ return null;
447
+ }
448
+ if (!credential_is_live) {
449
+ log.info('ws upgrade admission: credential revoked during the upgrade', connection_id, account_id);
450
+ refuse_upgrade(ws, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON);
451
+ return null;
452
+ }
453
+ }
454
+ // The cap's eviction happens here, after every gate and the re-read,
455
+ // so a refused upgrade never closes someone else's socket.
456
+ if (!transport.admit(connection_id)) {
457
+ log.info('ws upgrade admission: closed by a revocation while pending', connection_id, account_id);
458
+ refuse_upgrade(ws, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON);
459
+ return null;
460
+ }
461
+ was_admitted = true;
462
+ return connection_id;
463
+ };
464
+ const open_socket = async (ws) => {
465
+ const connection_id = await admit_upgrade(ws);
466
+ if (connection_id === null)
467
+ return;
468
+ log.debug('ws opened', connection_id);
469
+ // Not on a socket that ended in the tick since `admit` (a revocation
470
+ // can land there): its abort listener has already run, so nothing
471
+ // would ever stop the timer.
472
+ if (heartbeat_enabled && !phase_is('ended')) {
473
+ last_receive_time = Date.now();
474
+ heartbeat_timer = setInterval(() => {
475
+ const now = Date.now();
476
+ const silence = now - last_receive_time;
477
+ if (silence >= heartbeat_timeout) {
478
+ log.info(`heartbeat timeout (${silence}ms) — closing ${WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT}`, connection_id, identity.account_id);
479
+ stop_heartbeat_timer();
480
+ end_socket(ws, WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT, 'server heartbeat timeout');
481
+ }
482
+ }, heartbeat_tick_interval);
483
+ }
484
+ if (on_socket_open) {
485
+ try {
486
+ await on_socket_open({
487
+ ws,
488
+ connection_id,
489
+ identity,
490
+ notify: notify_socket(ws),
491
+ signal: socket_abort_controller.signal
492
+ });
242
493
  }
243
- if (on_socket_open) {
494
+ catch (error) {
495
+ log.error('on_socket_open failed — closing socket:', error);
496
+ // an ended socket has no one to tell
497
+ if (phase_is('ended'))
498
+ return;
244
499
  try {
245
- await on_socket_open({
246
- ws,
247
- connection_id,
248
- identity,
249
- notify: notify_socket(ws),
250
- signal: socket_abort_controller.signal
251
- });
500
+ send_error_response(ws, null, jsonrpc_error_messages.internal_error());
252
501
  }
253
- catch (error) {
254
- log.error('on_socket_open failed — closing socket:', error);
255
- try {
256
- send_error_response(ws, null, jsonrpc_error_messages.internal_error());
502
+ catch {
503
+ // ignore — socket may already be dead
504
+ }
505
+ end_socket(ws, WS_CLOSE_INTERNAL_ERROR, 'socket bootstrap failed');
506
+ return;
507
+ }
508
+ }
509
+ // Ended while the hook ran (a revocation, the client leaving) — the
510
+ // queue is already dropped.
511
+ if (!phase_is('opening'))
512
+ return;
513
+ // Open: dispatch what arrived meanwhile, in arrival order and back
514
+ // to back, the way the adapter would have delivered it.
515
+ phase = 'open';
516
+ for (const frame of queued_frames.splice(0)) {
517
+ if (transport.is_registered(connection_id)) {
518
+ handle_frame(frame.data, frame.ws, connection_id).then(frame.resolve, frame.reject);
519
+ }
520
+ else {
521
+ frame.resolve();
522
+ }
523
+ }
524
+ };
525
+ // Dispatch one inbound frame on an admitted, still-registered
526
+ // connection.
527
+ const handle_frame = async (data, ws, connection_id) => {
528
+ let json;
529
+ try {
530
+ json = JSON.parse(String(data));
531
+ }
532
+ catch (error) {
533
+ log.error('JSON parse error:', error);
534
+ send_error_response(ws, null, jsonrpc_error_messages.parse_error());
535
+ return;
536
+ }
537
+ // Batch JSON-RPC is not supported on the WebSocket path.
538
+ if (Array.isArray(json)) {
539
+ send_error_response(ws, null, jsonrpc_error_messages.invalid_request('batch JSON-RPC requests are not supported on WebSocket'));
540
+ return;
541
+ }
542
+ // Inbound responses to server-initiated requests (`peer/ping`,
543
+ // etc.) — route to the pending-request registry scoped to THIS
544
+ // socket. A response carries a string/number `id`, exactly one
545
+ // of `result` / `error`, and no string `method`, so it precedes the
546
+ // request/notification split below. An unmatched id
547
+ // (unsolicited, cross-connection, or already settled) resolves
548
+ // nothing and is dropped, so the read loop survives a junk
549
+ // frame.
550
+ if (is_peer_response(json)) {
551
+ const matched = transport.resolve_peer_response(connection_id, json);
552
+ if (!matched)
553
+ audit_unmatched_peer_response(log, connection_id, json.id);
554
+ return;
555
+ }
556
+ // Envelope version — the first thing the Rust twin's `classify`
557
+ // checks (version → id → method → params). A frame without
558
+ // `jsonrpc: '2.0'` is `invalid_request` on both transports
559
+ // rather than falling through to the notification branch,
560
+ // which would silently drop `{method: 'x'}` and
561
+ // `{jsonrpc: '1.0', method: 'x'}`. It also covers a
562
+ // non-object frame (a scalar has no id to echo — `id: null`).
563
+ // A genuine peer response carries the version and is matched
564
+ // above; a response-shaped frame that misses that shape check
565
+ // falls through here on purpose, so it is answered rather
566
+ // than swallowed.
567
+ if (!is_jsonrpc_object(json)) {
568
+ send_error_response(ws, to_jsonrpc_envelope_id(json), jsonrpc_error_messages.invalid_request());
569
+ return;
570
+ }
571
+ // Notifications (string method + no id) — `cancel` is
572
+ // intercepted for request-scoped cancellation; other
573
+ // notifications are silenced per JSON-RPC spec (consumer
574
+ // notification handlers are not a feature yet). The method
575
+ // axis is string-typed like the twin's `classify`, so a
576
+ // non-string `method` is answered `invalid_request` below
577
+ // rather than silently dropped as a notification.
578
+ if (!is_jsonrpc_request(json)) {
579
+ const notification_method = json.method;
580
+ if (typeof notification_method === 'string' && !('id' in json)) {
581
+ if (notification_method === cancel_action_spec.method) {
582
+ const parsed = CancelNotificationParams.safeParse(json.params);
583
+ if (!parsed.success) {
584
+ log.debug('cancel: invalid params, ignoring', parsed.error.issues);
585
+ return;
257
586
  }
258
- catch {
259
- // ignore — socket may already be dead
587
+ const controller = pending_controllers.get(parsed.data.request_id);
588
+ if (controller) {
589
+ controller.abort();
590
+ }
591
+ else {
592
+ log.debug('cancel: no pending request for id', parsed.data.request_id);
260
593
  }
261
- ws.close(1011, 'socket bootstrap failed');
262
594
  }
263
- }
264
- },
265
- onMessage: async (event, ws) => {
266
- last_receive_time = Date.now();
267
- const size = ws_message_size_over(event.data, max_message_bytes);
268
- if (size !== null) {
269
- log.warn(`closing socket: ${size}-byte message exceeds the ${max_message_bytes}-byte cap`);
270
- ws.close(WS_CLOSE_MESSAGE_TOO_BIG, 'message too big');
271
595
  return;
272
596
  }
273
- let json;
597
+ send_error_response(ws, to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request());
598
+ return;
599
+ }
600
+ // Envelope validation — the same `JsonrpcRequest.safeParse` the
601
+ // HTTP POST path runs, so a malformed envelope (`params: null`,
602
+ // an array `params`, a boolean `id`) answers `invalid_request`
603
+ // on both transports instead of reaching the dispatcher and
604
+ // surfacing as `invalid_params`. It sits after the
605
+ // notification / cancel block so notifications stay
606
+ // unvalidated (they carry no id to echo) and `cancel` routing
607
+ // precedes it.
608
+ const envelope = JsonrpcRequest.safeParse(json);
609
+ if (!envelope.success) {
610
+ send_error_response(ws, to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request(dev_only({ issues: envelope.error.issues })));
611
+ return;
612
+ }
613
+ const { method, id, params } = envelope.data;
614
+ // Per-action method lookup — return method_not_found before
615
+ // we engage the dispatch machinery. Specs without a handler
616
+ // (client-only / dispatcher-handled) miss action_map and
617
+ // surface as method_not_found just like unknown methods.
618
+ const action = action_map.get(method);
619
+ if (!action) {
620
+ send_error_response(ws, id, jsonrpc_error_messages.method_not_found(method));
621
+ return;
622
+ }
623
+ if (artificial_delay > 0) {
624
+ log.debug(`throttling ${artificial_delay}ms`);
625
+ await wait(artificial_delay);
626
+ }
627
+ // Per-request controller — fires on explicit `cancel` or when the
628
+ // socket ends (via the socket_abort_controller chain below).
629
+ // Registered before dispatch so a cancel arriving mid-handler
630
+ // finds it; cleared in `finally` so late cancels for a
631
+ // completed id (or a future request that reuses the id) can't
632
+ // null-abort the wrong handler.
633
+ const request_controller = new AbortController();
634
+ pending_controllers.set(id, request_controller);
635
+ // Per-message side-effect queues. `pending_effects` collects
636
+ // eager fire-and-forget pool writes (audit emits, etc.);
637
+ // `post_commit_effects` collects deferred thunks pushed
638
+ // via `emit_after_commit` (WS notifications). Both flush
639
+ // in the `finally` so the next message sees a clean slate.
640
+ //
641
+ // Ordering invariant — the deferred queue starts first. Its
642
+ // thunks are invoked before the eager writes are awaited, so
643
+ // a revocation's close never waits on the audit INSERT: held
644
+ // behind a slow write, the socket would stay open on a
645
+ // credential already committed gone, and its next frame
646
+ // would be dispatched. The thunks that need a write (a
647
+ // success row's listener fan-out) await it themselves.
648
+ //
649
+ // Ordering invariant — reply-before-flush is load-bearing.
650
+ // A handler that revokes its own credential
651
+ // (`session_revoke_all`, `token_revoke` of the calling
652
+ // bearer) queues a close of this very socket on
653
+ // `post_commit_effects` (`queue_connection_close`), and
654
+ // the audit listeners repeat it from the same queue. The
655
+ // `ws.send` on the success path runs before the `finally`
656
+ // flushes that queue, so the caller reads its reply and
657
+ // then the 4001 close. Inverting the order — flushing the
658
+ // queues before the send — would strand the caller without
659
+ // a reply.
660
+ const pending_effects = [];
661
+ const post_commit_effects = [];
662
+ const notify = notify_socket(ws);
663
+ // Server→client request seam (ActionPeer) — closes over this
664
+ // socket's connection_id so a handler can initiate a request to
665
+ // the originating client and await the reply.
666
+ const request_client = (request_method, request_params, request_options) => transport.request_connection(connection_id, request_method, request_params, request_options);
667
+ const signal = AbortSignal.any([socket_abort_controller.signal, request_controller.signal]);
668
+ try {
669
+ const result = await perform_action({
670
+ action,
671
+ raw_params: params,
672
+ request_id: id,
673
+ account_id,
674
+ credential_type,
675
+ token_scope,
676
+ client_ip,
677
+ signal,
678
+ notify,
679
+ connection_id,
680
+ request_client,
681
+ preset: upgrade_preset
682
+ }, {
683
+ db,
684
+ pending_effects,
685
+ post_commit_effects,
686
+ log,
687
+ action_ip_rate_limiter,
688
+ action_account_rate_limiter
689
+ });
690
+ let frame;
274
691
  try {
275
- json = JSON.parse(String(event.data)); // eslint-disable-line @typescript-eslint/no-base-to-string
692
+ frame = JSON.stringify(perform_action_result_to_envelope(id, result));
276
693
  }
277
694
  catch (error) {
278
- log.error('JSON parse error:', error);
279
- send_error_response(ws, null, jsonrpc_error_messages.parse_error());
280
- return;
695
+ // The handler's output is not JSON (a `bigint`, a cycle). Answer
696
+ // `internal_error` rather than throwing: the adapter does not await
697
+ // this promise, so a rejection here is unhandled, and the caller
698
+ // would get no reply to a request that did run.
699
+ log.error(`action result is not serializable: ${method}`, error);
700
+ frame = JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.internal_error(dev_only(error instanceof Error ? error.message : undefined))));
281
701
  }
282
- // Batch JSON-RPC is not supported on the WebSocket path.
283
- if (Array.isArray(json)) {
284
- send_error_response(ws, null, jsonrpc_error_messages.invalid_request('batch JSON-RPC requests are not supported on WebSocket'));
285
- return;
702
+ ws.send(frame);
703
+ }
704
+ finally {
705
+ pending_controllers.delete(id);
706
+ // deferred first — see the ordering invariant above
707
+ const deferred = flush_post_commit_effects(post_commit_effects, log);
708
+ await flush_pending_effects(pending_effects, log);
709
+ await deferred;
710
+ }
711
+ };
712
+ return {
713
+ onOpen: async (_event, ws) => {
714
+ // An adapter may not await this (`@hono/node-ws` doesn't), so it
715
+ // must never reject.
716
+ try {
717
+ await open_socket(ws);
286
718
  }
287
- // Inbound responses to server-initiated requests (`peer/ping`,
288
- // etc.) — route to the pending-request registry scoped to THIS
289
- // socket. A response carries a string/number `id`, exactly one
290
- // of `result` / `error`, and no string `method`, so it precedes the
291
- // request/notification split below. An unmatched id
292
- // (unsolicited, cross-connection, or already settled) resolves
293
- // nothing and is dropped, so the read loop survives a junk
294
- // frame.
295
- if (is_peer_response(json)) {
296
- if (captured_connection_id !== undefined) {
297
- const matched = transport.resolve_peer_response(captured_connection_id, json);
298
- if (!matched)
299
- audit_unmatched_peer_response(log, captured_connection_id, json.id);
300
- }
301
- return;
719
+ catch (error) {
720
+ log.error('ws open failed — closing socket:', error);
721
+ refuse_upgrade(ws, WS_CLOSE_INTERNAL_ERROR, 'internal error');
302
722
  }
303
- // Envelope version — the first thing the Rust twin's `classify`
304
- // checks (version → id → method → params). A frame without
305
- // `jsonrpc: '2.0'` is `invalid_request` on both transports
306
- // rather than falling through to the notification branch,
307
- // which would silently drop `{method: 'x'}` and
308
- // `{jsonrpc: '1.0', method: 'x'}`. It also covers a
309
- // non-object frame (a scalar has no id to echo — `id: null`).
310
- // A genuine peer response carries the version and is matched
311
- // above; a response-shaped frame that misses that shape check
312
- // falls through here on purpose, so it is answered rather
313
- // than swallowed.
314
- if (!is_jsonrpc_object(json)) {
315
- send_error_response(ws, to_jsonrpc_envelope_id(json), jsonrpc_error_messages.invalid_request());
723
+ },
724
+ onMessage: (event, ws) => {
725
+ // A socket the server closed — or refused — dispatches nothing
726
+ // more, however long its client withholds the close frame.
727
+ if (phase_is('ended'))
728
+ return;
729
+ const size = ws_message_size_over(event.data, max_message_bytes);
730
+ if (size !== null) {
731
+ log.warn(`closing socket: ${size}-byte message exceeds the ${max_message_bytes}-byte cap`);
732
+ end_socket(ws, WS_CLOSE_MESSAGE_TOO_BIG, 'message too big');
316
733
  return;
317
734
  }
318
- // Notifications (string method + no id) — `cancel` is
319
- // intercepted for request-scoped cancellation; other
320
- // notifications are silenced per JSON-RPC spec (consumer
321
- // notification handlers are not a feature yet). The method
322
- // axis is string-typed like the twin's `classify`, so a
323
- // non-string `method` is answered `invalid_request` below
324
- // rather than silently dropped as a notification.
325
- if (!is_jsonrpc_request(json)) {
326
- const notification_method = json.method;
327
- if (typeof notification_method === 'string' && !('id' in json)) {
328
- if (notification_method === cancel_action_spec.method) {
329
- const parsed = CancelNotificationParams.safeParse(json.params);
330
- if (!parsed.success) {
331
- log.debug('cancel: invalid params, ignoring', parsed.error.issues);
332
- return;
333
- }
334
- const controller = pending_controllers.get(parsed.data.request_id);
335
- if (controller) {
336
- controller.abort();
337
- }
338
- else {
339
- log.debug('cancel: no pending request for id', parsed.data.request_id);
340
- }
341
- }
735
+ if (phase_is('opening')) {
736
+ // Not open yet: queue, never dispatch — nothing runs on a
737
+ // credential that has not been re-read. Bounded by count and
738
+ // by bytes, since the adapter applies no backpressure.
739
+ const bytes = ws_message_size(event.data);
740
+ if (queued_frames.length >= MAX_PRE_ADMISSION_FRAMES ||
741
+ queued_bytes + bytes > pre_admission_queue_max_bytes) {
742
+ log.warn(`closing socket: pre-admission queue overflow (${queued_frames.length} frames, ${queued_bytes} + ${bytes} bytes)`);
743
+ end_socket(ws, WS_CLOSE_POLICY_VIOLATION, WS_CLOSE_PRE_ADMISSION_OVERFLOW_REASON);
342
744
  return;
343
745
  }
344
- send_error_response(ws, to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request());
345
- return;
346
- }
347
- // Envelope validation — the same `JsonrpcRequest.safeParse` the
348
- // HTTP POST path runs, so a malformed envelope (`params: null`,
349
- // an array `params`, a boolean `id`) answers `invalid_request`
350
- // on both transports instead of reaching the dispatcher and
351
- // surfacing as `invalid_params`. It sits after the
352
- // notification / cancel block so notifications stay
353
- // unvalidated (they carry no id to echo) and `cancel` routing
354
- // precedes it.
355
- const envelope = JsonrpcRequest.safeParse(json);
356
- if (!envelope.success) {
357
- send_error_response(ws, to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request(dev_only({ issues: envelope.error.issues })));
358
- return;
359
- }
360
- const { method, id, params } = envelope.data;
361
- // Per-action method lookup — return method_not_found before
362
- // we engage the dispatch machinery. Specs without a handler
363
- // (client-only / dispatcher-handled) miss action_map and
364
- // surface as method_not_found just like unknown methods.
365
- const action = action_map.get(method);
366
- if (!action) {
367
- send_error_response(ws, id, jsonrpc_error_messages.method_not_found(method));
368
- return;
369
- }
370
- if (artificial_delay > 0) {
371
- log.debug(`throttling ${artificial_delay}ms`);
372
- await wait(artificial_delay);
373
- }
374
- // Per-request controller — fires on explicit `cancel` or on
375
- // socket close (via the socket_abort_controller chain below).
376
- // Registered before dispatch so a cancel arriving mid-handler
377
- // finds it; cleared in `finally` so late cancels for a
378
- // completed id (or a future request that reuses the id) can't
379
- // null-abort the wrong handler.
380
- const request_controller = new AbortController();
381
- pending_controllers.set(id, request_controller);
382
- // Per-message side-effect queues. `pending_effects` collects
383
- // eager fire-and-forget pool writes (audit emits, etc.);
384
- // `post_commit_effects` collects deferred thunks pushed
385
- // via `emit_after_commit` (WS notifications). Both flush
386
- // in the `finally` so the next message sees a clean slate.
387
- //
388
- // Ordering invariant — reply-before-flush is load-bearing.
389
- // Handlers that revoke their own credential
390
- // (`session_revoke_all`, `token_revoke` of the calling
391
- // bearer) audit-emit events whose listener chain — wired
392
- // by the WS auth guard in `transports_ws_auth_guard.ts` —
393
- // closes this socket when the audit row writes. The
394
- // synchronous `ws.send` on the success path returns
395
- // before any close can fire (the DB write that triggers
396
- // the chain is async — even in production with
397
- // `await_pending_effects: false`, the listener chain only
398
- // runs after the row lands). Inverting the order —
399
- // flushing the queues before the send — would silently
400
- // strand the caller without a reply.
401
- const pending_effects = [];
402
- const post_commit_effects = [];
403
- const notify = notify_socket(ws);
404
- // Server→client request seam (ActionPeer) — closes over this
405
- // socket's connection_id so a handler can initiate a request to
406
- // the originating client and await the reply. `const` capture so
407
- // the narrowed (non-undefined) type holds inside the closure;
408
- // `undefined` only in the degenerate case where a message
409
- // dispatches before onOpen assigned the id.
410
- const conn_id = captured_connection_id;
411
- const request_client = conn_id === undefined
412
- ? undefined
413
- : (request_method, request_params, request_options) => transport.request_connection(conn_id, request_method, request_params, request_options);
414
- const signal = AbortSignal.any([
415
- socket_abort_controller.signal,
416
- request_controller.signal
417
- ]);
418
- try {
419
- const result = await perform_action({
420
- action,
421
- raw_params: params,
422
- request_id: id,
423
- account_id,
424
- credential_type,
425
- token_scope,
426
- client_ip,
427
- signal,
428
- notify,
429
- connection_id: captured_connection_id,
430
- request_client,
431
- preset: upgrade_preset
432
- }, {
433
- db,
434
- pending_effects,
435
- post_commit_effects,
436
- log,
437
- action_ip_rate_limiter,
438
- action_account_rate_limiter
746
+ queued_bytes += bytes;
747
+ last_receive_time = Date.now();
748
+ return new Promise((resolve, reject) => {
749
+ queued_frames.push({ data: event.data, ws, resolve, reject });
439
750
  });
440
- ws.send(JSON.stringify(perform_action_result_to_envelope(id, result)));
441
751
  }
442
- finally {
443
- pending_controllers.delete(id);
444
- await flush_pending_effects(pending_effects, log);
445
- await flush_post_commit_effects(post_commit_effects, log);
752
+ // The transport is the authority on whether the connection is
753
+ // still live. A close it made (revocation, cap eviction) ended the
754
+ // socket above; a connection it no longer holds though nothing
755
+ // closed the socket — a bare `remove_connection` — is ended here,
756
+ // rather than left reading every frame and answering none, where
757
+ // a chatty client would also keep the heartbeat from reaping it.
758
+ const connection_id = captured_connection_id;
759
+ if (connection_id === undefined || !transport.is_registered(connection_id)) {
760
+ log.warn('closing socket: its connection is no longer registered', connection_id);
761
+ end_socket(ws, WS_CLOSE_INTERNAL_ERROR, 'connection unregistered');
762
+ return;
446
763
  }
764
+ // only a frame that is accepted counts as activity
765
+ last_receive_time = Date.now();
766
+ return handle_frame(event.data, ws, connection_id);
447
767
  },
448
768
  onClose: async (event, ws) => {
769
+ // belt and braces: the abort listener stops the timer and the
770
+ // start is skipped on an ended socket, so this matters only if
771
+ // one of those is ever broken
449
772
  stop_heartbeat_timer();
450
773
  socket_abort_controller.abort();
451
- if (on_socket_close && captured_connection_id) {
774
+ // Removed by the id captured on open — an adapter may hand each
775
+ // event its own `WSContext` (`hono/bun` does), so the one this
776
+ // event carries can't identify the connection. And removed before
777
+ // the hook is awaited: the socket is gone, so a slow hook must not
778
+ // keep a dead entry that broadcasts still target and the
779
+ // per-account cap still counts.
780
+ if (captured_connection_id)
781
+ transport.remove_connection(captured_connection_id);
782
+ if (on_socket_close && captured_connection_id && was_admitted) {
452
783
  try {
453
784
  await on_socket_close({
454
785
  ws,
@@ -460,7 +791,6 @@ export const register_action_ws = (options) => {
460
791
  log.error('on_socket_close failed:', error);
461
792
  }
462
793
  }
463
- transport.remove_connection(ws);
464
794
  log.debug('ws closed', captured_connection_id, {
465
795
  code: event.code,
466
796
  reason: event.reason
@@ -470,18 +800,28 @@ export const register_action_ws = (options) => {
470
800
  }));
471
801
  return { transport };
472
802
  };
803
+ /**
804
+ * The byte size of an inbound WebSocket message — UTF-8 bytes for a text
805
+ * frame, the buffer or blob size for a binary one.
806
+ */
807
+ const ws_message_size = (data) => {
808
+ if (typeof data === 'string')
809
+ return utf8_length(data);
810
+ if (data instanceof Blob)
811
+ return data.size;
812
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data))
813
+ return data.byteLength;
814
+ return 0;
815
+ };
473
816
  /**
474
817
  * The byte size of an inbound WebSocket message when it exceeds `max_bytes`,
475
818
  * else `null` — UTF-8 bytes for a text frame, the buffer or blob size for a
476
- * binary one.
819
+ * binary one. A text frame that cannot exceed the cap is not encoded to be
820
+ * measured.
477
821
  */
478
822
  const ws_message_size_over = (data, max_bytes) => {
479
823
  if (typeof data === 'string')
480
824
  return utf8_length_over(data, max_bytes);
481
- let size = null;
482
- if (data instanceof Blob)
483
- size = data.size;
484
- else if (data instanceof ArrayBuffer || ArrayBuffer.isView(data))
485
- size = data.byteLength;
486
- return size !== null && size > max_bytes ? size : null;
825
+ const size = ws_message_size(data);
826
+ return size > max_bytes ? size : null;
487
827
  };