@every-app/sdk 0.1.13 → 0.1.14

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 (32) hide show
  1. package/dist/shared/bypassGatewayLocalOnly.d.ts +2 -1
  2. package/dist/shared/bypassGatewayLocalOnly.d.ts.map +1 -1
  3. package/dist/shared/bypassGatewayLocalOnly.js +6 -2
  4. package/dist/tanstack/server/authenticateRequest.d.ts +2 -0
  5. package/dist/tanstack/server/authenticateRequest.d.ts.map +1 -1
  6. package/dist/tanstack/server/authenticateRequest.js +75 -3
  7. package/package.json +3 -3
  8. package/src/cloudflare/getLocalD1Url.ts +0 -64
  9. package/src/cloudflare/index.ts +0 -1
  10. package/src/cloudflare/lazyInit.ts +0 -67
  11. package/src/cloudflare/server/gateway.test.ts +0 -262
  12. package/src/cloudflare/server/gateway.ts +0 -114
  13. package/src/cloudflare/server/index.ts +0 -2
  14. package/src/core/authenticatedFetch.ts +0 -54
  15. package/src/core/index.ts +0 -12
  16. package/src/core/sessionManager.test.ts +0 -939
  17. package/src/core/sessionManager.ts +0 -492
  18. package/src/env.d.ts +0 -13
  19. package/src/shared/bypassGatewayLocalOnly.ts +0 -55
  20. package/src/shared/parseMessagePayload.ts +0 -22
  21. package/src/tanstack/EmbeddedAppProvider.tsx +0 -96
  22. package/src/tanstack/GatewayRequiredError.tsx +0 -150
  23. package/src/tanstack/_internal/useEveryAppSession.test.ts +0 -40
  24. package/src/tanstack/_internal/useEveryAppSession.tsx +0 -74
  25. package/src/tanstack/index.ts +0 -3
  26. package/src/tanstack/server/authConfig.ts +0 -19
  27. package/src/tanstack/server/authenticateRequest.test.ts +0 -482
  28. package/src/tanstack/server/authenticateRequest.ts +0 -143
  29. package/src/tanstack/server/index.ts +0 -3
  30. package/src/tanstack/server/types.ts +0 -4
  31. package/src/tanstack/useEveryAppRouter.tsx +0 -83
  32. package/src/tanstack/useSessionTokenClientMiddleware.ts +0 -43
