@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.
Files changed (179) hide show
  1. package/dist/actions/CLAUDE.md +301 -73
  2. package/dist/actions/action_rpc.d.ts.map +1 -1
  3. package/dist/actions/action_rpc.js +16 -1
  4. package/dist/actions/connection_closer.d.ts +136 -51
  5. package/dist/actions/connection_closer.d.ts.map +1 -1
  6. package/dist/actions/connection_closer.js +133 -34
  7. package/dist/actions/register_action_ws.d.ts +176 -24
  8. package/dist/actions/register_action_ws.d.ts.map +1 -1
  9. package/dist/actions/register_action_ws.js +581 -241
  10. package/dist/actions/register_ws_endpoint.d.ts +1 -0
  11. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  12. package/dist/actions/register_ws_endpoint.js +1 -0
  13. package/dist/actions/socket.svelte.d.ts +43 -9
  14. package/dist/actions/socket.svelte.d.ts.map +1 -1
  15. package/dist/actions/socket.svelte.js +59 -8
  16. package/dist/actions/transports.d.ts +55 -1
  17. package/dist/actions/transports.d.ts.map +1 -1
  18. package/dist/actions/transports.js +55 -1
  19. package/dist/actions/transports_ws_auth_guard.d.ts +53 -67
  20. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  21. package/dist/actions/transports_ws_auth_guard.js +87 -111
  22. package/dist/actions/transports_ws_backend.d.ts +212 -30
  23. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  24. package/dist/actions/transports_ws_backend.js +327 -70
  25. package/dist/actions/ws_endpoint_spec.d.ts +26 -13
  26. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  27. package/dist/auth/CLAUDE.md +125 -31
  28. package/dist/auth/account_actions.d.ts +12 -18
  29. package/dist/auth/account_actions.d.ts.map +1 -1
  30. package/dist/auth/account_actions.js +47 -33
  31. package/dist/auth/account_routes.d.ts +5 -11
  32. package/dist/auth/account_routes.d.ts.map +1 -1
  33. package/dist/auth/account_routes.js +40 -36
  34. package/dist/auth/account_schema.d.ts +27 -0
  35. package/dist/auth/account_schema.d.ts.map +1 -1
  36. package/dist/auth/account_schema.js +22 -0
  37. package/dist/auth/admin_actions.d.ts +8 -17
  38. package/dist/auth/admin_actions.d.ts.map +1 -1
  39. package/dist/auth/admin_actions.js +33 -21
  40. package/dist/auth/api_token_queries.d.ts +23 -2
  41. package/dist/auth/api_token_queries.d.ts.map +1 -1
  42. package/dist/auth/api_token_queries.js +39 -3
  43. package/dist/auth/audit_emitter.d.ts +72 -44
  44. package/dist/auth/audit_emitter.d.ts.map +1 -1
  45. package/dist/auth/audit_emitter.js +60 -16
  46. package/dist/auth/audit_log_route_schema.js +7 -7
  47. package/dist/auth/audit_log_routes.d.ts +31 -6
  48. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  49. package/dist/auth/audit_log_routes.js +85 -11
  50. package/dist/auth/audit_log_schema.d.ts +48 -0
  51. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  52. package/dist/auth/audit_log_schema.js +58 -0
  53. package/dist/auth/bootstrap_account.d.ts +3 -2
  54. package/dist/auth/bootstrap_account.d.ts.map +1 -1
  55. package/dist/auth/bootstrap_account.js +3 -3
  56. package/dist/auth/cleanup.d.ts +219 -30
  57. package/dist/auth/cleanup.d.ts.map +1 -1
  58. package/dist/auth/cleanup.js +260 -45
  59. package/dist/auth/deps.d.ts +29 -1
  60. package/dist/auth/deps.d.ts.map +1 -1
  61. package/dist/auth/migrations.d.ts.map +1 -1
  62. package/dist/auth/migrations.js +27 -0
  63. package/dist/auth/resolved_auth.d.ts +105 -0
  64. package/dist/auth/resolved_auth.d.ts.map +1 -0
  65. package/dist/auth/resolved_auth.js +124 -0
  66. package/dist/auth/role_grant_offer_ddl.d.ts +4 -2
  67. package/dist/auth/role_grant_offer_ddl.d.ts.map +1 -1
  68. package/dist/auth/role_grant_offer_ddl.js +10 -3
  69. package/dist/auth/role_grant_offer_queries.d.ts +16 -7
  70. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  71. package/dist/auth/role_grant_offer_queries.js +33 -15
  72. package/dist/auth/role_grant_offer_schema.d.ts +9 -0
  73. package/dist/auth/role_grant_offer_schema.d.ts.map +1 -1
  74. package/dist/auth/session_middleware.d.ts +8 -1
  75. package/dist/auth/session_middleware.d.ts.map +1 -1
  76. package/dist/auth/session_middleware.js +9 -3
  77. package/dist/auth/session_queries.d.ts +31 -6
  78. package/dist/auth/session_queries.d.ts.map +1 -1
  79. package/dist/auth/session_queries.js +31 -6
  80. package/dist/auth/standard_rpc_actions.d.ts +9 -9
  81. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  82. package/dist/auth/standard_rpc_actions.js +3 -3
  83. package/dist/db/CLAUDE.md +7 -1
  84. package/dist/db/migrate.d.ts +8 -13
  85. package/dist/db/migrate.d.ts.map +1 -1
  86. package/dist/db/migrate.js +9 -38
  87. package/dist/db/status.d.ts +58 -7
  88. package/dist/db/status.d.ts.map +1 -1
  89. package/dist/db/status.js +205 -97
  90. package/dist/http/CLAUDE.md +7 -3
  91. package/dist/http/db_routes.d.ts +6 -2
  92. package/dist/http/db_routes.d.ts.map +1 -1
  93. package/dist/http/db_routes.js +2 -2
  94. package/dist/periodic.d.ts +80 -0
  95. package/dist/periodic.d.ts.map +1 -0
  96. package/dist/periodic.js +122 -0
  97. package/dist/realtime/sse_auth_guard.d.ts +79 -49
  98. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  99. package/dist/realtime/sse_auth_guard.js +103 -73
  100. package/dist/realtime/subscriber_registry.d.ts +119 -8
  101. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  102. package/dist/realtime/subscriber_registry.js +184 -25
  103. package/dist/runtime/deno.d.ts.map +1 -1
  104. package/dist/runtime/deno.js +9 -6
  105. package/dist/runtime/deps.d.ts +3 -2
  106. package/dist/runtime/deps.d.ts.map +1 -1
  107. package/dist/runtime/secure_file.d.ts +23 -9
  108. package/dist/runtime/secure_file.d.ts.map +1 -1
  109. package/dist/runtime/secure_file.js +29 -10
  110. package/dist/server/app_backend.d.ts +13 -2
  111. package/dist/server/app_backend.d.ts.map +1 -1
  112. package/dist/server/app_backend.js +5 -2
  113. package/dist/server/app_server.d.ts +55 -15
  114. package/dist/server/app_server.d.ts.map +1 -1
  115. package/dist/server/app_server.js +41 -13
  116. package/dist/testing/CLAUDE.md +140 -48
  117. package/dist/testing/app_server.d.ts +2 -2
  118. package/dist/testing/app_server.d.ts.map +1 -1
  119. package/dist/testing/app_server.js +11 -2
  120. package/dist/testing/audit_drift_guard.d.ts +3 -52
  121. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  122. package/dist/testing/audit_drift_guard.js +2 -46
  123. package/dist/testing/connection_closer_helpers.d.ts +9 -22
  124. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  125. package/dist/testing/connection_closer_helpers.js +11 -17
  126. package/dist/testing/cross_backend/capabilities.d.ts +67 -13
  127. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  128. package/dist/testing/cross_backend/capabilities.js +27 -12
  129. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  130. package/dist/testing/cross_backend/default_backend_configs.js +21 -0
  131. package/dist/testing/cross_backend/expected_schema.json +1 -0
  132. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  133. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
  134. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  135. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
  136. package/dist/testing/cross_backend/session_cap.d.ts +13 -0
  137. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
  138. package/dist/testing/cross_backend/session_cap.js +55 -19
  139. package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
  140. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  141. package/dist/testing/cross_backend/sse_round_trip.js +93 -26
  142. package/dist/testing/cross_backend/testing_server_bun.js +4 -4
  143. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  144. package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
  145. package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
  146. package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
  147. package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
  148. package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
  149. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  150. package/dist/testing/cross_backend/ws_round_trip.js +156 -19
  151. package/dist/testing/sse_round_trip.d.ts +6 -4
  152. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  153. package/dist/testing/stubs.d.ts +1 -1
  154. package/dist/testing/stubs.d.ts.map +1 -1
  155. package/dist/testing/stubs.js +6 -4
  156. package/dist/testing/transports/sse_transport.d.ts +1 -1
  157. package/dist/testing/transports/ws_client.d.ts +7 -0
  158. package/dist/testing/transports/ws_client.d.ts.map +1 -1
  159. package/dist/testing/transports/ws_raw_client.d.ts +51 -0
  160. package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
  161. package/dist/testing/transports/ws_raw_client.js +156 -0
  162. package/dist/testing/transports/ws_transport.d.ts +28 -0
  163. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  164. package/dist/testing/transports/ws_transport.js +45 -1
  165. package/dist/testing/ws_round_trip.d.ts +8 -3
  166. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  167. package/dist/testing/ws_round_trip.js +19 -4
  168. package/dist/ui/AdminAccounts.svelte +4 -3
  169. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  170. package/dist/ui/AdminOverview.svelte +3 -1
  171. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  172. package/dist/ui/CLAUDE.md +22 -6
  173. package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
  174. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  175. package/dist/ui/admin_accounts_state.svelte.js +16 -0
  176. package/dist/ui/auth_state.svelte.d.ts +25 -1
  177. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  178. package/dist/ui/auth_state.svelte.js +28 -1
  179. package/package.json +2 -2
