@fuzdev/fuz_app 0.140.0 → 0.141.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 (182) hide show
  1. package/dist/actions/CLAUDE.md +62 -17
  2. package/dist/actions/action_rpc.d.ts +5 -1
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +9 -1
  5. package/dist/actions/connection_closer.d.ts +25 -2
  6. package/dist/actions/connection_closer.d.ts.map +1 -1
  7. package/dist/actions/connection_closer.js +8 -1
  8. package/dist/actions/register_action_ws.d.ts +42 -6
  9. package/dist/actions/register_action_ws.d.ts.map +1 -1
  10. package/dist/actions/register_action_ws.js +87 -19
  11. package/dist/actions/register_ws_endpoint.d.ts +15 -6
  12. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  13. package/dist/actions/register_ws_endpoint.js +5 -2
  14. package/dist/actions/socket.svelte.d.ts +19 -4
  15. package/dist/actions/socket.svelte.d.ts.map +1 -1
  16. package/dist/actions/socket.svelte.js +31 -4
  17. package/dist/actions/transports.d.ts +30 -1
  18. package/dist/actions/transports.d.ts.map +1 -1
  19. package/dist/actions/transports.js +30 -1
  20. package/dist/actions/transports_ws_auth_guard.d.ts +13 -6
  21. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  22. package/dist/actions/transports_ws_auth_guard.js +24 -10
  23. package/dist/actions/transports_ws_backend.d.ts +51 -5
  24. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  25. package/dist/actions/transports_ws_backend.js +62 -9
  26. package/dist/actions/ws_endpoint_spec.d.ts +11 -10
  27. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  28. package/dist/auth/CLAUDE.md +11 -9
  29. package/dist/auth/account_route_schema.d.ts +1 -0
  30. package/dist/auth/account_route_schema.d.ts.map +1 -1
  31. package/dist/auth/account_route_schema.js +4 -0
  32. package/dist/auth/api_token.d.ts +20 -0
  33. package/dist/auth/api_token.d.ts.map +1 -1
  34. package/dist/auth/api_token.js +21 -1
  35. package/dist/auth/api_token_queries.d.ts +22 -4
  36. package/dist/auth/api_token_queries.d.ts.map +1 -1
  37. package/dist/auth/api_token_queries.js +11 -6
  38. package/dist/auth/audit_log_routes.d.ts +10 -0
  39. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  40. package/dist/auth/audit_log_routes.js +20 -0
  41. package/dist/auth/audit_log_schema.d.ts +30 -5
  42. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  43. package/dist/auth/audit_log_schema.js +30 -0
  44. package/dist/auth/bearer_auth.d.ts +4 -2
  45. package/dist/auth/bearer_auth.d.ts.map +1 -1
  46. package/dist/auth/bearer_auth.js +19 -8
  47. package/dist/auth/bootstrap_route_schema.d.ts +1 -0
  48. package/dist/auth/bootstrap_route_schema.d.ts.map +1 -1
  49. package/dist/auth/bootstrap_route_schema.js +1 -0
  50. package/dist/auth/cleanup.d.ts +9 -9
  51. package/dist/auth/cleanup.js +9 -9
  52. package/dist/auth/credential_deadline.d.ts +95 -0
  53. package/dist/auth/credential_deadline.d.ts.map +1 -0
  54. package/dist/auth/credential_deadline.js +139 -0
  55. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  56. package/dist/auth/daemon_token_middleware.js +4 -1
  57. package/dist/auth/middleware.d.ts +90 -12
  58. package/dist/auth/middleware.d.ts.map +1 -1
  59. package/dist/auth/middleware.js +115 -16
  60. package/dist/auth/request_context.d.ts +4 -2
  61. package/dist/auth/request_context.d.ts.map +1 -1
  62. package/dist/auth/request_context.js +14 -3
  63. package/dist/auth/resolved_auth.d.ts +13 -2
  64. package/dist/auth/resolved_auth.d.ts.map +1 -1
  65. package/dist/auth/resolved_auth.js +5 -4
  66. package/dist/auth/session_cookie.d.ts +4 -0
  67. package/dist/auth/session_cookie.d.ts.map +1 -1
  68. package/dist/auth/session_cookie.js +3 -2
  69. package/dist/auth/session_middleware.d.ts +1 -1
  70. package/dist/auth/session_middleware.d.ts.map +1 -1
  71. package/dist/auth/session_middleware.js +4 -1
  72. package/dist/auth/session_queries.d.ts +19 -2
  73. package/dist/auth/session_queries.d.ts.map +1 -1
  74. package/dist/auth/session_queries.js +8 -2
  75. package/dist/auth/signup_route_schema.d.ts.map +1 -1
  76. package/dist/auth/signup_route_schema.js +1 -0
  77. package/dist/hono_context.d.ts +23 -0
  78. package/dist/hono_context.d.ts.map +1 -1
  79. package/dist/hono_context.js +19 -0
  80. package/dist/http/CLAUDE.md +41 -15
  81. package/dist/http/middleware_spec.d.ts +16 -0
  82. package/dist/http/middleware_spec.d.ts.map +1 -1
  83. package/dist/http/origin.d.ts +113 -18
  84. package/dist/http/origin.d.ts.map +1 -1
  85. package/dist/http/origin.js +247 -127
  86. package/dist/http/route_spec.d.ts +21 -0
  87. package/dist/http/route_spec.d.ts.map +1 -1
  88. package/dist/http/schema_helpers.d.ts +22 -0
  89. package/dist/http/schema_helpers.d.ts.map +1 -1
  90. package/dist/http/schema_helpers.js +24 -0
  91. package/dist/http/surface.d.ts +6 -1
  92. package/dist/http/surface.d.ts.map +1 -1
  93. package/dist/http/surface.js +12 -5
  94. package/dist/realtime/sse_auth_guard.d.ts +22 -6
  95. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  96. package/dist/realtime/sse_auth_guard.js +31 -12
  97. package/dist/realtime/subscriber_registry.d.ts +10 -1
  98. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  99. package/dist/realtime/subscriber_registry.js +10 -1
  100. package/dist/server/app_server.d.ts +20 -7
  101. package/dist/server/app_server.d.ts.map +1 -1
  102. package/dist/server/app_server.js +8 -3
  103. package/dist/server/env.d.ts +2 -1
  104. package/dist/server/env.d.ts.map +1 -1
  105. package/dist/server/env.js +7 -17
  106. package/dist/server/serve_fact_route.d.ts.map +1 -1
  107. package/dist/server/serve_fact_route.js +1 -0
  108. package/dist/testing/CLAUDE.md +154 -22
  109. package/dist/testing/adversarial_headers.d.ts.map +1 -1
  110. package/dist/testing/adversarial_headers.js +13 -6
  111. package/dist/testing/connection_closer_helpers.d.ts +6 -1
  112. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  113. package/dist/testing/connection_closer_helpers.js +4 -0
  114. package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
  115. package/dist/testing/cross_backend/body_size.js +14 -65
  116. package/dist/testing/cross_backend/capabilities.d.ts +26 -2
  117. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  118. package/dist/testing/cross_backend/capabilities.js +9 -3
  119. package/dist/testing/cross_backend/conformance_case.d.ts +11 -3
  120. package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
  121. package/dist/testing/cross_backend/conformance_case.js +6 -0
  122. package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
  123. package/dist/testing/cross_backend/conformance_table.js +20 -19
  124. package/dist/testing/cross_backend/credential_deadline.d.ts +25 -0
  125. package/dist/testing/cross_backend/credential_deadline.d.ts.map +1 -0
  126. package/dist/testing/cross_backend/credential_deadline.js +185 -0
  127. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  128. package/dist/testing/cross_backend/default_backend_configs.js +13 -2
  129. package/dist/testing/cross_backend/full_spine_mount.d.ts +3 -0
  130. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
  131. package/dist/testing/cross_backend/full_spine_mount.js +7 -1
  132. package/dist/testing/cross_backend/narrowed_api_token.d.ts +23 -0
  133. package/dist/testing/cross_backend/narrowed_api_token.d.ts.map +1 -0
  134. package/dist/testing/cross_backend/narrowed_api_token.js +40 -0
  135. package/dist/testing/cross_backend/origin.d.ts.map +1 -1
  136. package/dist/testing/cross_backend/origin.js +6 -26
  137. package/dist/testing/cross_backend/origin_conformance.d.ts +152 -0
  138. package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -0
  139. package/dist/testing/cross_backend/origin_conformance.js +136 -0
  140. package/dist/testing/cross_backend/origin_conformance.json +1679 -0
  141. package/dist/testing/cross_backend/origin_scope.d.ts +16 -0
  142. package/dist/testing/cross_backend/origin_scope.d.ts.map +1 -0
  143. package/dist/testing/cross_backend/origin_scope.js +140 -0
  144. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts +35 -0
  145. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts.map +1 -0
  146. package/dist/testing/cross_backend/role_grant_cross_helpers.js +64 -0
  147. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +8 -0
  148. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  149. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +44 -33
  150. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  151. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +6 -4
  152. package/dist/testing/cross_backend/spine_surface_constants.d.ts +19 -0
  153. package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
  154. package/dist/testing/cross_backend/spine_surface_constants.js +22 -0
  155. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  156. package/dist/testing/cross_backend/testing_backdoor.js +20 -7
  157. package/dist/testing/cross_backend/testing_reset_actions.d.ts +180 -5
  158. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  159. package/dist/testing/cross_backend/testing_reset_actions.js +154 -5
  160. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  161. package/dist/testing/cross_backend/token_scope_surface.js +25 -21
  162. package/dist/testing/cross_backend/transport_composition.d.ts +60 -0
  163. package/dist/testing/cross_backend/transport_composition.d.ts.map +1 -0
  164. package/dist/testing/cross_backend/transport_composition.js +364 -0
  165. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  166. package/dist/testing/cross_backend/ts_spine_backend_config.js +21 -6
  167. package/dist/testing/cross_backend/ws_role_gated.d.ts +38 -0
  168. package/dist/testing/cross_backend/ws_role_gated.d.ts.map +1 -0
  169. package/dist/testing/cross_backend/ws_role_gated.js +142 -0
  170. package/dist/testing/entities.d.ts +23 -0
  171. package/dist/testing/entities.d.ts.map +1 -1
  172. package/dist/testing/entities.js +37 -0
  173. package/dist/testing/integration.d.ts.map +1 -1
  174. package/dist/testing/integration.js +3 -1
  175. package/dist/testing/middleware.d.ts +10 -0
  176. package/dist/testing/middleware.d.ts.map +1 -1
  177. package/dist/testing/middleware.js +31 -7
  178. package/dist/testing/stubs.d.ts +16 -3
  179. package/dist/testing/stubs.d.ts.map +1 -1
  180. package/dist/testing/stubs.js +20 -7
  181. package/dist/testing/surface_invariants.js +2 -2
  182. package/package.json +1 -1
