@fuzdev/fuz_app 0.125.0 → 0.127.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 (94) hide show
  1. package/dist/actions/CLAUDE.md +56 -20
  2. package/dist/actions/action_rpc.d.ts +14 -11
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +5 -4
  5. package/dist/actions/connection_closer.d.ts +13 -3
  6. package/dist/actions/connection_closer.d.ts.map +1 -1
  7. package/dist/actions/connection_closer.js +7 -5
  8. package/dist/actions/register_action_ws.d.ts +18 -13
  9. package/dist/actions/register_action_ws.d.ts.map +1 -1
  10. package/dist/actions/register_action_ws.js +8 -6
  11. package/dist/actions/register_ws_endpoint.d.ts +6 -3
  12. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  13. package/dist/actions/register_ws_endpoint.js +6 -3
  14. package/dist/actions/transports.d.ts +9 -0
  15. package/dist/actions/transports.d.ts.map +1 -1
  16. package/dist/actions/transports.js +9 -0
  17. package/dist/actions/transports_ws_backend.d.ts +15 -0
  18. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  19. package/dist/actions/transports_ws_backend.js +25 -5
  20. package/dist/actions/ws_endpoint_spec.d.ts +24 -4
  21. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  22. package/dist/actions/ws_endpoint_spec.js +15 -1
  23. package/dist/auth/middleware.d.ts +29 -1
  24. package/dist/auth/middleware.d.ts.map +1 -1
  25. package/dist/auth/middleware.js +35 -1
  26. package/dist/http/CLAUDE.md +3 -3
  27. package/dist/http/surface.d.ts +12 -9
  28. package/dist/http/surface.d.ts.map +1 -1
  29. package/dist/rate_limiter.d.ts +43 -3
  30. package/dist/rate_limiter.d.ts.map +1 -1
  31. package/dist/rate_limiter.js +27 -3
  32. package/dist/realtime/sse_auth_guard.d.ts +2 -1
  33. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  34. package/dist/realtime/sse_auth_guard.js +4 -2
  35. package/dist/realtime/subscriber_registry.d.ts +12 -0
  36. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  37. package/dist/realtime/subscriber_registry.js +18 -2
  38. package/dist/server/app_server.d.ts +128 -55
  39. package/dist/server/app_server.d.ts.map +1 -1
  40. package/dist/server/app_server.js +110 -61
  41. package/dist/server/app_server_context.d.ts +17 -9
  42. package/dist/server/app_server_context.d.ts.map +1 -1
  43. package/dist/testing/CLAUDE.md +83 -25
  44. package/dist/testing/app_server.d.ts +6 -3
  45. package/dist/testing/app_server.d.ts.map +1 -1
  46. package/dist/testing/app_server.js +20 -20
  47. package/dist/testing/connection_closer_helpers.d.ts +10 -4
  48. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  49. package/dist/testing/connection_closer_helpers.js +4 -0
  50. package/dist/testing/cross_backend/capabilities.d.ts +3 -3
  51. package/dist/testing/cross_backend/default_backend_configs.d.ts +12 -12
  52. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  53. package/dist/testing/cross_backend/default_backend_configs.js +11 -10
  54. package/dist/testing/cross_backend/full_spine_mount.d.ts +35 -11
  55. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
  56. package/dist/testing/cross_backend/full_spine_mount.js +26 -9
  57. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +16 -1
  58. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  59. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +5 -4
  60. package/dist/testing/cross_backend/spine_surface_constants.d.ts +43 -3
  61. package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
  62. package/dist/testing/cross_backend/spine_surface_constants.js +43 -3
  63. package/dist/testing/cross_backend/testing_backdoor.d.ts +15 -1
  64. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  65. package/dist/testing/cross_backend/testing_backdoor.js +51 -6
  66. package/dist/testing/cross_backend/testing_reset_actions.d.ts +6 -3
  67. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  68. package/dist/testing/cross_backend/testing_reset_actions.js +6 -3
  69. package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -1
  70. package/dist/testing/cross_backend/testing_server_bun.js +5 -1
  71. package/dist/testing/cross_backend/testing_server_core.d.ts +72 -41
  72. package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -1
  73. package/dist/testing/cross_backend/testing_server_core.js +22 -14
  74. package/dist/testing/cross_backend/testing_server_deno.js +1 -1
  75. package/dist/testing/cross_backend/testing_server_node.d.ts.map +1 -1
  76. package/dist/testing/cross_backend/testing_server_node.js +20 -7
  77. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +15 -2
  78. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  79. package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -8
  80. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts +26 -0
  81. package/dist/testing/cross_backend/ws_action_rate_limit.d.ts.map +1 -0
  82. package/dist/testing/cross_backend/ws_action_rate_limit.js +261 -0
  83. package/dist/testing/middleware.d.ts.map +1 -1
  84. package/dist/testing/middleware.js +4 -3
  85. package/dist/testing/stubs.d.ts +17 -4
  86. package/dist/testing/stubs.d.ts.map +1 -1
  87. package/dist/testing/stubs.js +46 -13
  88. package/dist/testing/transports/ws_transport.d.ts +7 -0
  89. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  90. package/dist/testing/transports/ws_transport.js +1 -1
  91. package/dist/testing/ws_round_trip.d.ts +43 -37
  92. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  93. package/dist/testing/ws_round_trip.js +28 -46
  94. package/package.json +2 -2