@@ -1,939 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
-
3
- // Mock import.meta.env before any imports
4
- vi.stubEnv("VITE_GATEWAY_URL", "https://gateway.example.com");
5
-
6
- describe("SessionManager", () => {
7
- let SessionManager: typeof import("./sessionManager").SessionManager;
8
- let mockPostMessage: ReturnType<typeof vi.fn>;
9
- let messageHandler: ((event: MessageEvent) => void) | null = null;
10
- let addEventListenerSpy: ReturnType<typeof vi.fn>;
11
- let removeEventListenerSpy: ReturnType<typeof vi.fn>;
12
-
13
- // Helper function to simulate token responses from parent window
14
- function simulateTokenResponse(options: {
15
- token?: string;
16
- error?: string;
17
- expiresAt?: string;
18
- requestId?: string;
19
- origin?: string;
20
- type?: string;
21
- }) {
22
- if (!messageHandler) {
23
- throw new Error("messageHandler not initialized");
24
- }
25
- messageHandler({
26
- origin: options.origin ?? "https://gateway.example.com",
27
- data: {
28
- type: options.type ?? "SESSION_TOKEN_RESPONSE",
29
- requestId: options.requestId ?? "test-uuid-123",
30
- ...(options.token !== undefined && { token: options.token }),
31
- ...(options.error !== undefined && { error: options.error }),
32
- ...(options.expiresAt !== undefined && {
33
- expiresAt: options.expiresAt,
34
- }),
35
- },
36
- } as MessageEvent);
37
- }
38
-
39
- // Helper function to simulate malformed message events (for security edge case testing)
40
- function simulateMalformedMessage(origin: string | null, data: unknown) {
41
- if (!messageHandler) {
42
- throw new Error("messageHandler not initialized");
43
- }
44
- messageHandler({
45
- origin,
46
- data,
47
- } as MessageEvent);
48
- }
49
-
50
- function createJwtLikeToken(payload: Record<string, unknown>): string {
51
- return `header.${btoa(JSON.stringify(payload))}.signature`;
52
- }
53
-
54
- function createBase64UrlJwtLikeToken(
55
- payload: Record<string, unknown>,
56
- ): string {
57
- const encoded = btoa(JSON.stringify(payload))
58
- .replace(/\+/g, "-")
59
- .replace(/\//g, "_")
60
- .replace(/=+$/g, "");
61
-
62
- return `header.${encoded}.signature`;
63
- }
64
-
65
- beforeEach(async () => {
66
- vi.resetModules();
67
- vi.stubEnv("VITE_GATEWAY_URL", "https://gateway.example.com");
68
-
69
- // Mock window and postMessage
70
- mockPostMessage = vi.fn();
71
- messageHandler = null;
72
- addEventListenerSpy = vi.fn((event: string, handler: Function) => {
73
- if (event === "message") {
74
- messageHandler = handler as (event: MessageEvent) => void;
75
- }
76
- });
77
- removeEventListenerSpy = vi.fn();
78
-
79
- vi.stubGlobal("window", {
80
- addEventListener: addEventListenerSpy,
81
- removeEventListener: removeEventListenerSpy,
82
- parent: {
83
- postMessage: mockPostMessage,
84
- },
85
- });
86
-
87
- vi.stubGlobal("crypto", {
88
- randomUUID: () => "test-uuid-123",
89
- });
90
-
91
- // Import fresh module
92
- const module = await import("./sessionManager");
93
- SessionManager = module.SessionManager;
94
- });
95
-
96
- afterEach(() => {
97
- vi.useRealTimers(); // Safe to call even if real timers are active
98
- vi.unstubAllEnvs();
99
- vi.unstubAllGlobals();
100
- vi.restoreAllMocks();
101
- messageHandler = null;
102
- });
103
-
104
- describe("constructor", () => {
105
- it("throws error when appId is not provided", () => {
106
- expect(() => new SessionManager({ appId: "" })).toThrow(
107
- "[SessionManager] appId is required.",
108
- );
109
- });
110
-
111
- it("throws error when VITE_GATEWAY_URL is not set", async () => {
112
- vi.resetModules();
113
- vi.stubEnv("VITE_GATEWAY_URL", "");
114
-
115
- const module = await import("./sessionManager");
116
-
117
- expect(() => new module.SessionManager({ appId: "test-app" })).toThrow(
118
- "[SessionManager] VITE_GATEWAY_URL env var is required.",
119
- );
120
- });
121
-
122
- it("throws error for invalid gateway URL", async () => {
123
- vi.resetModules();
124
- vi.stubEnv("VITE_GATEWAY_URL", "not-a-valid-url");
125
-
126
- const module = await import("./sessionManager");
127
-
128
- expect(() => new module.SessionManager({ appId: "test-app" })).toThrow(
129
- "[SessionManager] Invalid gateway URL: not-a-valid-url",
130
- );
131
- });
132
-
133
- it("successfully creates instance with valid config", () => {
134
- const manager = new SessionManager({ appId: "test-app" });
135
-
136
- expect(manager.appId).toBe("test-app");
137
- expect(manager.parentOrigin).toBe("https://gateway.example.com");
138
- });
139
- });
140
-
141
- describe("getTokenState", () => {
142
- it("returns NO_TOKEN when no token exists", () => {
143
- const manager = new SessionManager({ appId: "test-app" });
144
-
145
- const state = manager.getTokenState();
146
-
147
- expect(state.status).toBe("NO_TOKEN");
148
- expect(state.token).toBeNull();
149
- });
150
-
151
- it("returns VALID when token exists and is not expiring", async () => {
152
- const manager = new SessionManager({ appId: "test-app" });
153
-
154
- const tokenPromise = manager.requestNewToken();
155
-
156
- simulateTokenResponse({
157
- token: "valid-token",
158
- expiresAt: new Date(Date.now() + 60000).toISOString(), // Expires in 60 seconds
159
- });
160
-
161
- await tokenPromise;
162
-
163
- const state = manager.getTokenState();
164
-
165
- expect(state.status).toBe("VALID");
166
- expect(state.token).toBe("valid-token");
167
- });
168
-
169
- it("returns EXPIRED when token is past expiration", async () => {
170
- const manager = new SessionManager({ appId: "test-app" });
171
-
172
- const tokenPromise = manager.requestNewToken();
173
-
174
- simulateTokenResponse({
175
- token: "expired-token",
176
- expiresAt: new Date(Date.now() - 1000).toISOString(), // Already expired
177
- });
178
-
179
- await tokenPromise;
180
-
181
- const state = manager.getTokenState();
182
-
183
- expect(state.status).toBe("EXPIRED");
184
- expect(state.token).toBe("expired-token");
185
- });
186
-
187
- it("returns REFRESHING when token request is in progress", async () => {
188
- const manager = new SessionManager({ appId: "test-app" });
189
-
190
- // Start a request but don't complete it yet
191
- const tokenPromise = manager.requestNewToken();
192
-
193
- // Check state while request is pending
194
- const state = manager.getTokenState();
195
-
196
- expect(state.status).toBe("REFRESHING");
197
- expect(state.token).toBeNull();
198
-
199
- // Complete the request to clean up
200
- simulateTokenResponse({
201
- token: "token",
202
- expiresAt: new Date(Date.now() + 60000).toISOString(),
203
- });
204
-
205
- await tokenPromise;
206
- });
207
- });
208
-
209
- describe("requestNewToken", () => {
210
- it("sends correct postMessage to parent", async () => {
211
- const manager = new SessionManager({ appId: "my-app" });
212
-
213
- const tokenPromise = manager.requestNewToken();
214
-
215
- expect(mockPostMessage).toHaveBeenCalledWith(
216
- {
217
- type: "SESSION_TOKEN_REQUEST",
218
- requestId: "test-uuid-123",
219
- appId: "my-app",
220
- },
221
- "https://gateway.example.com",
222
- );
223
-
224
- // Complete the promise
225
- simulateTokenResponse({
226
- token: "new-token",
227
- expiresAt: new Date(Date.now() + 60000).toISOString(),
228
- });
229
-
230
- await tokenPromise;
231
- });
232
-
233
- it("rejects when response contains error", async () => {
234
- const manager = new SessionManager({ appId: "test-app" });
235
-
236
- const tokenPromise = manager.requestNewToken();
237
-
238
- simulateTokenResponse({ error: "Token request denied" });
239
-
240
- await expect(tokenPromise).rejects.toThrow("Token request denied");
241
- });
242
-
243
- it("rejects when response has no token", async () => {
244
- const manager = new SessionManager({ appId: "test-app" });
245
-
246
- const tokenPromise = manager.requestNewToken();
247
-
248
- // Simulate response with no token field
249
- simulateTokenResponse({});
250
-
251
- await expect(tokenPromise).rejects.toThrow("No token in response");
252
- });
253
-
254
- it("times out after MESSAGE_TIMEOUT_MS", async () => {
255
- vi.useFakeTimers();
256
-
257
- const manager = new SessionManager({ appId: "test-app" });
258
-
259
- const tokenPromise = manager.requestNewToken();
260
-
261
- // Fast-forward past the timeout (5000ms)
262
- vi.advanceTimersByTime(5001);
263
-
264
- await expect(tokenPromise).rejects.toThrow(
265
- "Token request timeout - parent did not respond",
266
- );
267
- });
268
-
269
- it("ignores messages from wrong origin (security-critical)", async () => {
270
- vi.useFakeTimers();
271
-
272
- const manager = new SessionManager({ appId: "test-app" });
273
-
274
- const tokenPromise = manager.requestNewToken();
275
-
276
- // Send message from wrong origin - this should be ignored
277
- simulateTokenResponse({
278
- token: "malicious-token",
279
- origin: "https://malicious.example.com",
280
- });
281
-
282
- // Should still timeout because the message was ignored
283
- vi.advanceTimersByTime(5001);
284
-
285
- await expect(tokenPromise).rejects.toThrow(
286
- "Token request timeout - parent did not respond",
287
- );
288
- });
289
-
290
- it("ignores messages with wrong requestId (security-critical)", async () => {
291
- vi.useFakeTimers();
292
-
293
- const manager = new SessionManager({ appId: "test-app" });
294
-
295
- const tokenPromise = manager.requestNewToken();
296
-
297
- // Send message with wrong requestId - this should be ignored
298
- simulateTokenResponse({
299
- token: "wrong-token",
300
- requestId: "wrong-request-id",
301
- });
302
-
303
- // Should still timeout
304
- vi.advanceTimersByTime(5001);
305
-
306
- await expect(tokenPromise).rejects.toThrow(
307
- "Token request timeout - parent did not respond",
308
- );
309
- });
310
-
311
- it("ignores messages with wrong type", async () => {
312
- vi.useFakeTimers();
313
-
314
- const manager = new SessionManager({ appId: "test-app" });
315
-
316
- const tokenPromise = manager.requestNewToken();
317
-
318
- // Send message with wrong type
319
- simulateTokenResponse({
320
- token: "wrong-token",
321
- type: "WRONG_MESSAGE_TYPE",
322
- });
323
-
324
- vi.advanceTimersByTime(5001);
325
-
326
- await expect(tokenPromise).rejects.toThrow(
327
- "Token request timeout - parent did not respond",
328
- );
329
- });
330
-
331
- it("deduplicates concurrent token requests", async () => {
332
- const manager = new SessionManager({ appId: "test-app" });
333
-
334
- // Start two concurrent requests
335
- const promise1 = manager.requestNewToken();
336
- const promise2 = manager.requestNewToken();
337
-
338
- // Should only send one postMessage
339
- expect(mockPostMessage).toHaveBeenCalledTimes(1);
340
-
341
- // Complete the request
342
- simulateTokenResponse({
343
- token: "shared-token",
344
- expiresAt: new Date(Date.now() + 60000).toISOString(),
345
- });
346
-
347
- // Both promises should resolve with the same token
348
- const [token1, token2] = await Promise.all([promise1, promise2]);
349
-
350
- expect(token1).toBe("shared-token");
351
- expect(token2).toBe("shared-token");
352
- });
353
- });
354
-
355
- describe("getToken", () => {
356
- it("requests new token when no token exists", async () => {
357
- const manager = new SessionManager({ appId: "test-app" });
358
-
359
- const tokenPromise = manager.getToken();
360
-
361
- simulateTokenResponse({
362
- token: "new-token",
363
- expiresAt: new Date(Date.now() + 60000).toISOString(),
364
- });
365
-
366
- const token = await tokenPromise;
367
-
368
- expect(token).toBe("new-token");
369
- expect(mockPostMessage).toHaveBeenCalled();
370
- });
371
-
372
- it("returns cached token when not expiring soon", async () => {
373
- const manager = new SessionManager({ appId: "test-app" });
374
-
375
- // First, get a token
376
- const firstPromise = manager.getToken();
377
-
378
- simulateTokenResponse({
379
- token: "cached-token",
380
- expiresAt: new Date(Date.now() + 60000).toISOString(), // 60 seconds, well above 10s buffer
381
- });
382
-
383
- await firstPromise;
384
-
385
- // Reset mock to check if second call makes new request
386
- mockPostMessage.mockClear();
387
-
388
- // Get token again - should use cache
389
- const cachedToken = await manager.getToken();
390
-
391
- expect(cachedToken).toBe("cached-token");
392
- expect(mockPostMessage).not.toHaveBeenCalled();
393
- });
394
-
395
- it("requests new token when token is expiring soon", async () => {
396
- const manager = new SessionManager({ appId: "test-app" });
397
-
398
- // Get initial token that's about to expire
399
- const firstPromise = manager.getToken();
400
-
401
- simulateTokenResponse({
402
- token: "expiring-token",
403
- expiresAt: new Date(Date.now() + 5000).toISOString(), // 5 seconds, under 10s buffer
404
- });
405
-
406
- await firstPromise;
407
-
408
- mockPostMessage.mockClear();
409
-
410
- // Request new UUID for second request
411
- vi.stubGlobal("crypto", {
412
- randomUUID: () => "second-uuid-456",
413
- });
414
-
415
- // Get token again - should request new token since current is expiring soon
416
- const secondPromise = manager.getToken();
417
-
418
- expect(mockPostMessage).toHaveBeenCalled();
419
-
420
- simulateTokenResponse({
421
- token: "fresh-token",
422
- expiresAt: new Date(Date.now() + 60000).toISOString(),
423
- requestId: "second-uuid-456",
424
- });
425
-
426
- const newToken = await secondPromise;
427
-
428
- expect(newToken).toBe("fresh-token");
429
- });
430
- });
431
-
432
- describe("getUser", () => {
433
- it("returns null when no token exists", () => {
434
- const manager = new SessionManager({ appId: "test-app" });
435
-
436
- const user = manager.getUser();
437
-
438
- expect(user).toBeNull();
439
- });
440
-
441
- it("returns null for malformed JWT", async () => {
442
- const manager = new SessionManager({ appId: "test-app" });
443
-
444
- const tokenPromise = manager.requestNewToken();
445
-
446
- simulateTokenResponse({
447
- token: "not-a-valid-jwt",
448
- expiresAt: new Date(Date.now() + 60000).toISOString(),
449
- });
450
-
451
- await tokenPromise;
452
-
453
- const user = manager.getUser();
454
-
455
- expect(user).toBeNull();
456
- });
457
-
458
- it("returns null for JWT with only two parts", async () => {
459
- const manager = new SessionManager({ appId: "test-app" });
460
-
461
- const tokenPromise = manager.requestNewToken();
462
-
463
- simulateTokenResponse({
464
- token: "header.payload", // Missing signature
465
- expiresAt: new Date(Date.now() + 60000).toISOString(),
466
- });
467
-
468
- await tokenPromise;
469
-
470
- const user = manager.getUser();
471
-
472
- expect(user).toBeNull();
473
- });
474
-
475
- it("returns null for JWT with invalid base64 payload", async () => {
476
- const manager = new SessionManager({ appId: "test-app" });
477
-
478
- const tokenPromise = manager.requestNewToken();
479
-
480
- // Invalid base64 that will throw in atob()
481
- simulateTokenResponse({
482
- token: "header.!!!invalid-base64!!!.signature",
483
- expiresAt: new Date(Date.now() + 60000).toISOString(),
484
- });
485
-
486
- await tokenPromise;
487
-
488
- const user = manager.getUser();
489
-
490
- expect(user).toBeNull();
491
- });
492
-
493
- it("returns null for JWT with missing sub claim", async () => {
494
- const manager = new SessionManager({ appId: "test-app" });
495
-
496
- // Create a JWT-like token with no sub claim
497
- const payload = btoa(JSON.stringify({ email: "test@example.com" }));
498
- const fakeToken = `header.${payload}.signature`;
499
-
500
- const tokenPromise = manager.requestNewToken();
501
-
502
- simulateTokenResponse({
503
- token: fakeToken,
504
- expiresAt: new Date(Date.now() + 60000).toISOString(),
505
- });
506
-
507
- await tokenPromise;
508
-
509
- const user = manager.getUser();
510
-
511
- expect(user).toBeNull();
512
- });
513
-
514
- it("extracts user info from valid JWT", async () => {
515
- const manager = new SessionManager({ appId: "test-app" });
516
-
517
- // Create a valid JWT-like token
518
- const payload = btoa(
519
- JSON.stringify({
520
- sub: "user-123",
521
- email: "test@example.com",
522
- }),
523
- );
524
- const fakeToken = `header.${payload}.signature`;
525
-
526
- const tokenPromise = manager.requestNewToken();
527
-
528
- simulateTokenResponse({
529
- token: fakeToken,
530
- expiresAt: new Date(Date.now() + 60000).toISOString(),
531
- });
532
-
533
- await tokenPromise;
534
-
535
- const user = manager.getUser();
536
-
537
- expect(user).toEqual({
538
- userId: "user-123",
539
- email: "test@example.com",
540
- });
541
- });
542
-
543
- it("handles missing email in JWT", async () => {
544
- const manager = new SessionManager({ appId: "test-app" });
545
-
546
- const payload = btoa(
547
- JSON.stringify({
548
- sub: "user-123",
549
- // No email field
550
- }),
551
- );
552
- const fakeToken = `header.${payload}.signature`;
553
-
554
- const tokenPromise = manager.requestNewToken();
555
-
556
- simulateTokenResponse({
557
- token: fakeToken,
558
- expiresAt: new Date(Date.now() + 60000).toISOString(),
559
- });
560
-
561
- await tokenPromise;
562
-
563
- const user = manager.getUser();
564
-
565
- expect(user).toEqual({
566
- userId: "user-123",
567
- email: "", // Defaults to empty string
568
- });
569
- });
570
-
571
- it("extracts user info from base64url-encoded JWT payload", async () => {
572
- const manager = new SessionManager({ appId: "test-app" });
573
-
574
- const fakeToken = createBase64UrlJwtLikeToken({
575
- sub: "user-123",
576
- email: "test@example.com",
577
- });
578
-
579
- const tokenPromise = manager.requestNewToken();
580
-
581
- simulateTokenResponse({
582
- token: fakeToken,
583
- expiresAt: new Date(Date.now() + 60000).toISOString(),
584
- });
585
-
586
- await tokenPromise;
587
-
588
- const user = manager.getUser();
589
-
590
- expect(user).toEqual({
591
- userId: "user-123",
592
- email: "test@example.com",
593
- });
594
- });
595
- });
596
-
597
- describe("default token lifetime", () => {
598
- it("uses DEFAULT_TOKEN_LIFETIME_MS when expiresAt not provided", async () => {
599
- const manager = new SessionManager({ appId: "test-app" });
600
-
601
- const tokenPromise = manager.requestNewToken();
602
-
603
- // Simulate response with no expiresAt field
604
- simulateTokenResponse({
605
- token: "token-without-expiry",
606
- });
607
-
608
- await tokenPromise;
609
-
610
- const state = manager.getTokenState();
611
-
612
- // Token should be valid (default lifetime is 60000ms)
613
- expect(state.status).toBe("VALID");
614
- });
615
- });
616
-
617
- describe("security edge cases", () => {
618
- it("cleans up event listener on successful response", async () => {
619
- const manager = new SessionManager({ appId: "test-app" });
620
-
621
- const tokenPromise = manager.requestNewToken();
622
-
623
- simulateTokenResponse({
624
- token: "valid-token",
625
- expiresAt: new Date(Date.now() + 60000).toISOString(),
626
- });
627
-
628
- await tokenPromise;
629
-
630
- expect(removeEventListenerSpy).toHaveBeenCalledWith(
631
- "message",
632
- expect.any(Function),
633
- );
634
- });
635
-
636
- it("cleans up event listener on timeout", async () => {
637
- vi.useFakeTimers();
638
-
639
- const manager = new SessionManager({ appId: "test-app" });
640
-
641
- const tokenPromise = manager.requestNewToken();
642
-
643
- vi.advanceTimersByTime(5001);
644
-
645
- await expect(tokenPromise).rejects.toThrow(
646
- "Token request timeout - parent did not respond",
647
- );
648
-
649
- expect(removeEventListenerSpy).toHaveBeenCalledWith(
650
- "message",
651
- expect.any(Function),
652
- );
653
- });
654
-
655
- it("cleans up event listener on error response", async () => {
656
- const manager = new SessionManager({ appId: "test-app" });
657
-
658
- const tokenPromise = manager.requestNewToken();
659
-
660
- simulateTokenResponse({ error: "Access denied" });
661
-
662
- await expect(tokenPromise).rejects.toThrow("Access denied");
663
-
664
- expect(removeEventListenerSpy).toHaveBeenCalledWith(
665
- "message",
666
- expect.any(Function),
667
- );
668
- });
669
-
670
- it("ignores messages with null origin (sandboxed iframes, file:// URLs)", async () => {
671
- vi.useFakeTimers();
672
-
673
- const manager = new SessionManager({ appId: "test-app" });
674
-
675
- const tokenPromise = manager.requestNewToken();
676
-
677
- // Null origin occurs with sandboxed iframes and file:// URLs
678
- simulateMalformedMessage(null, {
679
- type: "SESSION_TOKEN_RESPONSE",
680
- requestId: "test-uuid-123",
681
- token: "suspicious-token",
682
- });
683
-
684
- // Should timeout because null origin doesn't match
685
- vi.advanceTimersByTime(5001);
686
-
687
- await expect(tokenPromise).rejects.toThrow(
688
- "Token request timeout - parent did not respond",
689
- );
690
- });
691
-
692
- it("ignores messages with null event.data", async () => {
693
- vi.useFakeTimers();
694
-
695
- const manager = new SessionManager({ appId: "test-app" });
696
-
697
- const tokenPromise = manager.requestNewToken();
698
-
699
- // Send message with null data - should not crash
700
- simulateMalformedMessage("https://gateway.example.com", null);
701
-
702
- vi.advanceTimersByTime(5001);
703
-
704
- await expect(tokenPromise).rejects.toThrow(
705
- "Token request timeout - parent did not respond",
706
- );
707
- });
708
-
709
- it("ignores messages with primitive event.data", async () => {
710
- vi.useFakeTimers();
711
-
712
- const manager = new SessionManager({ appId: "test-app" });
713
-
714
- const tokenPromise = manager.requestNewToken();
715
-
716
- // Send message with string data - should not crash
717
- simulateMalformedMessage("https://gateway.example.com", "not an object");
718
-
719
- // Send message with number data
720
- simulateMalformedMessage("https://gateway.example.com", 12345);
721
-
722
- vi.advanceTimersByTime(5001);
723
-
724
- await expect(tokenPromise).rejects.toThrow(
725
- "Token request timeout - parent did not respond",
726
- );
727
- });
728
-
729
- it("ignores messages with undefined event.data", async () => {
730
- vi.useFakeTimers();
731
-
732
- const manager = new SessionManager({ appId: "test-app" });
733
-
734
- const tokenPromise = manager.requestNewToken();
735
-
736
- // Send message with undefined data - should not crash
737
- simulateMalformedMessage("https://gateway.example.com", undefined);
738
-
739
- vi.advanceTimersByTime(5001);
740
-
741
- await expect(tokenPromise).rejects.toThrow(
742
- "Token request timeout - parent did not respond",
743
- );
744
- });
745
- });
746
-
747
- describe("expiresAt handling", () => {
748
- it("uses default lifetime when expiresAt is invalid date string", async () => {
749
- const manager = new SessionManager({ appId: "test-app" });
750
-
751
- const tokenPromise = manager.requestNewToken();
752
-
753
- simulateTokenResponse({
754
- token: "token-with-invalid-expiry",
755
- expiresAt: "not-a-valid-date",
756
- });
757
-
758
- await tokenPromise;
759
-
760
- const state = manager.getTokenState();
761
-
762
- // Should be valid because it fell back to default lifetime (60s)
763
- expect(state.status).toBe("VALID");
764
- expect(state.token).toBe("token-with-invalid-expiry");
765
- });
766
-
767
- it("uses default lifetime when expiresAt is empty string", async () => {
768
- const manager = new SessionManager({ appId: "test-app" });
769
-
770
- const tokenPromise = manager.requestNewToken();
771
-
772
- simulateTokenResponse({
773
- token: "token-with-empty-expiry",
774
- expiresAt: "",
775
- });
776
-
777
- await tokenPromise;
778
-
779
- const state = manager.getTokenState();
780
-
781
- // Empty string is falsy, so should use default lifetime
782
- expect(state.status).toBe("VALID");
783
- });
784
- });
785
-
786
- describe("concurrent request error handling", () => {
787
- it("propagates error to all waiting callers when request fails", async () => {
788
- const manager = new SessionManager({ appId: "test-app" });
789
-
790
- // Start three concurrent requests
791
- const promise1 = manager.requestNewToken();
792
- const promise2 = manager.requestNewToken();
793
- const promise3 = manager.requestNewToken();
794
-
795
- // Should only send one postMessage (deduplication)
796
- expect(mockPostMessage).toHaveBeenCalledTimes(1);
797
-
798
- // Simulate error response
799
- simulateTokenResponse({ error: "Authentication failed" });
800
-
801
- // All three promises should reject with the same error
802
- await expect(promise1).rejects.toThrow("Authentication failed");
803
- await expect(promise2).rejects.toThrow("Authentication failed");
804
- await expect(promise3).rejects.toThrow("Authentication failed");
805
- });
806
-
807
- it("allows new request after previous request failed", async () => {
808
- const manager = new SessionManager({ appId: "test-app" });
809
-
810
- // First request fails
811
- const failedPromise = manager.requestNewToken();
812
- simulateTokenResponse({ error: "Temporary failure" });
813
- await expect(failedPromise).rejects.toThrow("Temporary failure");
814
-
815
- // Reset mock and UUID for second request
816
- mockPostMessage.mockClear();
817
- vi.stubGlobal("crypto", {
818
- randomUUID: () => "second-uuid-456",
819
- });
820
-
821
- // Second request should work
822
- const successPromise = manager.requestNewToken();
823
-
824
- expect(mockPostMessage).toHaveBeenCalledTimes(1);
825
-
826
- simulateTokenResponse({
827
- token: "success-token",
828
- expiresAt: new Date(Date.now() + 60000).toISOString(),
829
- requestId: "second-uuid-456",
830
- });
831
-
832
- const token = await successPromise;
833
- expect(token).toBe("success-token");
834
- });
835
- });
836
-
837
- describe("react-native-webview token push", () => {
838
- beforeEach(() => {
839
- messageHandler = null;
840
- addEventListenerSpy = vi.fn((event: string, handler: Function) => {
841
- if (event === "message") {
842
- messageHandler = handler as (event: MessageEvent) => void;
843
- }
844
- });
845
-
846
- vi.stubGlobal("window", {
847
- addEventListener: addEventListenerSpy,
848
- removeEventListener: removeEventListenerSpy,
849
- ReactNativeWebView: {
850
- postMessage: vi.fn(),
851
- },
852
- parent: {
853
- postMessage: mockPostMessage,
854
- },
855
- });
856
- });
857
-
858
- it("accepts pushed token only when appId and audience match", async () => {
859
- const manager = new SessionManager({ appId: "todo-app" });
860
- const tokenPromise = manager.requestNewToken();
861
-
862
- const token = createJwtLikeToken({
863
- sub: "user-1",
864
- aud: "todo-app",
865
- });
866
-
867
- simulateMalformedMessage("react-native", {
868
- type: "SESSION_TOKEN_UPDATE",
869
- appId: "todo-app",
870
- token,
871
- expiresAt: new Date(Date.now() + 60000).toISOString(),
872
- });
873
-
874
- await expect(tokenPromise).resolves.toBe(token);
875
- });
876
-
877
- it("accepts stringified token update payload with null-like origin", async () => {
878
- const manager = new SessionManager({ appId: "todo-app" });
879
- const tokenPromise = manager.requestNewToken();
880
-
881
- const token = createJwtLikeToken({
882
- sub: "user-1",
883
- aud: "todo-app",
884
- });
885
-
886
- simulateMalformedMessage(
887
- "null",
888
- JSON.stringify({
889
- type: "SESSION_TOKEN_UPDATE",
890
- appId: "todo-app",
891
- token,
892
- expiresAt: new Date(Date.now() + 60000).toISOString(),
893
- }),
894
- );
895
-
896
- await expect(tokenPromise).resolves.toBe(token);
897
- });
898
-
899
- it("ignores malformed JSON token updates", async () => {
900
- vi.useFakeTimers();
901
-
902
- const manager = new SessionManager({ appId: "todo-app" });
903
- const tokenPromise = manager.requestNewToken();
904
-
905
- simulateMalformedMessage("react-native", "{not-json");
906
-
907
- vi.advanceTimersByTime(10001);
908
-
909
- await expect(tokenPromise).rejects.toThrow(
910
- "Timed out waiting for token from React Native bridge",
911
- );
912
- });
913
-
914
- it("rejects pushed token when audience does not match expected app", async () => {
915
- vi.useFakeTimers();
916
-
917
- const manager = new SessionManager({ appId: "todo-app" });
918
- const tokenPromise = manager.requestNewToken();
919
-
920
- const wrongAudienceToken = createJwtLikeToken({
921
- sub: "user-1",
922
- aud: "chef-app",
923
- });
924
-
925
- simulateMalformedMessage("react-native", {
926
- type: "SESSION_TOKEN_UPDATE",
927
- appId: "todo-app",
928
- token: wrongAudienceToken,
929
- expiresAt: new Date(Date.now() + 60000).toISOString(),
930
- });
931
-
932
- vi.advanceTimersByTime(10001);
933
-
934
- await expect(tokenPromise).rejects.toThrow(
935
- "Timed out waiting for token from React Native bridge",
936
- );
937
- });
938
- });
939
- });