@@ -234,6 +234,12 @@ e.g. when consumers like zzz throw their own `ThrownJsonrpcError`)
234
234
  preserves code + data verbatim. Generic throws become `internal_error` 500;
235
235
  message is the raw error under `DEV`, "internal server error" otherwise.
236
236
 
237
+ A result `c.json` cannot serialize (a `bigint`, a cycle) answers the same
238
+ `internal_error` 500 rather than throwing. By then `perform_action` has
239
+ committed, and a throw would have the route wrapper
240
+ (`dispatch_with_post_commit_rollback`) discard the handler's
241
+ `post_commit_effects` — the connection closes of a revocation that did land.
242
+
237
243
  ### Per-request handler shape
238
244
 
239
245
  Unified across HTTP RPC + WS via `ActionContext`:
@@ -348,16 +354,19 @@ and `allow_fallback: boolean` (default `true`). Explicit
348
354
 
349
355
  ### WS close codes (`actions/transports.ts`)
350
356
 
351
- - `WS_CLOSE_SESSION_REVOKED = 4001` — server revoked auth; client enters permanent `revoked` state, no reconnect.
357
+ - `WS_CLOSE_SESSION_REVOKED = 4001` — server revoked auth (reason `WS_CLOSE_SESSION_REVOKED_REASON`, `Session revoked`); client enters permanent `revoked` state, no reconnect. Also the close for an upgrade refused at admission because its credential was revoked mid-upgrade — indistinguishable on the wire by design.
352
358
  - `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` — client observed receive-silence past its receive timeout (`resolve_heartbeat_receive_timeout`).
353
359
  - `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003` — server observed receive-silence past `DEFAULT_SERVER_HEARTBEAT_TIMEOUT` (60s).
360
+ - `WS_CLOSE_CONNECTION_LIMIT = 4004` — server closed this socket to admit a newer one on the same account, past the per-account connection cap (reason `connection limit`). Not a revocation: the client stays `closed` with `superseded` set and no reconnect, until `connect()` is called. Twin of the Rust spine's constant.
354
361
  - `WS_CLOSE_MESSAGE_TOO_BIG = 1009` — server received a message over its `max_message_bytes` cap (RFC 6455 "Message Too Big").
