@cosmicdrift/kumiko-framework 0.326.1 → 0.327.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 (36) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/auth-middleware-transport.test.ts +23 -1
  3. package/src/api/__tests__/client-ip.test.ts +20 -0
  4. package/src/api/__tests__/origin-middleware.test.ts +65 -0
  5. package/src/api/__tests__/websocket-route.test.ts +552 -0
  6. package/src/api/auth-middleware.ts +29 -7
  7. package/src/api/client-ip.ts +15 -2
  8. package/src/api/index.ts +24 -1
  9. package/src/api/jwt.ts +5 -0
  10. package/src/api/origin-middleware.ts +27 -1
  11. package/src/api/server.ts +161 -2
  12. package/src/api/websocket-route.ts +327 -0
  13. package/src/bun-db/query.ts +7 -5
  14. package/src/changes.json +18 -0
  15. package/src/crypto/__tests__/system-event-pii.test.ts +77 -0
  16. package/src/crypto/event-pii.ts +20 -4
  17. package/src/crypto/system-event-pii.ts +57 -0
  18. package/src/db/__tests__/apply-entity-event-removed-column.integration.test.ts +109 -0
  19. package/src/engine/__tests__/describe-workflow-step-error.test.ts +17 -0
  20. package/src/engine/__tests__/steps-mail-send.test.ts +6 -5
  21. package/src/engine/__tests__/steps-webhook-send.test.ts +16 -8
  22. package/src/engine/define-feature.ts +1 -0
  23. package/src/engine/feature-builder-state.ts +3 -0
  24. package/src/engine/feature-ui-extensions.ts +52 -0
  25. package/src/engine/index.ts +5 -0
  26. package/src/engine/steps/describe-workflow-step-error.ts +6 -0
  27. package/src/engine/steps/mail-send.ts +5 -1
  28. package/src/engine/steps/retry.ts +13 -1
  29. package/src/engine/steps/webhook-send.ts +6 -16
  30. package/src/engine/types/index.ts +7 -0
  31. package/src/event-store/provenance-append.ts +1 -1
  32. package/src/event-store/transfer.ts +2 -2
  33. package/src/jobs/__tests__/jobs.integration.test.ts +31 -0
  34. package/src/jobs/job-runner.ts +18 -0
  35. package/src/pipeline/append-event-core.ts +1 -8
  36. package/src/pipeline/irreversible-operation-gate.ts +4 -3