@@ -54,10 +54,19 @@
54
54
  * admits a revoked credential; `docs/security.md` §Connection Admission says
55
55
  * what that rests on.
56
56
  *
57
+ * An endpoint gated on `required_roles` re-reads them beside the credential:
58
+ * its upgrade passed the role gate before the socket registered, so a role
59
+ * revocation landing in between would close nothing (`close_sockets_for_role`
60
+ * matches registrations), and the socket would be admitted without the role
61
+ * for its lifetime. The re-read reads the acting actor's grants afresh and
62
+ * admits on a global grant of any listed role, as the gate did.
63
+ *
57
64
  * A refused upgrade is closed with `WS_CLOSE_SESSION_REVOKED` (4001, the
58
- * reason a revoked socket gets), or `WS_CLOSE_INTERNAL_ERROR` (1011) when the
59
- * re-read itself failed — fail closed, never admitted unchecked. The handshake
60
- * has already answered `101`, so a refusal can only be a close frame.
65
+ * reason a revoked socket gets) for a dead credential,
66
+ * `WS_CLOSE_PERMISSION_REVOKED` (4006) for a lost role, or
67
+ * `WS_CLOSE_INTERNAL_ERROR` (1011) when either re-read itself failed — fail
68
+ * closed, never admitted unchecked. The handshake has already answered `101`,
69
+ * so a refusal can only be a close frame.
61
70
  *
62
71
  * Once the server is shutting down (`AppServer.close` ran the transport's
63
72
  * `close_all_sockets`) a refusal is `WS_CLOSE_GOING_AWAY` instead, so the
@@ -75,8 +84,17 @@
75
84
  * `PRE_ADMISSION_QUEUE_BYTES_FACTOR` × `max_message_bytes` bytes; past either
76
85
  * the socket is closed.
77
86
  *
