@cosmicdrift/kumiko-server-runtime 0.329.0 → 0.330.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 (90) hide show
  1. package/README.md +15 -0
  2. package/dist/boot/apply-boot-seeds.d.ts +8 -0
  3. package/{src/boot/apply-boot-seeds.ts → dist/boot/apply-boot-seeds.js} +2 -10
  4. package/dist/boot/boot-crypto.d.ts +9 -0
  5. package/dist/boot/boot-crypto.js +39 -0
  6. package/dist/boot/job-run-logger.d.ts +19 -0
  7. package/dist/boot/job-run-logger.js +44 -0
  8. package/dist/boot/pii-event-backfill-on-boot.d.ts +13 -0
  9. package/dist/boot/pii-event-backfill-on-boot.js +25 -0
  10. package/dist/build-prod-bundle.d.ts +103 -0
  11. package/dist/build-prod-bundle.js +751 -0
  12. package/dist/bun-serve-options.d.ts +32 -0
  13. package/dist/bun-serve-options.js +49 -0
  14. package/dist/compose-features.d.ts +31 -0
  15. package/dist/compose-features.js +124 -0
  16. package/dist/extra-routes-deps.d.ts +30 -0
  17. package/dist/extra-routes-deps.js +4 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +7 -0
  20. package/dist/pii-boot-gate.d.ts +13 -0
  21. package/dist/pii-boot-gate.js +46 -0
  22. package/dist/renderer-web-fonts.d.ts +9 -0
  23. package/{src/renderer-web-fonts.ts → dist/renderer-web-fonts.js} +14 -18
  24. package/dist/resolve-tailwind-cli.d.ts +14 -0
  25. package/dist/resolve-tailwind-cli.js +36 -0
  26. package/dist/run-prod-app-boot-context.d.ts +43 -0
  27. package/dist/run-prod-app-boot-context.js +198 -0
  28. package/dist/run-prod-app-static-files.d.ts +32 -0
  29. package/dist/run-prod-app-static-files.js +280 -0
  30. package/dist/run-prod-app.d.ts +471 -0
  31. package/dist/run-prod-app.js +741 -0
  32. package/dist/run-worker-app.d.ts +92 -0
  33. package/dist/run-worker-app.js +220 -0
  34. package/dist/security-headers.d.ts +20 -0
  35. package/dist/security-headers.js +49 -0
  36. package/dist/session-boot-gate.d.ts +11 -0
  37. package/dist/session-boot-gate.js +22 -0
  38. package/dist/session-wiring.d.ts +11 -0
  39. package/{src/session-wiring.ts → dist/session-wiring.js} +2 -6
  40. package/dist/try-hono-first.d.ts +24 -0
  41. package/{src/try-hono-first.ts → dist/try-hono-first.js} +8 -36
  42. package/package.json +102 -36
  43. package/src/changes.json +6 -0
  44. package/src/__tests__/boot-extra-context.test.ts +0 -163
  45. package/src/__tests__/boot-probe-fixture.ts +0 -64
  46. package/src/__tests__/build-prod-bundle.inprocess.test.ts +0 -233
  47. package/src/__tests__/build-prod-bundle.test.ts +0 -724
  48. package/src/__tests__/cache-headers.test.ts +0 -83
  49. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
  50. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -609
  51. package/src/__tests__/compose-features.test.ts +0 -206
  52. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  53. package/src/__tests__/pii-boot-gate.test.ts +0 -112
  54. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  55. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  56. package/src/__tests__/renderer-web-theme-plugin.integration.test.ts +0 -77
  57. package/src/__tests__/require-env.test.ts +0 -34
  58. package/src/__tests__/resolve-auth-mail.test.ts +0 -144
  59. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  60. package/src/__tests__/run-prod-app-dry-run.test.ts +0 -135
  61. package/src/__tests__/run-prod-app-env-source.test.ts +0 -118
  62. package/src/__tests__/run-prod-app-kms-slots.test.ts +0 -108
  63. package/src/__tests__/run-prod-app-spec.test.ts +0 -167
  64. package/src/__tests__/run-prod-app-static-files.test.ts +0 -298
  65. package/src/__tests__/run-prod-app.integration.test.ts +0 -1525
  66. package/src/__tests__/run-worker-app-boot.test.ts +0 -183
  67. package/src/__tests__/run-worker-app.integration.test.ts +0 -218
  68. package/src/__tests__/security-headers.test.ts +0 -88
  69. package/src/__tests__/session-boot-gate.test.ts +0 -70
  70. package/src/__tests__/session-wiring.test.ts +0 -16
  71. package/src/__tests__/try-hono-first.test.ts +0 -108
  72. package/src/__tests__/websocket-route.integration.test.ts +0 -695
  73. package/src/boot/__tests__/job-run-logger.integration.test.ts +0 -262
  74. package/src/boot/__tests__/job-run-logger.test.ts +0 -26
  75. package/src/boot/boot-crypto.ts +0 -67
  76. package/src/boot/job-run-logger.ts +0 -63
  77. package/src/boot/pii-event-backfill-on-boot.ts +0 -43
  78. package/src/build-prod-bundle.ts +0 -996
  79. package/src/bun-serve-options.ts +0 -110
  80. package/src/compose-features.ts +0 -197
  81. package/src/extra-routes-deps.ts +0 -38
  82. package/src/index.ts +0 -27
  83. package/src/pii-boot-gate.ts +0 -80
  84. package/src/resolve-tailwind-cli.ts +0 -45
  85. package/src/run-prod-app-boot-context.ts +0 -319
  86. package/src/run-prod-app-static-files.ts +0 -359
  87. package/src/run-prod-app.ts +0 -1420
  88. package/src/run-worker-app.ts +0 -366
  89. package/src/security-headers.ts +0 -67
  90. package/src/session-boot-gate.ts +0 -34
