@fuzdev/fuz_app 0.118.0 → 0.119.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +301 -73
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +16 -1
- package/dist/actions/connection_closer.d.ts +136 -51
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +133 -34
- package/dist/actions/register_action_ws.d.ts +176 -24
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +581 -241
- package/dist/actions/register_ws_endpoint.d.ts +1 -0
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +1 -0
- package/dist/actions/socket.svelte.d.ts +43 -9
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +59 -8
- package/dist/actions/transports.d.ts +55 -1
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +55 -1
- package/dist/actions/transports_ws_auth_guard.d.ts +53 -67
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/actions/transports_ws_auth_guard.js +87 -111
- package/dist/actions/transports_ws_backend.d.ts +212 -30
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +327 -70
- package/dist/actions/ws_endpoint_spec.d.ts +26 -13
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/auth/CLAUDE.md +113 -32
- package/dist/auth/account_actions.d.ts +12 -18
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +47 -33
- package/dist/auth/account_routes.d.ts +5 -11
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +40 -36
- package/dist/auth/account_schema.d.ts +27 -0
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +22 -0
- package/dist/auth/admin_actions.d.ts +8 -17
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +33 -21
- package/dist/auth/api_token_queries.d.ts +23 -2
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +39 -3
- package/dist/auth/audit_emitter.d.ts +61 -38
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +57 -14
- package/dist/auth/audit_log_route_schema.js +7 -7
- package/dist/auth/audit_log_routes.d.ts +31 -6
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +85 -11
- package/dist/auth/audit_log_schema.d.ts +48 -0
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +58 -0
- package/dist/auth/cleanup.d.ts +203 -28
- package/dist/auth/cleanup.d.ts.map +1 -1
- package/dist/auth/cleanup.js +222 -26
- package/dist/auth/deps.d.ts +28 -0
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/resolved_auth.d.ts +105 -0
- package/dist/auth/resolved_auth.d.ts.map +1 -0
- package/dist/auth/resolved_auth.js +124 -0
- package/dist/auth/session_middleware.d.ts +8 -1
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +9 -3
- package/dist/auth/session_queries.d.ts +31 -6
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +31 -6
- package/dist/auth/standard_rpc_actions.d.ts +9 -9
- package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
- package/dist/auth/standard_rpc_actions.js +3 -3
- package/dist/db/CLAUDE.md +7 -1
- package/dist/db/status.d.ts +58 -1
- package/dist/db/status.d.ts.map +1 -1
- package/dist/db/status.js +205 -77
- package/dist/http/CLAUDE.md +7 -3
- package/dist/http/db_routes.d.ts +6 -2
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +2 -2
- package/dist/periodic.d.ts +80 -0
- package/dist/periodic.d.ts.map +1 -0
- package/dist/periodic.js +122 -0
- package/dist/realtime/sse_auth_guard.d.ts +79 -49
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +103 -73
- package/dist/realtime/subscriber_registry.d.ts +119 -8
- package/dist/realtime/subscriber_registry.d.ts.map +1 -1
- package/dist/realtime/subscriber_registry.js +184 -25
- package/dist/server/app_backend.d.ts +12 -1
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +5 -2
- package/dist/server/app_server.d.ts +55 -15
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +41 -13
- package/dist/testing/CLAUDE.md +140 -48
- package/dist/testing/app_server.d.ts +2 -2
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +11 -2
- package/dist/testing/audit_drift_guard.d.ts +1 -48
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +0 -42
- package/dist/testing/connection_closer_helpers.d.ts +9 -22
- package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
- package/dist/testing/connection_closer_helpers.js +11 -17
- package/dist/testing/cross_backend/capabilities.d.ts +67 -13
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +27 -12
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +21 -0
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
- package/dist/testing/cross_backend/session_cap.d.ts +13 -0
- package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
- package/dist/testing/cross_backend/session_cap.js +55 -19
- package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/sse_round_trip.js +93 -26
- package/dist/testing/cross_backend/testing_server_bun.js +4 -4
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
- package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
- package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +156 -19
- package/dist/testing/sse_round_trip.d.ts +6 -4
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/stubs.d.ts +1 -1
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +6 -4
- package/dist/testing/transports/sse_transport.d.ts +1 -1
- package/dist/testing/transports/ws_client.d.ts +7 -0
- package/dist/testing/transports/ws_client.d.ts.map +1 -1
- package/dist/testing/transports/ws_raw_client.d.ts +51 -0
- package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
- package/dist/testing/transports/ws_raw_client.js +156 -0
- package/dist/testing/transports/ws_transport.d.ts +28 -0
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +45 -1
- package/dist/testing/ws_round_trip.d.ts +8 -3
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +19 -4
- package/dist/ui/AdminAccounts.svelte +4 -3
- package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
- package/dist/ui/AdminOverview.svelte +3 -1
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +22 -6
- package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +16 -0
- package/dist/ui/auth_state.svelte.d.ts +25 -1
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +28 -1
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ import '../assert_dev_env.js';
|
|
|
11
11
|
* (`describe_standard_cross_process_tests`) omits SSE by design, so consumers
|
|
12
12
|
* call this alongside it (paralleling `describe_cross_process_ws_tests`).
|
|
13
13
|
*
|
|
14
|
-
*
|
|
14
|
+
* Its cases follow the in-process SSE self-test against fuz_app's
|
|
15
15
|
* standard audit-log stream:
|
|
16
16
|
*
|
|
17
17
|
* 1. **connects** — the stream opens and emits the `: connected` comment.
|
|
@@ -23,20 +23,28 @@ import '../assert_dev_env.js';
|
|
|
23
23
|
* stream opens so `create_account`'s own audit events (invite / signup /
|
|
24
24
|
* login / token) don't land on it.
|
|
25
25
|
* 3. **close-on-revoke, account-wide** (gated on `rpc_path`) — the subscriber's
|
|
26
|
-
* *own* sessions are revoked (`account_session_revoke_all`),
|
|
27
|
-
*
|
|
28
|
-
* the
|
|
29
|
-
* via `SseTransport.wait_for_close`.
|
|
26
|
+
* *own* sessions are revoked (`account_session_revoke_all`), which closes
|
|
27
|
+
* every stream of the account once the revoke commits (the handler's
|
|
28
|
+
* close, repeated by the audit guard on the `session_revoke_all` row).
|
|
29
|
+
* Asserted via `SseTransport.wait_for_close`.
|
|
30
30
|
* 4. **close-on-revoke, session-scoped** (gated on `rpc_path`) — the
|
|
31
31
|
* subscriber's *own* single session is revoked (`account_session_revoke`),
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* which closes only the stream registered under that session's hash (the
|
|
33
|
+
* distinct close cases 2–3 don't reach).
|
|
34
|
+
* 5. **close on account delete** (gated on `rpc_path` and
|
|
35
|
+
* `capabilities.account_lifecycle`) — the keeper soft-deletes a second
|
|
36
|
+
* admin whose stream is open; the delete revokes its credentials and
|
|
37
|
+
* closes its stream.
|
|
38
|
+
* 6. **per-session cap** — one stream past `max_per_scope` on one session
|
|
39
|
+
* closes the oldest (evict-oldest, no final frame) and leaves every other
|
|
40
|
+
* stream open. Needs no RPC: the cap runs inside the subscribe itself.
|
|
34
41
|
*
|
|
35
|
-
* The close-on-revoke matrix is layered: cases 3–
|
|
36
|
-
* and session-scoped paths cross-process; the remaining
|
|
37
|
-
* (`token_revoke_all` / `logout` / `password_change`, all
|
|
38
|
-
* `
|
|
39
|
-
*
|
|
42
|
+
* The close-on-revoke matrix is layered: cases 3–5 exercise the account-wide
|
|
43
|
+
* and session-scoped paths cross-process; the remaining revocation events
|
|
44
|
+
* (`token_revoke_all` / `logout` / `password_change` / `account_purge`, all
|
|
45
|
+
* account-wide; `token_revoke`, token-scoped; and `role_grant_revoke`,
|
|
46
|
+
* role-matched) are covered by the spine's `fuz_realtime` SSE-registry unit
|
|
47
|
+
* tests and the in-process guard self-test, so a cross-process
|
|
40
48
|
* `token_revoke_all`-with-zero-tokens case (which may emit no audit row) stays
|
|
41
49
|
* out to keep the spawned-backend suite non-flaky.
|
|
42
50
|
*
|
|
@@ -49,8 +57,9 @@ import '../assert_dev_env.js';
|
|
|
49
57
|
*/
|
|
50
58
|
import { assert, describe } from 'vitest';
|
|
51
59
|
import { account_session_list_action_spec, account_session_revoke_action_spec, account_session_revoke_all_action_spec } from '../../auth/account_action_specs.js';
|
|
52
|
-
import { admin_session_revoke_all_action_spec } from '../../auth/admin_action_specs.js';
|
|
53
|
-
import {
|
|
60
|
+
import { account_delete_action_spec, admin_session_revoke_all_action_spec } from '../../auth/admin_action_specs.js';
|
|
61
|
+
import { ROLE_ADMIN } from '../../auth/role_schema.js';
|
|
62
|
+
import { AUDIT_LOG_SSE_MAX_PER_SCOPE, audit_log_event_specs } from '../../realtime/sse_auth_guard.js';
|
|
54
63
|
import { SSE_CONNECTED_COMMENT } from '../../realtime/sse_constants.js';
|
|
55
64
|
import { create_sse_transport } from '../transports/sse_transport.js';
|
|
56
65
|
import { create_rpc_post_init } from '../rpc_helpers.js';
|
|
@@ -73,13 +82,15 @@ const assert_audit_data_frame = (frame) => {
|
|
|
73
82
|
assert.ok(result.success, `audit data frame params mismatch for '${String(payload.method)}': ${result.success ? '' : JSON.stringify(result.error.issues)}`);
|
|
74
83
|
};
|
|
75
84
|
/**
|
|
76
|
-
* Register the cross-process SSE round-trip suite.
|
|
85
|
+
* Register the cross-process SSE round-trip suite. Its cases run over a
|
|
77
86
|
* real streaming `fetch`: connected-comment, audit data frame, account-wide
|
|
78
|
-
* close-on-revoke,
|
|
87
|
+
* close-on-revoke, session-scoped close-on-revoke, close on account delete,
|
|
88
|
+
* and the per-session cap.
|
|
79
89
|
*/
|
|
80
90
|
export const describe_cross_process_sse_tests = (options) => {
|
|
81
91
|
const { setup_test, capabilities, base_url, rpc_path, origin } = options;
|
|
82
92
|
const sse_path = options.sse_path ?? DEFAULT_SSE_PATH;
|
|
93
|
+
const max_per_scope = options.max_per_scope === undefined ? AUDIT_LOG_SSE_MAX_PER_SCOPE : options.max_per_scope;
|
|
83
94
|
describe('cross-process sse', () => {
|
|
84
95
|
test_if(capabilities.sse, 'connects and emits the connected comment', async () => {
|
|
85
96
|
const fixture = await setup_test();
|
|
@@ -127,8 +138,9 @@ export const describe_cross_process_sse_tests = (options) => {
|
|
|
127
138
|
await sse.close();
|
|
128
139
|
}
|
|
129
140
|
});
|
|
130
|
-
// Revoke the subscriber's OWN sessions →
|
|
131
|
-
//
|
|
141
|
+
// Revoke the subscriber's OWN sessions → the handler closes the keeper's
|
|
142
|
+
// streams once the revoke commits (the audit guard repeats the close on
|
|
143
|
+
// the `session_revoke_all` row).
|
|
132
144
|
test_if(capabilities.sse && rpc_path !== undefined, 'stream closes when the subscriber sessions are revoked', async () => {
|
|
133
145
|
const fixture = await setup_test();
|
|
134
146
|
const sse = await create_sse_transport({
|
|
@@ -150,14 +162,15 @@ export const describe_cross_process_sse_tests = (options) => {
|
|
|
150
162
|
}
|
|
151
163
|
});
|
|
152
164
|
// Single `session_revoke` of the subscriber's OWN session → the
|
|
153
|
-
// session-hash-scoped close
|
|
154
|
-
// `close_by_identity(
|
|
155
|
-
// so revoking it by its blake3 hash drops the stream
|
|
156
|
-
// This is the close-on-revoke path the account-wide
|
|
157
|
-
// exercise; the remaining
|
|
158
|
-
// `
|
|
159
|
-
// `session_revoke_all`
|
|
160
|
-
//
|
|
165
|
+
// session-hash-scoped close (the Rust registry's `close_for_session`, the
|
|
166
|
+
// TS registry's `close_by_identity(session_hash)`). The keeper holds
|
|
167
|
+
// exactly one session, so revoking it by its blake3 hash drops the stream
|
|
168
|
+
// opened under it. This is the close-on-revoke path the account-wide case
|
|
169
|
+
// above doesn't exercise; the remaining account-wide events
|
|
170
|
+
// (`token_revoke_all` / `logout` / `password_change` / `account_purge`)
|
|
171
|
+
// share the close the `session_revoke_all` and account-delete cases cover,
|
|
172
|
+
// and `token_revoke`'s token-scoped and `role_grant_revoke`'s role-matched
|
|
173
|
+
// closes are covered by `fuz_realtime`'s SSE registry unit tests.
|
|
161
174
|
test_if(capabilities.sse && rpc_path !== undefined, 'stream closes on a single session_revoke of the subscriber session', async () => {
|
|
162
175
|
const fixture = await setup_test();
|
|
163
176
|
const sse = await create_sse_transport({
|
|
@@ -183,5 +196,59 @@ export const describe_cross_process_sse_tests = (options) => {
|
|
|
183
196
|
await sse.close();
|
|
184
197
|
}
|
|
185
198
|
});
|
|
199
|
+
// A soft-deleted account's credentials stop authenticating, and a stream
|
|
200
|
+
// never rechecks its own — so the delete must close it. The keeper
|
|
201
|
+
// deletes a second admin whose stream is open; the keeper's own admin
|
|
202
|
+
// grant keeps the delete clear of the last-admin guard.
|
|
203
|
+
test_if(capabilities.sse && capabilities.account_lifecycle && rpc_path !== undefined, 'stream closes when the subscriber account is deleted', async () => {
|
|
204
|
+
const fixture = await setup_test();
|
|
205
|
+
const target = await fixture.create_account({
|
|
206
|
+
username: 'sse_delete_target',
|
|
207
|
+
roles: [ROLE_ADMIN]
|
|
208
|
+
});
|
|
209
|
+
const cookie = target.create_session_headers().cookie;
|
|
210
|
+
assert.ok(cookie, 'expected a session cookie for the target account');
|
|
211
|
+
const sse = await create_sse_transport({ base_url, sse_path, cookies: [cookie], origin });
|
|
212
|
+
try {
|
|
213
|
+
const first = await sse.read_frame();
|
|
214
|
+
assert.strictEqual(first + '\n\n', SSE_CONNECTED_COMMENT, 'first frame must be the connected comment');
|
|
215
|
+
const res = await fixture.transport(rpc_path, create_rpc_post_init(account_delete_action_spec.method, {
|
|
216
|
+
account_id: target.account.id
|
|
217
|
+
}));
|
|
218
|
+
assert.strictEqual(res.status, 200, `account_delete RPC failed (status=${res.status})`);
|
|
219
|
+
const closed = await sse.wait_for_close(2000);
|
|
220
|
+
assert.ok(closed, 'stream did not close within 2s after account_delete');
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await sse.close();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
// One stream past the cap on one session → the oldest closes, the newest
|
|
227
|
+
// stays open. Opened in sequence, each awaited to its connect comment, so
|
|
228
|
+
// the server registered them in order and "oldest" is stream 0 on both
|
|
229
|
+
// spines. The evicted stream ends with no final frame, so a browser
|
|
230
|
+
// `EventSource` would reconnect — this asserts the server side only.
|
|
231
|
+
test_if(capabilities.sse && max_per_scope !== null, 'one stream past the per-session cap closes the oldest', async () => {
|
|
232
|
+
const fixture = await setup_test();
|
|
233
|
+
const cookies = fixture.transport.cookies();
|
|
234
|
+
const streams = [];
|
|
235
|
+
try {
|
|
236
|
+
for (let i = 0; i <= max_per_scope; i++) {
|
|
237
|
+
const sse = await create_sse_transport({ base_url, sse_path, cookies, origin });
|
|
238
|
+
streams.push(sse);
|
|
239
|
+
const first = await sse.read_frame();
|
|
240
|
+
assert.strictEqual(first + '\n\n', SSE_CONNECTED_COMMENT, `stream ${i}: first frame must be the connected comment`);
|
|
241
|
+
}
|
|
242
|
+
const [oldest, ...survivors] = streams;
|
|
243
|
+
const closed = await oldest.wait_for_close(2000);
|
|
244
|
+
assert.ok(closed, 'the oldest stream did not close within 2s of the cap overflow');
|
|
245
|
+
// only the oldest — every later stream on the session stays open
|
|
246
|
+
const survivors_closed = await Promise.all(survivors.map((sse) => sse.wait_for_close(200)));
|
|
247
|
+
assert.deepStrictEqual(survivors_closed, survivors.map(() => false), 'only the oldest stream may close; the rest of the session stays open');
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
await Promise.all(streams.map((sse) => sse.close()));
|
|
251
|
+
}
|
|
252
|
+
});
|
|
186
253
|
});
|
|
187
254
|
};
|
|
@@ -53,10 +53,10 @@ export const create_bun_testing_adapter = () => ({
|
|
|
53
53
|
// hang), and the client runtime — a single server-closed WS is
|
|
54
54
|
// necessary and sufficient. Client-initiated close or leaving the
|
|
55
55
|
// socket open both stop cleanly in ~0ms. In this suite the trigger is
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
56
|
+
// any server-initiated WS close — a revocation's (the `ws.cross.test.ts`
|
|
57
|
+
// close-on-revoke cases), a cap eviction's, an oversized message's —
|
|
58
|
+
// which is why teardown hangs after those and not under HTTP-only or
|
|
59
|
+
// client-closed WS traffic.
|
|
60
60
|
//
|
|
61
61
|
// So initiate a force-close (`true` drops active connections, no
|
|
62
62
|
// drain) but DON'T await it: awaiting hangs `start_testing_server`'s
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AASzD,8GAA8G;AAC9G,eAAO,MAAM,gBAAgB,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"ts_spine_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ts_spine_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AASzD,8GAA8G;AAC9G,eAAO,MAAM,gBAAgB,6BAA6B,CAAC;AA0C3D,6FAA6F;AAC7F,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,iDAAiD;AACjD,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,gDAAgD;AAChD,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAE9C,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,yDAAyD;AACzD,eAAO,MAAM,mBAAmB,wDAAwD,CAAC;AAEzF,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,uDAAuD,CAAC;AAEvF,MAAM,WAAW,2BAA2B;IAC3C,mFAAmF;IACnF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;;;;OASG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;CAC3C;AAWD;;;GAGG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAmBF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,GACvC,UAAS,2BAAgC,KACvC,aAmBF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GACxC,UAAS,2BAAgC,KACvC,aAgCF,CAAC"}
|
|
@@ -9,6 +9,7 @@ export const TS_SPINE_DIR_ENV = 'FUZ_TESTING_TS_SPINE_DIR';
|
|
|
9
9
|
* (the binary wires `audit_log_sse`), `ready` (the binary live-mounts the
|
|
10
10
|
* `/ready` deploy gate in `build_spine_app`), `peer_request` (the backend
|
|
11
11
|
* WS transport's `request_connection` path drives server→client `peer/ping`),
|
|
12
|
+
* `ws_account_actions` (the binary mounts the account actions on `/api/ws`),
|
|
12
13
|
* and `cell_gated_create` (the binary's `full_spine_mount` mounts the
|
|
13
14
|
* `test_cell_gated_create_authorize` policy).
|
|
14
15
|
*/
|
|
@@ -17,11 +18,13 @@ const ts_spine_capabilities = Object.freeze({
|
|
|
17
18
|
sse: true,
|
|
18
19
|
ready: true,
|
|
19
20
|
peer_request: true,
|
|
21
|
+
ws_account_actions: true,
|
|
20
22
|
cell_gated_create: true
|
|
21
23
|
});
|
|
22
24
|
/**
|
|
23
25
|
* Capabilities for the **Bun** spine binary — `ts_spine_capabilities` with
|
|
24
|
-
* `oversized_reject_closes_connection: false
|
|
26
|
+
* `oversized_reject_closes_connection: false` and
|
|
27
|
+
* `ws_handshake_pipelining: false`. `Bun.serve` drains the declared
|
|
25
28
|
* `Content-Length` of an oversized-body `413` reject and keeps the socket
|
|
26
29
|
* alive (processing the correctly-framed pipelined request) even when the
|
|
27
30
|
* response carries `Connection: close`, unlike `@hono/node-server` / Deno /
|
|
@@ -29,10 +32,15 @@ const ts_spine_capabilities = Object.freeze({
|
|
|
29
32
|
* there is no desync — but the smuggling suite's strong "connection closes"
|
|
30
33
|
* assertion doesn't hold; this flag routes Bun onto the suite's no-desync arm.
|
|
31
34
|
* See `docs/security.md` §"Body Size Limiting".
|
|
35
|
+
*
|
|
36
|
+
* `Bun.serve` also answers `400` to an upgrade request that carries WebSocket
|
|
37
|
+
* frames in the same TCP write, before any handler runs, so the WS suite's
|
|
38
|
+
* frames-with-the-handshake case has nothing to drive there.
|
|
32
39
|
*/
|
|
33
40
|
const ts_spine_bun_capabilities = Object.freeze({
|
|
34
41
|
...ts_spine_capabilities,
|
|
35
|
-
oversized_reject_closes_connection: false
|
|
42
|
+
oversized_reject_closes_connection: false,
|
|
43
|
+
ws_handshake_pipelining: false
|
|
36
44
|
});
|
|
37
45
|
/** Default port for the Node TS spine binary — slots beside the Rust `spine_stub` (1177). */
|
|
38
46
|
export const TS_SPINE_NODE_DEFAULT_PORT = 1178;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import { type BackendCapabilities } from './capabilities.js';
|
|
3
|
+
import type { SetupTest } from './setup.js';
|
|
4
|
+
/** Options for the WebSocket connection-cap parity suite. */
|
|
5
|
+
export interface WsConnectionCapCrossTestOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Per-test fixture producer (`default_cross_process_setup(handle)`). Every
|
|
8
|
+
* socket in a case upgrades with the fresh-per-test keeper's session
|
|
9
|
+
* cookies, so they all land on one account.
|
|
10
|
+
*/
|
|
11
|
+
readonly setup_test: SetupTest;
|
|
12
|
+
/** Backend capability flags; every case gates on `capabilities.ws`. */
|
|
13
|
+
readonly capabilities: BackendCapabilities;
|
|
14
|
+
/** Base URL the backend is reachable at (e.g. `http://localhost:1178`). */
|
|
15
|
+
readonly base_url: string;
|
|
16
|
+
/** WebSocket endpoint path on the backend (e.g. `/api/ws`). */
|
|
17
|
+
readonly ws_path: string;
|
|
18
|
+
/** Origin for the upgrades. Defaults to `base_url`. */
|
|
19
|
+
readonly origin?: string;
|
|
20
|
+
/**
|
|
21
|
+
* The backend's per-account connection cap, which each case opens past.
|
|
22
|
+
* Defaults to `DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT`, the cap both spines
|
|
23
|
+
* apply unless overridden; pass the consumer's value when it sets one, or
|
|
24
|
+
* `null` (a backend with the cap disabled) to skip the cases.
|
|
25
|
+
*/
|
|
26
|
+
readonly max_connections_per_account?: number | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Register the WebSocket connection-cap parity suite over a real upgrade:
|
|
30
|
+
* one connection past the cap closes the account's oldest with
|
|
31
|
+
* `WS_CLOSE_CONNECTION_LIMIT` and nothing else, and a closed connection
|
|
32
|
+
* frees its slot.
|
|
33
|
+
*/
|
|
34
|
+
export declare const describe_ws_connection_cap_cross_tests: (options: WsConnectionCapCrossTestOptions) => void;
|
|
35
|
+
//# sourceMappingURL=ws_connection_cap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws_connection_cap.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_connection_cap.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAsD9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,6DAA6D;AAC7D,MAAM,WAAW,+BAA+B;IAC/C;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,uEAAuE;IACvE,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uDAAuD;IACvD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,2BAA2B,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrD;AAED;;;;;GAKG;AACH,eAAO,MAAM,sCAAsC,GAClD,SAAS,+BAA+B,KACtC,IA4GF,CAAC"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend parity suite for the **per-account WebSocket connection cap**
|
|
4
|
+
* over a real upgrade.
|
|
5
|
+
*
|
|
6
|
+
* Both spines bound an account's concurrent WebSocket connections
|
|
7
|
+
* (`BackendWebsocketTransport`'s `max_connections_per_account` on the TS side,
|
|
8
|
+
* the Rust `fuz_realtime` `ConnectionRegistry` on the other; both default to
|
|
9
|
+
* `DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT`) with the same policy: **evict-oldest**
|
|
10
|
+
* — a connection past the cap is always admitted, and the account's oldest is
|
|
11
|
+
* closed with `WS_CLOSE_CONNECTION_LIMIT` and the reason `connection limit`.
|
|
12
|
+
* A client reads that close as "superseded": closed until the user acts, not
|
|
13
|
+
* revoked and not reconnecting (`FrontendWebsocketClient.superseded`). The
|
|
14
|
+
* code, the reason, and which socket closes are therefore wire contract, and
|
|
15
|
+
* this suite is the pin that runs on both. Two cases:
|
|
16
|
+
*
|
|
17
|
+
* - **the oldest is superseded, and it is not a revocation** — on one session,
|
|
18
|
+
* opening one socket past the cap closes exactly the first with the
|
|
19
|
+
* connection-limit code and reason; every other socket, the newest included,
|
|
20
|
+
* stays open and still dispatches. The session then upgrades again, which a
|
|
21
|
+
* revoked session could not — and that upgrade supersedes the next-oldest
|
|
22
|
+
* in turn.
|
|
23
|
+
* - **a closed connection frees its slot** — one socket held open while a
|
|
24
|
+
* cap's worth of others open and close in sequence is never evicted. A
|
|
25
|
+
* backend that leaks closed connections in its registry counts them toward
|
|
26
|
+
* the cap and closes the held socket, which is what this catches.
|
|
27
|
+
*
|
|
28
|
+
* Sockets open in sequence, each answering a `heartbeat` before the next
|
|
29
|
+
* opens (`create_admitted_ws_transport`), so the backend registered them in
|
|
30
|
+
* that order and "oldest" names the same socket on both spines (an upgrade
|
|
31
|
+
* answers its handshake before the connection is registered and admitted, so
|
|
32
|
+
* the handshake alone doesn't order them).
|
|
33
|
+
*
|
|
34
|
+
* **Consumer-agnostic**, like `describe_cross_process_ws_tests`: it drives
|
|
35
|
+
* only the `heartbeat` protocol action, present on every WS endpoint. Gated on
|
|
36
|
+
* `capabilities.ws`; cross-process only (`create_ws_transport` needs a real
|
|
37
|
+
* bound socket). Each case opens more than a cap's worth of real sockets,
|
|
38
|
+
* which is why it's a separate call rather than more cases on the WS
|
|
39
|
+
* round-trip suite.
|
|
40
|
+
*
|
|
41
|
+
* `$lib`-free by contract (relative specifiers only), like the sibling
|
|
42
|
+
* cross-backend suites.
|
|
43
|
+
*
|
|
44
|
+
* @module
|
|
45
|
+
*/
|
|
46
|
+
import { assert, describe } from 'vitest';
|
|
47
|
+
import { heartbeat_action_spec } from '../../actions/heartbeat.js';
|
|
48
|
+
import { WS_CLOSE_CONNECTION_LIMIT } from '../../actions/transports.js';
|
|
49
|
+
import { DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT } from '../../actions/transports_ws_backend.js';
|
|
50
|
+
import { create_admitted_ws_transport } from '../transports/ws_transport.js';
|
|
51
|
+
import { test_if } from './capabilities.js';
|
|
52
|
+
/** The close reason both spines send with `WS_CLOSE_CONNECTION_LIMIT`. */
|
|
53
|
+
const CONNECTION_LIMIT_REASON = 'connection limit';
|
|
54
|
+
/**
|
|
55
|
+
* Register the WebSocket connection-cap parity suite over a real upgrade:
|
|
56
|
+
* one connection past the cap closes the account's oldest with
|
|
57
|
+
* `WS_CLOSE_CONNECTION_LIMIT` and nothing else, and a closed connection
|
|
58
|
+
* frees its slot.
|
|
59
|
+
*/
|
|
60
|
+
export const describe_ws_connection_cap_cross_tests = (options) => {
|
|
61
|
+
const { setup_test, capabilities, base_url, ws_path, origin } = options;
|
|
62
|
+
const max = options.max_connections_per_account === undefined
|
|
63
|
+
? DEFAULT_MAX_CONNECTIONS_PER_ACCOUNT
|
|
64
|
+
: options.max_connections_per_account;
|
|
65
|
+
const enabled = capabilities.ws && max !== null;
|
|
66
|
+
/**
|
|
67
|
+
* Open a socket the backend has admitted (a `heartbeat` round trip), so
|
|
68
|
+
* the connections are registered in the order they were opened.
|
|
69
|
+
*/
|
|
70
|
+
const open_registered = (cookies) => create_admitted_ws_transport({ base_url, ws_path, cookies, origin });
|
|
71
|
+
const assert_superseded = async (client, label) => {
|
|
72
|
+
const closed = await client.wait_for_close(2000);
|
|
73
|
+
assert.ok(closed, `${label} did not close within 2s of the cap overflow`);
|
|
74
|
+
assert.strictEqual(client.close_code, WS_CLOSE_CONNECTION_LIMIT, `${label} close code`);
|
|
75
|
+
assert.strictEqual(client.close_reason, CONNECTION_LIMIT_REASON, `${label} close reason`);
|
|
76
|
+
};
|
|
77
|
+
/** Every client still open and answering — a fresh request id per call via `id`. */
|
|
78
|
+
const assert_all_answer = async (clients, id, message) => {
|
|
79
|
+
const results = await Promise.all(clients.map((client) => client.request(id, heartbeat_action_spec.method, {})));
|
|
80
|
+
assert.deepStrictEqual(results, clients.map(() => ({})), message);
|
|
81
|
+
assert.deepStrictEqual(clients.map((client) => client.close_code), clients.map(() => null), message);
|
|
82
|
+
};
|
|
83
|
+
// each case opens more than a cap's worth of real sockets in sequence, so its
|
|
84
|
+
// time scales with the cap and the backend's upgrade cost
|
|
85
|
+
describe('per-account websocket connection cap parity', { timeout: 30_000 }, () => {
|
|
86
|
+
test_if(enabled, 'one connection past the cap closes the oldest with WS_CLOSE_CONNECTION_LIMIT, and only it', async () => {
|
|
87
|
+
const fixture = await setup_test();
|
|
88
|
+
const cookies = fixture.transport.cookies();
|
|
89
|
+
const sockets = [];
|
|
90
|
+
try {
|
|
91
|
+
for (let i = 0; i < max; i++) {
|
|
92
|
+
sockets.push(await open_registered(cookies));
|
|
93
|
+
}
|
|
94
|
+
// at the cap, nothing has closed
|
|
95
|
+
await assert_all_answer(sockets, 'at_cap', 'every socket at the cap stays open');
|
|
96
|
+
// one past it: always admitted, never refused
|
|
97
|
+
sockets.push(await open_registered(cookies));
|
|
98
|
+
const [oldest, ...survivors] = sockets;
|
|
99
|
+
await assert_superseded(oldest, 'the oldest socket');
|
|
100
|
+
await assert_all_answer(survivors, 'past_cap', 'only the oldest may close; every other socket, the newest included, keeps answering');
|
|
101
|
+
// Not a revocation — the session still authenticates an upgrade
|
|
102
|
+
// (a revoked one is refused there). That upgrade is one more past
|
|
103
|
+
// the cap, so it supersedes the next-oldest.
|
|
104
|
+
sockets.push(await open_registered(cookies));
|
|
105
|
+
await assert_superseded(survivors[0], 'the next-oldest socket');
|
|
106
|
+
await assert_all_answer(sockets.slice(2), 'past_cap_again', 'the session keeps its newest sockets after a second overflow');
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await Promise.all(sockets.map((client) => client.close().catch(() => { })));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
test_if(enabled, 'a closed connection frees its slot', async () => {
|
|
113
|
+
const fixture = await setup_test();
|
|
114
|
+
const cookies = fixture.transport.cookies();
|
|
115
|
+
const held = await open_registered(cookies);
|
|
116
|
+
try {
|
|
117
|
+
// A cap's worth of connections, one at a time, each closed before
|
|
118
|
+
// the next opens — the account never holds more than a few at once,
|
|
119
|
+
// however late the backend notices each close.
|
|
120
|
+
for (let i = 0; i < max; i++) {
|
|
121
|
+
const client = await open_registered(cookies);
|
|
122
|
+
await client.close();
|
|
123
|
+
}
|
|
124
|
+
await assert_all_answer([held], 'held', 'closed connections must not count toward the cap — the held socket was evicted');
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await held.close().catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
};
|
|
@@ -37,7 +37,8 @@ export interface CrossProcessWsTestOptions {
|
|
|
37
37
|
* `heartbeat` round-trip, `heartbeat`'s parameterless-shape contract, an
|
|
38
38
|
* oversized message closing with `WS_CLOSE_MESSAGE_TOO_BIG`, anonymous-upgrade
|
|
39
39
|
* refusal, disallowed-origin refusal, and — when `rpc_path` is supplied —
|
|
40
|
-
* session
|
|
40
|
+
* session revocation and account deletion closing the live socket, plus a
|
|
41
|
+
* self-revoking socket reading its reply before its close.
|
|
41
42
|
*/
|
|
42
43
|
export declare const describe_cross_process_ws_tests: (options: CrossProcessWsTestOptions) => void;
|
|
43
44
|
//# sourceMappingURL=ws_round_trip.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA6F9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,iEAAiE;AACjE,MAAM,WAAW,yBAAyB;IACzC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,uEAAuE;IACvE,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,GAAI,SAAS,yBAAyB,KAAG,IA+RpF,CAAC"}
|