@fuzdev/fuz_app 0.134.0 → 0.135.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 (83) hide show
  1. package/dist/actions/CLAUDE.md +91 -9
  2. package/dist/actions/register_action_ws.d.ts +23 -3
  3. package/dist/actions/register_action_ws.d.ts.map +1 -1
  4. package/dist/actions/register_action_ws.js +57 -37
  5. package/dist/actions/transports.d.ts +23 -0
  6. package/dist/actions/transports.d.ts.map +1 -1
  7. package/dist/actions/transports.js +23 -0
  8. package/dist/actions/transports_ws_backend.d.ts +132 -9
  9. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  10. package/dist/actions/transports_ws_backend.js +236 -33
  11. package/dist/actions/ws_endpoint_spec.d.ts +9 -0
  12. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  13. package/dist/actions/ws_outbound_writer.d.ts +206 -0
  14. package/dist/actions/ws_outbound_writer.d.ts.map +1 -0
  15. package/dist/actions/ws_outbound_writer.js +431 -0
  16. package/dist/auth/CLAUDE.md +2 -2
  17. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  18. package/dist/auth/audit_log_routes.js +3 -1
  19. package/dist/auth/cell_actions.js +1 -1
  20. package/dist/auth/role_schema.d.ts +14 -3
  21. package/dist/auth/role_schema.d.ts.map +1 -1
  22. package/dist/auth/role_schema.js +14 -2
  23. package/dist/db/migrate.d.ts +1 -1
  24. package/dist/db/migrate.js +2 -2
  25. package/dist/http/CLAUDE.md +2 -1
  26. package/dist/http/body_limit.d.ts +61 -0
  27. package/dist/http/body_limit.d.ts.map +1 -0
  28. package/dist/http/body_limit.js +87 -0
  29. package/dist/http/db_routes.js +2 -2
  30. package/dist/http/error_schemas.d.ts +2 -0
  31. package/dist/http/error_schemas.d.ts.map +1 -1
  32. package/dist/http/error_schemas.js +2 -0
  33. package/dist/realtime/outbound_budget.d.ts +91 -0
  34. package/dist/realtime/outbound_budget.d.ts.map +1 -0
  35. package/dist/realtime/outbound_budget.js +108 -0
  36. package/dist/realtime/sse.d.ts +36 -3
  37. package/dist/realtime/sse.d.ts.map +1 -1
  38. package/dist/realtime/sse.js +58 -6
  39. package/dist/realtime/sse_constants.d.ts +12 -0
  40. package/dist/realtime/sse_constants.d.ts.map +1 -1
  41. package/dist/realtime/sse_constants.js +12 -0
  42. package/dist/server/app_server.d.ts.map +1 -1
  43. package/dist/server/app_server.js +3 -6
  44. package/dist/testing/CLAUDE.md +70 -10
  45. package/dist/testing/admin_integration.js +1 -1
  46. package/dist/testing/audit_completeness.d.ts +1 -1
  47. package/dist/testing/audit_completeness.js +1 -1
  48. package/dist/testing/audit_drift_guard.d.ts +1 -1
  49. package/dist/testing/audit_drift_guard.js +1 -1
  50. package/dist/testing/cross_backend/body_size_smuggling.d.ts +9 -9
  51. package/dist/testing/cross_backend/body_size_smuggling.d.ts.map +1 -1
  52. package/dist/testing/cross_backend/body_size_smuggling.js +110 -60
  53. package/dist/testing/cross_backend/capabilities.d.ts +18 -15
  54. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  55. package/dist/testing/cross_backend/capabilities.js +1 -0
  56. package/dist/testing/cross_backend/default_backend_configs.js +5 -5
  57. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts +6 -0
  58. package/dist/testing/cross_backend/role_grant_role_grammar.d.ts.map +1 -0
  59. package/dist/testing/cross_backend/role_grant_role_grammar.js +117 -0
  60. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +2 -3
  61. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  62. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +2 -3
  63. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  64. package/dist/testing/cross_backend/testing_backdoor.js +8 -4
  65. package/dist/testing/cross_backend/testing_reset_actions.d.ts +82 -2
  66. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  67. package/dist/testing/cross_backend/testing_reset_actions.js +101 -2
  68. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  69. package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -7
  70. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts +29 -0
  71. package/dist/testing/cross_backend/ws_outbound_overflow.d.ts.map +1 -0
  72. package/dist/testing/cross_backend/ws_outbound_overflow.js +211 -0
  73. package/dist/testing/integration.js +1 -1
  74. package/dist/testing/schema_introspect.d.ts +10 -2
  75. package/dist/testing/schema_introspect.d.ts.map +1 -1
  76. package/dist/testing/schema_introspect.js +15 -7
  77. package/dist/testing/transports/ws_transport.d.ts +4 -2
  78. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  79. package/dist/testing/transports/ws_transport.js +2 -1
  80. package/dist/testing/ws_round_trip.d.ts +32 -2
  81. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  82. package/dist/testing/ws_round_trip.js +26 -4
  83. package/package.json +1 -1
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The outbound side of one server WebSocket connection — a byte-budgeted
3
+ * queue of frames, the pump that hands them to the socket, and the deadlines
4
+ * on what the socket has not yet written.
5
+ *
6
+ * Every frame the server sends a connection goes through
7
+ * `WsOutboundWriter.push`: a response, an error frame, a notification, a
8
+ * broadcast, a server-initiated request. `BackendWebsocketTransport` owns one
9
+ * writer per connection and is its only caller.
10
+ *
11
+ * ## What waits
12
+ *
13
+ * A frame pushed at an idle connection — nothing waiting, nothing unwritten in
14
+ * the runtime's socket buffer, and no other frame pushed in the same
15
+ * synchronous run — goes straight to the socket. Every other frame **waits**
16
+ * in the writer's queue: the rest of a burst pushed in one synchronous run
17
+ * (a loop with no `await` in it gives the pump no turn), and anything pushed
18
+ * while the socket's buffer is at or over `WS_WRITE_HIGH_WATER_BYTES`. The
19
+ * pump hands waiting frames over in order — on the microtask after a push, and
20
+ * whenever the buffer has drained below the high-water mark.
21
+ *
22
+ * What waits is held to the connection's budget (`OutboundBudget`): each
23
+ * waiting frame is charged its UTF-8 length plus
24
+ * `OUTBOUND_FRAME_OVERHEAD_BYTES`, and a frame is always admitted when nothing
25
+ * is waiting. A frame handed to the socket is no longer charged — the budget
26
+ * bounds what waits, and the write deadline bounds what is in flight.
27
+ *
28
+ * A frame that does not fit is reported as an overflow and not queued; the
29
+ * owner closes the connection for it (`WS_CLOSE_OUTBOUND_OVERFLOW`).
30
+ *
31
+ * The queue is a linked list: a frame handed to the socket is unlinked, so the
32
+ * writer holds the frames that wait and no others, however long the queue goes
33
+ * without emptying.
34
+ *
35
+ * ## Closing
36
+ *
37
+ * `close` sends the close frame and takes nothing more. With `'flush'` it
38
+ * first runs the pump's rule once: waiting frames are handed to the socket
39
+ * while its buffer is below `WS_WRITE_HIGH_WATER_BYTES`. Whatever still waits
40
+ * is discarded, with either backlog choice. So a frame sent to an otherwise
41
+ * idle connection just before a close reaches the client ahead of the close
42
+ * frame, and a frame queued behind a backed-up socket does not. After a close
43
+ * a socket whose buffer the writer can read ("Runtimes") holds less than the
44
+ * high-water mark plus one frame, and the close frame.
45
+ *
46
+ * ## Deadlines
47
+ *
48
+ * - **A frame**: bytes the socket's buffer still holds
49
+ * `WS_WRITE_STALL_TIMEOUT` after they were handed to it mean the client is
50
+ * not reading. The writer discards what waits, reports the stall, and cuts
51
+ * the socket with no close frame (the client sees an abnormal close, 1006).
52
+ * - **A close frame**: a close still unwritten `WS_CLOSE_WRITE_TIMEOUT` after
53
+ * `close` cuts the socket the same way, so a closed connection whose client
54
+ * stopped reading does not hold its buffered frames for the runtime's own
55
+ * close timeout — where the runtime can cut a socket ("Runtimes").
56
+ *
57
+ * ## Runtimes
58
+ *
59
+ * The buffer is read through the socket Hono's `WSContext` wraps (`raw`):
60
+ * `bufferedAmount` on Node's `ws` and on Deno's standard `WebSocket`,
61
+ * `getBufferedAmount()` on Bun's `ServerWebSocket`. A socket that exposes
62
+ * neither reads as never backed up: its frames are still budgeted while they
63
+ * wait for the pump, but nothing detects a stall. The cut is `terminate()`,
64
+ * which Node's `ws` and Bun's `ServerWebSocket` have. Deno's standard
65
+ * `WebSocket` has no way to drop a socket without the closing handshake, so
66
+ * there a stalled connection is closed with `WS_CLOSE_OUTBOUND_OVERFLOW`
67
+ * instead — a frame the client reads only if it starts reading again — and
68
+ * the close deadline cuts nothing: a closed socket whose client is not reading
69
+ * stays in the runtime, holding what its buffer held at the close ("Closing"),
70
+ * until the runtime or the client ends it.
71
+ *
72
+ * @module
73
+ */
74
+ import type { WSContext } from 'hono/ws';
75
+ import type { Logger } from '@fuzdev/fuz_util/log.ts';
76
+ import { type OutboundOverflow } from '../realtime/outbound_budget.js';
77
+ /**
78
+ * How long bytes handed to a socket may stay unwritten in its buffer before
79
+ * the connection is dropped (module doc, "Deadlines"). Equal to
80
+ * `DEFAULT_PEER_REQUEST_TIMEOUT`: a server-initiated request that cannot even
81
+ * be written within its own reply deadline has already failed. Same value as
82
+ * the Rust spine's `WS_WRITE_STALL_TIMEOUT`.
83
+ *
84
+ * The deadline covers the bytes in the buffer when it starts, not progress: a
85
+ * client taking them too slowly to finish inside it is dropped the same way,
86
+ * and reconnects.
87
+ */
88
+ export declare const WS_WRITE_STALL_TIMEOUT = 10000;
89
+ /**
90
+ * How long a close frame may stay unwritten before the socket is cut without
91
+ * it (module doc, "Deadlines"). Same value as the Rust spine's bound on the
92
+ * same write.
93
+ */
94
+ export declare const WS_CLOSE_WRITE_TIMEOUT = 1000;
95
+ /**
96
+ * How many unwritten bytes a socket's buffer may hold before the pump stops
97
+ * handing it waiting frames. Below it the pump keeps writing, so a runtime
98
+ * that reports a frame as buffered until its write completes (Deno) is not
99
+ * held to one frame per turn; at or over it frames wait in the budgeted queue
100
+ * instead of the runtime's unbounded buffer.
101
+ */
102
+ export declare const WS_WRITE_HIGH_WATER_BYTES: number;
103
+ /** The outcome of one `WsOutboundWriter.push`. */
104
+ export type WsOutboundPush =
105
+ /** The frame went to the socket or is waiting for it. */
106
+ {
107
+ kind: 'accepted';
108
+ }
109
+ /** The frame was dropped: the writer has closed, or the socket refused it. */
110
+ | {
111
+ kind: 'refused';
112
+ }
113
+ /** The frame did not fit the budget and was not queued. */
114
+ | {
115
+ kind: 'overflow';
116
+ overflow: OutboundOverflow;
117
+ };
118
+ /** What a stalled connection held when its write deadline passed. */
119
+ export interface WsOutboundStall {
120
+ /** Payload bytes that were waiting, now discarded. */
121
+ queued_bytes: number;
122
+ /** Frames that were waiting, now discarded. */
123
+ queued_frames: number;
124
+ /** Bytes the socket's buffer still held. */
125
+ buffered_bytes: number;
126
+ }
127
+ /** What `WsOutboundWriter.close` does with the frames still waiting. */
128
+ export type WsOutboundBacklog =
129
+ /**
130
+ * Hand them to the socket ahead of the close frame while its buffer is below
131
+ * `WS_WRITE_HIGH_WATER_BYTES`, and drop the rest.
132
+ */
133
+ 'flush'
134
+ /** Drop them all. */
135
+ | 'discard';
136
+ /** Options for `WsOutboundWriter`. */
137
+ export interface WsOutboundWriterOptions {
138
+ /** The socket to write to. */
139
+ ws: WSContext;
140
+ /** The byte budget of the frames waiting for the socket. */
141
+ max_bytes: number;
142
+ /**
143
+ * Called once when a write deadline passes, after the writer discarded what
144
+ * was waiting and before it cuts the socket. The owner removes the
145
+ * connection and logs.
146
+ */
147
+ on_stall: (stall: WsOutboundStall) => void;
148
+ /** Logger for a socket that throws on a send or a cut; `null` silences it. */
149
+ log: Logger | null;
150
+ }
151
+ /**
152
+ * The byte-budgeted outbound queue and write pump of one WebSocket connection
153
+ * (module doc).
154
+ */
155
+ export declare class WsOutboundWriter {
156
+ #private;
157
+ constructor(options: WsOutboundWriterOptions);
158
+ /** Payload bytes waiting for the socket, without the per-frame overhead. */
159
+ get queued_bytes(): number;
160
+ /** Frames waiting for the socket. */
161
+ get queued_frames(): number;
162
+ /**
163
+ * Send `text` as one frame: straight to the socket when the connection is
164
+ * idle, else queued behind what waits when it fits the budget (module doc,
165
+ * "What waits"). Never waits and never throws.
166
+ *
167
+ * @param text - the frame's text
168
+ * @param bytes - the UTF-8 length of `text` when the caller has measured it —
169
+ * a fan-out measures one frame once for its recipients; measured here when
170
+ * omitted. Trusted, not checked against `text`: a wrong value corrupts the
171
+ * budget
172
+ * @returns `accepted`, `refused` once the writer has closed, or `overflow`
173
+ * when the frame did not fit — the caller closes the connection
174
+ * @mutates this - queues the frame or writes it to the socket, and schedules
175
+ * the pump
176
+ */
177
+ push(text: string, bytes?: number): WsOutboundPush;
178
+ /**
179
+ * Close the socket with `code` and `reason`, and take nothing more.
180
+ *
181
+ * With `backlog: 'flush'` the frames still waiting are handed to the socket
182
+ * ahead of the close frame under the pump's rule — while the socket's buffer
183
+ * is below `WS_WRITE_HIGH_WATER_BYTES` — so a reply sent to an otherwise idle
184
+ * connection just before a close is not lost to it. What still waits after
185
+ * that is dropped, as all of it is with `'discard'` (module doc, "Closing").
186
+ * Either way the close frame gets `WS_CLOSE_WRITE_TIMEOUT` to be written,
187
+ * after which a socket still holding unwritten bytes is cut where the runtime
188
+ * can cut one. A no-op once the writer has closed.
189
+ *
190
+ * @param code - the close code
191
+ * @param reason - the close reason
192
+ * @param backlog - what to do with the frames still waiting
193
+ * @param on_settled - called once the close deadline has passed, unless
194
+ * `dispose` ran first
195
+ * @mutates this - empties the queue, stops the pump, starts the close deadline
196
+ * @throws whatever the socket's `close` throws, with the writer already closed
197
+ */
198
+ close(code: number, reason: string, backlog: WsOutboundBacklog, on_settled?: () => void): void;
199
+ /**
200
+ * The socket is gone: drop what waits and stop every timer. Idempotent.
201
+ *
202
+ * @mutates this - empties the queue and clears the pump and deadline timers
203
+ */
204
+ dispose(): void;
205
+ }
206
+ //# sourceMappingURL=ws_outbound_writer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ws_outbound_writer.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/ws_outbound_writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAOvF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,QAAS,CAAC;AAE7C;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,OAAQ,CAAC;AAE5C;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,QAAY,CAAC;AAQnD,kDAAkD;AAClD,MAAM,MAAM,cAAc;AACzB,yDAAyD;AACvD;IAAE,IAAI,EAAE,UAAU,CAAA;CAAE;AACtB,8EAA8E;GAC5E;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE;AACrB,2DAA2D;GACzD;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,gBAAgB,CAAA;CAAE,CAAC;AAEpD,qEAAqE;AACrE,MAAM,WAAW,eAAe;IAC/B,sDAAsD;IACtD,YAAY,EAAE,MAAM,CAAC;IACrB,+CAA+C;IAC/C,aAAa,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,cAAc,EAAE,MAAM,CAAC;CACvB;AAED,wEAAwE;AACxE,MAAM,MAAM,iBAAiB;AAC5B;;;GAGG;AACD,OAAO;AACT,qBAAqB;GACnB,SAAS,CAAC;AAEb,sCAAsC;AACtC,MAAM,WAAW,uBAAuB;IACvC,8BAA8B;IAC9B,EAAE,EAAE,SAAS,CAAC;IACd,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,QAAQ,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAC3C,8EAA8E;IAC9E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CACnB;AA2CD;;;GAGG;AACH,qBAAa,gBAAgB;;gBA+BhB,OAAO,EAAE,uBAAuB;IAO5C,4EAA4E;IAC5E,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED,qCAAqC;IACrC,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED;;;;;;;;;;;;;;OAcG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,cAAc;IAyBlD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,EAAE,UAAU,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI;IA4B9F;;;;OAIG;IACH,OAAO,IAAI,IAAI;CAoJf"}
@@ -0,0 +1,431 @@
1
+ /**
2
+ * The outbound side of one server WebSocket connection — a byte-budgeted
3
+ * queue of frames, the pump that hands them to the socket, and the deadlines
4
+ * on what the socket has not yet written.
5
+ *
6
+ * Every frame the server sends a connection goes through
7
+ * `WsOutboundWriter.push`: a response, an error frame, a notification, a
8
+ * broadcast, a server-initiated request. `BackendWebsocketTransport` owns one
9
+ * writer per connection and is its only caller.
10
+ *
11
+ * ## What waits
12
+ *
13
+ * A frame pushed at an idle connection — nothing waiting, nothing unwritten in
14
+ * the runtime's socket buffer, and no other frame pushed in the same
15
+ * synchronous run — goes straight to the socket. Every other frame **waits**
16
+ * in the writer's queue: the rest of a burst pushed in one synchronous run
17
+ * (a loop with no `await` in it gives the pump no turn), and anything pushed
18
+ * while the socket's buffer is at or over `WS_WRITE_HIGH_WATER_BYTES`. The
19
+ * pump hands waiting frames over in order — on the microtask after a push, and
20
+ * whenever the buffer has drained below the high-water mark.
21
+ *
22
+ * What waits is held to the connection's budget (`OutboundBudget`): each
23
+ * waiting frame is charged its UTF-8 length plus
24
+ * `OUTBOUND_FRAME_OVERHEAD_BYTES`, and a frame is always admitted when nothing
25
+ * is waiting. A frame handed to the socket is no longer charged — the budget
26
+ * bounds what waits, and the write deadline bounds what is in flight.
27
+ *
28
+ * A frame that does not fit is reported as an overflow and not queued; the
29
+ * owner closes the connection for it (`WS_CLOSE_OUTBOUND_OVERFLOW`).
30
+ *
31
+ * The queue is a linked list: a frame handed to the socket is unlinked, so the
32
+ * writer holds the frames that wait and no others, however long the queue goes
33
+ * without emptying.
34
+ *
35
+ * ## Closing
36
+ *
37
+ * `close` sends the close frame and takes nothing more. With `'flush'` it
38
+ * first runs the pump's rule once: waiting frames are handed to the socket
39
+ * while its buffer is below `WS_WRITE_HIGH_WATER_BYTES`. Whatever still waits
40
+ * is discarded, with either backlog choice. So a frame sent to an otherwise
41
+ * idle connection just before a close reaches the client ahead of the close
42
+ * frame, and a frame queued behind a backed-up socket does not. After a close
43
+ * a socket whose buffer the writer can read ("Runtimes") holds less than the
44
+ * high-water mark plus one frame, and the close frame.
45
+ *
46
+ * ## Deadlines
47
+ *
48
+ * - **A frame**: bytes the socket's buffer still holds
49
+ * `WS_WRITE_STALL_TIMEOUT` after they were handed to it mean the client is
50
+ * not reading. The writer discards what waits, reports the stall, and cuts
51
+ * the socket with no close frame (the client sees an abnormal close, 1006).
52
+ * - **A close frame**: a close still unwritten `WS_CLOSE_WRITE_TIMEOUT` after
53
+ * `close` cuts the socket the same way, so a closed connection whose client
54
+ * stopped reading does not hold its buffered frames for the runtime's own
55
+ * close timeout — where the runtime can cut a socket ("Runtimes").
56
+ *
57
+ * ## Runtimes
58
+ *
59
+ * The buffer is read through the socket Hono's `WSContext` wraps (`raw`):
60
+ * `bufferedAmount` on Node's `ws` and on Deno's standard `WebSocket`,
61
+ * `getBufferedAmount()` on Bun's `ServerWebSocket`. A socket that exposes
62
+ * neither reads as never backed up: its frames are still budgeted while they
63
+ * wait for the pump, but nothing detects a stall. The cut is `terminate()`,
64
+ * which Node's `ws` and Bun's `ServerWebSocket` have. Deno's standard
65
+ * `WebSocket` has no way to drop a socket without the closing handshake, so
66
+ * there a stalled connection is closed with `WS_CLOSE_OUTBOUND_OVERFLOW`
67
+ * instead — a frame the client reads only if it starts reading again — and
68
+ * the close deadline cuts nothing: a closed socket whose client is not reading
69
+ * stays in the runtime, holding what its buffer held at the close ("Closing"),
70
+ * until the runtime or the client ends it.
71
+ *
72
+ * @module
73
+ */
74
+ import { OutboundBudget } from '../realtime/outbound_budget.js';
75
+ import { WS_CLOSE_OUTBOUND_OVERFLOW, WS_CLOSE_OUTBOUND_OVERFLOW_REASON, utf8_length } from './transports.js';
76
+ /**
77
+ * How long bytes handed to a socket may stay unwritten in its buffer before
78
+ * the connection is dropped (module doc, "Deadlines"). Equal to
79
+ * `DEFAULT_PEER_REQUEST_TIMEOUT`: a server-initiated request that cannot even
80
+ * be written within its own reply deadline has already failed. Same value as
81
+ * the Rust spine's `WS_WRITE_STALL_TIMEOUT`.
82
+ *
83
+ * The deadline covers the bytes in the buffer when it starts, not progress: a
84
+ * client taking them too slowly to finish inside it is dropped the same way,
85
+ * and reconnects.
86
+ */
87
+ export const WS_WRITE_STALL_TIMEOUT = 10_000;
88
+ /**
89
+ * How long a close frame may stay unwritten before the socket is cut without
90
+ * it (module doc, "Deadlines"). Same value as the Rust spine's bound on the
91
+ * same write.
92
+ */
93
+ export const WS_CLOSE_WRITE_TIMEOUT = 1_000;
94
+ /**
95
+ * How many unwritten bytes a socket's buffer may hold before the pump stops
96
+ * handing it waiting frames. Below it the pump keeps writing, so a runtime
97
+ * that reports a frame as buffered until its write completes (Deno) is not
98
+ * held to one frame per turn; at or over it frames wait in the budgeted queue
99
+ * instead of the runtime's unbounded buffer.
100
+ */
101
+ export const WS_WRITE_HIGH_WATER_BYTES = 64 * 1024;
102
+ /** The longest wait between two reads of a backed-up socket's buffer. */
103
+ const DRAIN_POLL_MAX_DELAY = 32;
104
+ /** `WebSocket.CLOSED`. */
105
+ const READY_STATE_CLOSED = 3;
106
+ /** A timer that does not keep the process alive, where the runtime allows. */
107
+ const set_unref_timeout = (fn, ms) => {
108
+ const timer = setTimeout(fn, ms);
109
+ if (typeof timer === 'object' && 'unref' in timer)
110
+ timer.unref();
111
+ return timer;
112
+ };
113
+ /**
114
+ * The bytes handed to `ws` that its runtime has not yet written — `0` for a
115
+ * socket that does not say.
116
+ */
117
+ const read_buffered_bytes = (ws) => {
118
+ const raw = ws.raw;
119
+ if (typeof raw !== 'object' || raw === null)
120
+ return 0;
121
+ if (typeof raw.getBufferedAmount === 'function') {
122
+ const buffered = raw.getBufferedAmount.call(raw);
123
+ return typeof buffered === 'number' ? buffered : 0;
124
+ }
125
+ return typeof raw.bufferedAmount === 'number' ? raw.bufferedAmount : 0;
126
+ };
127
+ /**
128
+ * Drop `ws` without a closing handshake, where its runtime can.
129
+ *
130
+ * @returns whether the socket was cut
131
+ */
132
+ const terminate_socket = (ws) => {
133
+ const raw = ws.raw;
134
+ if (typeof raw !== 'object' || raw === null || typeof raw.terminate !== 'function')
135
+ return false;
136
+ raw.terminate.call(raw);
137
+ return true;
138
+ };
139
+ /**
140
+ * The byte-budgeted outbound queue and write pump of one WebSocket connection
141
+ * (module doc).
142
+ */
143
+ export class WsOutboundWriter {
144
+ #ws;
145
+ #budget;
146
+ #on_stall;
147
+ #log;
148
+ // `open` takes frames; `closing` has sent its close and waits out the close
149
+ // deadline; `ended` is done with the socket
150
+ #state = 'open';
151
+ // the frames waiting, oldest first: a frame is unlinked as it is handed to
152
+ // the socket, so nothing here refers to a frame that is not waiting
153
+ #head = null;
154
+ #tail = null;
155
+ // a pump is scheduled for the end of the current synchronous run — which is
156
+ // also what marks a frame as already written through in this run
157
+ #pump_pending = false;
158
+ #poll_timer = null;
159
+ #poll_delay = 1;
160
+ #last_buffered = 0;
161
+ // The write deadline covers the bytes in the buffer when it starts: it is
162
+ // met once the buffer holds no more than what was handed over since.
163
+ #stall_timer = null;
164
+ #handed_since_stall_start = 0;
165
+ #close_timer = null;
166
+ #on_close_settled = null;
167
+ constructor(options) {
168
+ this.#ws = options.ws;
169
+ this.#budget = new OutboundBudget(options.max_bytes);
170
+ this.#on_stall = options.on_stall;
171
+ this.#log = options.log;
172
+ }
173
+ /** Payload bytes waiting for the socket, without the per-frame overhead. */
174
+ get queued_bytes() {
175
+ return this.#budget.queued_bytes;
176
+ }
177
+ /** Frames waiting for the socket. */
178
+ get queued_frames() {
179
+ return this.#budget.queued_frames;
180
+ }
181
+ /**
182
+ * Send `text` as one frame: straight to the socket when the connection is
183
+ * idle, else queued behind what waits when it fits the budget (module doc,
184
+ * "What waits"). Never waits and never throws.
185
+ *
186
+ * @param text - the frame's text
187
+ * @param bytes - the UTF-8 length of `text` when the caller has measured it —
188
+ * a fan-out measures one frame once for its recipients; measured here when
189
+ * omitted. Trusted, not checked against `text`: a wrong value corrupts the
190
+ * budget
191
+ * @returns `accepted`, `refused` once the writer has closed, or `overflow`
192
+ * when the frame did not fit — the caller closes the connection
193
+ * @mutates this - queues the frame or writes it to the socket, and schedules
194
+ * the pump
195
+ */
196
+ push(text, bytes) {
197
+ if (this.#state !== 'open')
198
+ return { kind: 'refused' };
199
+ if (!this.#pump_pending && this.#head === null && read_buffered_bytes(this.#ws) === 0) {
200
+ // idle, and everything handed over earlier is written
201
+ this.#clear_stall_timer();
202
+ this.#schedule_pump();
203
+ if (!this.#write(text))
204
+ return { kind: 'refused' };
205
+ // TODO: a runtime that reports a frame as buffered until its write
206
+ // completes (Deno) arms the deadline and the poll here and clears both
207
+ // a tick later, for each idle send
208
+ this.#watch(read_buffered_bytes(this.#ws));
209
+ return { kind: 'accepted' };
210
+ }
211
+ const frame_bytes = bytes ?? utf8_length(text);
212
+ if (!this.#budget.admit(frame_bytes)) {
213
+ return { kind: 'overflow', overflow: this.#budget.to_overflow(frame_bytes) };
214
+ }
215
+ const frame = { text, bytes: frame_bytes, next: null };
216
+ if (this.#tail === null)
217
+ this.#head = frame;
218
+ else
219
+ this.#tail.next = frame;
220
+ this.#tail = frame;
221
+ this.#schedule_pump();
222
+ return { kind: 'accepted' };
223
+ }
224
+ /**
225
+ * Close the socket with `code` and `reason`, and take nothing more.
226
+ *
227
+ * With `backlog: 'flush'` the frames still waiting are handed to the socket
228
+ * ahead of the close frame under the pump's rule — while the socket's buffer
229
+ * is below `WS_WRITE_HIGH_WATER_BYTES` — so a reply sent to an otherwise idle
230
+ * connection just before a close is not lost to it. What still waits after
231
+ * that is dropped, as all of it is with `'discard'` (module doc, "Closing").
232
+ * Either way the close frame gets `WS_CLOSE_WRITE_TIMEOUT` to be written,
233
+ * after which a socket still holding unwritten bytes is cut where the runtime
234
+ * can cut one. A no-op once the writer has closed.
235
+ *
236
+ * @param code - the close code
237
+ * @param reason - the close reason
238
+ * @param backlog - what to do with the frames still waiting
239
+ * @param on_settled - called once the close deadline has passed, unless
240
+ * `dispose` ran first
241
+ * @mutates this - empties the queue, stops the pump, starts the close deadline
242
+ * @throws whatever the socket's `close` throws, with the writer already closed
243
+ */
244
+ close(code, reason, backlog, on_settled) {
245
+ if (this.#state !== 'open')
246
+ return;
247
+ this.#state = 'closing';
248
+ this.#clear_poll_timer();
249
+ this.#clear_stall_timer();
250
+ // a socket that throws on a send is not offered the frames behind it
251
+ if (backlog === 'flush')
252
+ this.#hand_over(read_buffered_bytes(this.#ws), true);
253
+ // what the socket did not take is not delivered
254
+ this.#discard_waiting();
255
+ this.#on_close_settled = on_settled ?? null;
256
+ this.#close_timer = set_unref_timeout(() => {
257
+ this.#close_timer = null;
258
+ this.#state = 'ended';
259
+ const settled = this.#on_close_settled;
260
+ this.#on_close_settled = null;
261
+ try {
262
+ if (this.#ws.readyState !== READY_STATE_CLOSED && read_buffered_bytes(this.#ws) > 0) {
263
+ // the client is not reading: the close frame cannot be written
264
+ terminate_socket(this.#ws);
265
+ }
266
+ }
267
+ catch (error) {
268
+ this.#log?.error('error cutting a socket past its close deadline:', error);
269
+ }
270
+ settled?.();
271
+ }, WS_CLOSE_WRITE_TIMEOUT);
272
+ this.#ws.close(code, reason);
273
+ }
274
+ /**
275
+ * The socket is gone: drop what waits and stop every timer. Idempotent.
276
+ *
277
+ * @mutates this - empties the queue and clears the pump and deadline timers
278
+ */
279
+ dispose() {
280
+ this.#state = 'ended';
281
+ this.#clear_poll_timer();
282
+ this.#clear_stall_timer();
283
+ if (this.#close_timer !== null) {
284
+ clearTimeout(this.#close_timer);
285
+ this.#close_timer = null;
286
+ }
287
+ this.#on_close_settled = null;
288
+ this.#discard_waiting();
289
+ }
290
+ /** Hand one frame to the socket. `false` when the socket threw. */
291
+ #write(text) {
292
+ try {
293
+ this.#ws.send(text);
294
+ return true;
295
+ }
296
+ catch (error) {
297
+ this.#log?.error('error sending to client:', error);
298
+ return false;
299
+ }
300
+ }
301
+ #discard_waiting() {
302
+ this.#head = null;
303
+ this.#tail = null;
304
+ this.#budget.clear();
305
+ }
306
+ /**
307
+ * Hand waiting frames to the socket, oldest first, while its buffer is below
308
+ * the high-water mark — the one rule for what leaves the queue, at a pump
309
+ * turn and at a close.
310
+ *
311
+ * @param buffered_before - the socket's unwritten bytes as last read
312
+ * @param stop_when_refused - stop at the first frame the socket throws on
313
+ * @returns the socket's unwritten bytes after the last frame handed over
314
+ */
315
+ #hand_over(buffered_before, stop_when_refused) {
316
+ let buffered = buffered_before;
317
+ let frame = this.#head;
318
+ while (frame !== null && buffered < WS_WRITE_HIGH_WATER_BYTES) {
319
+ // unlinked before the write, so the queue never refers to a frame the
320
+ // socket has
321
+ this.#head = frame.next;
322
+ if (this.#head === null)
323
+ this.#tail = null;
324
+ this.#budget.release(frame.bytes);
325
+ this.#handed_since_stall_start += frame.bytes;
326
+ if (!this.#write(frame.text) && stop_when_refused)
327
+ break;
328
+ buffered = read_buffered_bytes(this.#ws);
329
+ frame = this.#head;
330
+ }
331
+ return buffered;
332
+ }
333
+ #schedule_pump() {
334
+ if (this.#pump_pending)
335
+ return;
336
+ this.#pump_pending = true;
337
+ queueMicrotask(() => {
338
+ this.#pump_pending = false;
339
+ if (this.#state === 'open')
340
+ this.#drain();
341
+ });
342
+ }
343
+ /** Hand waiting frames to the socket while its buffer is below the high-water mark. */
344
+ #drain() {
345
+ let buffered = read_buffered_bytes(this.#ws);
346
+ if (this.#stall_timer !== null && buffered <= this.#handed_since_stall_start) {
347
+ // what the deadline covered is written
348
+ this.#clear_stall_timer();
349
+ }
350
+ const waiting = this.#budget.queued_frames;
351
+ buffered = this.#hand_over(buffered, false);
352
+ // progress: read the buffer again soon
353
+ if (this.#budget.queued_frames !== waiting)
354
+ this.#poll_delay = 1;
355
+ this.#watch(buffered);
356
+ }
357
+ /** Keep the write deadline and the buffer poll running while anything is unwritten. */
358
+ #watch(buffered) {
359
+ if (buffered > 0 && this.#stall_timer === null) {
360
+ this.#handed_since_stall_start = 0;
361
+ this.#stall_timer = set_unref_timeout(() => this.#stall(), WS_WRITE_STALL_TIMEOUT);
362
+ }
363
+ if (buffered === 0 && this.#head === null) {
364
+ this.#clear_poll_timer();
365
+ return;
366
+ }
367
+ if (buffered < this.#last_buffered)
368
+ this.#poll_delay = 1;
369
+ this.#last_buffered = buffered;
370
+ if (this.#poll_timer !== null)
371
+ return;
372
+ this.#poll_timer = set_unref_timeout(() => {
373
+ this.#poll_timer = null;
374
+ if (this.#state !== 'open')
375
+ return;
376
+ this.#poll_delay = Math.min(this.#poll_delay * 2, DRAIN_POLL_MAX_DELAY);
377
+ this.#drain();
378
+ }, this.#poll_delay);
379
+ }
380
+ /** The write deadline passed: discard, report, and cut the socket. */
381
+ #stall() {
382
+ this.#stall_timer = null;
383
+ if (this.#state !== 'open')
384
+ return;
385
+ this.#state = 'ended';
386
+ this.#clear_poll_timer();
387
+ let buffered_bytes = 0;
388
+ try {
389
+ buffered_bytes = read_buffered_bytes(this.#ws);
390
+ }
391
+ catch {
392
+ // a socket that cannot be read is cut all the same
393
+ }
394
+ const stall = {
395
+ queued_bytes: this.#budget.queued_bytes,
396
+ queued_frames: this.#budget.queued_frames,
397
+ buffered_bytes
398
+ };
399
+ this.#discard_waiting();
400
+ try {
401
+ this.#on_stall(stall);
402
+ }
403
+ catch (error) {
404
+ this.#log?.error('error reporting a stalled socket:', error);
405
+ }
406
+ try {
407
+ if (!terminate_socket(this.#ws)) {
408
+ // no way to drop the socket on this runtime (module doc, "Runtimes")
409
+ this.#ws.close(WS_CLOSE_OUTBOUND_OVERFLOW, WS_CLOSE_OUTBOUND_OVERFLOW_REASON);
410
+ }
411
+ }
412
+ catch (error) {
413
+ this.#log?.error('error cutting a stalled socket:', error);
414
+ }
415
+ }
416
+ #clear_poll_timer() {
417
+ if (this.#poll_timer !== null) {
418
+ clearTimeout(this.#poll_timer);
419
+ this.#poll_timer = null;
420
+ }
421
+ this.#poll_delay = 1;
422
+ this.#last_buffered = 0;
423
+ }
424
+ #clear_stall_timer() {
425
+ if (this.#stall_timer !== null) {
426
+ clearTimeout(this.#stall_timer);
427
+ this.#stall_timer = null;
428
+ }
429
+ this.#handed_since_stall_start = 0;
430
+ }
431
+ }
@@ -57,7 +57,7 @@ Cross-cutting notes that don't live on any single symbol:
57
57
  Convention — `*_schema.ts` is Zod-only; `*_ddl.ts` holds DDL strings.
58
58
 
59
59
  - `auth/account_schema.ts` — `Account`, `Actor`, `RoleGrant`, `AuthSession`, `ApiToken` + client-safe JSON shapes. Grant predicates over wire fields: `is_role_grant_active` (not revoked, not expired) and `has_global_role(role_grants, role, now?)` (an active grant of the role with `scope_id === null` — the bare-list form of `has_scoped_role(ctx, role, null)`, used client-side by `AuthState`).
60
- - `auth/role_schema.ts` — `RoleName`, `RoleSpec`, `ROLE_KEEPER`, `ROLE_ADMIN`, `is_builtin_role`, `create_role_schema`, `builtin_role_specs_by_name`, `role_has_grant_path`, `list_roles_with_grant_path`.
60
+ - `auth/role_schema.ts` — `RoleName` (lowercase letters and underscores, no leading or trailing underscore, at most `ROLE_NAME_LENGTH_MAX` = 64 characters — the grammar every `role` input and every registered role holds, twin of the Rust `is_valid_role_name`), `RoleSpec`, `ROLE_KEEPER`, `ROLE_ADMIN`, `is_builtin_role`, `create_role_schema`, `builtin_role_specs_by_name`, `role_has_grant_path`, `list_roles_with_grant_path`.
61
61
  - `auth/scope_kind_schema.ts` — `ScopeKindName`, `create_scope_kind_schema` (open registry, no builtins).
62
62
  - `auth/credential_type_schema.ts` — `CredentialTypeName`, `CREDENTIAL_TYPE_SESSION` / `_API_TOKEN` / `_DAEMON_TOKEN`, `create_credential_type_schema`.
63
63
  - `auth/grant_path_schema.ts` — `GrantPathName`, `GRANT_PATH_ADMIN` / `_SELF_SERVICE` / `_SYSTEM` / `_BOOTSTRAP`, `create_grant_path_schema`.
@@ -296,7 +296,7 @@ auth, ids)`: batched strict relation-read filter. Every relation read —
296
296
  The authorizer is **pure** — the handler reads the governing root's `data`
297
297
  in-tx and hands it over as `root_data`, so a directory policy resolves
298
298
  `root.data.policy[kind]` without its own DB read (which also dodges the
299
- single-connection PGlite deadlock). `None`/omitted = open create (the default;
299
+ single-connection deadlock of the in-memory drivers). `None`/omitted = open create (the default;
300
300
  mirrors `validate_data`). `cell_moderate` is the `pending → approved | rejected`
301
301
  transition, gated on `can_manage_cell` of the **governing root** (admin / root
302
302
  owner — the author lands a 403, the anti-self-approval guard); approve flips
@@ -1 +1 @@
1
- {"version":3,"file":"audit_log_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_log_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAUvD,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAU7E,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE;QACR,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;QAC9C,GAAG,EAAE,MAAM,CAAC;KACZ,CAAC;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,4BAA4B,GAAI,UAAU,oBAAoB,KAAG,KAAK,CAAC,SAAS,CAoG5F,CAAC"}
1
+ {"version":3,"file":"audit_log_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_log_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAUvD,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAU7E,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE;QACR,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;QAC9C,GAAG,EAAE,MAAM,CAAC;KACZ,CAAC;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,4BAA4B,GAAI,UAAU,oBAAoB,KAAG,KAAK,CAAC,SAAS,CAsG5F,CAAC"}
@@ -134,7 +134,9 @@ export const create_audit_log_route_specs = (options) => {
134
134
  // left during the re-reads gets a stream that is already closed,
135
135
  // whose listener runs at once and removes the registration, so
136
136
  // `admit` refuses it rather than evicting a live stream for it.
137
- const { response, stream } = create_sse_response(c, log);
137
+ const { response, stream } = create_sse_response(c, log, {
138
+ account_id: ctx.account.id
139
+ });
138
140
  stream.on_close(pending.unsubscribe);
139
141
  admitted = registry.admit(pending, stream);
140
142
  if (!admitted) {