362
+ - `WS_CLOSE_POLICY_VIOLATION = 1008` — a socket sent too much while it was still opening (before admission and `on_socket_open` completed): past `MAX_PRE_ADMISSION_FRAMES` frames or the `PRE_ADMISSION_QUEUE_BYTES_FACTOR` byte budget.
363
+ - `WS_CLOSE_INTERNAL_ERROR = 1011` — the admission credential re-check itself failed (reason `internal error`), `on_socket_open` threw (reason `socket bootstrap failed`), or the socket's connection was removed from the transport without a close (reason `connection unregistered`). Not a revocation: the client reconnects under its ordinary backoff. Twin of the Rust spine's constant.
355
364
 
356
365
  ### Transport modules
357
366
 
358
367
  - `actions/transports_http.ts` — `frontend_http_rpc`; thin `fetch` adapter, POST default, GET on `has_side_effects(method) === false`.
359
368
  - `actions/transports_ws.ts` — `frontend_websocket_rpc`; thin adapter over `WebsocketRpcConnection` (default impl: `FrontendWebsocketClient`). Answers inbound server→client requests: a built-in `peer_ping_responder` for `peer/ping` (zero-wiring liveness), any other request routed through `peer.receive` with the response sent back over the socket (the frontend half of the ActionPeer receive loop).
360
- - `actions/transports_ws_backend.ts` — `backend_websocket_rpc`; server-side WS with session tracking; satisfies `FilterableBroadcastTransport`. Server→client requests via `request_connection` (correlation registry in `actions/peer_request.ts`).
369
+ - `actions/transports_ws_backend.ts` — `backend_websocket_rpc`; server-side WS with session tracking, two-phase registration (`register_pending` / `admit`), and the per-account cap; satisfies `FilterableBroadcastTransport`. Server→client requests via `request_connection` (correlation registry in `actions/peer_request.ts`).
361
370
 
362
371
  `FrontendHttpTransport` on non-OK HTTP returns the body as-is when it's a
363
372
  JSON-RPC error response for the request (same `id` compared as strings since GET sends it as a query param, or `null`) — the
@@ -390,22 +399,90 @@ client; `null` skips the client-side measurement.
390
399
 
391
400
  ### `BackendWebsocketTransport` — server-side WS state
392
401
 
393
- Three aligned maps keyed by `connection_id` (branded `Uuid`):
394
-
395
- - `#connections: Map<Uuid, WSContext>` — id → socket
396
- - `#connection_ids: WeakMap<WSContext, Uuid>` — socket → id (reverse)
397
- - `#connection_identities: Map<Uuid, ConnectionIdentity>` — id → `{token_hash, account_id, api_token_id}` (session sets `token_hash`, bearer sets `api_token_id`, daemon-token sets both null)
402
+ One map keyed by `connection_id` (branded `Uuid`), in registration order:
403
+ `#connections: Map<Uuid, ConnectionEntry>`, each entry `{ws, identity,
404
+ admitted, abort_controller}`. `identity` is the `ConnectionIdentity`
405
+ `{token_hash, account_id, api_token_id}` (session sets `token_hash`, bearer
406
+ sets `api_token_id`, daemon-token sets both null).
407
+
408
+ The id is the connection's only handle — `register_pending` / `add_connection`
409
+ return it and `admit` / `remove_connection` / `is_registered` take it. There is
410
+ no socket → id lookup: a runtime adapter may hand each socket event its own
411
+ `WSContext` (`hono/bun` builds one per event), so the object a close event
412
+ carries can't identify the connection. `register_action_ws` captures the id on
413
+ open and removes by it.
414
+
415
+ **Two-phase registration.** An upgrade resolves its credential before the
416
+ socket exists, and a revocation closes only the connections *registered* under
417
+ what it revoked — so a one-step registration leaves a window in which a
418
+ revocation closes nothing and the socket is then admitted on a dead credential.
419
+ The transport splits registration to close it (the twin of the Rust
420
+ `fuz_realtime` `ConnectionRegistry`, same method names):
421
+
422
+ - `register_pending(ws, token_hash, account_id, api_token_id?, abort_controller?)`
423
+ inserts the entry **un-admitted**. Every `close_sockets_for_*` matches it
424
+ from that instant. Otherwise it is inert: no `send` broadcast,
425
+ `broadcast_filtered` (the predicate never sees it), `send_to_account`, or
426
+ `request_connection` (`connection_gone`) reaches it; `get_connection_count`,
427
+ `is_ready`, and `is_registered` don't see it
428
+ (`get_pending_connection_count` does); the cap neither counts nor evicts it.
429
+ - the caller re-reads the credential (`revalidate_resolved_auth`).
430
+ - `admit(connection_id)` returns `false` when the entry is gone — a revocation
431
+ closed it while pending — and evicts nothing; otherwise it enforces the cap
432
+ (counting admitted connections only) and marks the entry admitted.
433
+
434
+ Nothing is queued for a pending entry, so a server-initiated message sent
435
+ between the handshake and admission is not delivered — the same as one sent
436
+ before the handshake. `add_connection` is the one-step form (register and admit
437
+ together) for a connection with nothing revocable behind it: tests and
438
+ harnesses that register a socket directly. An upgrade that authenticated a session or token
439
+ uses the two-phase form.
440
+
441
+ **Abort at close time.** The `abort_controller` passed at registration is the
442
+ socket's per-connection controller. The transport aborts it when it closes the
443
+ connection — a revocation or a cap eviction — so in-flight handlers see
444
+ `signal.aborted` at once, not when the close handshake lands.
445
+ `remove_connection` only removes: it neither closes nor aborts.
446
+
447
+ **Per-account connection cap.** `new BackendWebsocketTransport({max_connections_per_account})`
448
+ — default `DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT` (50), `null` disables, anything
449
+ but a positive integer throws. The policy is **evict-oldest**: admission always
450
+ admits the new connection, first closing the account's oldest admitted ones
451
+ (registration order) with `WS_CLOSE_CONNECTION_LIMIT` so the account never
452
+ holds more than the cap. It keys on `account_id` across every credential type —
453
+ session, bearer, and daemon-token connections all count, none exempt. Refusing
454
+ the newcomer instead would let half-open sockets lock a user out with their
455
+ own dead connections, and let a stolen credential hold every slot ahead of the
456
+ real user. The check runs at **admission** (`admit`, or `add_connection`),
457
+ which `register_action_ws` reaches in `onOpen` — after every upgrade gate
458
+ (origin, auth, token scope, role) and the credential re-read — so a refused or
459
+ just-revoked request never closes someone's socket. An evicted connection
460
+ leaves the transport's map at once — no broadcast or peer request reaches it,
461
+ its pending peer requests drain as `connection_gone`, it stops counting toward
462
+ the cap, its controller is aborted, and `register_action_ws` dispatches nothing
463
+ more on it — while the socket itself is only closed once the close handshake
464
+ completes. Its `on_socket_close` fires when the close lands, and the
465
+ `remove_connection` before it is a no-op. An eviction logs
466
+ `ws: connection cap closed oldest` at
467
+ info with `{account_id, closed, max}` (the Rust spine's line) through the
468
+ transport's `log` (a `[ws]` logger when unset, `null` silences;
469
+ `register_action_ws` passes its own to the transport it creates). The cap is per transport:
470
+ endpoints sharing one transport share one count per account. The twin of the
471
+ Rust `fuz_realtime` `ConnectionRegistry`'s cap — same default, same policy,
472
+ same close code and reason — pinned on both by the
473
+ `describe_ws_connection_cap_cross_tests` suite.
398
474
 