78
- * The upgrade's role gate is *not* re-read: a role revocation closes no
79
- * WebSocket in the first place (per-message dispatch re-reads role grants).
87
+ * ## Credential deadline
88
+ *
89
+ * Expiry writes nothing a revocation listener could hear, so the socket
90
+ * watches its own credential's expiry (`ResolvedAuth.credential_deadline`,
91
+ * the earlier of a session's cookie and row, an API token's row, none for a
92
+ * daemon token or an eternal token). From admission, a timer closes it at the
93
+ * deadline with `WS_CLOSE_SESSION_REVOKED` (4001) and
94
+ * `WS_CLOSE_CREDENTIAL_EXPIRED_REASON`. The wait is a chain of timers of at
95
+ * most an hour (`schedule_credential_deadline`), so a session's 30 days — past
96
+ * `setTimeout`'s ceiling — does not fire at once, and each wake-up checks the
97
+ * wall clock too, so a host suspend does not extend the deadline.
80
98
  *
81
99
  * ## A server-closed socket dispatches nothing
82
100
  *
@@ -96,8 +114,9 @@
96
114
  */
97
115
  import { wait } from '@fuzdev/fuz_util/async.ts';
98
116
  import { Logger } from '@fuzdev/fuz_util/log.ts';
99
- import { get_request_context, require_request_context, token_scope_surface_denial } from '../auth/request_context.js';
117
+ import { get_request_context, has_any_scoped_role, refresh_role_grants, require_request_context, token_scope_surface_denial } from '../auth/request_context.js';
100
118
  import { get_resolved_auth, revalidate_resolved_auth } from '../auth/resolved_auth.js';
119
+ import { schedule_credential_deadline } from '../auth/credential_deadline.js';
101
120
  import { get_client_ip } from '../http/client_ip.js';
102
121
  import { flush_pending_effects, flush_post_commit_effects } from '../http/pending_effects.js';
103
122
  import { JsonrpcRequest } from '../http/jsonrpc.js';
@@ -107,7 +126,7 @@ import { TOKEN_SCOPE_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
107
126
  import {} from './action_types.js';
108
127
  import { compile_action_registry } from './compile_action_registry.js';
109
128
  import { cancel_action_spec, CancelNotificationParams } from './cancel.js';
110
- 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';
129
+ import { DEFAULT_WS_MAX_MESSAGE_BYTES, WS_CLOSE_GOING_AWAY, WS_CLOSE_INTERNAL_ERROR, WS_CLOSE_MESSAGE_TOO_BIG, WS_CLOSE_PERMISSION_REVOKED, WS_CLOSE_PERMISSION_REVOKED_REASON, WS_CLOSE_POLICY_VIOLATION, WS_CLOSE_CREDENTIAL_EXPIRED_REASON, WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON, utf8_length, utf8_length_over } from './transports.js';
111
130
  import { BackendWebsocketTransport } from './transports_ws_backend.js';
112
131
  import { audit_unmatched_peer_response } from './peer_request.js';
113
132
  import { perform_action, perform_action_result_to_envelope } from './perform_action.js';
@@ -247,8 +266,12 @@ const is_peer_response = (json) => {
247
266
  * on the audit row.
248
267
  * - Admission re-reads the credential once, after the handshake: a socket
249
268
  * whose session or token was revoked while it was upgrading closes with
250
- * `WS_CLOSE_SESSION_REVOKED`, and one whose re-check failed with
269
+ * `WS_CLOSE_SESSION_REVOKED`, one that lost a role `required_roles` lists
270
+ * with `WS_CLOSE_PERMISSION_REVOKED`, and one whose re-check failed with
251
271
  * `WS_CLOSE_INTERNAL_ERROR`. Frames sent before admission wait for it.
272
+ * - A revoked global grant of a role `required_roles` lists closes the
273
+ * account's sockets on this endpoint with `WS_CLOSE_PERMISSION_REVOKED`,
274
+ * through `create_ws_auth_guard`; nothing more is sent on them.
252
275
  * - A socket the server has closed dispatches nothing more, whether or not
253
276
  * its client answers the close.
254
277
  * - Every frame sent — responses, error frames, notifications — goes through
@@ -279,7 +302,7 @@ export const register_action_ws = (options) => {
279
302
  throw new Error(`register_action_ws: outbound_queue_bytes configures the transport created for ${options.path}, ` +
280
303
  'but a transport was supplied — set the budget on that BackendWebsocketTransport instead');
281
304
  }
282
- 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, action_account_rate_limiter } = options;
305
+ 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, action_account_rate_limiter, required_roles = [] } = options;
283
306
  const transport = options.transport ??