@@ -0,0 +1,552 @@
1
+ // r.webSocketRoute boot validation + the Bun websocket handler's behavior
2
+ // against a recording fake socket. The real upgrade over TCP is covered by
3
+ // server-runtime's websocket-route.integration.test.ts.
4
+
5
+ import { describe, expect, spyOn, test } from "bun:test";
6
+ import { createRegistry, defineFeature } from "../../engine";
7
+ import type { SessionUser, WebSocketRouteDefinition } from "../../engine/types";
8
+ import type { AuthSessionChecker } from "../auth-middleware";
9
+ import { requestContext } from "../request-context";
10
+ import { buildServer } from "../server";
11
+ import {
12
+ buildWebSocketSessionRevalidator,
13
+ createWebSocketConnectionLimiter,
14
+ isWebSocketUpgradeRequest,
15
+ type KumikoServerWebSocket,
16
+ type KumikoWebSocketData,
17
+ kumikoWebSocketHandler,
18
+ WEBSOCKET_HEARTBEAT_INTERVAL_MS,
19
+ WEBSOCKET_REVALIDATION_FAILURE_LIMIT,
20
+ } from "../websocket-route";
21
+
22
+ const JWT_SECRET = "websocket-route-test-secret-min-32-characters";
23
+ const MIB = 1024 * 1024;
24
+
25
+ function routeFeature(name: string, ...routes: readonly Partial<WebSocketRouteDefinition>[]) {
26
+ return defineFeature(name, (r) => {
27
+ for (const route of routes) {
28
+ r.webSocketRoute({ path: "/api/ws/echo", connect: () => ({}), ...route });
29
+ }
30
+ });
31
+ }
32
+
33
+ describe("r.webSocketRoute validation", () => {
34
+ test("accepts a path under /api/ws/ with :param segments and a valid cap", () => {
35
+ expect(() =>
36
+ routeFeature("ok", { path: "/api/ws/rooms/:roomId", maxMessageBytes: MIB }),
37
+ ).not.toThrow();
38
+ });
39
+
40
+ test("rejects a path outside /api/ws/", () => {
41
+ expect(() => routeFeature("bad-prefix", { path: "/ws/echo" })).toThrow(/must start with/);
42
+ expect(() => routeFeature("bad-api", { path: "/api/echo" })).toThrow(/must start with/);
43
+ });
44
+
45
+ test("rejects wildcards", () => {
46
+ expect(() => routeFeature("wild", { path: "/api/ws/*" })).toThrow(/must not contain "\*"/);
47
+ });
48
+
49
+ test.each([0, MIB + 1, 1.5, Number.NaN, -1])("rejects maxMessageBytes %p", (maxMessageBytes) => {
50
+ expect(() => routeFeature("cap", { maxMessageBytes })).toThrow(/maxMessageBytes/);
51
+ });
52
+
53
+ test.each([0, 101, 1.5, Number.NaN, -1])(
54
+ "rejects maxConnectionsPerUser %p",
55
+ (maxConnectionsPerUser) => {
56
+ expect(() => routeFeature("conn-cap", { maxConnectionsPerUser })).toThrow(
57
+ /maxConnectionsPerUser/,
58
+ );
59
+ },
60
+ );
61
+
62
+ test("accepts maxConnectionsPerUser 1 and 100", () => {
63
+ expect(() => routeFeature("conn-min", { maxConnectionsPerUser: 1 })).not.toThrow();
64
+ expect(() => routeFeature("conn-max", { maxConnectionsPerUser: 100 })).not.toThrow();
65
+ });
66
+
67
+ test("rejects a duplicate path within a feature", () => {
68
+ expect(() => routeFeature("dup", { path: "/api/ws/a" }, { path: "/api/ws/a" })).toThrow(
69
+ /already registered/,
70
+ );
71
+ });
72
+
73
+ test("buildServer rejects the same path in two features", () => {
74
+ const registry = createRegistry([
75
+ routeFeature("first", { path: "/api/ws/shared" }),
76
+ routeFeature("second", { path: "/api/ws/shared" }),
77
+ ]);
78
+ expect(() => buildServer({ registry, context: {}, jwtSecret: JWT_SECRET })).toThrow(
79
+ /declared by both feature "first" and "second"/,
80
+ );
81
+ });
82
+ });
83
+
84
+ describe("isWebSocketUpgradeRequest", () => {
85
+ test("matches the Upgrade header case-insensitively", () => {
86
+ const upgrade = (value: string) =>
87
+ isWebSocketUpgradeRequest(new Request("http://x/", { headers: { Upgrade: value } }));
88
+ expect(upgrade("websocket")).toBe(true);
89
+ expect(upgrade("WebSocket")).toBe(true);
90
+ expect(upgrade("h2c")).toBe(false);
91
+ expect(isWebSocketUpgradeRequest(new Request("http://x/"))).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe("buildWebSocketSessionRevalidator", () => {
96
+ const user: SessionUser = {
97
+ id: "user-1",
98
+ tenantId: "tenant-1" as SessionUser["tenantId"],
99
+ roles: ["User", "Admin"],
100
+ sid: "sid-1",
101
+ };
102
+ const live = (roles?: readonly string[]) => async () =>
103
+ roles ? { status: "live" as const, roles } : ("live" as const);
104
+
105
+ test("undefined when neither a checker nor a lifecycle resolver is wired", () => {
106
+ expect(buildWebSocketSessionRevalidator({ user })).toBeUndefined();
107
+ });
108
+
109
+ test("live session with the same roles (any order) stays open", async () => {
110
+ const check = buildWebSocketSessionRevalidator({
111
+ user,
112
+ sessionChecker: live(["Admin", "User"]),
113
+ });
114
+ expect(await check?.()).toBe("live");
115
+ });
116
+
117
+ test("bare live (no derived roles) stays open", async () => {
118
+ expect(await buildWebSocketSessionRevalidator({ user, sessionChecker: live() })?.()).toBe(
119
+ "live",
120
+ );
121
+ });
122
+
123
+ test("revoked session closes", async () => {
124
+ const check = buildWebSocketSessionRevalidator({
125
+ user,
126
+ sessionChecker: async () => "revoked",
127
+ });
128
+ expect(await check?.()).toBe("invalid");
129
+ });
130
+
131
+ test("changed roles close", async () => {
132
+ const fewer = buildWebSocketSessionRevalidator({ user, sessionChecker: live(["User"]) });
133
+ const more = buildWebSocketSessionRevalidator({
134
+ user,
135
+ sessionChecker: live(["User", "Admin", "Auditor"]),
136
+ });
137
+ expect(await fewer?.()).toBe("invalid");
138
+ expect(await more?.()).toBe("invalid");
139
+ });
140
+
141
+ test("tenant in teardown closes, a serving tenant stays open", async () => {
142
+ const status = { current: "active" };
143
+ const check = buildWebSocketSessionRevalidator({
144
+ user,
145
+ resolveTenantLifecycleStatus: async () => ({ status: status.current }),
146
+ });
147
+ expect(await check?.()).toBe("live");
148
+ status.current = "destroying";
149
+ expect(await check?.()).toBe("invalid");
150
+ });
151
+
152
+ describe("token expiry", () => {
153
+ const expiresAtSec = 1_000;
154
+ const at = (ms: number) => () => ms;
155
+
156
+ test("live before exp, expired at and after exp — without touching the store", async () => {
157
+ const calls: string[] = [];
158
+ const checker: AuthSessionChecker = async () => {
159
+ calls.push("checked");
160
+ return "live";
161
+ };
162
+ const build = (nowMs: number) =>
163
+ buildWebSocketSessionRevalidator({
164
+ user,
165
+ sessionChecker: checker,
166
+ tokenExpiresAtSec: expiresAtSec,
167
+ nowMs: at(nowMs),
168
+ });
169
+ expect(await build(999_999)?.()).toBe("live");
170
+ expect(await build(1_000_000)?.()).toBe("expired");
171
+ expect(await build(2_000_000)?.()).toBe("expired");
172
+ expect(calls).toEqual(["checked"]);
173
+ });
174
+
175
+ test("an expiry alone is enough to build a revalidator", () => {
176
+ expect(
177
+ buildWebSocketSessionRevalidator({ user, tokenExpiresAtSec: expiresAtSec }),
178
+ ).toBeDefined();
179
+ });
180
+ });
181
+ });
182
+
183
+ describe("createWebSocketConnectionLimiter", () => {
184
+ test("caps per key, frees a slot on release, and release is idempotent", () => {
185
+ const limiter = createWebSocketConnectionLimiter();
186
+ const first = limiter.tryAcquire("a", 2);
187
+ const second = limiter.tryAcquire("a", 2);
188
+ expect(first).toBeDefined();
189
+ expect(second).toBeDefined();
190
+ expect(limiter.tryAcquire("a", 2)).toBeUndefined();
191
+ expect(limiter.tryAcquire("b", 2)).toBeDefined();
192
+ first?.();
193
+ first?.();
194
+ expect(limiter.tryAcquire("a", 2)).toBeDefined();
195
+ expect(limiter.tryAcquire("a", 2)).toBeUndefined();
196
+ });
197
+ });
198
+
199
+ type SocketLog = {
200
+ readonly sent: (string | Uint8Array)[];
201
+ readonly closed: { code?: number; reason?: string }[];
202
+ pings: number;
203
+ };
204
+
205
+ function fakeSocket(data: Partial<KumikoWebSocketData>): {
206
+ ws: KumikoServerWebSocket;
207
+ log: SocketLog;
208
+ } {
209
+ const log: SocketLog = { sent: [], closed: [], pings: 0 };
210
+ const ws: KumikoServerWebSocket = {
211
+ data: {
212
+ handlers: {},
213
+ maxMessageBytes: 1024,
214
+ requestContextData: undefined,
215
+ revalidateSession: undefined,
216
+ ...data,
217
+ },
218
+ send: (payload) => {
219
+ log.sent.push(payload);
220
+ },
221
+ close: (code, reason) => {
222
+ log.closed.push({
223
+ ...(code !== undefined ? { code } : {}),
224
+ ...(reason !== undefined ? { reason } : {}),
225
+ });
226
+ },
227
+ ping: () => {
228
+ log.pings += 1;
229
+ },
230
+ };
231
+ return { ws, log };
232
+ }
233
+
234
+ // The handler wraps every callback in an async guard; awaiting a macrotask
235
+ // lets those settle without timers that could mask a bug.
236
+ const settle = () => new Promise<void>((resolve) => setImmediate(resolve));
237
+
238
+ describe("kumikoWebSocketHandler", () => {
239
+ test("message over the cap closes with 1009 and never reaches the handler", async () => {
240
+ const received: unknown[] = [];
241
+ const { ws, log } = fakeSocket({
242
+ maxMessageBytes: 4,
243
+ handlers: { onMessage: (data) => void received.push(data) },
244
+ });
245
+ kumikoWebSocketHandler.message(ws, "12345");
246
+ kumikoWebSocketHandler.message(ws, new Uint8Array(5));
247
+ await settle();
248
+ expect(received).toEqual([]);
249
+ expect(log.closed).toEqual([
250
+ { code: 1009, reason: "message too big" },
251
+ { code: 1009, reason: "message too big" },
252
+ ]);
253
+ });
254
+
255
+ test("the cap counts UTF-8 bytes, not characters", () => {
256
+ const { ws, log } = fakeSocket({ maxMessageBytes: 4 });
257
+ kumikoWebSocketHandler.message(ws, "äää");
258
+ expect(log.closed[0]?.code).toBe(1009);
259
+ });
260
+
261
+ test("binary messages arrive as an exact-length Uint8Array copy", async () => {
262
+ const pool = new Uint8Array([9, 9, 1, 2, 3, 9]);
263
+ const view = Buffer.from(pool.buffer, 2, 3);
264
+ const messages: (string | Uint8Array)[] = [];
265
+ const { ws } = fakeSocket({ handlers: { onMessage: (data) => void messages.push(data) } });
266
+ kumikoWebSocketHandler.message(ws, view);
267
+ await settle();
268
+ const received = messages[0];
269
+ if (!(received instanceof Uint8Array)) throw new Error("expected binary data");
270
+ expect(Array.from(received)).toEqual([1, 2, 3]);
271
+ expect(received.buffer).not.toBe(pool.buffer);
272
+ });
273
+
274
+ test("a throwing or rejecting handler closes with 1011 instead of an unhandled rejection", async () => {
275
+ const errorLog = spyOn(console, "error").mockImplementation(() => {});
276
+ try {
277
+ const sync = fakeSocket({
278
+ handlers: {
279
+ onMessage: () => {
280
+ throw new Error("boom");
281
+ },
282
+ },
283
+ });
284
+ kumikoWebSocketHandler.message(sync.ws, "x");
285
+ const rejected = fakeSocket({
286
+ handlers: { onOpen: () => Promise.reject(new Error("nope")) },
287
+ });
288
+ kumikoWebSocketHandler.open(rejected.ws);
289
+ await settle();
290
+ kumikoWebSocketHandler.close(rejected.ws, 1006, "");
291
+ expect(sync.log.closed).toEqual([{ code: 1011, reason: "internal error" }]);
292
+ expect(rejected.log.closed).toEqual([{ code: 1011, reason: "internal error" }]);
293
+ } finally {
294
+ errorLog.mockRestore();
295
+ }
296
+ });
297
+
298
+ test("handlers run inside the captured request context", async () => {
299
+ const seenRequestIds: (string | undefined)[] = [];
300
+ const { ws } = fakeSocket({
301
+ requestContextData: { requestId: "req-1", correlationId: "corr-1" },
302
+ handlers: { onMessage: () => void seenRequestIds.push(requestContext.get()?.requestId) },
303
+ });
304
+ kumikoWebSocketHandler.message(ws, "x");
305
+ await settle();
306
+ expect(seenRequestIds).toEqual(["req-1"]);
307
+ });
308
+
309
+ describe("heartbeat", () => {
310
+ function captureHeartbeat(ws: KumikoServerWebSocket) {
311
+ const setSpy = spyOn(globalThis, "setInterval");
312
+ const clearSpy = spyOn(globalThis, "clearInterval");
313
+ let tick: (() => void) | undefined;
314
+ let delay: number | undefined;
315
+ setSpy.mockImplementation(((callback: () => void, ms: number) => {
316
+ tick = callback;
317
+ delay = ms;
318
+ return 42;
319
+ }) as unknown as typeof setInterval);
320
+ clearSpy.mockImplementation((() => {}) as unknown as typeof clearInterval);
321
+ kumikoWebSocketHandler.open(ws);
322
+ return {
323
+ tick: () => tick?.(),
324
+ delay,
325
+ cleared: () => clearSpy.mock.calls.length,
326
+ restore: () => {
327
+ setSpy.mockRestore();
328
+ clearSpy.mockRestore();
329
+ },
330
+ };
331
+ }
332
+
333
+ test("pings on the heartbeat cadence and stops on close", () => {
334
+ const { ws, log } = fakeSocket({});
335
+ const heartbeat = captureHeartbeat(ws);
336
+ try {
337
+ expect(heartbeat.delay).toBe(WEBSOCKET_HEARTBEAT_INTERVAL_MS);
338
+ heartbeat.tick();
339
+ expect(log.pings).toBe(1);
340
+ kumikoWebSocketHandler.close(ws, 1000, "bye");
341
+ expect(heartbeat.cleared()).toBe(1);
342
+ } finally {
343
+ heartbeat.restore();
344
+ }
345
+ });
346
+
347
+ test("closes with 1008 once the session is no longer live", async () => {
348
+ let live = true;
349
+ const { ws, log } = fakeSocket({
350
+ revalidateSession: async () => (live ? "live" : "invalid"),
351
+ });
352
+ const heartbeat = captureHeartbeat(ws);
353
+ try {
354
+ heartbeat.tick();
355
+ await settle();
356
+ expect(log.closed).toEqual([]);
357
+ live = false;
358
+ heartbeat.tick();
359
+ await settle();
360
+ expect(log.closed).toEqual([{ code: 1008, reason: "session changed" }]);
361
+ } finally {
362
+ heartbeat.restore();
363
+ }
364
+ });
365
+
366
+ test("a failing session check keeps the socket open", async () => {
367
+ const errorLog = spyOn(console, "error").mockImplementation(() => {});
368
+ const { ws, log } = fakeSocket({
369
+ revalidateSession: () => Promise.reject(new Error("db down")),
370
+ });
371
+ const heartbeat = captureHeartbeat(ws);
372
+ try {
373
+ heartbeat.tick();
374
+ await settle();
375
+ expect(log.closed).toEqual([]);
376
+ } finally {
377
+ heartbeat.restore();
378
+ errorLog.mockRestore();
379
+ }
380
+ });
381
+
382
+ test("counts unanswered checks as failures, starts no second check, and closes with 1013", async () => {
383
+ const errorLog = spyOn(console, "error").mockImplementation(() => {});
384
+ const started: string[] = [];
385
+ const { ws, log } = fakeSocket({
386
+ revalidateSession: () => {
387
+ started.push("check");
388
+ return new Promise(() => {});
389
+ },
390
+ });
391
+ const heartbeat = captureHeartbeat(ws);
392
+ try {
393
+ // Tick 1 starts the check that never settles; ticks 2..4 find it pending.
394
+ for (let i = 0; i < WEBSOCKET_REVALIDATION_FAILURE_LIMIT; i += 1) {
395
+ heartbeat.tick();
396
+ await settle();
397
+ expect(log.closed).toEqual([]);
398
+ }
399
+ heartbeat.tick();
400
+ await settle();
401
+ expect(started).toEqual(["check"]);
402
+ expect(log.pings).toBe(WEBSOCKET_REVALIDATION_FAILURE_LIMIT + 1);
403
+ expect(log.closed).toEqual([{ code: 1013, reason: "try again later" }]);
404
+ } finally {
405
+ heartbeat.restore();
406
+ errorLog.mockRestore();
407
+ }
408
+ });
409
+
410
+ test("an expired token closes with 1008 session expired", async () => {
411
+ const { ws, log } = fakeSocket({ revalidateSession: async () => "expired" });
412
+ const heartbeat = captureHeartbeat(ws);
413
+ try {
414
+ heartbeat.tick();
415
+ await settle();
416
+ expect(log.closed).toEqual([{ code: 1008, reason: "session expired" }]);
417
+ } finally {
418
+ heartbeat.restore();
419
+ }
420
+ });
421
+
422
+ test("only consecutive check failures close the socket (1013), a success resets the count", async () => {
423
+ const errorLog = spyOn(console, "error").mockImplementation(() => {});
424
+ const outcomes: ("fail" | "ok")[] = ["fail", "fail", "ok", "fail", "fail"];
425
+ const { ws, log } = fakeSocket({
426
+ revalidateSession: async () => {
427
+ if (outcomes.shift() === "fail") throw new Error("store down");
428
+ return "live";
429
+ },
430
+ });
431
+ const heartbeat = captureHeartbeat(ws);
432
+ try {
433
+ for (let i = 0; i < 5; i += 1) {
434
+ heartbeat.tick();
435
+ await settle();
436
+ }
437
+ expect(log.closed).toEqual([]);
438
+
439
+ outcomes.push("fail");
440
+ heartbeat.tick();
441
+ await settle();
442
+ expect(WEBSOCKET_REVALIDATION_FAILURE_LIMIT).toBe(3);
443
+ expect(log.closed).toEqual([{ code: 1013, reason: "try again later" }]);
444
+ } finally {
445
+ heartbeat.restore();
446
+ errorLog.mockRestore();
447
+ }
448
+ });
449
+ });
450
+
451
+ describe("per-connection ordering and slot release", () => {
452
+ test("handlers run one after another in arrival order", async () => {
453
+ const events: string[] = [];
454
+ let releaseFirst: () => void = () => {};
455
+ const firstGate = new Promise<void>((resolve) => {
456
+ releaseFirst = resolve;
457
+ });
458
+ const { ws } = fakeSocket({
459
+ handlers: {
460
+ onMessage: async (data) => {
461
+ events.push(`start:${String(data)}`);
462
+ if (data === "slow") await firstGate;
463
+ events.push(`end:${String(data)}`);
464
+ },
465
+ },
466
+ });
467
+ kumikoWebSocketHandler.message(ws, "slow");
468
+ kumikoWebSocketHandler.message(ws, "fast");
469
+ await settle();
470
+ expect(events).toEqual(["start:slow"]);
471
+ releaseFirst();
472
+ await settle();
473
+ expect(events).toEqual(["start:slow", "end:slow", "start:fast", "end:fast"]);
474
+ });
475
+
476
+ test("close runs onClose at once, aborts the signal, and queued messages never start", async () => {
477
+ const events: string[] = [];
478
+ let heldSignal: AbortSignal | undefined;
479
+ const { ws } = fakeSocket({
480
+ handlers: {
481
+ onMessage: (data, connection) => {
482
+ events.push(`start:${String(data)}`);
483
+ heldSignal = connection.signal;
484
+ return new Promise<void>(() => {});
485
+ },
486
+ onClose: (_code, _reason, connection) => {
487
+ events.push(`close:aborted=${connection.signal.aborted}`);
488
+ },
489
+ },
490
+ });
491
+ kumikoWebSocketHandler.message(ws, "hangs");
492
+ kumikoWebSocketHandler.message(ws, "queued");
493
+ await settle();
494
+ expect(heldSignal?.aborted).toBe(false);
495
+ kumikoWebSocketHandler.close(ws, 1000, "");
496
+ await settle();
497
+ kumikoWebSocketHandler.message(ws, "after-close");
498
+ await settle();
499
+ expect(events).toEqual(["start:hangs", "close:aborted=true"]);
500
+ expect(heldSignal?.aborted).toBe(true);
501
+ });
502
+
503
+ test("a failing handler closes with 1011 and onClose still runs afterwards", async () => {
504
+ const errorLog = spyOn(console, "error").mockImplementation(() => {});
505
+ const events: string[] = [];
506
+ const { ws, log } = fakeSocket({
507
+ handlers: {
508
+ onMessage: () => {
509
+ throw new Error("boom");
510
+ },
511
+ onClose: () => void events.push("close"),
512
+ },
513
+ });
514
+ try {
515
+ kumikoWebSocketHandler.message(ws, "x");
516
+ await settle();
517
+ expect(log.closed).toEqual([{ code: 1011, reason: "internal error" }]);
518
+ kumikoWebSocketHandler.close(ws, 1006, "");
519
+ await settle();
520
+ expect(events).toEqual(["close"]);
521
+ } finally {
522
+ errorLog.mockRestore();
523
+ }
524
+ });
525
+
526
+ test("a throwing onClose is logged, not rethrown", async () => {
527
+ const errorLog = spyOn(console, "error").mockImplementation(() => {});
528
+ const { ws, log } = fakeSocket({
529
+ handlers: {
530
+ onClose: () => {
531
+ throw new Error("cleanup failed");
532
+ },
533
+ },
534
+ });
535
+ try {
536
+ kumikoWebSocketHandler.close(ws, 1000, "");
537
+ await settle();
538
+ expect(log.closed).toEqual([]);
539
+ expect(errorLog).toHaveBeenCalled();
540
+ } finally {
541
+ errorLog.mockRestore();
542
+ }
543
+ });
544
+
545
+ test("close frees the connection slot", () => {
546
+ const released: string[] = [];
547
+ const { ws } = fakeSocket({ releaseConnectionSlot: () => void released.push("released") });
548
+ kumikoWebSocketHandler.close(ws, 1000, "");
549
+ expect(released).toEqual(["released"]);
550
+ });
551
+ });
552
+ });
@@ -10,6 +10,7 @@ import { isForeignCookieOrigin } from "./origin-middleware";
10
10
 
11
11
  const USER_KEY = "pipelineUser";
12
12
  const AUTH_TRANSPORT_KEY = "authTransport";
13
+ const AUTH_TOKEN_EXPIRY_KEY = "authTokenExpiry";
13
14
 
14
15
  // Names used across middleware and auth-routes. Kept here so csrf-middleware
15
16
  // and auth-routes import them from a single source of truth — renaming a
@@ -48,6 +49,10 @@ export type AuthSessionCheckResult =
48
49
  | AuthSessionStatus
49
50
  | { readonly status: "live"; readonly roles: readonly string[] };
50
51
 
52
+ export function sessionCheckStatus(result: AuthSessionCheckResult): AuthSessionStatus {
53
+ return typeof result === "string" ? result : result.status;
54
+ }
55
+
51
56
  // Called by the middleware after JWT-verify. Gets the sid AND the expected
52
57
  // userId from the JWT's `sub` — the checker MUST confirm the session row
53
58
  // both exists + is live AND belongs to expectedUserId. Without the userId
@@ -321,7 +326,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
321
326
  if (sessionChecker) {
322
327
  if (payload.jti) {
323
328
  const result = await sessionChecker(payload.jti, payload.sub);
324
- const status = typeof result === "string" ? result : result.status;
329
+ const status = sessionCheckStatus(result);
325
330
  if (status !== "live") {
326
331
  return sessionInvalid(c, status);
327
332
  }
@@ -366,6 +371,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
366
371
  if (lifecycleReject) return lifecycleReject;
367
372
  c.set(USER_KEY, user);
368
373
  c.set(AUTH_TRANSPORT_KEY, transport);
374
+ if (payload.exp !== undefined) c.set(AUTH_TOKEN_EXPIRY_KEY, payload.exp);
369
375
  await next();
370
376
  };
371
377
  }
@@ -380,6 +386,12 @@ export function getAuthTransport(c: Context): AuthTransport | undefined {
380
386
  return c.get(AUTH_TRANSPORT_KEY) as AuthTransport | undefined;
381
387
  }
382
388
 
389
+ /** JWT `exp` (epoch seconds) of the request's token; undefined for anonymous and verified-bearer (PAT) callers. */
390
+ export function getAuthTokenExpiry(c: Context): number | undefined {
391
+ // @cast-boundary engine-bridge — Hono context.get returns unknown
392
+ return c.get(AUTH_TOKEN_EXPIRY_KEY) as number | undefined;
393
+ }
394
+
383
395
  // Verified-bearer request flow. `user` was already resolved by the wired
384
396
  // tokenVerifier (PAT today, future JWT-provider). Apply the same
385
397
  // X-Tenant-mismatch guard as the JWT path before continuing. Structured like
@@ -651,22 +663,32 @@ async function requestsCancelDestruction(c: Context): Promise<boolean> {
651
663
  return false;
652
664
  }
653
665
 
666
+ // The teardown status the HTTP guard rejects on, shared with the WebSocket
667
+ // revalidator so both agree on what "tenant unavailable" means.
668
+ export async function resolveTenantTeardownStatus(
669
+ tenantId: TenantId,
670
+ resolveTenantLifecycleStatus: TenantLifecycleStatusResolver | undefined,
671
+ ): Promise<string | undefined> {
672
+ if (!resolveTenantLifecycleStatus) return undefined;
673
+ const lifecycle = await resolveTenantLifecycleStatus(tenantId);
674
+ return lifecycle && TENANT_TEARDOWN_STATUSES.has(lifecycle.status) ? lifecycle.status : undefined;
675
+ }
676
+
654
677
  async function rejectIfTenantTeardown(
655
678
  c: Context,
656
679
  tenantId: TenantId,
657
680
  resolveTenantLifecycleStatus: TenantLifecycleStatusResolver | undefined,
658
681
  ): Promise<Response | undefined> {
659
- if (!resolveTenantLifecycleStatus) return undefined;
660
- const lifecycle = await resolveTenantLifecycleStatus(tenantId);
661
- if (!lifecycle || !TENANT_TEARDOWN_STATUSES.has(lifecycle.status)) return undefined;
662
- if (lifecycle.status === "destroyRequested" && (await requestsCancelDestruction(c))) {
682
+ const status = await resolveTenantTeardownStatus(tenantId, resolveTenantLifecycleStatus);
683
+ if (status === undefined) return undefined;
684
+ if (status === "destroyRequested" && (await requestsCancelDestruction(c))) {
663
685
  return undefined;
664
686
  }
665
687
  return middlewareReject(c, {
666
688
  code: "tenant_unavailable",
667
689
  status: 410,
668
- message: `tenant "${tenantId}" is unavailable (${lifecycle.status})`,
690
+ message: `tenant "${tenantId}" is unavailable (${status})`,
669
691
  i18nKey: "auth.errors.tenantUnavailable",
670
- details: { tenantId, status: lifecycle.status },
692
+ details: { tenantId, status },
671
693
  });
672
694
  }
@@ -34,8 +34,21 @@ export type ClientIpResolver = {
34
34
  // created, and a cloned/rebuilt Request (tryHonoFirst's req.clone(), etc.)
35
35
  // would silently return null. The address is extracted once at the
36
36
  // outermost Bun.serve fetch handler and threaded down as a plain value.
37
- export function extractSocketAddress(env: unknown): string | undefined {
38
- return typeof env === "string" && env.length > 0 ? env : undefined;
37
+ // WebSocket-upgrade requests carry `{ socketAddress, server }` instead (see
38
+ // KumikoServeEnv) — the server handle is needed for `server.upgrade`.
39
+ export function extractSocketAddress(honoEnv: unknown): string | undefined {
40
+ if (typeof honoEnv === "string") return honoEnv.length > 0 ? honoEnv : undefined;
41
+ if (hasSocketAddress(honoEnv) && honoEnv.socketAddress.length > 0) return honoEnv.socketAddress;
42
+ return undefined;
43
+ }
44
+
45
+ function hasSocketAddress(value: unknown): value is { readonly socketAddress: string } {
46
+ return (
47
+ typeof value === "object" &&
48
+ value !== null &&
49
+ "socketAddress" in value &&
50
+ typeof value.socketAddress === "string"
51
+ );
39
52
  }
40
53
 
41
54
  export function assertValidTrustedProxyHops(value: number | undefined, context: string): void {
package/src/api/index.ts CHANGED
@@ -13,7 +13,13 @@ export type {
13
13
  TenantResolver,
14
14
  TokenVerifier,
15
15
  } from "./auth-middleware";
16
- export { authMiddleware, getUser, PAT_TOKEN_PREFIX } from "./auth-middleware";
16
+ export {
17
+ AUTH_COOKIE_NAME,
18
+ authMiddleware,
19
+ getUser,
20
+ PAT_TOKEN_PREFIX,
21
+ sessionCheckStatus,
22
+ } from "./auth-middleware";
17
23
  export type {
18
24
  AuthRoutesConfig,
19
25
  LoginRateLimiter,
@@ -92,3 +98,20 @@ export type { SseBroker, SseClient, SseEvent } from "./sse-broker";
92
98
  export { createSseBroker } from "./sse-broker";
93
99
  export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
94
100
  export { generateToken } from "./tokens";
101
+ export type {
102
+ KumikoServeEnv,
103
+ KumikoServerWebSocket,
104
+ KumikoWebSocketData,
105
+ WebSocketUpgradeServer,
106
+ } from "./websocket-route";
107
+ export {
108
+ createKumikoWebSocketHandler,
109
+ isWebSocketUpgradeRequest,
110
+ kumikoWebSocketHandler,
111
+ WEBSOCKET_BACKPRESSURE_LIMIT_BYTES,
112
+ WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER,
113
+ WEBSOCKET_HEARTBEAT_INTERVAL_MS,
114
+ WEBSOCKET_MAX_PAYLOAD_BYTES,
115
+ WEBSOCKET_REVALIDATION_FAILURE_LIMIT,
116
+ WEBSOCKET_ROUTE_PATH_PREFIX,
117
+ } from "./websocket-route";