399
475
  Targeted closure (all return socket count closed, use
400
- `WS_CLOSE_SESSION_REVOKED`):
476
+ `WS_CLOSE_SESSION_REVOKED`, match pending and admitted entries alike, and abort
477
+ each closed connection's controller):
401
478
 
402
479
  - `close_sockets_for_session(token_hash)`
403
480
  - `close_sockets_for_token(api_token_id)`
404
481
  - `close_sockets_for_account(account_id)` — coarse, covers session + bearer + daemon-token
405
482
 
406
- Fan-out: `send(notification)` broadcasts to every connection;
407
- `broadcast_filtered(message, predicate)` runs per-connection ACL predicate
408
- over `ConnectionIdentity`; `send_to_account` wraps `broadcast_filtered` and
483
+ Fan-out, to admitted connections only: `send(notification)` broadcasts to every
484
+ connection; `broadcast_filtered(message, predicate)` runs per-connection ACL
485
+ predicate over `ConnectionIdentity`; `send_to_account` wraps `broadcast_filtered` and
409
486
  structurally satisfies `NotificationSender` (see `auth/CLAUDE.md` §WS
410
487
  notifications). `send(request)` is a misuse (a request has no single
411
488
  broadcast target) and returns an error envelope.
@@ -434,60 +511,101 @@ Return values are bookkeeping, not delivery receipts — `0` means no live
434
511
  sockets, non-zero means `ws.send` did not throw. Durable delivery requires
435
512
  persistence + rehydration by the consumer.
436
513
 
437
- ## WS auth guard (`actions/transports_ws_auth_guard.ts`)
438
-
439
- Closes WS sockets on audit revoke events — per-message dispatch doesn't
440
- re-check session/token validity, so this guard is the revocation seam for
441
- open connections.
442
-
443
- `create_ws_auth_guard(transport, log)` returns an `on_audit_event` callback.
444
- For standard WS endpoints mounted via `AppServerOptions.ws_endpoints`,
445
- `create_app_server` registers this guard via `backend.deps.audit.add_listener`
446
- automatically (per `WsEndpointSpec.auth_guard`). For custom wiring, register
447
- inside the consumer's `audit_factory` body.
448
-
449
- `ws_disconnect_event_types` (ReadonlySet): `session_revoke`, `token_revoke`,
450
- `session_revoke_all`, `token_revoke_all`, `password_change`.
451
- `role_grant_revoke` is intentionally **omitted** — the WS transport doesn't
452
- track per-connection role requirements, so role-scoped disconnection would
453
- require either closing all sockets (too aggressive) or new per-connection
454
- role tracking (out of scope). Consumers that need it compose their own
455
- callback.
456
-
457
- `outcome === 'failure'` events are ignored — they carry attacker-controlled
458
- identifiers. Reacting to them would let an authenticated caller close
459
- another user's socket by guessing a session hash or token id.
460
-
461
- `create_ws_logout_closer(transport, log)` is the sibling helper for
462
- user-initiated `logout` events — kept separate because
463
- `ws_disconnect_event_types` deliberately omits `logout` (admin-initiated
464
- revocations use `session_revoke`, while `logout` is the user-initiated
465
- case). Closes via `close_sockets_for_account(event.account_id)`.
466
-
467
514
  ## Connection closer (`actions/connection_closer.ts`)
468
515
 
469
- Narrow structural capability for handler-side eager WS socket closure on
470
- revocation — belt+suspenders layer that complements the audit-listener
471
- guards above.
516
+ A WebSocket and an SSE stream are authorized once, at open, so a revocation
517
+ takes effect on an open connection only when something closes it.
518
+ `ConnectionCloser` is that capability — the twin of the Rust spine's
519
+ `SocketRevoker`:
472
520
 
473
521
  ```ts
474
522
  interface ConnectionCloser {
475
523
  close_sockets_for_session: (session_token_hash: string) => number;
476
524
  close_sockets_for_token: (api_token_id: string) => number;
477
- close_sockets_for_account: (account_id: string) => number;
525
+ close_sockets_for_account: (account_id: Uuid) => number;
478
526
  }
479
527
  ```
480
528
 