284
307
  new BackendWebsocketTransport({
285
308
  max_connections_per_account: options.max_connections_per_account,
@@ -402,7 +425,12 @@ export const register_action_ws = (options) => {
402
425
  // read it — by the time the hook runs the transport record is gone,
403
426
  // removed in `onClose` or earlier by a revocation, a connection-cap
404
427
  // eviction, or another server-side close.
405
- const identity = { token_hash, account_id, api_token_id };
428
+ const identity = {
429
+ token_hash,
430
+ account_id,
431
+ api_token_id,
432
+ required_roles
433
+ };
406
434
  // Captured on open, consumed on close. Undefined before onOpen
407
435
  // fires or when a consumer never opens (e.g. immediate disconnect).
408
436
  let captured_connection_id;
@@ -420,6 +448,10 @@ export const register_action_ws = (options) => {
420
448
  // Whether the connection was ever admitted — gates `on_socket_close`,
421
449
  // the counterpart of an `on_socket_open` a refused socket never ran.
422
450
  let was_admitted = false;
451
+ // The code the transport closed the pending registration with, taken
452
+ // before `onClose` removes it: the client's answer to that close can
453
+ // arrive before the re-read returns.
454
+ let pending_close_code = null;
423
455
  // Frames received while `opening`, in arrival order — dispatched once
424
456
  // the socket is open, dropped if it ends first. Each carries the
425
457
  // settlers of the promise its `onMessage` call returned, so a caller
@@ -445,12 +477,22 @@ export const register_action_ws = (options) => {
445
477
  heartbeat_timer = null;
446
478
  }
447
479
  };
480
+ // The credential deadline: the socket closes when the credential it
481
+ // upgraded on expires (4001, `WS_CLOSE_CREDENTIAL_EXPIRED_REASON`) —
482
+ // expiry writes nothing a revocation listener hears. Started at
483
+ // admission, cancelled when the socket ends.
484
+ let cancel_credential_deadline = null;
485
+ const stop_credential_deadline = () => {
486
+ cancel_credential_deadline?.();
487
+ cancel_credential_deadline = null;
488
+ };
448
489
  // The one place a socket's end is recorded, whoever ended it — the
449
490
  // transport (revocation, cap eviction), `end_socket`, or `onClose` all
450
491
  // abort this controller.
451
492
  socket_abort_controller.signal.addEventListener('abort', () => {
452
493
  phase = 'ended';
453
494
  stop_heartbeat_timer();
495
+ stop_credential_deadline();
454
496
  drop_queued_frames();
455
497
  }, { once: true });
456
498
  // End the socket from the server side: unregister it and abort its
@@ -505,7 +547,7 @@ export const register_action_ws = (options) => {
505
547
  // refusing — the socket is then already closed.
506
548
  const admit_upgrade = async (ws) => {
507
549
  // Pending first, synchronously: from here every revocation finds it.
508
- const connection_id = transport.register_pending(ws, token_hash, account_id, api_token_id, socket_abort_controller);
550
+ const connection_id = transport.register_pending(ws, token_hash, account_id, api_token_id, socket_abort_controller, required_roles);
509
551
  captured_connection_id = connection_id;
510
552
  // Born closed — the server is shutting down, and the transport has
511
553
  // already closed the socket with `WS_CLOSE_GOING_AWAY` — or ended
@@ -520,10 +562,16 @@ export const register_action_ws = (options) => {
520
562
  }
521
563
  if (!upgrade_preset) {
522
564
  let credential_is_live;
565
+ let role_is_held = true;
523
566
  try {
524
567
  // pool-level `db`: the statements must see every revocation
525
568
  // committed before they start
526
569
  credential_is_live = await revalidate_resolved_auth({ db }, resolved);
570
+ // Global grants only, like the gate in front: a scoped grant
571
+ // never admitted this socket.
572
+ if (credential_is_live && required_roles.length > 0) {
573
+ role_is_held = has_any_scoped_role(await refresh_role_grants(upgrade_context, { db }), required_roles, null);
574
+ }
527
575
  }
528
576
  catch (error) {
529
577
  // fail closed — never admitted unchecked. During shutdown the
@@ -533,7 +581,7 @@ export const register_action_ws = (options) => {
533
581
  log.info('ws upgrade admission: re-check ended by the shutdown', error);
534
582
  }
535
583
  else {
536
- log.error('ws upgrade admission: credential re-check failed', error);
584
+ log.error('ws upgrade admission: credential or role re-check failed', error);
537
585
  }
538
586
  refuse_upgrade(ws, WS_CLOSE_INTERNAL_ERROR, 'internal error');
539
587
  return null;
@@ -543,16 +591,24 @@ export const register_action_ws = (options) => {
543
591
  refuse_upgrade(ws, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON);
544
592
  return null;
545
593
  }
594
+ if (!role_is_held) {
595
+ log.info('ws upgrade admission: role revoked during the upgrade', connection_id, account_id);
596
+ refuse_upgrade(ws, WS_CLOSE_PERMISSION_REVOKED, WS_CLOSE_PERMISSION_REVOKED_REASON);
597
+ return null;
598
+ }
546
599
  }
547
600
  // The cap's eviction happens here, after every gate and the re-read,
548
601
  // so a refused upgrade never closes someone else's socket.
549
602
  if (!transport.admit(connection_id)) {
550
- // chosen from the transport's state now, not why the entry closed — the
551
- // abort carries no per-entry reason, so an entry a revocation closed logs as
552
- // closed by the shutdown when the shutdown began before this check
553
- log.info(transport.is_closing()
554
- ? 'ws upgrade admission: closed by the shutdown while pending'
555
- : 'ws upgrade admission: closed by a revocation while pending', connection_id, account_id);
603
+ // why the entry closed, from the code the transport closed it with
604
+ const code = pending_close_code ?? transport.take_pending_close_code(connection_id);
605
+ log.info(code === WS_CLOSE_PERMISSION_REVOKED
606
+ ? 'ws upgrade admission: closed by a permission revocation while pending'
607
+ : code === WS_CLOSE_SESSION_REVOKED
608
+ ? 'ws upgrade admission: closed by a credential revocation while pending'
609
+ : code === WS_CLOSE_GOING_AWAY || transport.is_closing()
610
+ ? 'ws upgrade admission: closed by the shutdown while pending'
611
+ : 'ws upgrade admission: ended while pending', connection_id, account_id);
556
612
  refuse_upgrade(ws, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON);
557
613
  return null;
558
614
  }
@@ -564,6 +620,14 @@ export const register_action_ws = (options) => {
564
620
  if (connection_id === null)
565
621
  return;
566
622
  log.debug('ws opened', connection_id);
623
+ // Not on a socket that ended in the tick since `admit`, for the same
624
+ // reason as the heartbeat timer below.
625
+ if (!phase_is('ended')) {
626
+ cancel_credential_deadline = schedule_credential_deadline(resolved.credential_deadline, () => {
627
+ log.info('ws credential expired — closing', WS_CLOSE_SESSION_REVOKED, connection_id, identity.account_id);
628
+ end_socket(ws, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_CREDENTIAL_EXPIRED_REASON);
629
+ });
630
+ }
567
631
  // Not on a socket that ended in the tick since `admit` (a revocation
568
632
  // can land there): its abort listener has already run, so nothing
569
633
  // would ever stop the timer.
@@ -894,8 +958,12 @@ export const register_action_ws = (options) => {
894
958
  // the hook is awaited: the socket is gone, so a slow hook must not
895
959
  // keep a dead entry that broadcasts still target and the
896
960
  // per-account cap still counts.
897
- if (captured_connection_id)
961
+ if (captured_connection_id) {
962
+ if (!was_admitted) {
963
+ pending_close_code = transport.take_pending_close_code(captured_connection_id);
964
+ }
898
965
  transport.remove_connection(captured_connection_id);
966
+ }
899
967
  if (on_socket_close && captured_connection_id && was_admitted) {
900
968
  try {
901
969
  await on_socket_close({
@@ -22,7 +22,10 @@
22
22
  * Multi-actor accounts must supply `?acting` to pick a persona;
23
23
  * single-actor accounts work without it.
24
24
  * 5. Optional `require_role(required_roles)` — for endpoints gated to a
25
- * non-empty any-of set of roles.
25
+ * non-empty any-of set of roles. `register_action_ws` records the roles on
26
+ * each registration and re-reads them at admission, so the gate holds for
27
+ * the socket's life (a revoked role closes it with
28
+ * `WS_CLOSE_PERMISSION_REVOKED`).
26
29
  *
27
30
  * Then delegates to `register_action_ws` for per-message JSON-RPC
28
31
  * dispatch.
@@ -41,12 +44,18 @@ export interface RegisterWsEndpointOptions extends RegisterActionWsOptions {
41
44
  allowed_origins: ReadonlyArray<RegExp>;
42
45
  /**
43
46
  * Roles permitted to upgrade — any-of disjunction (matches the
44
- * underlying `require_role` semantics). Omit (or pass `[]`) for any
45
- * authenticated account (`require_auth` + actor resolution alone);
46
- * set to e.g. `[ROLE_ADMIN]` to gate the endpoint behind a single role
47
- * or `[ROLE_ADMIN, ROLE_KEEPER]` to permit either. The per-action
47
+ * underlying `require_role` semantics, global grants only). Omit (or pass
48
+ * `[]`) for any authenticated account (`require_auth` + actor resolution
49
+ * alone); set to e.g. `[ROLE_ADMIN]` to gate the endpoint behind a single
50
+ * role or `[ROLE_ADMIN, ROLE_KEEPER]` to permit either. The per-action
48
51
  * `auth` in each spec still applies at dispatch time — this is a coarse
49
- * upgrade-time gate.
52
+ * upgrade-time gate on who may hold a socket, and so on who receives
53
+ * the endpoint's server pushes.
54
+ *
55
+ * It holds for the socket's life: `register_action_ws` re-reads it at
56
+ * admission, and a revoked global grant of a listed role closes the
57
+ * account's sockets here with `WS_CLOSE_PERMISSION_REVOKED` (any listed
58
+ * role — see `close_sockets_for_role` on the over-close).
50
59
  */
51
60
  required_roles?: ReadonlyArray<RoleName>;
52
61
  }
@@ -1 +1 @@
1
- {"version":3,"file":"register_ws_endpoint.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_ws_endpoint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAaH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGvD,OAAO,EAEN,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,MAAM,yBAAyB,CAAC;AAEjC,0CAA0C;AAC1C,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACzE;;;;OAIG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;CACzC;AAgDD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,oBAAoB,GAChC,SAAS,yBAAyB,KAChC,sBAoBF,CAAC"}
1
+ {"version":3,"file":"register_ws_endpoint.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_ws_endpoint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAaH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGvD,OAAO,EAEN,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,MAAM,yBAAyB,CAAC;AAEjC,0CAA0C;AAC1C,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACzE;;;;OAIG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;;;;;;;;;;OAcG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;CACzC;AAgDD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,oBAAoB,GAChC,SAAS,yBAAyB,KAChC,sBAoBF,CAAC"}
@@ -22,7 +22,10 @@
22
22
  * Multi-actor accounts must supply `?acting` to pick a persona;
23
23
  * single-actor accounts work without it.
24
24
  * 5. Optional `require_role(required_roles)` — for endpoints gated to a
25
- * non-empty any-of set of roles.
25
+ * non-empty any-of set of roles. `register_action_ws` records the roles on
26
+ * each registration and re-reads them at admission, so the gate holds for
27
+ * the socket's life (a revoked role closes it with
28
+ * `WS_CLOSE_PERMISSION_REVOKED`).
26
29
  *
27
30
  * Then delegates to `register_action_ws` for per-message JSON-RPC
28
31
  * dispatch.
@@ -99,5 +102,5 @@ export const register_ws_endpoint = (options) => {
99
102
  if (required_roles?.length) {
100
103
  app.use(path, require_role(required_roles));
101
104
  }
102
- return register_action_ws({ app, path, db, log, ...rest });
105
+ return register_action_ws({ app, path, db, log, required_roles, ...rest });
103
106
  };
@@ -5,9 +5,11 @@
5
5
  * Drop into any SvelteKit frontend as the underlying connection for
6
6
  * `FrontendWebsocketTransport`. Handles auto-reconnect with exponential
7
7
  * backoff, respects `WS_CLOSE_SESSION_REVOKED` (no reconnect loop after the
8
- * server revokes auth) and `WS_CLOSE_CONNECTION_LIMIT` (no reconnect after the
8
+ * server revokes auth), `WS_CLOSE_CONNECTION_LIMIT` (no reconnect after the
9
9
  * server closes this socket to admit a newer one — closed until the user
10
- * acts), exposes reactive status for UI indicators, and ships three
10
+ * acts), and `WS_CLOSE_PERMISSION_REVOKED` (no reconnect after the account
11
+ * lost the role the endpoint requires — closed until the user acts), exposes
12
+ * reactive status for UI indicators, and ships three
11
13
  * correctness primitives default-on:
12
14
  *
13
15
  * - `FrontendWebsocketClient.request` — promise-based JSON-RPC with
@@ -76,7 +78,7 @@ export declare const DEFAULT_QUEUE_MAX_SIZE = 100;
76
78
  * - `reconnecting` — close fired; waiting out backoff before next attempt.
77
79
  * - `closed` — socket is not open, and nothing is scheduled to reopen it.
78
80
  * Terminal only when `revoked` is `true`; otherwise `connect()` reopens,
79
- * including after a `superseded` close.
81
+ * including after a `superseded` or permission-revoked close.
80
82
  */
81
83
  export type SocketStatus = 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed';
82
84
  export type SocketMessageHandler = (event: MessageEvent) => void;
@@ -153,6 +155,13 @@ export interface FrontendWebsocketClientOptions {
153
155
  * closed with `superseded` set and no reconnect scheduled — the server closed
154
156
  * this socket to admit a newer one on the same account, and reconnecting
155
157
  * would close that one in turn. An explicit `connect()` reopens.
158
+ *
159
+ * A permission-revoked close (`WS_CLOSE_PERMISSION_REVOKED`) leaves the client
160
+ * closed with no reconnect scheduled, and neither `revoked` nor `superseded`
161
+ * set: the credential is still good, but the account lost the role the
162
+ * endpoint requires, so a reconnect would be refused at its role gate. Pending
163
+ * and queued requests reject `forbidden`. `last_close_code` says why; an
164
+ * explicit `connect()` reopens, for when the role has been granted again.
156
165
  */
157
166
  export declare class FrontendWebsocketClient implements WebsocketConnection, Disposable {
158
167
  #private;
@@ -339,6 +348,8 @@ export declare class FrontendWebsocketClient implements WebsocketConnection, Dis
339
348
  * - `queue_overflow` — durable queue full
340
349
  * - `service_unavailable` — socket not connected / closed / torn down
341
350
  * mid-flight, including a `superseded` close
351
+ * - `forbidden` — in flight or queued at a `WS_CLOSE_PERMISSION_REVOKED`
352
+ * close
342
353
  * - `internal_error` — `ws.send` threw (serialization, buffer full)
343
354
  * - server's wire code verbatim — JSON-RPC error frame from peer
344
355
  */
@@ -361,7 +372,11 @@ export declare class FrontendWebsocketClient implements WebsocketConnection, Dis
361
372
  * an explicit `connect()` reopens it; read `FrontendWebsocketClient.superseded`
362
373
  * to tell the user why, and before connecting on an `initial` status without
363
374
  * the user asking (connecting a superseded client on its own closes a newer
364
- * socket in turn, which is the loop the close code exists to stop).
375
+ * socket in turn, which is the loop the close code exists to stop). A
376
+ * `WS_CLOSE_PERMISSION_REVOKED` close reads as `initial` the same way, with
377
+ * neither flag set: check `FrontendWebsocketClient.last_close_code` before
378
+ * connecting on your own, since without the role the upgrade is refused at
379
+ * the gate.
365
380
  *
366
381
  * @param revoked - whether the session has been permanently revoked
367
382
  * (typically `FrontendWebsocketClient.revoked`)
@@ -1 +1 @@
1
- {"version":3,"file":"socket.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/socket.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAE7D,OAAO,EAA0C,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AASnG,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAE9D,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAC5C,8DAA8D;AAC9D,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AACjD,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAC1C,qDAAqD;AACrD,eAAO,MAAM,0BAA0B,QAAS,CAAC;AACjD;;;GAGG;AACH,eAAO,MAAM,iCAAiC,QAAS,CAAC;AACxD;;;;;;;;GAQG;AACH,eAAO,MAAM,oCAAoC,IAAI,CAAC;AAEtD;;;;;;;;GAQG;AACH,eAAO,MAAM,iCAAiC,GAC7C,UAAU,MAAM,EAChB,kBAAiB,MAA0C,KACzD,MAAoF,CAAC;AACxF,+EAA+E;AAC/E,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;;;;;;GAUG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;AAE9F,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;AACjE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;AAExD,MAAM,WAAW,iCAAiC;IACjD,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iCAAiC;IACjD;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,6BAA6B;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,8BAA8B;IAC9C;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,GAAG,IAAI,CAAC;IAC/D;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,GAAG,IAAI,CAAC;IAC/D;;;;;OAKG;IACH,KAAK,CAAC,EAAE,OAAO,GAAG,6BAA6B,CAAC;IAChD,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAiBD;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,uBAAwB,YAAW,mBAAmB,EAAE,UAAU;;IA4B9E,EAAE,EAAE,SAAS,GAAG,IAAI,CAAoB;IACxC,MAAM,EAAE,YAAY,CAAyB;IAE7C,eAAe,EAAE,MAAM,CAAiB;IACxC,uBAAuB,EAAE,MAAM,CAAiB;IAChD,2EAA2E;IAC3E,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD,yEAAyE;IACzE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,kFAAkF;IAClF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD;;;OAGG;IACH,cAAc,EAAE,MAAM,GAAG,IAAI,CAAoB;IACjD;;;;OAIG;IACH,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD;;;;;;;;OAQG;IACH,eAAe,EAAE,KAAK,GAAG,IAAI,CAAoB;IAUjD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IA0BrE;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,aAAa,CAAC,SAAS,GAAE,OAAO,GAAG,iCAAiC,GAAG,IAAW,GAAG,IAAI;IA2CzF;;;;;;;;;;;;;;OAcG;IACH,aAAa,CAAC,SAAS,GAAE,OAAO,GAAG,iCAAiC,GAAG,IAAW,GAAG,IAAI;IAgBzF;;;;;;;;;;;;;;;OAeG;IACH,gBAAgB,IAAI,IAAI;IAOxB,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;;;OAIG;IACH,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,IAAI,UAAU,IAAI,OAAO,CAExB;IAED;;;;;;;;;;OAUG;IACH,OAAO,IAAI,IAAI;IA4Bf;;;;;;;;OAQG;IACH,UAAU,CAAC,IAAI,GAAE,MAA2B,GAAG,IAAI;IASnD,sGAAsG;IACtG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;IAIxB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAgB3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAyCG;IACH,OAAO,CAAC,CAAC,GAAG,OAAO,EAClB,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,OAAO,EAChB,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,EAAE,CAAC,EAAE,gBAAgB,CAAA;KAAO,GAC5E,OAAO,CAAC,CAAC,CAAC;IAkFb,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CAyW1D;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,6BAA6B,GACzC,QAAQ,YAAY,EACpB,SAAS,OAAO,KACd,WAaF,CAAC"}
1
+ {"version":3,"file":"socket.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/socket.svelte.ts"],"names":[],"mappings":"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"}
@@ -5,9 +5,11 @@
5
5
  * Drop into any SvelteKit frontend as the underlying connection for
6
6
  * `FrontendWebsocketTransport`. Handles auto-reconnect with exponential
7
7
  * backoff, respects `WS_CLOSE_SESSION_REVOKED` (no reconnect loop after the
8
- * server revokes auth) and `WS_CLOSE_CONNECTION_LIMIT` (no reconnect after the
8
+ * server revokes auth), `WS_CLOSE_CONNECTION_LIMIT` (no reconnect after the
9
9
  * server closes this socket to admit a newer one — closed until the user
10
- * acts), exposes reactive status for UI indicators, and ships three
10
+ * acts), and `WS_CLOSE_PERMISSION_REVOKED` (no reconnect after the account
11
+ * lost the role the endpoint requires — closed until the user acts), exposes
12
+ * reactive status for UI indicators, and ships three
11
13
  * correctness primitives default-on:
12
14
  *
13
15
  * - `FrontendWebsocketClient.request` — promise-based JSON-RPC with
@@ -29,7 +31,7 @@
29
31
  import { BROWSER } from 'esm-env';
30
32
  import { JSONRPC_VERSION } from '../http/jsonrpc.js';
31
33
  import { JSONRPC_ERROR_CODES, ThrownJsonrpcError, jsonrpc_errors } from '../http/jsonrpc_errors.js';
32
- import { WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT, WS_CLOSE_CONNECTION_LIMIT, WS_CLOSE_SESSION_REVOKED } from './transports.js';
34
+ import { WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT, WS_CLOSE_CONNECTION_LIMIT, WS_CLOSE_PERMISSION_REVOKED, WS_CLOSE_SESSION_REVOKED } from './transports.js';
33
35
  import { cancel_action_spec } from './cancel.js';
34
36
  import { heartbeat_action_spec } from './heartbeat.js';
35
37
  /** Default WebSocket close code (normal closure). */
@@ -84,6 +86,13 @@ export const DEFAULT_QUEUE_MAX_SIZE = 100;
84
86
  * closed with `superseded` set and no reconnect scheduled — the server closed
85
87
  * this socket to admit a newer one on the same account, and reconnecting
86
88
  * would close that one in turn. An explicit `connect()` reopens.
89
+ *
90
+ * A permission-revoked close (`WS_CLOSE_PERMISSION_REVOKED`) leaves the client
91
+ * closed with no reconnect scheduled, and neither `revoked` nor `superseded`
92
+ * set: the credential is still good, but the account lost the role the
93
+ * endpoint requires, so a reconnect would be refused at its role gate. Pending
94
+ * and queued requests reject `forbidden`. `last_close_code` says why; an
95
+ * explicit `connect()` reopens, for when the role has been granted again.
87
96
  */
88
97
  export class FrontendWebsocketClient {
89
98
  #url;
@@ -423,6 +432,8 @@ export class FrontendWebsocketClient {
423
432
  * - `queue_overflow` — durable queue full
424
433
  * - `service_unavailable` — socket not connected / closed / torn down
425
434
  * mid-flight, including a `superseded` close
435
+ * - `forbidden` — in flight or queued at a `WS_CLOSE_PERMISSION_REVOKED`
436
+ * close
426
437
  * - `internal_error` — `ws.send` threw (serialization, buffer full)
427
438
  * - server's wire code verbatim — JSON-RPC error frame from peer
428
439
  */
@@ -743,6 +754,18 @@ export class FrontendWebsocketClient {
743
754
  this.#reject_all('connection superseded', jsonrpc_errors.service_unavailable);
744
755
  return;
745
756
  }
757
+ // The account lost a role the endpoint requires. Reconnecting would be
758
+ // refused at the role gate, so stay closed until `connect()` is called.
759
+ // Not a revocation: the credential is still good, and nothing will
760
+ // reopen the socket on its own, so the queue is rejected with the pending
761
+ // requests.
762
+ if (event.code === WS_CLOSE_PERMISSION_REVOKED) {
763
+ this.status = 'closed';
764
+ this.#cancel_reconnect();
765
+ this.#reset_reconnect_counters();
766
+ this.#reject_all('permission revoked', jsonrpc_errors.forbidden);
767
+ return;
768
+ }
746
769
  // Pending in-flight requests can't be correlated post-reconnect; reject
747
770
  // them. Queue stays so the flush on reopen replays unsent work.
748
771
  this.#reject_pending_only(`connection closed (code ${event.code})`, jsonrpc_errors.service_unavailable);
@@ -835,7 +858,11 @@ export class FrontendWebsocketClient {
835
858
  * an explicit `connect()` reopens it; read `FrontendWebsocketClient.superseded`
836
859
  * to tell the user why, and before connecting on an `initial` status without
837
860
  * the user asking (connecting a superseded client on its own closes a newer
838
- * socket in turn, which is the loop the close code exists to stop).
861
+ * socket in turn, which is the loop the close code exists to stop). A
862
+ * `WS_CLOSE_PERMISSION_REVOKED` close reads as `initial` the same way, with
863
+ * neither flag set: check `FrontendWebsocketClient.last_close_code` before
864
+ * connecting on your own, since without the role the upgrade is refused at
865
+ * the gate.
839
866
  *
840
867
  * @param revoked - whether the session has been permanently revoked
841
868
  * (typically `FrontendWebsocketClient.revoked`)
@@ -9,7 +9,8 @@
9
9
  import { z } from 'zod';
10
10
  import type { JsonrpcMessageFromClientToServer, JsonrpcMessageFromServerToClient, JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
11
11
  /**
12
- * WebSocket close code for session revocation.
12
+ * WebSocket close code for session revocation — and for a credential that
13
+ * expired while the socket was open (`WS_CLOSE_CREDENTIAL_EXPIRED_REASON`).
13
14
  *
14
15
  * Also the close for an upgrade refused at admission because its credential
15
16
  * was revoked while the upgrade was in flight (`register_action_ws`) — the
@@ -22,6 +23,15 @@ export declare const WS_CLOSE_SESSION_REVOKED = 4001;
22
23
  * indistinguishable on the wire. The Rust spine sends the same text.
23
24
  */
24
25
  export declare const WS_CLOSE_SESSION_REVOKED_REASON = "Session revoked";
26
+ /**
27
+ * The close reason sent with `WS_CLOSE_SESSION_REVOKED` when the socket's
28
+ * credential expired while it was open — its session or API token passed its
29
+ * expiry (`ResolvedAuth.credential_deadline`). The code is the revocation's on
30
+ * purpose: the client cannot reconnect on the credential either way, so
31
+ * `FrontendWebsocketClient` enters the same `revoked` state. The Rust spine
32
+ * sends the same text.
33
+ */
34
+ export declare const WS_CLOSE_CREDENTIAL_EXPIRED_REASON = "Credential expired";
25
35
  /** WebSocket close code — client timed out waiting for a response. */
26
36
  export declare const WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002;
27
37
  /** WebSocket close code — server timed out with no incoming activity. */
@@ -62,6 +72,25 @@ export declare const WS_CLOSE_OUTBOUND_OVERFLOW = 4005;
62
72
  * sends the same text.
63
73
  */
64
74
  export declare const WS_CLOSE_OUTBOUND_OVERFLOW_REASON = "outbound queue overflow";
75
+ /**
76
+ * WebSocket close code — the socket's account lost a role its endpoint
77
+ * requires (`register_ws_endpoint`'s `required_roles`): a global grant of one
78
+ * of those roles was revoked while the socket was open, or while its upgrade
79
+ * was in flight (the admission re-read, `register_action_ws`). Nothing more
80
+ * is sent on the socket.
81
+ *
82
+ * Distinct from `WS_CLOSE_SESSION_REVOKED`: the credential is still good, so
83
+ * a client must not enter its `revoked` state — but a reconnect would be
84
+ * refused at the role gate, so `FrontendWebsocketClient` stays closed until
85
+ * `connect()` is called and rejects its pending work as `forbidden`. The twin
86
+ * of the Rust spine's `WS_CLOSE_PERMISSION_REVOKED`.
87
+ */
88
+ export declare const WS_CLOSE_PERMISSION_REVOKED = 4006;
89
+ /**
90
+ * The close reason sent with `WS_CLOSE_PERMISSION_REVOKED`, mid-life and at
91
+ * admission alike. The Rust spine sends the same text.
92
+ */
93
+ export declare const WS_CLOSE_PERMISSION_REVOKED_REASON = "Permission revoked";
65
94
  /**
66
95
  * WebSocket close code — the server is shutting down (RFC 6455 §7.4.1 "Going
67
96
  * Away"). Sent to every live socket by `close_all_sockets`, which
@@ -1 +1 @@
1
- {"version":3,"file":"transports.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"transports.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports.ts"],"names":[],"mappings":"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"}
@@ -8,7 +8,8 @@
8
8
  */
9
9
  import { z } from 'zod';
10
10
  /**
11
- * WebSocket close code for session revocation.
11
+ * WebSocket close code for session revocation — and for a credential that
12
+ * expired while the socket was open (`WS_CLOSE_CREDENTIAL_EXPIRED_REASON`).
12
13
  *
13
14
  * Also the close for an upgrade refused at admission because its credential
14
15
  * was revoked while the upgrade was in flight (`register_action_ws`) — the
@@ -21,6 +22,15 @@ export const WS_CLOSE_SESSION_REVOKED = 4001;
21
22
  * indistinguishable on the wire. The Rust spine sends the same text.
22
23
  */
23
24
  export const WS_CLOSE_SESSION_REVOKED_REASON = 'Session revoked';
25
+ /**
26
+ * The close reason sent with `WS_CLOSE_SESSION_REVOKED` when the socket's
27
+ * credential expired while it was open — its session or API token passed its
28
+ * expiry (`ResolvedAuth.credential_deadline`). The code is the revocation's on
29
+ * purpose: the client cannot reconnect on the credential either way, so
30
+ * `FrontendWebsocketClient` enters the same `revoked` state. The Rust spine
31
+ * sends the same text.
32
+ */
33
+ export const WS_CLOSE_CREDENTIAL_EXPIRED_REASON = 'Credential expired';
24
34
  /** WebSocket close code — client timed out waiting for a response. */
25
35
  export const WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002;
26
36
  /** WebSocket close code — server timed out with no incoming activity. */
@@ -61,6 +71,25 @@ export const WS_CLOSE_OUTBOUND_OVERFLOW = 4005;
61
71
  * sends the same text.
62
72
  */
63
73
  export const WS_CLOSE_OUTBOUND_OVERFLOW_REASON = 'outbound queue overflow';
74
+ /**
75
+ * WebSocket close code — the socket's account lost a role its endpoint
76
+ * requires (`register_ws_endpoint`'s `required_roles`): a global grant of one
77
+ * of those roles was revoked while the socket was open, or while its upgrade
78
+ * was in flight (the admission re-read, `register_action_ws`). Nothing more
79
+ * is sent on the socket.
80
+ *
81
+ * Distinct from `WS_CLOSE_SESSION_REVOKED`: the credential is still good, so
82
+ * a client must not enter its `revoked` state — but a reconnect would be
83
+ * refused at the role gate, so `FrontendWebsocketClient` stays closed until
84
+ * `connect()` is called and rejects its pending work as `forbidden`. The twin
85
+ * of the Rust spine's `WS_CLOSE_PERMISSION_REVOKED`.
86
+ */
87
+ export const WS_CLOSE_PERMISSION_REVOKED = 4006;
88
+ /**
89
+ * The close reason sent with `WS_CLOSE_PERMISSION_REVOKED`, mid-life and at
90
+ * admission alike. The Rust spine sends the same text.
91
+ */
92
+ export const WS_CLOSE_PERMISSION_REVOKED_REASON = 'Permission revoked';
64
93
  /**
65
94
  * WebSocket close code — the server is shutting down (RFC 6455 §7.4.1 "Going
66
95
  * Away"). Sent to every live socket by `close_all_sockets`, which
@@ -28,11 +28,16 @@
28
28
  * two cannot drift into closing a revoked token's socket while leaving its
29
29
  * stream open.
30
30
  *
31
- * `role` is the one scope this guard deliberately does not act on:
32
- * `perform_action` re-authorizes every message, so a socket whose account just
33
- * lost the gating role is refused at its next message, and closing it would
34
- * only change when the caller learns. A stream has no later check, so the SSE
35
- * guard closes there.
31
+ * `role` closes the target account's sockets whose endpoint gated the upgrade
32
+ * on the revoked role (`close_sockets_for_role`), with
33
+ * `WS_CLOSE_PERMISSION_REVOKED`, when the revoked grant was global
34
+ * (`to_global_role_revocation` — a role gate admits on global grants only).
35
+ * `perform_action` re-authorizes every *request* against its own action, but
36
+ * the role gate is the only check on what the socket *receives*: broadcasts
37
+ * and notifications reach every admitted connection of the endpoint with no
38
+ * per-recipient filter, so a socket left open past the revocation would keep
39
+ * receiving what the gate exists to withhold. The role is the only revocation
40
+ * no handler closes for, so this guard is its one closer.
36
41
  *
37
42
  * For standard WS endpoints mounted via `AppServerOptions.ws_endpoints`,
38
43
  * `create_app_server` registers the guard automatically per
@@ -66,7 +71,9 @@ export type AuditEventHandler = (event: AuditLogEvent) => void;
66
71
  * - `account` → `close_sockets_for_account(target_account_id ?? account_id)`
67
72
  * — `logout`, `password_change`, `session_revoke_all`, `token_revoke_all`,
68
73
  * `account_delete`, `account_purge`
69
- * - `role` → nothing, deliberately (module doc)
74
+ * - `role` → `close_sockets_for_role(target_account_id ?? account_id,
75
+ * metadata.role)` when the revoked grant was global; a scoped revoke closes
76
+ * nothing (module doc)
70
77
  * - `none` → nothing — every other event, and any event type fuz_app does not
71
78
  * define
72
79
  *
@@ -1 +1 @@
1
- {"version":3,"file":"transports_ws_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,6BAA6B,CAAC;AACtF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D;;;;;;;;GAQG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;AAE/D;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,gBAAgB,EAAE,KAAK,MAAM,KAAG,iBAoD5E,CAAC"}
1
+ {"version":3,"file":"transports_ws_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,EAGN,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D;;;;;;;;GAQG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;AAE/D;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,gBAAgB,EAAE,KAAK,MAAM,KAAG,iBA4D5E,CAAC"}