@fuzdev/fuz_app 0.117.1 → 0.119.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +301 -73
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +16 -1
- package/dist/actions/connection_closer.d.ts +136 -51
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +133 -34
- package/dist/actions/register_action_ws.d.ts +176 -24
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +581 -241
- package/dist/actions/register_ws_endpoint.d.ts +1 -0
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +1 -0
- package/dist/actions/socket.svelte.d.ts +43 -9
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +59 -8
- package/dist/actions/transports.d.ts +55 -1
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +55 -1
- package/dist/actions/transports_ws_auth_guard.d.ts +53 -67
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/actions/transports_ws_auth_guard.js +87 -111
- package/dist/actions/transports_ws_backend.d.ts +212 -30
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +327 -70
- package/dist/actions/ws_endpoint_spec.d.ts +26 -13
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/auth/CLAUDE.md +125 -31
- package/dist/auth/account_actions.d.ts +12 -18
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +47 -33
- package/dist/auth/account_routes.d.ts +5 -11
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +40 -36
- package/dist/auth/account_schema.d.ts +27 -0
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +22 -0
- package/dist/auth/admin_actions.d.ts +8 -17
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +33 -21
- package/dist/auth/api_token_queries.d.ts +23 -2
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +39 -3
- package/dist/auth/audit_emitter.d.ts +72 -44
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +60 -16
- package/dist/auth/audit_log_route_schema.js +7 -7
- package/dist/auth/audit_log_routes.d.ts +31 -6
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +85 -11
- package/dist/auth/audit_log_schema.d.ts +48 -0
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +58 -0
- package/dist/auth/bootstrap_account.d.ts +3 -2
- package/dist/auth/bootstrap_account.d.ts.map +1 -1
- package/dist/auth/bootstrap_account.js +3 -3
- package/dist/auth/cleanup.d.ts +219 -30
- package/dist/auth/cleanup.d.ts.map +1 -1
- package/dist/auth/cleanup.js +260 -45
- package/dist/auth/deps.d.ts +29 -1
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +27 -0
- package/dist/auth/resolved_auth.d.ts +105 -0
- package/dist/auth/resolved_auth.d.ts.map +1 -0
- package/dist/auth/resolved_auth.js +124 -0
- package/dist/auth/role_grant_offer_ddl.d.ts +4 -2
- package/dist/auth/role_grant_offer_ddl.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_ddl.js +10 -3
- package/dist/auth/role_grant_offer_queries.d.ts +16 -7
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_queries.js +33 -15
- package/dist/auth/role_grant_offer_schema.d.ts +9 -0
- package/dist/auth/role_grant_offer_schema.d.ts.map +1 -1
- package/dist/auth/session_middleware.d.ts +8 -1
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +9 -3
- package/dist/auth/session_queries.d.ts +31 -6
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +31 -6
- package/dist/auth/standard_rpc_actions.d.ts +9 -9
- package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
- package/dist/auth/standard_rpc_actions.js +3 -3
- package/dist/db/CLAUDE.md +7 -1
- package/dist/db/migrate.d.ts +8 -13
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +9 -38
- package/dist/db/status.d.ts +58 -7
- package/dist/db/status.d.ts.map +1 -1
- package/dist/db/status.js +205 -97
- package/dist/http/CLAUDE.md +7 -3
- package/dist/http/db_routes.d.ts +6 -2
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +2 -2
- package/dist/periodic.d.ts +80 -0
- package/dist/periodic.d.ts.map +1 -0
- package/dist/periodic.js +122 -0
- package/dist/realtime/sse_auth_guard.d.ts +79 -49
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +103 -73
- package/dist/realtime/subscriber_registry.d.ts +119 -8
- package/dist/realtime/subscriber_registry.d.ts.map +1 -1
- package/dist/realtime/subscriber_registry.js +184 -25
- package/dist/runtime/deno.d.ts.map +1 -1
- package/dist/runtime/deno.js +9 -6
- package/dist/runtime/deps.d.ts +3 -2
- package/dist/runtime/deps.d.ts.map +1 -1
- package/dist/runtime/secure_file.d.ts +23 -9
- package/dist/runtime/secure_file.d.ts.map +1 -1
- package/dist/runtime/secure_file.js +29 -10
- package/dist/server/app_backend.d.ts +13 -2
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +5 -2
- package/dist/server/app_server.d.ts +55 -15
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +41 -13
- package/dist/testing/CLAUDE.md +140 -48
- package/dist/testing/app_server.d.ts +2 -2
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +11 -2
- package/dist/testing/audit_drift_guard.d.ts +3 -52
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +2 -46
- package/dist/testing/connection_closer_helpers.d.ts +9 -22
- package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
- package/dist/testing/connection_closer_helpers.js +11 -17
- package/dist/testing/cross_backend/capabilities.d.ts +67 -13
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +27 -12
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +21 -0
- package/dist/testing/cross_backend/expected_schema.json +1 -0
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
- package/dist/testing/cross_backend/session_cap.d.ts +13 -0
- package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
- package/dist/testing/cross_backend/session_cap.js +55 -19
- package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/sse_round_trip.js +93 -26
- package/dist/testing/cross_backend/testing_server_bun.js +4 -4
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
- package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
- package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +156 -19
- package/dist/testing/sse_round_trip.d.ts +6 -4
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/stubs.d.ts +1 -1
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +6 -4
- package/dist/testing/transports/sse_transport.d.ts +1 -1
- package/dist/testing/transports/ws_client.d.ts +7 -0
- package/dist/testing/transports/ws_client.d.ts.map +1 -1
- package/dist/testing/transports/ws_raw_client.d.ts +51 -0
- package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
- package/dist/testing/transports/ws_raw_client.js +156 -0
- package/dist/testing/transports/ws_transport.d.ts +28 -0
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +45 -1
- package/dist/testing/ws_round_trip.d.ts +8 -3
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +19 -4
- package/dist/ui/AdminAccounts.svelte +4 -3
- package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
- package/dist/ui/AdminOverview.svelte +3 -1
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +22 -6
- package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +16 -0
- package/dist/ui/auth_state.svelte.d.ts +25 -1
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +28 -1
- package/package.json +2 -2
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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 -
|
|
103
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
692
|
+
frame = JSON.stringify(perform_action_result_to_envelope(id, result));
|
|
276
693
|
}
|
|
277
694
|
catch (error) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
};
|