481
- `BackendWebsocketTransport` satisfies this structurally — consumers pass
482
- the transport instance directly (same shape as `NotificationSender`). Wired
483
- into `AccountRouteOptions.connection_closer` (logout / password),
484
- `AccountActionOptions.connection_closer` (session/token revoke), and
485
- `AdminActionOptions.connection_closer` (admin revoke-all). Each handler
486
- calls the appropriate `close_sockets_for_*` synchronously **before** the
487
- audit emit so revocation lands even on audit INSERT failure. Failure
488
- outcomes (`revoked: false`, 404 not-found) skip the eager close — mirrors
489
- the listener's `outcome === 'failure'` guard so attacker-guessable ids can
490
- never target arbitrary sockets.
529
+ `BackendWebsocketTransport` satisfies it structurally;
530
+ `create_sse_connection_closer(registry)` (`realtime/sse_auth_guard.ts`) adapts
531
+ a `SubscriberRegistry`. Closing is idempotent and synchronous.
532
+
533
+ **Every revocation closes after its commit.** A handler never calls a closer
534
+ inline: it calls `queue_connection_close(ctx, deps.connection_closer, target)`,
535
+ which puts the close on the request's post-commit queue (`emit_after_commit`).
536
+ The close runs once the handler's transaction has committed and is discarded
537
+ when the handler throws — a rolled-back revocation closes nothing. `target` is
538
+ a `ConnectionCloseTarget` (`{kind: 'session', session_token_hash}` /
539
+ `{kind: 'token', api_token_id}` / `{kind: 'account', account_id}`);
540
+ `close_connections(closer, target)` is the immediate form the queue runs. Twin
541
+ of `queue_socket_close` / `SocketCloseTarget`.
542
+
543
+ The order is load-bearing for admission: a connection registers, then re-reads
544
+ its credential, so a close that missed the registration must belong to a
545
+ revocation the re-read can see — true only if the close came after the commit
546
+ (../../../docs/security.md §Closing on Revocation).
547
+
548
+ The close does not depend on the audit write, which is pool-routed and
549
+ fail-open. Failure outcomes (`revoked: false`, a 404) queue no close — they
550
+ carry caller-supplied ids.
551
+
552
+ **One closer for every transport.** `create_realtime_closer()` returns a
553
+ `RealtimeCloser` — a `ConnectionCloser` plus `add(closer)` (idempotent by
554
+ reference) — whose every close fans out to each member and returns the sum.
555
+ `create_app_backend` puts one on `AppDeps.connection_closer`; `create_app_server`
556
+ adds each mounted WS transport and the audit stream registry. Twin of
557
+ `RealtimeRevoker`. A member that throws does not stop the fan-out — every
558
+ member is called, then the first error is thrown for the caller to log;
559
+ `BackendWebsocketTransport` and `SubscriberRegistry.close_by_identity` treat a
560
+ throwing socket or stream the same way. `noop_connection_closer` is a `ConnectionCloser` that closes
561
+ nothing, for hand-built action-factory deps on a backend with no
562
+ live-connection surface.
563
+
564
+ It is required where it is read, so it can't be forgotten:
565
+ `RevokingActionFactoryDeps.connection_closer` (`create_account_actions`,
566
+ `create_admin_actions`, `create_standard_rpc_actions`), `AppDeps` /
567
+ `RouteFactoryDeps` (`create_account_route_specs`),
568
+ `AuthCleanupDeps.connection_closer` (the auth cleanup's session sweep closes
569
+ the connections of each expired session it deletes — `auth/CLAUDE.md`
570
+ §Cleanup), and the
571
+ `connection_closer: RealtimeCloser | null` option of `register_action_ws` /
572
+ `register_ws_endpoint` / `create_audit_log_sse`, which add their transport to it
573
+ (`null` is the explicit opt-out).
574
+
575
+ ## WS auth guard (`actions/transports_ws_auth_guard.ts`)
576
+
577
+ The audit-listener half of revocation: `create_ws_auth_guard(closer, log)`
578
+ returns an `on_audit_event` callback that repeats a revocation's close when its
579
+ audit row is announced. It covers a revocation emitted by code that holds no
580
+ closer; for the builtin handlers it is a repeat of the close they already
581
+ queued. A success row is announced only after its request's transaction commits
582
+ (`auth/CLAUDE.md` §Audit emitter), so the guard's close is post-commit too. Its
583
+ closes reach a connection still pending admission.
584
+
585
+ `closer` is any `ConnectionCloser` — a `BackendWebsocketTransport`, or a
586
+ `RealtimeCloser` to cover every transport from one listener. For standard WS
587
+ endpoints mounted via `AppServerOptions.ws_endpoints`, `create_app_server`
588
+ registers the guard via `backend.deps.audit.add_listener` automatically (per
589
+ `WsEndpointSpec.auth_guard`). For custom wiring, register inside the consumer's
590
+ `audit_factory` body.
591
+
592
+ It dispatches on the `RevocationScope` each event declares in
593
+ `audit_event_revocation_scopes` (`auth/audit_log_schema.ts`), the table the SSE
594
+ guard reads too — one declaration, so the two can't drift:
595
+
596
+ - `session` (`session_revoke`) → `close_sockets_for_session(metadata.session_id)`
597
+ - `token` (`token_revoke`) → `close_sockets_for_token(metadata.token_id)`
598
+ - `account` (`session_revoke_all`, `token_revoke_all`, `password_change`,
599
+ `logout`, `account_delete`, `account_purge`) →
600
+ `close_sockets_for_account(target_account_id ?? account_id)`
601
+ - `role` (`role_grant_revoke`) → nothing, deliberately: per-message dispatch
602
+ re-authorizes, so the next message is refused. The SSE guard closes here.
603
+ - `none` → nothing — every other event, and any event type fuz_app doesn't
604
+ define
605
+
606
+ `outcome === 'failure'` events are ignored — they carry attacker-controlled
607
+ identifiers. Reacting to them would let an authenticated caller close
608
+ another user's socket by guessing a session hash or token id.
491
609
 
492
610
  ## WebSocket dispatch — three layered entry points
493
611
 
@@ -508,10 +626,19 @@ but `upgradeWebSocket` is missing. A factory returning `[]` does NOT trip
508
626
  the check, so feature-flag gated WS surfaces stay safe.
509
627
 
510
628
  `WsEndpointSpec` fields: `path`, `allowed_origins`, `actions`,
511
- `required_roles?`, `transport?`, `heartbeat?`, `artificial_delay?`, `max_message_bytes?`,
629
+ `required_roles?`, `transport?`, `max_connections_per_account?`, `heartbeat?`,
630
+ `artificial_delay?`, `max_message_bytes?`,
512
631
  `on_socket_open?`, `on_socket_close?`, `auth_guard?` (default `true`,
513
632
  deduped by reference identity via `WeakSet<BackendWebsocketTransport>`),