@@ -1,695 +0,0 @@
1
- // r.webSocketRoute over a real Bun.serve socket: upgrade guards (origin,
2
- // session, upgrade header), connect-time rejection, and the live message
3
- // path (text/binary echo, size cap, handler failure). anonymousAccess is wired
4
- // on purpose (see http-route-entry.integration.test.ts): without it a route
5
- // mounted behind the wrong guard would 401 on a missing token too, masking the
6
- // bug the "no session" test exists to catch.
7
-
8
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
- import {
10
- AUTH_COOKIE_NAME,
11
- type KumikoWebSocketData,
12
- WEBSOCKET_PENDING_BUFFER_LIMIT_BYTES,
13
- } from "@cosmicdrift/kumiko-framework/api";
14
- import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
15
- import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
16
- import { buildBunServeOptions } from "../bun-serve-options";
17
-
18
- const APP_ORIGIN = "https://app.example";
19
- const EVIL_ORIGIN = "https://evil.example";
20
- const MAX_MESSAGE_BYTES = 1024;
21
- const OPEN_FRAME_BYTES = 64 * 1024;
22
- const membershipQuery = "tenant:query:memberships";
23
-
24
- type Deferred = { readonly promise: Promise<void>; readonly resolve: () => void };
25
-
26
- function deferred(): Deferred {
27
- let resolve: () => void = () => {};
28
- const promise = new Promise<void>((done) => {
29
- resolve = done;
30
- });
31
- return { promise, resolve };
32
- }
33
-
34
- // Per-test gates the routes below wait on, so ordering and slot release are
35
- // driven by explicit signals instead of timers.
36
- let orderedGate = deferred();
37
- let hangingClosed = deferred();
38
- let hangingSignal: AbortSignal | undefined;
39
- const hangingStarted: string[] = [];
40
- // A function read keeps TS from narrowing the module-level `let` to its last assignment.
41
- const heldSignal = (): AbortSignal | undefined => hangingSignal;
42
- let limitedClosed = deferred();
43
- let limitedConnectCalls = 0;
44
- let gatedRejects = true;
45
- let openGate = deferred();
46
- let openFinished = deferred();
47
- const gatedOpenReceived: string[] = [];
48
- let messageGate = deferred();
49
- const slowMessageStarted: string[] = [];
50
-
51
- const wsFeature = defineFeature("ws-test", (r) => {
52
- r.webSocketRoute({
53
- path: "/api/ws/ordered",
54
- connect: () => ({
55
- onMessage: async (data, connection) => {
56
- if (data === "slow") await orderedGate.promise;
57
- connection.send(data);
58
- },
59
- }),
60
- });
61
- r.webSocketRoute({
62
- path: "/api/ws/slow-open",
63
- maxMessageBytes: OPEN_FRAME_BYTES,
64
- connect: () => ({
65
- onOpen: async () => {
66
- await openGate.promise;
67
- openFinished.resolve();
68
- },
69
- onMessage: (data, connection) => {
70
- gatedOpenReceived.push(String(data).slice(0, 1));
71
- connection.send(data);
72
- },
73
- }),
74
- });
75
- r.webSocketRoute({
76
- path: "/api/ws/slow-message",
77
- maxMessageBytes: OPEN_FRAME_BYTES,
78
- connect: () => ({
79
- onMessage: async (data, connection) => {
80
- slowMessageStarted.push(String(data).slice(0, 1));
81
- await messageGate.promise;
82
- connection.send(data);
83
- },
84
- }),
85
- });
86
- r.webSocketRoute({
87
- path: "/api/ws/hanging",
88
- connect: () => ({
89
- onMessage: (data, connection) => {
90
- hangingStarted.push(String(data));
91
- hangingSignal = connection.signal;
92
- return new Promise<void>(() => {});
93
- },
94
- onClose: (_code, _reason, connection) => {
95
- hangingSignal = connection.signal;
96
- hangingClosed.resolve();
97
- },
98
- }),
99
- });
100
- r.webSocketRoute({
101
- path: "/api/ws/limited",
102
- maxConnectionsPerUser: 2,
103
- connect: () => {
104
- limitedConnectCalls += 1;
105
- return { onClose: () => limitedClosed.resolve() };
106
- },
107
- });
108
- r.webSocketRoute({
109
- path: "/api/ws/gated",
110
- maxConnectionsPerUser: 1,
111
- connect: () => (gatedRejects ? new Response(null, { status: 409 }) : {}),
112
- });
113
- r.webSocketRoute({
114
- path: "/api/ws/echo",
115
- maxMessageBytes: MAX_MESSAGE_BYTES,
116
- connect: (_c, { user }) => ({
117
- onOpen: (connection) => connection.send(JSON.stringify({ userId: user.id })),
118
- onMessage: (data, connection) => connection.send(data),
119
- }),
120
- });
121
- r.webSocketRoute({
122
- path: "/api/ws/rejected",
123
- connect: () => new Response(null, { status: 409 }),
124
- });
125
- r.webSocketRoute({
126
- path: "/api/ws/explosive",
127
- connect: () => ({
128
- onMessage: () => {
129
- throw new Error("handler failure");
130
- },
131
- }),
132
- });
133
- });
134
-
135
- function upgradeHeaders(extra: Record<string, string>): Record<string, string> {
136
- return {
137
- Connection: "Upgrade",
138
- Upgrade: "websocket",
139
- "Sec-WebSocket-Version": "13",
140
- "Sec-WebSocket-Key": Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64"),
141
- ...extra,
142
- };
143
- }
144
-
145
- async function errorCode(res: Response): Promise<string | undefined> {
146
- const body: unknown = await res.json();
147
- if (typeof body !== "object" || body === null || !("error" in body)) return undefined;
148
- const { error } = body;
149
- if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
150
- return typeof error.code === "string" ? error.code : undefined;
151
- }
152
-
153
- // Bun's WebSocket accepts request headers (needed for Cookie/Origin), which lib.dom's typing omits.
154
- const BunWebSocket = WebSocket as unknown as new (
155
- url: string,
156
- options: { headers: Record<string, string> },
157
- ) => WebSocket;
158
-
159
- type ClientSocket = {
160
- readonly opened: Promise<void>;
161
- readonly closed: Promise<CloseEvent>;
162
- readonly next: () => Promise<unknown>;
163
- readonly send: (data: string | Uint8Array<ArrayBuffer>) => void;
164
- readonly close: () => void;
165
- };
166
-
167
- // Listeners attach at construction so a message sent during the upgrade
168
- // (onOpen) is never lost between "open" and the test's first await.
169
- function connectSocket(url: string, headers: Record<string, string>): ClientSocket {
170
- const ws = new BunWebSocket(url, { headers });
171
- ws.binaryType = "arraybuffer";
172
- const inbox: unknown[] = [];
173
- const waiters: ((value: unknown) => void)[] = [];
174
- ws.addEventListener("message", (event) => {
175
- const waiter = waiters.shift();
176
- if (waiter) waiter(event.data);
177
- else inbox.push(event.data);
178
- });
179
- return {
180
- opened: new Promise<void>((resolve, reject) => {
181
- ws.addEventListener("open", () => resolve());
182
- ws.addEventListener("error", () => reject(new Error(`WebSocket to ${url} failed`)));
183
- }),
184
- closed: new Promise<CloseEvent>((resolve) => ws.addEventListener("close", resolve)),
185
- next: () =>
186
- inbox.length > 0
187
- ? Promise.resolve(inbox.shift())
188
- : new Promise((resolve) => waiters.push(resolve)),
189
- send: (data) => ws.send(data),
190
- close: () => ws.close(),
191
- };
192
- }
193
-
194
- type Harness = {
195
- readonly stack: TestStack;
196
- readonly server: Bun.Server<KumikoWebSocketData>;
197
- readonly httpOrigin: string;
198
- readonly url: (path: string) => string;
199
- readonly cookie: (sid?: string) => Promise<string>;
200
- /** Call before sending: resolves once Bun's websocket message callback has run `count` more times. */
201
- readonly nextMessages: (count: number) => Promise<void>;
202
- };
203
-
204
- async function startHarness(
205
- authConfig: Parameters<typeof setupTestStack>[0]["authConfig"],
206
- heartbeatIntervalMs?: number,
207
- ): Promise<Harness> {
208
- const stack = await setupTestStack({
209
- features: [wsFeature],
210
- anonymousAccess: { defaultTenantId: TestUsers.user.tenantId },
211
- ...(authConfig ? { authConfig } : {}),
212
- });
213
- const options = buildBunServeOptions(
214
- 0,
215
- (req, socketAddress) => stack.app.fetch(req, socketAddress),
216
- undefined,
217
- {
218
- upgradeFetch: (req, serveEnv) => stack.app.fetch(req, serveEnv),
219
- ...(heartbeatIntervalMs !== undefined ? { heartbeatIntervalMs } : {}),
220
- },
221
- );
222
- let received = 0;
223
- const receivedWaiters: { readonly count: number; readonly resolve: () => void }[] = [];
224
- const server = Bun.serve<KumikoWebSocketData>({
225
- ...options,
226
- websocket: {
227
- ...options.websocket,
228
- message: (ws, message) => {
229
- options.websocket.message?.(ws, message);
230
- received += 1;
231
- for (const waiter of receivedWaiters) if (received >= waiter.count) waiter.resolve();
232
- },
233
- },
234
- });
235
- return {
236
- nextMessages: (count) =>
237
- new Promise<void>((resolve) => receivedWaiters.push({ count: received + count, resolve })),
238
- stack,
239
- server,
240
- httpOrigin: `http://localhost:${server.port}`,
241
- url: (path) => `ws://localhost:${server.port}${path}`,
242
- cookie: async (sid) =>
243
- `${AUTH_COOKIE_NAME}=${await stack.jwt.sign(sid ? { ...TestUsers.user, sid } : TestUsers.user)}`,
244
- };
245
- }
246
-
247
- async function stopHarness(harness: Harness): Promise<void> {
248
- await harness.server.stop(true);
249
- await harness.stack.cleanup();
250
- }
251
-
252
- async function upgradeAttempt(
253
- harness: Harness,
254
- path: string,
255
- headers: Record<string, string>,
256
- ): Promise<Response> {
257
- return fetch(`${harness.httpOrigin}${path}`, { headers: upgradeHeaders(headers) });
258
- }
259
-
260
- describe("r.webSocketRoute (integration) — same-host origin, no allowlist", () => {
261
- let harness: Harness;
262
- let cookie: string;
263
-
264
- beforeAll(async () => {
265
- harness = await startHarness({ membershipQuery });
266
- cookie = await harness.cookie();
267
- });
268
- afterAll(() => stopHarness(harness));
269
-
270
- test("no session → 401", async () => {
271
- const res = await upgradeAttempt(harness, "/api/ws/echo", { Origin: harness.httpOrigin });
272
- expect(res.status).toBe(401);
273
- expect(await errorCode(res)).toBe("unauthenticated");
274
- });
275
-
276
- test("valid cookie + foreign Origin → 403 origin_not_allowed", async () => {
277
- const res = await upgradeAttempt(harness, "/api/ws/echo", {
278
- Cookie: cookie,
279
- Origin: EVIL_ORIGIN,
280
- });
281
- expect(res.status).toBe(403);
282
- expect(await errorCode(res)).toBe("origin_not_allowed");
283
- });
284
-
285
- test("valid cookie + no Origin → 403 origin_not_allowed", async () => {
286
- const res = await upgradeAttempt(harness, "/api/ws/echo", { Cookie: cookie });
287
- expect(res.status).toBe(403);
288
- expect(await errorCode(res)).toBe("origin_not_allowed");
289
- });
290
-
291
- test("plain GET (no upgrade headers) → 426", async () => {
292
- const res = await fetch(`${harness.httpOrigin}/api/ws/echo`, {
293
- headers: { Cookie: cookie, Origin: harness.httpOrigin },
294
- });
295
- expect(res.status).toBe(426);
296
- expect(await errorCode(res)).toBe("websocket_upgrade_required");
297
- });
298
-
299
- test("upgrade request on a server without the upgrade wiring → 501 websocket_upgrade_not_wired", async () => {
300
- const res = await harness.stack.app.request(`${harness.httpOrigin}/api/ws/echo`, {
301
- headers: upgradeHeaders({
302
- Cookie: cookie,
303
- Origin: harness.httpOrigin,
304
- Host: new URL(harness.httpOrigin).host,
305
- }),
306
- });
307
- expect(res.status).toBe(501);
308
- expect(await errorCode(res)).toBe("websocket_upgrade_not_wired");
309
- });
310
-
311
- test("connect() returning a Response rejects the upgrade with it", async () => {
312
- const res = await upgradeAttempt(harness, "/api/ws/rejected", {
313
- Cookie: cookie,
314
- Origin: harness.httpOrigin,
315
- });
316
- expect(res.status).toBe(409);
317
- });
318
-
319
- test("same-host Origin + valid cookie opens and greets with the user id", async () => {
320
- const socket = connectSocket(harness.url("/api/ws/echo"), {
321
- Cookie: cookie,
322
- Origin: harness.httpOrigin,
323
- });
324
- await socket.opened;
325
- expect(JSON.parse(String(await socket.next()))).toEqual({ userId: TestUsers.user.id });
326
- socket.close();
327
- await socket.closed;
328
- });
329
-
330
- test("echoes text and binary messages unchanged", async () => {
331
- const socket = connectSocket(harness.url("/api/ws/echo"), {
332
- Cookie: cookie,
333
- Origin: harness.httpOrigin,
334
- });
335
- await socket.opened;
336
- await socket.next();
337
-
338
- socket.send("hello ä");
339
- expect(await socket.next()).toBe("hello ä");
340
-
341
- const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
342
- socket.send(bytes);
343
- const echoed = await socket.next();
344
- if (!(echoed instanceof ArrayBuffer)) throw new Error("expected binary echo");
345
- expect(Array.from(new Uint8Array(echoed))).toEqual(Array.from(bytes));
346
-
347
- socket.close();
348
- await socket.closed;
349
- });
350
-
351
- test("async handlers run in arrival order even when an earlier one is slower", async () => {
352
- orderedGate = deferred();
353
- const socket = connectSocket(harness.url("/api/ws/ordered"), {
354
- Cookie: cookie,
355
- Origin: harness.httpOrigin,
356
- });
357
- await socket.opened;
358
- const bothReceived = harness.nextMessages(2);
359
- socket.send("slow");
360
- socket.send("fast");
361
- // Both frames reached the server; unserialized handlers would now answer "fast" first.
362
- await bothReceived;
363
- orderedGate.resolve();
364
- expect(await socket.next()).toBe("slow");
365
- expect(await socket.next()).toBe("fast");
366
- socket.close();
367
- await socket.closed;
368
- });
369
-
370
- test("onClose runs and the signal aborts while onMessage hangs; queued messages never start", async () => {
371
- hangingClosed = deferred();
372
- hangingStarted.length = 0;
373
- hangingSignal = undefined;
374
- const socket = connectSocket(harness.url("/api/ws/hanging"), {
375
- Cookie: cookie,
376
- Origin: harness.httpOrigin,
377
- });
378
- await socket.opened;
379
- const bothReceived = harness.nextMessages(2);
380
- socket.send("hangs");
381
- socket.send("queued");
382
- await bothReceived;
383
- socket.close();
384
- await socket.closed;
385
- await hangingClosed.promise;
386
- expect(heldSignal()?.aborted).toBe(true);
387
- expect(hangingStarted).toEqual(["hangs"]);
388
- });
389
-
390
- test("frames buffered while onOpen runs arrive in order once it finishes", async () => {
391
- openGate = deferred();
392
- openFinished = deferred();
393
- gatedOpenReceived.length = 0;
394
- const socket = connectSocket(harness.url("/api/ws/slow-open"), {
395
- Cookie: cookie,
396
- Origin: harness.httpOrigin,
397
- });
398
- await socket.opened;
399
- const allReceived = harness.nextMessages(3);
400
- for (const tag of ["a", "b", "c"]) socket.send(tag.repeat(OPEN_FRAME_BYTES));
401
- await allReceived;
402
- expect(gatedOpenReceived).toEqual([]);
403
- openGate.resolve();
404
- for (const tag of ["a", "b", "c"]) {
405
- expect(String(await socket.next()).slice(0, 1)).toBe(tag);
406
- }
407
- expect(gatedOpenReceived).toEqual(["a", "b", "c"]);
408
- socket.close();
409
- await socket.closed;
410
- });
411
-
412
- test("frames over the limit behind a slow onOpen close with 1013 and never reach onMessage", async () => {
413
- openGate = deferred();
414
- openFinished = deferred();
415
- gatedOpenReceived.length = 0;
416
- const socket = connectSocket(harness.url("/api/ws/slow-open"), {
417
- Cookie: cookie,
418
- Origin: harness.httpOrigin,
419
- });
420
- await socket.opened;
421
- const frameCount = WEBSOCKET_PENDING_BUFFER_LIMIT_BYTES / OPEN_FRAME_BYTES + 1;
422
- for (let sent = 0; sent < frameCount; sent += 1) {
423
- socket.send("x".repeat(OPEN_FRAME_BYTES));
424
- }
425
- expect((await socket.closed).code).toBe(1013);
426
- openGate.resolve();
427
- await openFinished.promise;
428
- await new Promise<void>((resolve) => setImmediate(resolve));
429
- expect(gatedOpenReceived).toEqual([]);
430
- });
431
-
432
- test("frames queued behind a slow onMessage arrive in order once it finishes", async () => {
433
- messageGate = deferred();
434
- slowMessageStarted.length = 0;
435
- const socket = connectSocket(harness.url("/api/ws/slow-message"), {
436
- Cookie: cookie,
437
- Origin: harness.httpOrigin,
438
- });
439
- await socket.opened;
440
- const allReceived = harness.nextMessages(3);
441
- for (const tag of ["a", "b", "c"]) socket.send(tag.repeat(OPEN_FRAME_BYTES));
442
- await allReceived;
443
- messageGate.resolve();
444
- for (const tag of ["a", "b", "c"]) {
445
- expect(String(await socket.next()).slice(0, 1)).toBe(tag);
446
- }
447
- expect(slowMessageStarted).toEqual(["a", "b", "c"]);
448
- socket.close();
449
- await socket.closed;
450
- });
451
-
452
- test("frames over the limit behind a slow onMessage close with 1013 and never start", async () => {
453
- messageGate = deferred();
454
- slowMessageStarted.length = 0;
455
- const socket = connectSocket(harness.url("/api/ws/slow-message"), {
456
- Cookie: cookie,
457
- Origin: harness.httpOrigin,
458
- });
459
- await socket.opened;
460
- const frameCount = WEBSOCKET_PENDING_BUFFER_LIMIT_BYTES / OPEN_FRAME_BYTES + 1;
461
- for (let sent = 0; sent < frameCount; sent += 1) {
462
- socket.send("x".repeat(OPEN_FRAME_BYTES));
463
- }
464
- expect((await socket.closed).code).toBe(1013);
465
- messageGate.resolve();
466
- await new Promise<void>((resolve) => setImmediate(resolve));
467
- expect(slowMessageStarted).toEqual(["x"]);
468
- });
469
-
470
- test("a message above maxMessageBytes closes the socket with 1009", async () => {
471
- const socket = connectSocket(harness.url("/api/ws/echo"), {
472
- Cookie: cookie,
473
- Origin: harness.httpOrigin,
474
- });
475
- await socket.opened;
476
- await socket.next();
477
-
478
- socket.send("x".repeat(MAX_MESSAGE_BYTES * 2));
479
- expect((await socket.closed).code).toBe(1009);
480
- });
481
-
482
- test("a throwing handler closes the socket with 1011", async () => {
483
- const socket = connectSocket(harness.url("/api/ws/explosive"), {
484
- Cookie: cookie,
485
- Origin: harness.httpOrigin,
486
- });
487
- await socket.opened;
488
- socket.send("trigger");
489
- expect((await socket.closed).code).toBe(1011);
490
- });
491
- });
492
-
493
- describe("r.webSocketRoute (integration) — origin allowlist and session checker", () => {
494
- const revokedSessions = new Set<string>();
495
- let harness: Harness;
496
-
497
- beforeAll(async () => {
498
- harness = await startHarness({
499
- membershipQuery,
500
- allowedOrigins: [APP_ORIGIN],
501
- sessionChecker: async (sid) => (revokedSessions.has(sid) ? "revoked" : "live"),
502
- });
503
- });
504
- afterAll(() => stopHarness(harness));
505
-
506
- test("an allowlisted Origin opens", async () => {
507
- const socket = connectSocket(harness.url("/api/ws/echo"), {
508
- Cookie: await harness.cookie("sid-live"),
509
- Origin: APP_ORIGIN,
510
- });
511
- await socket.opened;
512
- expect(JSON.parse(String(await socket.next()))).toEqual({ userId: TestUsers.user.id });
513
- socket.close();
514
- await socket.closed;
515
- });
516
-
517
- test("the request's own host is NOT enough once an allowlist exists → 403", async () => {
518
- const res = await upgradeAttempt(harness, "/api/ws/echo", {
519
- Cookie: await harness.cookie("sid-live"),
520
- Origin: harness.httpOrigin,
521
- });
522
- expect(res.status).toBe(403);
523
- expect(await errorCode(res)).toBe("origin_not_allowed");
524
- });
525
-
526
- test("a revoked session is rejected at the upgrade with 401", async () => {
527
- revokedSessions.add("sid-revoked");
528
- const res = await upgradeAttempt(harness, "/api/ws/echo", {
529
- Cookie: await harness.cookie("sid-revoked"),
530
- Origin: APP_ORIGIN,
531
- });
532
- expect(res.status).toBe(401);
533
- });
534
- });
535
-
536
- describe("r.webSocketRoute (integration) — tenant lifecycle", () => {
537
- const lifecycle = { status: "active" };
538
- let harness: Harness;
539
-
540
- beforeAll(async () => {
541
- harness = await startHarness({
542
- membershipQuery,
543
- resolveTenantLifecycleStatus: async () => ({ status: lifecycle.status }),
544
- });
545
- });
546
- afterAll(() => stopHarness(harness));
547
-
548
- test("a serving tenant opens", async () => {
549
- lifecycle.status = "active";
550
- const socket = connectSocket(harness.url("/api/ws/echo"), {
551
- Cookie: await harness.cookie(),
552
- Origin: harness.httpOrigin,
553
- });
554
- await socket.opened;
555
- expect(JSON.parse(String(await socket.next()))).toEqual({ userId: TestUsers.user.id });
556
- socket.close();
557
- await socket.closed;
558
- });
559
-
560
- test("a tenant in teardown is rejected at the upgrade with 410, like HTTP", async () => {
561
- lifecycle.status = "destroying";
562
- const res = await upgradeAttempt(harness, "/api/ws/echo", {
563
- Cookie: await harness.cookie(),
564
- Origin: harness.httpOrigin,
565
- });
566
- expect(res.status).toBe(410);
567
- expect(await errorCode(res)).toBe("tenant_unavailable");
568
- });
569
- });
570
-
571
- describe("r.webSocketRoute (integration) — per-user connection cap", () => {
572
- let harness: Harness;
573
- let cookie: string;
574
-
575
- beforeAll(async () => {
576
- harness = await startHarness({ membershipQuery });
577
- cookie = await harness.cookie();
578
- });
579
- afterAll(() => stopHarness(harness));
580
-
581
- test("the third socket gets 429; closing one frees a slot", async () => {
582
- limitedClosed = deferred();
583
- const headers = { Cookie: cookie, Origin: harness.httpOrigin };
584
- const first = connectSocket(harness.url("/api/ws/limited"), headers);
585
- const second = connectSocket(harness.url("/api/ws/limited"), headers);
586
- await first.opened;
587
- await second.opened;
588
-
589
- limitedConnectCalls = 0;
590
- const rejected = await upgradeAttempt(harness, "/api/ws/limited", headers);
591
- expect(rejected.status).toBe(429);
592
- expect(await errorCode(rejected)).toBe("websocket_connection_limit");
593
- // The slot is taken before connect, so a refused attempt never runs it.
594
- expect(limitedConnectCalls).toBe(0);
595
-
596
- first.close();
597
- // The route's onClose runs after the slot is released.
598
- await limitedClosed.promise;
599
- const third = connectSocket(harness.url("/api/ws/limited"), headers);
600
- await third.opened;
601
-
602
- second.close();
603
- third.close();
604
- await Promise.all([second.closed, third.closed]);
605
- });
606
- });
607
-
608
- describe("r.webSocketRoute (integration) — connect rejection frees the slot", () => {
609
- let harness: Harness;
610
- let cookie: string;
611
-
612
- beforeAll(async () => {
613
- harness = await startHarness({ membershipQuery });
614
- cookie = await harness.cookie();
615
- });
616
- afterAll(() => stopHarness(harness));
617
-
618
- test("a connect() Response does not leak the single slot", async () => {
619
- const headers = { Cookie: cookie, Origin: harness.httpOrigin };
620
- gatedRejects = true;
621
- // With a cap of 1, a leaked slot would turn the second refusal into a 429.
622
- expect((await upgradeAttempt(harness, "/api/ws/gated", headers)).status).toBe(409);
623
- expect((await upgradeAttempt(harness, "/api/ws/gated", headers)).status).toBe(409);
624
-
625
- gatedRejects = false;
626
- const socket = connectSocket(harness.url("/api/ws/gated"), headers);
627
- await socket.opened;
628
- socket.close();
629
- await socket.closed;
630
- });
631
- });
632
-
633
- describe("r.webSocketRoute (integration) — revalidation on an open socket", () => {
634
- const HEARTBEAT_MS = 20;
635
- const revokedSessions = new Set<string>();
636
- const hangingSessions = new Set<string>();
637
- let currentRoles: readonly string[] = TestUsers.user.roles;
638
- let harness: Harness;
639
-
640
- beforeAll(async () => {
641
- harness = await startHarness(
642
- {
643
- membershipQuery,
644
- // In-memory checker: only a session listed in hangingSessions ever stalls.
645
- sessionChecker: (sid) =>
646
- hangingSessions.has(sid)
647
- ? new Promise(() => {})
648
- : Promise.resolve(
649
- revokedSessions.has(sid) ? "revoked" : { status: "live", roles: currentRoles },
650
- ),
651
- },
652
- HEARTBEAT_MS,
653
- );
654
- });
655
- afterAll(() => stopHarness(harness));
656
-
657
- test("a session revoked while the socket is open closes it with 1008", async () => {
658
- const socket = connectSocket(harness.url("/api/ws/echo"), {
659
- Cookie: await harness.cookie("sid-revoked-live"),
660
- Origin: harness.httpOrigin,
661
- });
662
- await socket.opened;
663
- await socket.next();
664
- revokedSessions.add("sid-revoked-live");
665
- const closed = await socket.closed;
666
- expect(closed.code).toBe(1008);
667
- expect(closed.reason).toBe("session changed");
668
- });
669
-
670
- test("changed roles close an open socket with 1008", async () => {
671
- currentRoles = TestUsers.user.roles;
672
- const socket = connectSocket(harness.url("/api/ws/echo"), {
673
- Cookie: await harness.cookie("sid-roles"),
674
- Origin: harness.httpOrigin,
675
- });
676
- await socket.opened;
677
- await socket.next();
678
- currentRoles = [...TestUsers.user.roles, "ExtraRole"];
679
- expect((await socket.closed).code).toBe(1008);
680
- currentRoles = TestUsers.user.roles;
681
- });
682
-
683
- test("a session store that stops answering closes the socket with 1013", async () => {
684
- const socket = connectSocket(harness.url("/api/ws/echo"), {
685
- Cookie: await harness.cookie("sid-hang"),
686
- Origin: harness.httpOrigin,
687
- });
688
- await socket.opened;
689
- await socket.next();
690
- hangingSessions.add("sid-hang");
691
- const closed = await socket.closed;
692
- expect(closed.code).toBe(1013);
693
- expect(closed.reason).toBe("try again later");
694
- });
695
- });