@@ -0,0 +1,261 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend parity suite for **action rate limiting over a real
4
+ * WebSocket**, and its budget shared with HTTP RPC.
5
+ *
6
+ * An action spec's `rate_limit` class (`'ip'` / `'account'` / `'both'`) is
7
+ * charged by the dispatcher on both transports, and a server holds one
8
+ * limiter per axis, so an action's budget is per caller, not per transport.
9
+ * The in-process tests pin that through an assembled app; this suite pins it
10
+ * over the wire, against backends spawned with small shared limiters (the
11
+ * action rate-limit toggles — `ACTION_RATE_LIMIT_ENABLED_ENV` and
12
+ * `ACTION_RATE_LIMIT_MAX_ATTEMPTS_ENV` — see `global_setup_login_security.ts`):
13
+ *
14
+ * - **account axis over WS** — `account_token_create` (`rate_limit:
15
+ * 'account'` on both spines) with malformed params: the first `max_attempts`
16
+ * calls each answer `invalid_params` (the throttle runs before params
17
+ * validation, so a malformed call still charges), and the next answers
18
+ * `rate_limited` with a `retry_after`.
19
+ * - **one account bucket across transports** — calls split between HTTP RPC and
20
+ * WS exhaust one budget: the call past the cap is refused on WS and on HTTP
21
+ * (`429`).
22
+ * - **IP axis, keyed by the resolved client IP** — `peer/ping` (`rate_limit:
23
+ * 'ip'`, public) with malformed params, from one `X-Forwarded-For` address
24
+ * over anonymous HTTP and over a WS whose upgrade carried the same header:
25
+ * the call past the cap is refused on both, while a second address keeps its
26
+ * own budget on both.
27
+ * - **account axis at the dispatchers** — `cell_create` (`rate_limit:
28
+ * 'account'`, on both spines' RPC and WS mounts) with malformed params: the
29
+ * call past the cap answers `429` over HTTP and `rate_limited` over WS, calls
30
+ * split between the two transports exhaust one budget, and a budget split
31
+ * between `cell_create` and `account_token_create` is one bucket.
32
+ *
33
+ * **Charge sites.** The TS spine charges every classed action in its
34
+ * dispatcher. The Rust stub has two sites holding the same limiters: the auth
35
+ * families (`account_token_create`) charge inside their handlers, and the
36
+ * dispatcher states charge every other classed spec. So the
37
+ * `account_token_create` cases pin the Rust in-handler site, `peer/ping` pins
38
+ * the Rust WS dispatcher's IP axis, and `cell_create` pins the account axis of
39
+ * both Rust dispatchers, RPC and WS.
40
+ *
41
+ * **Isolation.** Limiter state is in-memory and `_testing_reset` wipes only
42
+ * the database, so every case reads a bucket no other case touched: the
43
+ * account cases each run on the fresh keeper the reset seeds (a new account
44
+ * id), and the IP case uses addresses no other case sends. Login limiters are
45
+ * also on in this project; nothing here logs in.
46
+ *
47
+ * Cross-process only: the composition under test is the spawned binary's
48
+ * (`create_app_server` threading one limiter pair to both mounts on TS; the
49
+ * stub's dispatcher states plus its auth-family builders on Rust).
50
+ *
51
+ * `$lib`-free by contract (relative specifiers only), like the sibling
52
+ * cross-backend suites.
53
+ *
54
+ * @module
55
+ */
56
+ import { assert, describe, test } from 'vitest';
57
+ import { create_jsonrpc_request } from '../../http/jsonrpc_helpers.js';
58
+ import { create_ws_transport } from '../transports/ws_transport.js';
59
+ import { is_response_for } from '../transports/ws_client.js';
60
+ // Wire literals are deliberate independent copies of the producers'
61
+ // constants (`JSONRPC_ERROR_CODES` on TS, the `JsonrpcError` taxonomy on
62
+ // Rust) — parity by test, so a renamed or renumbered code fails here.
63
+ /** JSON-RPC `invalid_params`. */
64
+ const CODE_INVALID_PARAMS = -32602;
65
+ /** JSON-RPC `rate_limited` — the spines' server-side throttle code. */
66
+ const CODE_RATE_LIMITED = -32006;
67
+ /** An `account`-classed action on both spines (session-only, side-effecting). */
68
+ const ACCOUNT_LIMITED_METHOD = 'account_token_create';
69
+ /** Unknown key — refused by both spines' strict params decode, after the throttle. */
70
+ const ACCOUNT_LIMITED_BAD_PARAMS = { not_a_param: true };
71
+ /**
72
+ * An `account`-classed action the Rust stub charges at its dispatchers (not
73
+ * in-handler, unlike `ACCOUNT_LIMITED_METHOD`), mounted on both spines' RPC
74
+ * and WS. Actor-grain, so the keeper's actor resolves before the throttle; the same
75
+ * unknown-key params are refused by both spines' strict decode, after it.
76
+ */
77
+ const DISPATCHER_ACCOUNT_LIMITED_METHOD = 'cell_create';
78
+ /** The `ip`-classed public protocol action on both spines. */
79
+ const IP_LIMITED_METHOD = 'peer/ping';
80
+ /** Wrong-typed `nonce` — refused by both spines' params decode, after the throttle. */
81
+ const IP_LIMITED_BAD_PARAMS = { nonce: 'not_a_number' };
82
+ /**
83
+ * RFC 5737 TEST-NET-2 (`198.51.100.0/24`) documentation addresses, spoofed
84
+ * via `X-Forwarded-For` (both spines trust the loopback peer in this project).
85
+ * Distinct from `login_security.ts`'s TEST-NET-3 addresses, though the login
86
+ * and action limiters keep separate buckets anyway.
87
+ */
88
+ const XFF_IP_EXHAUSTED = '198.51.100.1';
89
+ const XFF_IP_FRESH = '198.51.100.2';
90
+ /** Generous per-response wait — the cases assert the server's answer, not a timeout. */
91
+ const RESPONSE_TIMEOUT_MS = 5000;
92
+ const outcome_from_frame = (status, frame) => ({
93
+ status,
94
+ code: frame?.error?.code ?? null,
95
+ retry_after: frame?.error?.data?.retry_after
96
+ });
97
+ /**
98
+ * Register the action rate-limit suite: the account axis over WS, one account
99
+ * bucket across HTTP RPC and WS, the per-IP axis keyed by `X-Forwarded-For`
100
+ * across both transports, and the account axis at the RPC and WS dispatchers.
101
+ */
102
+ export const describe_ws_action_rate_limit_cross_tests = (options) => {
103
+ const { setup_test, base_url, ws_path, max_attempts } = options;
104
+ const rpc_path = options.rpc_path ?? '/api/rpc';
105
+ assert(Number.isInteger(max_attempts) && max_attempts >= 2, 'ws_action_rate_limit needs max_attempts >= 2 to split a budget across transports');
106
+ let next_id = 1;
107
+ /** POST one JSON-RPC call on a fresh (cookie-jar-free) transport. */
108
+ const call_http = async (fixture, method, params, headers) => {
109
+ const res = await fixture.fresh_transport()(rpc_path, {
110
+ method: 'POST',
111
+ headers: { 'content-type': 'application/json', ...headers },
112
+ body: JSON.stringify(create_jsonrpc_request(method, params, next_id++))
113
+ });
114
+ const body = (await res.json().catch(() => undefined));
115
+ return outcome_from_frame(res.status, body);
116
+ };
117
+ /** Send one JSON-RPC call over an open socket and read its response. */
118
+ const call_ws = async (ws, method, params) => {
119
+ const id = next_id++;
120
+ await ws.send(create_jsonrpc_request(method, params, id));
121
+ const frame = await ws.wait_for(is_response_for(id), RESPONSE_TIMEOUT_MS);
122
+ return outcome_from_frame(null, frame);
123
+ };
124
+ /** Open a socket on the keeper's session, optionally behind a forwarded IP. */
125
+ const open_ws = (fixture, forwarded_for) => {
126
+ const cookie = fixture.create_session_headers().cookie;
127
+ assert.ok(cookie, 'expected a session cookie for the WS upgrade');
128
+ return create_ws_transport({
129
+ base_url,
130
+ ws_path,
131
+ cookies: [cookie],
132
+ origin: base_url,
133
+ headers: forwarded_for ? { 'x-forwarded-for': forwarded_for } : undefined
134
+ });
135
+ };
136
+ const assert_charged_not_limited = (outcome, label) => {
137
+ assert.strictEqual(outcome.code, CODE_INVALID_PARAMS, `${label}: expected invalid_params (charged, under the cap), got ${JSON.stringify(outcome)}`);
138
+ if (outcome.status !== null)
139
+ assert.strictEqual(outcome.status, 400, `${label}: HTTP status`);
140
+ };
141
+ const assert_rate_limited = (outcome, label) => {
142
+ assert.strictEqual(outcome.code, CODE_RATE_LIMITED, `${label}: expected rate_limited, got ${JSON.stringify(outcome)}`);
143
+ assert.ok(typeof outcome.retry_after === 'number' && outcome.retry_after > 0, `${label}: rate_limited carries a positive data.retry_after, got ${String(outcome.retry_after)}`);
144
+ if (outcome.status !== null)
145
+ assert.strictEqual(outcome.status, 429, `${label}: HTTP status`);
146
+ };
147
+ describe('action rate limiting (cross-process, real socket)', () => {
148
+ test(`an account-limited action is refused on the WS call past the cap`, async () => {
149
+ const fixture = await setup_test();
150
+ const ws = await open_ws(fixture);
151
+ try {
152
+ for (let i = 1; i <= max_attempts; i++) {
153
+ assert_charged_not_limited(await call_ws(ws, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${i}`);
154
+ }
155
+ assert_rate_limited(await call_ws(ws, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${max_attempts + 1}`);
156
+ }
157
+ finally {
158
+ await ws.close();
159
+ }
160
+ });
161
+ test('HTTP RPC and WS calls draw one account budget', async () => {
162
+ const fixture = await setup_test();
163
+ const session = fixture.create_session_headers();
164
+ const http_calls = Math.floor(max_attempts / 2);
165
+ for (let i = 1; i <= http_calls; i++) {
166
+ assert_charged_not_limited(await call_http(fixture, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP call ${i}`);
167
+ }
168
+ const ws = await open_ws(fixture);
169
+ try {
170
+ for (let i = http_calls + 1; i <= max_attempts; i++) {
171
+ assert_charged_not_limited(await call_ws(ws, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${i}`);
172
+ }
173
+ assert_rate_limited(await call_ws(ws, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), 'WS call past the shared cap');
174
+ }
175
+ finally {
176
+ await ws.close();
177
+ }
178
+ assert_rate_limited(await call_http(fixture, ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), 'HTTP call past the shared cap');
179
+ });
180
+ test('an account-limited action is refused at the RPC dispatcher past the cap', async () => {
181
+ const fixture = await setup_test();
182
+ const session = fixture.create_session_headers();
183
+ for (let i = 1; i <= max_attempts; i++) {
184
+ assert_charged_not_limited(await call_http(fixture, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP call ${i}`);
185
+ }
186
+ assert_rate_limited(await call_http(fixture, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP call ${max_attempts + 1}`);
187
+ });
188
+ test('an account-limited action is refused at the WS dispatcher past the cap', async () => {
189
+ const fixture = await setup_test();
190
+ const ws = await open_ws(fixture);
191
+ try {
192
+ for (let i = 1; i <= max_attempts; i++) {
193
+ assert_charged_not_limited(await call_ws(ws, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${i}`);
194
+ }
195
+ assert_rate_limited(await call_ws(ws, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${max_attempts + 1}`);
196
+ }
197
+ finally {
198
+ await ws.close();
199
+ }
200
+ });
201
+ test('the dispatchers draw one account budget across HTTP RPC and WS', async () => {
202
+ const fixture = await setup_test();
203
+ const session = fixture.create_session_headers();
204
+ const http_calls = Math.floor(max_attempts / 2);
205
+ for (let i = 1; i <= http_calls; i++) {
206
+ assert_charged_not_limited(await call_http(fixture, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP call ${i}`);
207
+ }
208
+ const ws = await open_ws(fixture);
209
+ try {
210
+ for (let i = http_calls + 1; i <= max_attempts; i++) {
211
+ assert_charged_not_limited(await call_ws(ws, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), `WS call ${i}`);
212
+ }
213
+ assert_rate_limited(await call_ws(ws, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS), 'WS call past the shared cap');
214
+ }
215
+ finally {
216
+ await ws.close();
217
+ }
218
+ assert_rate_limited(await call_http(fixture, DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_BAD_PARAMS, session), 'HTTP call past the shared cap');
219
+ });
220
+ test('two account-limited actions draw one account budget', async () => {
221
+ const fixture = await setup_test();
222
+ const session = fixture.create_session_headers();
223
+ const first_calls = Math.floor(max_attempts / 2);
224
+ for (let i = 1; i <= max_attempts; i++) {
225
+ const method = i <= first_calls ? DISPATCHER_ACCOUNT_LIMITED_METHOD : ACCOUNT_LIMITED_METHOD;
226
+ assert_charged_not_limited(await call_http(fixture, method, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP ${method} call ${i}`);
227
+ }
228
+ for (const method of [DISPATCHER_ACCOUNT_LIMITED_METHOD, ACCOUNT_LIMITED_METHOD]) {
229
+ assert_rate_limited(await call_http(fixture, method, ACCOUNT_LIMITED_BAD_PARAMS, session), `HTTP ${method} call past the shared cap`);
230
+ }
231
+ });
232
+ test('an IP-limited action is keyed by the forwarded client IP on both transports', async () => {
233
+ // the addresses are fixed per process and their bucket outlives
234
+ // `_testing_reset`, so a vitest retry of this case would start
235
+ // exhausted and fail
236
+ const fixture = await setup_test();
237
+ const exhausted = { 'x-forwarded-for': XFF_IP_EXHAUSTED };
238
+ // anonymous HTTP — `peer/ping` is public, so only the IP axis applies
239
+ for (let i = 1; i < max_attempts; i++) {
240
+ assert_charged_not_limited(await call_http(fixture, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS, exhausted), `HTTP call ${i} from ${XFF_IP_EXHAUSTED}`);
241
+ }
242
+ const ws_exhausted = await open_ws(fixture, XFF_IP_EXHAUSTED);
243
+ const ws_fresh = await open_ws(fixture, XFF_IP_FRESH);
244
+ try {
245
+ assert_charged_not_limited(await call_ws(ws_exhausted, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS), `WS call ${max_attempts} from ${XFF_IP_EXHAUSTED}`);
246
+ assert_rate_limited(await call_ws(ws_exhausted, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS), `WS call past the cap from ${XFF_IP_EXHAUSTED}`);
247
+ assert_rate_limited(await call_http(fixture, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS, exhausted), `HTTP call past the cap from ${XFF_IP_EXHAUSTED}`);
248
+ // a second address has its own bucket, on both transports, even on
249
+ // the same account's session
250
+ assert_charged_not_limited(await call_ws(ws_fresh, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS), `WS call from ${XFF_IP_FRESH}`);
251
+ assert_charged_not_limited(await call_http(fixture, IP_LIMITED_METHOD, IP_LIMITED_BAD_PARAMS, {
252
+ 'x-forwarded-for': XFF_IP_FRESH
253
+ }), `HTTP call from ${XFF_IP_FRESH}`);
254
+ }
255
+ finally {
256
+ await ws_exhausted.close();
257
+ await ws_fresh.close();
258
+ }
259
+ });
260
+ });
261
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/middleware.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/middleware.ts"],"names":[],"mappings":"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"}
@@ -12,6 +12,7 @@ import { vi, test, assert, describe } from 'vitest';
12
12
  import { Hono } from 'hono';
13
13
  import { Logger } from '@fuzdev/fuz_util/log.ts';
14
14
  import { create_bearer_auth_middleware } from '../auth/bearer_auth.js';
15
+ import { AUTH_MIDDLEWARE_PATH } from '../auth/middleware.js';
15
16
  import { query_validate_api_token } from '../auth/api_token_queries.js';
16
17
  import { query_account_by_id, query_actor_by_id, query_active_actors_by_account } from '../auth/account_queries.js';
17
18
  import { query_role_grant_find_active_for_actor } from '../auth/role_grant_queries.js';
@@ -117,7 +118,7 @@ export const create_bearer_auth_test_app = (tc) => {
117
118
  c.set('client_ip', TEST_CLIENT_IP);
118
119
  await next();
119
120
  });
120
- app.use('/api/*', bearer_middleware);
121
+ app.use(AUTH_MIDDLEWARE_PATH, bearer_middleware);
121
122
  // route handler echoes the account-grain identity the middleware writes
122
123
  // (bearer auth sets `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY` +
123
124
  // `AUTH_API_TOKEN_ID_KEY`; it never builds the full request context —
@@ -230,8 +231,8 @@ export const create_test_middleware_stack_app = (options) => {
230
231
  const bearer_mw = create_bearer_auth_middleware(STUB_DEPS, new Logger('test', { level: 'off' }));
231
232
  const app = new Hono();
232
233
  app.use('*', proxy_mw);
233
- app.use('/api/*', origin_mw);
234
- app.use('/api/*', bearer_mw);
234
+ app.use(AUTH_MIDDLEWARE_PATH, origin_mw);
235
+ app.use(AUTH_MIDDLEWARE_PATH, bearer_mw);
235
236
  // echo route for assertions — exposes the account-grain identity bearer
236
237
  // auth writes (`ACCOUNT_ID_KEY`); the full request context is the
237
238
  // dispatcher's authorization phase concern, not middleware.
@@ -9,7 +9,7 @@ import type { AppServerContext } from '../server/app_server_context.js';
9
9
  import { Db } from '../db/db.js';
10
10
  import { type RouteSpec } from '../http/route_spec.js';
11
11
  import { type AppSurfaceSpec, type RpcEndpointSpec } from '../http/surface.js';
12
- import type { WsEndpointSpec } from '../actions/ws_endpoint_spec.js';
12
+ import { type WsEndpointSpec } from '../actions/ws_endpoint_spec.js';
13
13
  import type { EventSpec } from '../realtime/sse.js';
14
14
  import { type AuditLogSse } from '../realtime/sse_auth_guard.js';
15
15
  /**
@@ -124,10 +124,18 @@ export interface CreateTestAppSurfaceSpecOptions {
124
124
  * to both entry points so the attack surface tests see the same WS
125
125
  * endpoints production auto-mounts. The factory runs once against
126
126
  * the stub `AppServerContext` this helper already builds. No
127
- * `upgradeWebSocket` needed — this helper produces an `AppSurfaceSpec`
128
- * only, never mounts.
127
+ * `create_upgrade_websocket` needed — this helper produces an
128
+ * `AppSurfaceSpec` only, never mounts.
129
129
  */
130
130
  ws_endpoints?: ReadonlyArray<WsEndpointSpec> | ((ctx: AppServerContext) => ReadonlyArray<WsEndpointSpec>);
131
+ /**
132
+ * The server's origin allowlist — `AppServerOptions.allowed_origins`.
133
+ * Read only as the default for a `ws_endpoints` spec that declares no
134
+ * `allowed_origins`, as `create_app_server` does; required when any spec
135
+ * omits its own — without it, such a spec throws, since the surface records
136
+ * the exact patterns the upgrade gate matches.
137
+ */
138
+ allowed_origins?: ReadonlyArray<RegExp>;
131
139
  /** Transform middleware array (e.g., zap's `extend_middleware_for_zap_binary`). */
132
140
  transform_middleware?: (specs: Array<MiddlewareSpec>) => Array<MiddlewareSpec>;
133
141
  /**
@@ -152,12 +160,17 @@ export interface CreateTestAppSurfaceSpecOptions {
152
160
  * file.
153
161
  *
154
162
  * Mirrors `create_app_server`'s route assembly: consumer routes +
155
- * factory-managed bootstrap routes + surface generation. If
163
+ * factory-managed bootstrap routes + surface generation — including its
164
+ * refusal of an `rpc_endpoints` or `ws_endpoints` path outside the auth
165
+ * middleware's scope (`assert_endpoint_in_auth_scope`). If
156
166
  * `create_app_server` changes how it wires routes, update this helper
157
167
  * to stay in sync (single source of truth for all consumers).
158
168
  *
159
169
  * @param options - surface spec options
160
170
  * @returns the surface spec for the standard suites
171
+ * @throws Error when an endpoint path is outside `AUTH_MIDDLEWARE_PATH`, or a
172
+ * `ws_endpoints` spec declares no `allowed_origins` and no `allowed_origins`
173
+ * option is given
161
174
  */
162
175
  export declare const create_test_app_surface_spec: (options: CreateTestAppSurfaceSpecOptions) => AppSurfaceSpec;
163
176
  //# sourceMappingURL=stubs.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"stubs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/stubs.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAa7B,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAEjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAE7D,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AACxE,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACjC,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAG3E,OAAO,EAEN,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AACrE,OAAO,KAAK,EAAE,SAAS,EAAmB,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAA+B,KAAK,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAI9F;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,oBAAoB,GAAI,CAAC,GAAG,GAAG,EAAE,OAAO,MAAM,KAAG,CAqBtD,CAAC;AAET;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,GAAG,GAAG,EAAE,QAAQ,MAAM,EAAE,YAAY,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,CAOxF,CAAC;AAET,iEAAiE;AACjE,eAAO,MAAM,IAAI,EAAE,GAAkC,CAAC;AAEtD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,QAAO,EAI/B,CAAC;AAEJ,gDAAgD;AAChD,eAAO,MAAM,YAAY,QAAO,QAAgC,CAAC;AAEjE,2CAA2C;AAC3C,eAAO,MAAM,OAAO,GAAU,IAAI,GAAG,EAAE,MAAM,GAAG,KAAG,OAAO,CAAC,IAAI,CAAW,CAAC;AAI3E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,QAAO,YAW3C,CAAC;AAEH;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,QAAO,WASxC,CAAC;AAEF,2EAA2E;AAC3E,eAAO,MAAM,aAAa,EAAE,OAS3B,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,oBAAoB,QAAO,OAWtC,CAAC;AAEH,2FAA2F;AAC3F,eAAO,MAAM,0BAA0B,GAAI,UAAU;IACpD,iDAAiD;IACjD,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAC/B,KAAG,KAAK,CAAC,cAAc,CAqBvB,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,8BAA8B,GAC1C,iBAAiB,cAAc,CAAC,MAAM,CAAC,KACrC,gBAsBF,CAAC;AAEF,kDAAkD;AAClD,MAAM,WAAW,+BAA+B;IAC/C,6DAA6D;IAC7D,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,qFAAqF;IACrF,kBAAkB,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IAChE,oEAAoE;IACpE,UAAU,CAAC,EAAE,CAAC,CAAC,SAAS,CAAC;IACzB,8CAA8C;IAC9C,WAAW,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAC/B;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,KAAK,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,gBAAgB,KAAK,KAAK,CAAC,eAAe,CAAC,CAAC,CAAC;IAC7F;;;;;;;;OAQG;IACH,YAAY,CAAC,EACZ,aAAa,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,gBAAgB,KAAK,aAAa,CAAC,cAAc,CAAC,CAAC,CAAC;IAC5F,mFAAmF;IACnF,oBAAoB,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,cAAc,CAAC,KAAK,KAAK,CAAC,cAAc,CAAC,CAAC;IAC/E;;;;;;;;OAQG;IACH,SAAS,CAAC,EAAE,sBAAsB,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,cAmDF,CAAC"}
1
+ {"version":3,"file":"stubs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/stubs.ts"],"names":[],"mappings":"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"}
@@ -15,9 +15,11 @@ import { prefix_route_specs } from '../http/route_spec.js';
15
15
  import { bootstrap_route_shape } from '../auth/bootstrap_route_schema.js';
16
16
  import { create_rpc_endpoint } from '../actions/action_rpc.js';
17
17
  import { create_app_surface_spec } from '../http/surface.js';
18
+ import { resolve_ws_endpoints } from '../actions/ws_endpoint_spec.js';
18
19
  import { AUDIT_LOG_SSE_MAX_PER_SCOPE } from '../realtime/sse_auth_guard.js';
19
20
  import { SubscriberRegistry } from '../realtime/subscriber_registry.js';
20
21
  import { BaseServerEnv } from '../server/env.js';
22
+ import { AUTH_MIDDLEWARE_PATH, assert_endpoint_in_auth_scope } from '../auth/middleware.js';
21
23
  /**
22
24
  * Create a Proxy that throws descriptive errors on any property access or method call.
23
25
  *
@@ -160,12 +162,12 @@ export const create_stub_app_deps = () => ({
160
162
  /** Create the API middleware stub array matching `create_auth_middleware_specs` output. */
161
163
  export const create_stub_api_middleware = (options) => {
162
164
  const specs = [
163
- { name: 'origin', path: '/api/*', handler: stub_mw, errors: { 403: ApiError } },
164
- { name: 'session', path: '/api/*', handler: stub_mw },
165
- { name: 'request_context', path: '/api/*', handler: stub_mw },
165
+ { name: 'origin', path: AUTH_MIDDLEWARE_PATH, handler: stub_mw, errors: { 403: ApiError } },
166
+ { name: 'session', path: AUTH_MIDDLEWARE_PATH, handler: stub_mw },
167
+ { name: 'request_context', path: AUTH_MIDDLEWARE_PATH, handler: stub_mw },
166
168
  {
167
169
  name: 'bearer_auth',
168
- path: '/api/*',
170
+ path: AUTH_MIDDLEWARE_PATH,
169
171
  handler: stub_mw,
170
172
  errors: { 401: ApiError, 403: ApiError, 429: RateLimitError }
171
173
  }
@@ -173,7 +175,7 @@ export const create_stub_api_middleware = (options) => {
173
175
  if (options?.include_daemon_token) {
174
176
  specs.push({
175
177
  name: 'daemon_token',
176
- path: '/api/*',
178
+ path: AUTH_MIDDLEWARE_PATH,
177
179
  handler: stub_mw,
178
180
  errors: { 401: ApiError, 500: ApiError, 503: ApiError }
179
181
  });
@@ -211,6 +213,21 @@ export const create_stub_app_server_context = (session_options) => {
211
213
  audit_sse: null
212
214
  };
213
215
  };
216
+ /**
217
+ * Narrow a WS endpoint spec that carries its own `allowed_origins` — the only
218
+ * way it resolves when no server list is given to default from.
219
+ *
220
+ * @throws Error when the spec declares no `allowed_origins`
221
+ */
222
+ const to_self_resolved_ws_endpoint = (endpoint) => {
223
+ const { allowed_origins } = endpoint;
224
+ if (!allowed_origins) {
225
+ throw new Error(`create_test_app_surface_spec: ws endpoint ${endpoint.path} has no allowed_origins — ` +
226
+ "pass the server's list as create_test_app_surface_spec's allowed_origins option, " +
227
+ 'or set allowed_origins on the spec');
228
+ }
229
+ return { ...endpoint, allowed_origins };
230
+ };
214
231
  /**
215
232
  * Create an `AppSurfaceSpec` for the standard testing suites.
216
233
  *
@@ -222,12 +239,17 @@ export const create_stub_app_server_context = (session_options) => {
222
239
  * file.
223
240
  *
224
241
  * Mirrors `create_app_server`'s route assembly: consumer routes +
225
- * factory-managed bootstrap routes + surface generation. If
242
+ * factory-managed bootstrap routes + surface generation — including its
243
+ * refusal of an `rpc_endpoints` or `ws_endpoints` path outside the auth
244
+ * middleware's scope (`assert_endpoint_in_auth_scope`). If
226
245
  * `create_app_server` changes how it wires routes, update this helper
227
246
  * to stay in sync (single source of truth for all consumers).
228
247
  *
229
248
  * @param options - surface spec options
230
249
  * @returns the surface spec for the standard suites
250
+ * @throws Error when an endpoint path is outside `AUTH_MIDDLEWARE_PATH`, or a
251
+ * `ws_endpoints` spec declares no `allowed_origins` and no `allowed_origins`
252
+ * option is given
231
253
  */
232
254
  export const create_test_app_surface_spec = (options) => {
233
255
  const ctx = create_stub_app_server_context(options.session_options);
@@ -237,14 +259,25 @@ export const create_test_app_surface_spec = (options) => {
237
259
  const resolved_rpc_endpoints = typeof options.rpc_endpoints === 'function'
238
260
  ? options.rpc_endpoints(ctx)
239
261
  : options.rpc_endpoints;
240
- const rpc_route_specs = resolved_rpc_endpoints?.flatMap((endpoint) => create_rpc_endpoint({
241
- path: endpoint.path,
242
- actions: endpoint.actions,
243
- log: ctx.deps.log
244
- })) ?? [];
262
+ const rpc_route_specs = resolved_rpc_endpoints?.flatMap((endpoint) => {
263
+ assert_endpoint_in_auth_scope('rpc_endpoints', endpoint.path);
264
+ return create_rpc_endpoint({
265
+ path: endpoint.path,
266
+ actions: endpoint.actions,
267
+ log: ctx.deps.log,
268
+ action_ip_rate_limiter: ctx.action_ip_rate_limiter,
269
+ action_account_rate_limiter: ctx.action_account_rate_limiter
270
+ });
271
+ }) ?? [];
245
272
  // Resolve ws endpoints (mirrors create_app_server). Surface-only —
246
- // no `register_ws_endpoint` call here, so no `upgradeWebSocket` needed.
247
- const resolved_ws_endpoints = typeof options.ws_endpoints === 'function' ? options.ws_endpoints(ctx) : options.ws_endpoints;
273
+ // no `register_ws_endpoint` call here, so no `create_upgrade_websocket` needed.
274
+ const declared_ws_endpoints = typeof options.ws_endpoints === 'function' ? options.ws_endpoints(ctx) : options.ws_endpoints;
275
+ const resolved_ws_endpoints = options.allowed_origins
276
+ ? resolve_ws_endpoints(declared_ws_endpoints, options.allowed_origins)
277
+ : declared_ws_endpoints?.map(to_self_resolved_ws_endpoint);
278
+ for (const endpoint of resolved_ws_endpoints ?? []) {
279
+ assert_endpoint_in_auth_scope('ws_endpoints', endpoint.path);
280
+ }
248
281
  // Bootstrap routes mirror `create_app_server`: mounted for `surface_only`
249
282
  // and `live` modes; omitted for `disabled` / undefined. Surface generation
250
283
  // uses an `available: false` placeholder regardless of mode — the handler
@@ -34,6 +34,13 @@ export interface WsTransportOptions {
34
34
  * behavior).
35
35
  */
36
36
  readonly on_request?: WsRequestResponder;
37
+ /**
38
+ * Extra headers for the upgrade request — e.g. an `X-Forwarded-For` that a
39
+ * backend trusting the loopback proxy resolves as the connection's client
40
+ * IP, which keys the per-IP action rate limiter for every message on the
41
+ * socket. `cookies` and `origin` set their own headers.
42
+ */
43
+ readonly headers?: Readonly<Record<string, string>>;
37
44
  }
38
45
  /**
39
46
  * Build a real-upgrade WS client pinned to `options.base_url` + `ws_path`.
@@ -1 +1 @@
1
- {"version":3,"file":"ws_transport.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_transport.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuB9B,OAAO,EAMN,KAAK,QAAQ,EACb,KAAK,kBAAkB,EAEvB,MAAM,gBAAgB,CAAC;AAIxB,sDAAsD;AACtD,MAAM,WAAW,kBAAkB;IAClC,gHAAgH;IAChH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,kBAAkB,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,mBAAmB,GAAU,SAAS,kBAAkB,KAAG,OAAO,CAAC,QAAQ,CAwJvF,CAAC;AAEF,mFAAmF;AACnF,eAAO,MAAM,qBAAqB,aAAa,CAAC;AAEhD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,kBAAkB,KACzB,OAAO,CAAC,QAAQ,CASlB,CAAC"}
1
+ {"version":3,"file":"ws_transport.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/transports/ws_transport.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuB9B,OAAO,EAMN,KAAK,QAAQ,EACb,KAAK,kBAAkB,EAEvB,MAAM,gBAAgB,CAAC;AAIxB,sDAAsD;AACtD,MAAM,WAAW,kBAAkB;IAClC,gHAAgH;IAChH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC;;;;;;;OAOG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,kBAAkB,CAAC;IACzC;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACpD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,mBAAmB,GAAU,SAAS,kBAAkB,KAAG,OAAO,CAAC,QAAQ,CAwJvF,CAAC;AAEF,mFAAmF;AACnF,eAAO,MAAM,qBAAqB,aAAa,CAAC;AAEhD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,kBAAkB,KACzB,OAAO,CAAC,QAAQ,CASlB,CAAC"}
@@ -45,7 +45,7 @@ export const create_ws_transport = async (options) => {
45
45
  const { base_url, ws_path, cookies, origin, default_timeout_ms, on_request } = options;
46
46
  const default_timeout = default_timeout_ms ?? WS_CLIENT_DEFAULT_TIMEOUT_MS;
47
47
  const ws_url = `${base_url.replace(/^http/i, 'ws')}${ws_path}`;
48
- const headers = {};
48
+ const headers = { ...options.headers };
49
49
  if (cookies.length > 0)
50
50
  headers.Cookie = cookies.join('; ');
51
51
  const socket = new WebSocket(ws_url, {
@@ -9,28 +9,27 @@ import './assert_dev_env.js';
9
9
  * token id), and exposes a `connect()` factory returning a `WsClient`
10
10
  * per connection.
11
11
  *
12
- * Three layers are exported:
12
+ * Two layers are exported:
13
13
  *
14
14
  * - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
15
15
  * `create_stub_upgrade`, `MinimalActionEnvironment`,
16
16
  * `dispatch_ws_message`) — used by fuz_app's own dispatcher tests
17
17
  * and by consumers wiring tight one-off tests.
18
- * - **Harness** (`create_ws_test_harness`, `keeper_identity`) — the
19
- * high-level driver. Give it specs + handlers, get back
18
+ * - **Harness** (`create_ws_test_harness`) — the high-level driver.
19
+ * Give it specs + handlers, get back
20
20
  * `{transport, connect()}`. `connect()` is async and resolves after
21
21
  * `on_socket_open` completes, so broadcasts sent immediately after
22
22
  * `await harness.connect()` reach the client. Returns a `WsClient`
23
23
  * (shared interface — see `transports/ws_client.ts`); the same
24
24
  * interface is implemented by `transports/ws_transport.ts` for
25
25
  * cross-process tests.
26
- * - **Broadcast wiring** — `build_broadcast_api` for wiring a typed
27
- * broadcast API against the harness's transport. Wire-frame types
28
- * + predicates (`is_notification`, `is_response_for`,
29
- * `JsonrpcNotificationFrame`, ...) live in `transports/ws_client.ts`
30
- * so both in-process and cross-process drivers reference one source.
31
26
  *
32
- * Hono's wire upgrade is skipped — the Node test runtime has no
33
- * `@hono/node-ws` adapter — but the full dispatch path is exercised
27
+ * Wire-frame types + predicates (`is_notification`, `is_response_for`,
28
+ * `JsonrpcNotificationFrame`, ...) live in `transports/ws_client.ts` so
29
+ * both in-process and cross-process drivers reference one source.
30
+ *
31
+ * Hono's wire upgrade is skipped — there is no server, so no runtime
32
+ * adapter — but the full dispatch path is exercised
34
33
  * (per-action auth, input validation, `ctx.notify` back to the
35
34
  * originating socket, broadcast via `BackendWebsocketTransport`, and
36
35
  * close-on-revoke).
@@ -87,15 +86,45 @@ export interface FakeHonoContextOptions {
87
86
  * Only `.get()` is populated — no other Hono context surface is simulated.
88
87
  */
89
88
  export declare const create_fake_hono_context: (opts: FakeHonoContextOptions) => Context;
90
- /** The return of `create_stub_upgrade` — fake `upgradeWebSocket` + factory capture. */
89
+ /** One upgrade that reached a `create_stub_upgrade` route. */
90
+ export interface StubUpgradeRecord {
91
+ /**
92
+ * The real Hono `Context` the request carried through the app's
93
+ * middleware chain — session, request context, client IP, and every
94
+ * other value the middleware set.
95
+ */
96
+ c: Context;
97
+ /** The events factory of the endpoint the upgrade reached. */
98
+ create_events: (c: Context) => WSEvents | Promise<WSEvents>;
99
+ }
100
+ /** The return of `create_stub_upgrade` — fake `upgradeWebSocket` + factory and upgrade capture. */
91
101
  export interface StubUpgrade {
92
102
  upgradeWebSocket: UpgradeWebSocket;
103
+ /**
104
+ * The events factory of the most recently mounted endpoint, for tests
105
+ * that build events from a hand-made context. Throws when no endpoint
106
+ * was mounted.
107
+ */
93
108
  get_create_events: () => (c: Context) => WSEvents | Promise<WSEvents>;
109
+ /**
110
+ * Every upgrade request that reached a stub route, in arrival order —
111
+ * what `app.request(path, {headers: {upgrade: 'websocket', ...}})`
112
+ * leaves behind once the app's middleware passed it. Build the socket's
113
+ * events with `record.create_events(record.c)`.
114
+ */
115
+ upgrades: ReadonlyArray<StubUpgradeRecord>;
94
116
  }
95
117
  /**
96
- * Build a fake `upgradeWebSocket` that captures the `createEvents`
97
- * callback. The returned middleware is inert — tests drive
98
- * `createEvents` directly.
118
+ * Build a fake `upgradeWebSocket` that stands in for a runtime adapter's.
119
+ *
120
+ * Like the real adapters, the middleware it returns passes a request
121
+ * without an `Upgrade: websocket` header to `next()`. An upgrade request
122
+ * is recorded in `upgrades` — its real `Context` and the endpoint's events
123
+ * factory — and answered `200` with no body (a `Response` cannot carry the
124
+ * `101` an adapter answers). No socket is opened: tests drive the recorded
125
+ * factory, so an upgrade sent through `app.request` exercises the app's
126
+ * whole middleware chain — origin, session or bearer, client IP — before
127
+ * the dispatcher sees it.
99
128
  */
100
129
  export declare const create_stub_upgrade: () => StubUpgrade;
101
130
  /**
@@ -193,27 +222,4 @@ export interface WsTestHarness {
193
222
  * `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
194
223
  */
195
224
  export declare const create_ws_test_harness: (options: CreateWsTestHarnessOptions) => WsTestHarness;
196
- /** Convenience: default identity for keeper-authenticated connections. */
197
- export declare const keeper_identity: () => WsConnectIdentity;
198
- /**
199
- * Wire a typed broadcast API against the harness's transport, matching
200
- * how a consumer's real backend composes the stack. Returns the typed
201
- * API so tests can call `.zap_run_created(...)` / `.workspace_changed(...)`
202
- * etc. directly.
203
- *
204
- * ```ts
205
- * const harness = create_ws_test_harness({actions});
206
- * const broadcast = build_broadcast_api<MyBackendActionsApi>({
207
- * harness,
208
- * specs: my_broadcast_action_specs,
209
- * });
210
- * const client = await harness.connect(keeper_identity());
211
- * await broadcast.zap_run_created({run_id: '...', ...});
212
- * await client.wait_for(is_notification('zap_run_created'));
213
- * ```
214
- */
215
- export declare const build_broadcast_api: <TApi extends object>(options: {
216
- harness: WsTestHarness;
217
- specs: ReadonlyArray<ActionSpecUnion>;
218
- }) => TApi;
219
225
  //# sourceMappingURL=ws_round_trip.d.ts.map