514
- `extra_audit_handlers?`.
633
+ `extra_audit_handlers?`. Every mounted transport is added to
634
+ `deps.connection_closer`, whatever `auth_guard` says — the flag opts out of
635
+ the audit listener, not of the handlers' closes.
636
+
637
+ `max_connections_per_account` sets the per-account connection cap on the
638
+ transport the mount creates (`RegisterActionWsOptions` carries the same field).
639
+ It throws alongside `transport` — a supplied transport carries its own cap, set
640
+ where it was constructed, and an option that silently did nothing would read as
641
+ enforced.
515
642
 
516
643
  Mounted transport reachable at `app_server.ws_endpoints[path]`
517
644
  (`Readonly<Record<string, BackendWebsocketTransport>>`). Duplicate paths
@@ -542,7 +669,8 @@ Composes the standard upgrade stack:
542
669
  5. Delegates to `register_action_ws`
543
670
 
544
671
  Extends `RegisterActionWsOptions` with `allowed_origins` and optional
545
- `required_roles`. Returns `{transport}`. Most consumers reach for
672
+ `required_roles`, and inherits its required `connection_closer`. Returns
673
+ `{transport}`. Most consumers reach for
546
674
  `ws_endpoints` above; this is the entry test harnesses use when they need
547
675
  the upgrade stack without `create_app_server`'s full assembly.
548
676
 
@@ -558,14 +686,79 @@ HTTP RPC also calls. `register_action_ws` owns only WS-specific concerns:
558
686
  - **Cancel-notification interception** — `{request_id → AbortController}` map; aborts the matching pending controller before the cancel bubbles past the dispatcher
559
687
  - **Socket-scoped notify** — `(method, params) => ws.send(notification)`, threaded into `perform_action` as `notify`
560
688
  - **Composed abort signal** — `AbortSignal.any([socket_close, per_request_cancel])`, threaded as `signal`
561
- - **Connection lifecycle** — `transport.add_connection` / `remove_connection`, `on_socket_open` / `_close` hooks, server heartbeat
689
+ - **Connection lifecycle** — admission (`transport.register_pending` → credential re-read → `transport.admit`, which enforces the per-account connection cap) / `remove_connection`, `on_socket_open` / `_close` hooks, server heartbeat
690
+ - **The inbound gate** — frames queue until the socket is admitted and `on_socket_open` has completed; frames on a socket the server closed are dropped
691
+
692
+ **Admission.** `onOpen` registers the connection pending (synchronously, with
693
+ the socket's `AbortController`), re-reads the credential captured at upgrade
694
+ (`revalidate_resolved_auth({db}, resolved)` — session row / token row / account
695
+ row, no touch; `resolved` is `get_resolved_auth(c)`, the one derivation the
696
+ audit stream shares, read off the keys the auth middleware set), then
697
+ `transport.admit`s. A dead credential, or a pending entry a revocation closed
698
+ meanwhile, closes the socket with `WS_CLOSE_SESSION_REVOKED` and the reason a
699
+ revoked socket gets; a re-check that throws closes it with
700
+ `WS_CLOSE_INTERNAL_ERROR` (`internal error`) — fail closed, never admitted
701
+ unchecked. A refused socket gets nothing else written to it, evicts nothing,
702
+ and runs neither lifecycle hook. An upgrade with no resolved credential on its
703
+ context, or one on another account than its request context, throws before the
704
+ socket exists. The test-preset escape hatch
705
+ (`TEST_CONTEXT_PRESET_KEY`) skips the re-read — a pre-baked credential has no
706
+ rows behind it — so `create_ws_test_harness` admits synchronously. Admission
707
+ leaves no interleaving that admits a revoked credential, because every
708
+ revocation closes only after its commit — see
709
+ ../../../docs/security.md §Connection Admission and §Closing on Revocation.
710
+
711
+ `connection_closer: RealtimeCloser | null` is a required option: the endpoint's
712
+ transport is added to it, so the revocation handlers' post-commit closes reach
713
+ the sockets opened here. `null` is for a transport no revocation has to reach
714
+ (a harness driving the dispatcher directly).
715
+
716
+ Because `onOpen` awaits, an adapter can deliver `onMessage` before admission
717
+ (`@hono/node-ws` doesn't await `onOpen` at all). Those frames are **queued, not
718
+ dispatched** — nothing runs on a credential that has not been re-read — and
719
+ dispatch in arrival order, back to back, once the socket is admitted and
720
+ `on_socket_open` has completed; a refusal drops them unread. The Rust spine
721
+ gets the same ordering from starting its read loop at admission, with the
722
+ kernel socket buffer as the queue; here the queue is in memory, bounded by
723
+ count (`MAX_PRE_ADMISSION_FRAMES`, 256) and by bytes
724
+ (`PRE_ADMISSION_QUEUE_BYTES_FACTOR`, 4, times the endpoint's
725
+ `max_message_bytes`) — the frame that would pass either closes the socket with
726
+ `WS_CLOSE_POLICY_VIOLATION` (reason `WS_CLOSE_PRE_ADMISSION_OVERFLOW_REASON`).
727
+ The byte budget is what bounds memory: pending sockets are not capped per
728
+ account. A consumer that raises the client's durable queue above the frame cap
729
+ makes a full reopen flush overflow it. The `onMessage` call for a queued frame
730
+ returns a promise that settles when the frame was dispatched or dropped.
731
+
732
+ The queue holds **every** inbound frame, replies to server-initiated requests
733
+ included — so an `on_socket_open` hook that awaits
734
+ `transport.request_connection` on its own connection gets no reply until it
735
+ returns (it times out); start such a request without awaiting it.
736
+
737
+ **A server-closed socket dispatches nothing.** An adapter can keep delivering
738
+ frames after the server calls `ws.close(…)`, until the close handshake
739
+ completes — `@hono/node-ws` does, for up to the `ws` close timeout when the
740
+ client withholds its close frame. So every server-side close removes the
741
+ connection from the transport and aborts the socket's controller **at close
742
+ time**: the transport does both for a revocation or a cap eviction, and
743
+ `register_action_ws`'s `end_socket` does both for a heartbeat timeout, an
744
+ oversized message, a failed `on_socket_open`, and a refused admission.
745
+ `onMessage` then drops any frame whose socket has ended — no dispatch, no
746
+ error reply. A connection the transport no longer holds though nothing closed
747
+ its socket (a bare `transport.remove_connection`; `transport.is_registered` is
748
+ the twin of the Rust `is_registered`) is ended on its next frame with
749
+ `WS_CLOSE_INTERNAL_ERROR` (`connection unregistered`), so it can't linger as a
750
+ socket that reads everything, answers nothing, and — being chatty — is never
751
+ reaped by the heartbeat. Only a frame that is queued or dispatched counts as
752
+ heartbeat activity.
562
753
 
563
754
  **Per-message authorization phase.** `perform_action` calls
564
755
  `apply_authorization_phase` per-message (HTTP and WS uniformly). Role grant
565
756
  changes during a connection lifetime are picked up on the next message —
566
757
  no in-place refresh, no socket-close on `role_grant_revoke`. Authentication
567
- invalidation (`session_revoke`, `password_change`, `token_revoke_all`)
568
- still closes the socket via `create_ws_auth_guard`.
758
+ invalidation (a session or token revoke, `password_change`, a logout, an
759
+ account delete, a cap eviction) still closes the socket — the revoking
760
+ handler through `connection_closer`, `create_ws_auth_guard` again on the
761
+ audit row.
569
762
 
570
763
  Per-message side-effect queues: `pending_effects` (eager) drains via
571
764
  `flush_pending_effects`; `post_commit_effects` (deferred — pushed by
@@ -573,26 +766,46 @@ handlers via `emit_after_commit`) drains via `flush_post_commit_effects`.
573
766
  Both flush in the same `try/finally` that releases the request controller,
574
767
  so fire-and-forget audit / notification effects pushed by the handler
575
768
  complete (or reject visibly) before the next message dispatches. The
769
+ deferred thunks are invoked first and the eager writes awaited after, so a
770
+ revocation's close never waits on a slow audit INSERT — held behind one, the
771
+ socket would stay open on a credential already committed gone and dispatch its
772
+ next frame. The
576
773
  deferred queue is **discarded on rollback** before it reaches that flush (a
577
774
  rolled-back message fires no post-commit effect). See `http/CLAUDE.md`
578
775
  §Pending Effects.
579
776
 
777
+ **Reply before flush.** The response is sent before either queue flushes. A
778
+ handler that revokes the credential its own socket runs on queues a close of
779
+ that socket on `post_commit_effects`, so the caller reads its reply and then the
780
+ `WS_CLOSE_SESSION_REVOKED` close — flushing first would strand it without an
781
+ answer. (The Rust spine does flush first, and drops the response: a divergence
782
+ the cross-backend WS suite records as `capabilities.ws_self_revocation_reply`.)
783
+
580
784
  **Lifecycle hooks.** `on_socket_open({ws, connection_id, identity, notify, signal})`
581
- fires after `transport.add_connection` but before the first message;
582
- awaited; throws log + close with `1011 'socket bootstrap failed'`.
583
- `on_socket_close({ws, connection_id, identity})` fires before
584
- `transport.remove_connection` so `identity` is still readable. Errors
585
- logged and swallowed.
785
+ fires once the connection is admitted and before the first message dispatches
786
+ (frames sent meanwhile wait); awaited; throws log + an `internal_error` frame +
787
+ close with `WS_CLOSE_INTERNAL_ERROR` `'socket bootstrap failed'`, unregistering
788
+ the connection.
789
+ `on_socket_close({ws, connection_id, identity})` fires — only for a socket that
790
+ was admitted — after
791
+ `transport.remove_connection(connection_id)` — a slow hook never holds a dead
792
+ entry that broadcasts still target and the per-account cap still counts.
793
+ `connection_id` and `identity` are the values captured at open, so the hook
794
+ reads them the same way after a client close, a revocation, or a connection-cap
795
+ eviction; its `ws` is the close event's context, which on some adapters is not
796
+ the object `on_socket_open` received. Errors logged and swallowed.
586
797
 
587
798
  **Server-side heartbeat** (`heartbeat?: boolean | ServerHeartbeatOptions`):
588
799
  default-on, 60s silence timeout. Any inbound message resets
589
- `last_receive_time` — chatty clients never trip it. First timeout window
590
- after open is exempt (cold-start grace). Tick interval is `timeout / 2`,
591
- so event-loop blockage pauses the timer itself.
800
+ `last_receive_time` — chatty clients never trip it. The timer starts at
801
+ admission, and the first timeout window after it is exempt (cold-start grace).
802
+ Tick interval is `timeout / 2`, so event-loop blockage pauses the timer itself.
803
+ A timeout unregisters the connection and aborts its signal along with the
804
+ `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT` close.
592
805
 
593
806
  Two abort signals composed via `AbortSignal.any`:
594
807
 
595
- - `socket_abort_controller` — per-socket, fires on close. Drives every handler's `ctx.signal`.
808
+ - `socket_abort_controller` — per-socket, fires when the socket ends: at the server's close (revocation, cap eviction, heartbeat timeout, …) or the client's. Registered with the transport, which aborts it on the closes it makes. Drives every handler's `ctx.signal`.
596
809
  - `pending_controllers: Map<JsonrpcRequestId, AbortController>` — per-request. Registered before dispatch, cleared in `finally` so late cancels for a completed id (or a reused id) can't null-abort the wrong handler. Unknown cancels no-op.
597
810
 
598
811
  ## Protocol actions (`actions/protocol.ts`)
@@ -684,8 +897,21 @@ recent frame each way — every frame, including heartbeats and the responses
684
897
  `request` consumes — for diagnostics UI.
685
898
 
686
899
  Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`,
687
- capped at `DEFAULT_RECONNECT_DELAY_MAX`. `WS_CLOSE_SESSION_REVOKED` is
688
- **terminal** — sets `#revoked = true`, no reconnect loop on 401.
900
+ capped at `DEFAULT_RECONNECT_DELAY_MAX`. Two close codes never reconnect:
901
+
902
+ - `WS_CLOSE_SESSION_REVOKED` is **terminal** — sets `revoked`, no reconnect
903
+ loop on 401, and `connect()` becomes a no-op. Pending and queued requests
904
+ reject `unauthenticated`.
905
+ - `WS_CLOSE_CONNECTION_LIMIT` is **closed until the user acts** — sets
906
+ `superseded` (reactive), status `closed`, no reconnect scheduled: the server
907
+ closed this socket to admit a newer one on the same account, and
908
+ reconnecting would close that one in turn, so clients past the cap would
909
+ close each other in a loop. The credential is still good, so `revoked` stays
910
+ `false` and an explicit `connect()` reopens and clears `superseded`. Pending
911
+ and queued requests reject `service_unavailable`; requests made while
912
+ superseded buffer in the durable queue like any call on a closed client. UI
913
+ reads `superseded` to say why the connection is closed and offer the
914
+ reconnect.
689
915
 
690
916
  Live policy swaps (behave like constructor — whole policy atomic, missing
691
917
  fields fall back to defaults, not "keep current"): `set_reconnect`,
@@ -693,7 +919,9 @@ fields fall back to defaults, not "keep current"): `set_reconnect`,
693
919
 
694
920
  `SocketStatus = 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed'`.
695
921
  `socket_status_to_async_status(status, revoked)` collapses to fuz_util's
696
- 4-way `AsyncStatus`.
922
+ 4-way `AsyncStatus`: `closed` reads `failure` when revoked and `initial`
923
+ otherwise — a `superseded` close included, since the client is not trying and
924
+ `connect()` reopens it.
697
925
 
698
926
  ## RPC client (`actions/rpc_client.ts`)
699
927
 
@@ -1 +1 @@
1
- {"version":3,"file":"action_rpc.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_rpc.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"action_rpc.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_rpc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACtD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAEnD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,kBAAkB,CAAC;AAClE,OAAO,EAAqB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE1E,OAAO,EAEN,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAKN,KAAK,cAAc,EACnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAEvD,OAAO,EAGN,KAAK,gBAAgB,EAErB,MAAM,oBAAoB,CAAC;AAQ5B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAGtD;;;;;;;;;;GAUG;AACH,MAAM,WAAW,aAAa;IAC7B,+DAA+D;IAC/D,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;IAC5B,iDAAiD;IACjD,UAAU,EAAE,gBAAgB,CAAC;IAC7B;;;;OAIG;IACH,aAAa,CAAC,EAAE,IAAI,CAAC;IACrB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC;IAC/B;;;;;OAKG;IACH,EAAE,EAAE,EAAE,CAAC;IACP;;;;;OAKG;IACH,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IACtC;;;;;OAKG;IACH,mBAAmB,EAAE,KAAK,CAAC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IACvD;;;;;;;OAOG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,eAAe,EAAE,cAAc,GAAG,IAAI,CAAC;IACvC,uBAAuB;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;OAQG;IACH,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAClD;;;;;OAKG;IACH,MAAM,EAAE,WAAW,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,MAAM,aAAa,CAAC,MAAM,GAAG,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,CACxD,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,aAAa,KACd,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC;;;;;;;;;GASG;AACH,MAAM,WAAW,iBAAkB,SAAQ,IAAI,CAAC,aAAa,EAAE,MAAM,CAAC;IACrE,IAAI,EAAE,cAAc,CAAC;CACrB;AAED;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,CAAC,MAAM,GAAG,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,CAC5D,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,iBAAiB,KAClB,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC;;;;;;;;GAQG;AACH,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,aAAa,EAAE,MAAM,CAAC;IACtE,IAAI,EAAE,mBAAmB,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,CAAC,MAAM,GAAG,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,CAC7D,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,kBAAkB,KACnB,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACzB,IAAI,EAAE,yBAAyB,CAAC;IAChC,OAAO,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,cAAc,CAAC,KAAK,SAAS,yBAAyB,IAAI;IACrE,KAAK,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC;CACtB,SAAS,CAAC,UAAU,CAAC,GACnB,kBAAkB,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,GACrE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,GAC9C,iBAAiB,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,GACpE,aAAa,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;AAErE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,eAAO,MAAM,UAAU,GAAI,KAAK,SAAS,yBAAyB,EACjE,MAAM,KAAK,EACX,SAAS,cAAc,CAAC,KAAK,CAAC,KAC5B,SAGD,CAAC;AAEH,yCAAyC;AACzC,MAAM,WAAW,wBAAwB;IACxC,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,4BAA4B;IAC5B,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAC1B,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;OAMG;IACH,sBAAsB,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAC5C;;;;;;;OAOG;IACH,2BAA2B,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;CACjD;AAkBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,eAAO,MAAM,mBAAmB,GAAI,SAAS,wBAAwB,KAAG,KAAK,CAAC,SAAS,CA6NtF,CAAC"}
@@ -133,6 +133,10 @@ export const create_rpc_endpoint = (options) => {
133
133
  * 3. Hand off to `perform_action` for the post-parse pipeline.
134
134
  * 4. Bind the result to `c.json` — `'ok'` returns the result envelope,
135
135
  * `'error'` returns the error envelope at the `result.status` HTTP code.
136
+ * A result that cannot be serialized answers `internal_error` rather
137
+ * than throwing: `perform_action` has already committed, and a throw
138
+ * here would have the route wrapper discard the post-commit effects
139
+ * the handler queued — a revocation's connection closes among them.
136
140
  *
137
141
  * @param restrict_to_reads - `true` for GET (rejects `side_effects: true` actions)
138
142
  */
@@ -186,7 +190,18 @@ export const create_rpc_endpoint = (options) => {
186
190
  // is verified by `jsonrpc_error_code_to_http_status`).
187
191
  return c.json(envelope, result.status);
188
192
  }
189
- return c.json(envelope);
193
+ try {
194
+ return c.json(envelope);
195
+ }
196
+ catch (err) {
197
+ // The handler's output is not JSON (a `bigint`, a cycle). Its
198
+ // transaction has committed, so this must not propagate: the route
199
+ // wrapper reads a throw as a rollback and discards
200
+ // `post_commit_effects`, which would drop the closes of a
201
+ // revocation that did land.
202
+ log.error(`action result is not serializable: ${method_name}`, err);
203
+ return c.json(jsonrpc_error_envelope(id, jsonrpc_error_messages.internal_error(dev_only(err instanceof Error ? err.message : undefined))), jsonrpc_error_code_to_http_status(JSONRPC_ERROR_CODES.internal_error));
204
+ }
190
205
  };
191
206
  // POST handler — parse JSON-RPC envelope from body
192
207
  const post_handler = async (c, route) => {