@luxexchange/sessions 1.0.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 (70) hide show
  1. package/.depcheckrc +20 -0
  2. package/.eslintrc.js +21 -0
  3. package/README.md +1 -0
  4. package/env.d.ts +12 -0
  5. package/package.json +50 -0
  6. package/project.json +42 -0
  7. package/src/challenge-solvers/createChallengeSolverService.ts +64 -0
  8. package/src/challenge-solvers/createHashcashMockSolver.ts +39 -0
  9. package/src/challenge-solvers/createHashcashSolver.test.ts +385 -0
  10. package/src/challenge-solvers/createHashcashSolver.ts +255 -0
  11. package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
  12. package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
  13. package/src/challenge-solvers/createTurnstileSolver.ts +353 -0
  14. package/src/challenge-solvers/hashcash/core.native.ts +34 -0
  15. package/src/challenge-solvers/hashcash/core.test.ts +314 -0
  16. package/src/challenge-solvers/hashcash/core.ts +35 -0
  17. package/src/challenge-solvers/hashcash/core.web.ts +123 -0
  18. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.test.ts +195 -0
  19. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.ts +120 -0
  20. package/src/challenge-solvers/hashcash/shared.ts +70 -0
  21. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.native.ts +22 -0
  22. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.ts +22 -0
  23. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.web.ts +212 -0
  24. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.native.ts +16 -0
  25. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.ts +16 -0
  26. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.web.ts +97 -0
  27. package/src/challenge-solvers/hashcash/worker/hashcash.worker.ts +91 -0
  28. package/src/challenge-solvers/hashcash/worker/types.ts +69 -0
  29. package/src/challenge-solvers/turnstileErrors.ts +49 -0
  30. package/src/challenge-solvers/turnstileScriptLoader.ts +325 -0
  31. package/src/challenge-solvers/turnstileSolver.integration.test.ts +331 -0
  32. package/src/challenge-solvers/types.ts +55 -0
  33. package/src/challengeFlow.integration.test.ts +627 -0
  34. package/src/device-id/createDeviceIdService.ts +19 -0
  35. package/src/device-id/types.ts +11 -0
  36. package/src/index.ts +137 -0
  37. package/src/lux-identifier/createLuxIdentifierService.ts +19 -0
  38. package/src/lux-identifier/luxIdentifierQuery.ts +20 -0
  39. package/src/lux-identifier/types.ts +11 -0
  40. package/src/oauth-service/createOAuthService.ts +125 -0
  41. package/src/oauth-service/types.ts +104 -0
  42. package/src/performance/createNoopPerformanceTracker.ts +12 -0
  43. package/src/performance/createPerformanceTracker.ts +43 -0
  44. package/src/performance/index.ts +7 -0
  45. package/src/performance/types.ts +11 -0
  46. package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
  47. package/src/session-initialization/createSessionInitializationService.ts +193 -0
  48. package/src/session-initialization/sessionErrors.ts +32 -0
  49. package/src/session-repository/createSessionClient.ts +10 -0
  50. package/src/session-repository/createSessionRepository.test.ts +313 -0
  51. package/src/session-repository/createSessionRepository.ts +242 -0
  52. package/src/session-repository/errors.ts +22 -0
  53. package/src/session-repository/types.ts +289 -0
  54. package/src/session-service/createNoopSessionService.ts +24 -0
  55. package/src/session-service/createSessionService.test.ts +388 -0
  56. package/src/session-service/createSessionService.ts +61 -0
  57. package/src/session-service/types.ts +59 -0
  58. package/src/session-storage/createSessionStorage.ts +28 -0
  59. package/src/session-storage/types.ts +15 -0
  60. package/src/session.integration.test.ts +480 -0
  61. package/src/sessionLifecycle.integration.test.ts +264 -0
  62. package/src/test-utils/createLocalCookieTransport.ts +52 -0
  63. package/src/test-utils/createLocalHeaderTransport.ts +45 -0
  64. package/src/test-utils/mocks.ts +122 -0
  65. package/src/test-utils.ts +200 -0
  66. package/tsconfig.json +19 -0
  67. package/tsconfig.lint.json +8 -0
  68. package/tsconfig.spec.json +8 -0
  69. package/vitest.config.ts +20 -0
  70. package/vitest.integration.config.ts +14 -0
@@ -0,0 +1,627 @@
1
+ import {
2
+ ChallengeResponse,
3
+ ChallengeType,
4
+ DeleteSessionResponse,
5
+ GetChallengeTypesResponse,
6
+ InitSessionResponse,
7
+ SignoutResponse,
8
+ VerifyResponse,
9
+ VerifySuccess,
10
+ } from '@uniswap/client-platform-service/dist/uniswap/platformservice/v1/sessionService_pb'
11
+ import { createChallengeSolverService } from '@luxexchange/sessions/src/challenge-solvers/createChallengeSolverService'
12
+ import type { ChallengeSolver } from '@luxexchange/sessions/src/challenge-solvers/types'
13
+ import type { PerformanceTracker } from '@luxexchange/sessions/src/performance/types'
14
+ import {
15
+ createSessionInitializationService,
16
+ type SessionInitializationService,
17
+ } from '@luxexchange/sessions/src/session-initialization/createSessionInitializationService'
18
+ import { createSessionRepository } from '@luxexchange/sessions/src/session-repository/createSessionRepository'
19
+ import { createSessionService } from '@luxexchange/sessions/src/session-service/createSessionService'
20
+ import type { SessionService } from '@luxexchange/sessions/src/session-service/types'
21
+ import {
22
+ createMockSessionClient,
23
+ createTestTransport,
24
+ InMemoryDeviceIdService,
25
+ InMemorySessionStorage,
26
+ InMemoryLuxIdentifierService,
27
+ type MockEndpoints,
28
+ } from '@luxexchange/sessions/src/test-utils'
29
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
30
+
31
+ // Helper: create a VerifyResponse with a success outcome (proto3 validation requires outcome.case)
32
+ function createSuccessVerifyResponse(): VerifyResponse {
33
+ const response = new VerifyResponse({ retry: false })
34
+ response.outcome = { case: 'success', value: new VerifySuccess({}) }
35
+ return response
36
+ }
37
+
38
+ // Mock performance tracker for testing
39
+ function createMockPerformanceTracker(): PerformanceTracker {
40
+ let time = 0
41
+ return {
42
+ now: (): number => {
43
+ time += 100
44
+ return time
45
+ },
46
+ }
47
+ }
48
+
49
+ // Mock Turnstile solver for integration tests
50
+ const mockTurnstileSolve = vi.fn()
51
+
52
+ describe('Challenge Flow Integration Tests', () => {
53
+ let sessionStorage: InMemorySessionStorage
54
+ let deviceIdService: InMemoryDeviceIdService
55
+ let luxIdentifierService: InMemoryLuxIdentifierService
56
+ let sessionService: SessionService
57
+ let sessionInitializationService: SessionInitializationService
58
+ let mockEndpoints: MockEndpoints
59
+
60
+ beforeEach(() => {
61
+ // Initialize in-memory storage
62
+ sessionStorage = new InMemorySessionStorage()
63
+ deviceIdService = new InMemoryDeviceIdService()
64
+ luxIdentifierService = new InMemoryLuxIdentifierService()
65
+
66
+ // Set up mock endpoints with default responses
67
+ mockEndpoints = {
68
+ '/lux.platformservice.v1.SessionService/InitSession': async (): Promise<InitSessionResponse> => {
69
+ return new InitSessionResponse({
70
+ sessionId: 'test-session-123',
71
+ needChallenge: true,
72
+ extra: {},
73
+ })
74
+ },
75
+ '/lux.platformservice.v1.SessionService/Challenge': async (): Promise<ChallengeResponse> => {
76
+ return new ChallengeResponse({
77
+ challengeId: '02c241f3-8d45-4a88-842a-d364c30a6c44',
78
+ challengeType: ChallengeType.TURNSTILE,
79
+ extra: {
80
+ challengeData: '{"siteKey":"0x4AAAAAABiAHneWOWZHzZtO","action":"session_verification"}',
81
+ },
82
+ })
83
+ },
84
+ '/lux.platformservice.v1.SessionService/Verify': async (): Promise<VerifyResponse> => {
85
+ return createSuccessVerifyResponse()
86
+ },
87
+ '/lux.platformservice.v1.SessionService/DeleteSession': async (): Promise<DeleteSessionResponse> => {
88
+ return new DeleteSessionResponse({})
89
+ },
90
+ '/lux.platformservice.v1.SessionService/GetChallengeTypes': async (): Promise<GetChallengeTypesResponse> => {
91
+ return new GetChallengeTypesResponse({ challengeTypes: [] })
92
+ },
93
+ '/lux.platformservice.v1.SessionService/Signout': async (): Promise<SignoutResponse> => {
94
+ return new SignoutResponse({})
95
+ },
96
+ } as unknown as MockEndpoints
97
+
98
+ // Create test transport
99
+ createTestTransport(mockEndpoints)
100
+
101
+ // Create session client
102
+ const sessionClient = createMockSessionClient(mockEndpoints, sessionStorage, deviceIdService)
103
+
104
+ // Create repository
105
+ const sessionRepository = createSessionRepository({
106
+ client: sessionClient as any,
107
+ })
108
+
109
+ // Create session service
110
+ sessionService = createSessionService({
111
+ sessionStorage,
112
+ deviceIdService,
113
+ luxIdentifierService,
114
+ sessionRepository,
115
+ })
116
+
117
+ // Create challenge solver service with mock Turnstile solver
118
+ const challengeSolverService = createChallengeSolverService()
119
+
120
+ // Mock the Turnstile solver
121
+ mockTurnstileSolve.mockResolvedValue('test-turnstile-solution-token')
122
+ challengeSolverService.getSolver = (type: ChallengeType): ChallengeSolver | null => {
123
+ if (type === ChallengeType.TURNSTILE) {
124
+ return {
125
+ solve: mockTurnstileSolve,
126
+ }
127
+ }
128
+ return null
129
+ }
130
+
131
+ // Create session initialization service
132
+ sessionInitializationService = createSessionInitializationService({
133
+ getSessionService: () => sessionService,
134
+ challengeSolverService,
135
+ performanceTracker: createMockPerformanceTracker(),
136
+ getIsSessionUpgradeAutoEnabled: () => true,
137
+ })
138
+ })
139
+
140
+ afterEach(async () => {
141
+ // Clean up any stored data
142
+ await sessionStorage.clear()
143
+ await deviceIdService.removeDeviceId()
144
+
145
+ // Reset mocks
146
+ vi.clearAllMocks()
147
+ mockTurnstileSolve.mockResolvedValue('test-turnstile-solution-token')
148
+ })
149
+
150
+ it('initializes a session with needChallenge: true and completes challenge flow', async () => {
151
+ // Update mock to return needChallenge: true
152
+ mockEndpoints['/lux.platformservice.v1.SessionService/InitSession'] =
153
+ async (): Promise<InitSessionResponse> => {
154
+ return new InitSessionResponse({
155
+ sessionId: '776973bd-bbc2-452b-9c35-1b72c475afbd',
156
+ needChallenge: true,
157
+ extra: {},
158
+ })
159
+ }
160
+
161
+ // Track calls to verify flow
162
+ const initCalls: Array<{ request: any; headers: Record<string, string> }> = []
163
+ const challengeCalls: Array<{ request: any; headers: Record<string, string> }> = []
164
+ const verifyCalls: Array<{ request: any; headers: Record<string, string> }> = []
165
+
166
+ // Wrap handlers to track calls
167
+ const originalInit = mockEndpoints['/lux.platformservice.v1.SessionService/InitSession']
168
+ mockEndpoints['/lux.platformservice.v1.SessionService/InitSession'] = async (
169
+ request,
170
+ headers,
171
+ ): Promise<InitSessionResponse> => {
172
+ initCalls.push({ request, headers })
173
+ return originalInit(request, headers)
174
+ }
175
+
176
+ const originalChallenge = mockEndpoints['/lux.platformservice.v1.SessionService/Challenge']
177
+ mockEndpoints['/lux.platformservice.v1.SessionService/Challenge'] = async (
178
+ request,
179
+ headers,
180
+ ): Promise<ChallengeResponse> => {
181
+ challengeCalls.push({ request, headers })
182
+ return originalChallenge(request, headers)
183
+ }
184
+
185
+ const originalVerify = mockEndpoints['/lux.platformservice.v1.SessionService/Verify']
186
+ mockEndpoints['/lux.platformservice.v1.SessionService/Verify'] = async (
187
+ request,
188
+ headers,
189
+ ): Promise<VerifyResponse> => {
190
+ verifyCalls.push({ request, headers })
191
+ return originalVerify(request, headers)
192
+ }
193
+
194
+ // Execute the full initialization flow
195
+ await sessionInitializationService.initialize()
196
+
197
+ // Verify all API calls were made in the correct order
198
+ expect(initCalls).toHaveLength(1)
199
+ expect(challengeCalls).toHaveLength(1)
200
+ expect(verifyCalls).toHaveLength(1)
201
+
202
+ // Verify session was stored
203
+ const storedSession = await sessionStorage.get()
204
+ expect(storedSession?.sessionId).toBe('776973bd-bbc2-452b-9c35-1b72c475afbd')
205
+
206
+ // Verify challenge request had session headers
207
+ expect(challengeCalls[0].headers['X-Session-ID']).toBe('776973bd-bbc2-452b-9c35-1b72c475afbd')
208
+
209
+ // Verify upgrade request had correct challenge ID and solution
210
+ expect(verifyCalls[0].request).toMatchObject({
211
+ challengeId: '02c241f3-8d45-4a88-842a-d364c30a6c44',
212
+ solution: 'test-turnstile-solution-token',
213
+ })
214
+ expect(verifyCalls[0].headers['X-Session-ID']).toBe('776973bd-bbc2-452b-9c35-1b72c475afbd')
215
+ })
216
+
217
+ it('handles challenge flow with proper request/response data', async () => {
218
+ // Set device ID
219
+ await deviceIdService.setDeviceId('66629fec-ff9d-430b-8a31-d256b4128527')
220
+
221
+ // Initialize session first
222
+ await sessionService.initSession()
223
+
224
+ // Request challenge
225
+ const challengeResponse = await sessionService.requestChallenge()
226
+
227
+ expect(challengeResponse).toEqual({
228
+ challengeId: '02c241f3-8d45-4a88-842a-d364c30a6c44',
229
+ challengeType: ChallengeType.TURNSTILE,
230
+ extra: {
231
+ challengeData: '{"siteKey":"0x4AAAAAABiAHneWOWZHzZtO","action":"session_verification"}',
232
+ },
233
+ challengeData: { case: undefined },
234
+ authorizeUrl: undefined,
235
+ })
236
+
237
+ // Verify mock solver is configured
238
+ expect(mockTurnstileSolve).toHaveBeenCalledTimes(0)
239
+
240
+ // Simulate solving the challenge and upgrading session
241
+ const solution = 'test-turnstile-solution-token'
242
+ const upgradeResponse = await sessionService.verifySession({
243
+ solution,
244
+ challengeId: challengeResponse.challengeId,
245
+ challengeType: challengeResponse.challengeType,
246
+ })
247
+ expect(upgradeResponse.retry).toBe(false)
248
+ })
249
+
250
+ it('always calls initSession even with existing session - backend handles reuse', async () => {
251
+ // Pre-populate storage with existing session
252
+ await sessionStorage.set({ sessionId: 'existing-session-123' })
253
+ await deviceIdService.setDeviceId('existing-device-123')
254
+
255
+ // Track calls
256
+ let initCallCount = 0
257
+ mockEndpoints['/lux.platformservice.v1.SessionService/InitSession'] =
258
+ async (): Promise<InitSessionResponse> => {
259
+ initCallCount++
260
+ // Backend returns the same session ID (simulating session reuse)
261
+ return new InitSessionResponse({
262
+ sessionId: 'existing-session-123',
263
+ needChallenge: false,
264
+ extra: {},
265
+ })
266
+ }
267
+
268
+ // Initialize should call API - backend decides whether to reuse session
269
+ // (in production, existing session ID is sent via X-Session-ID header)
270
+ await sessionInitializationService.initialize()
271
+
272
+ // Verify initialization call was made (previously this was skipped)
273
+ expect(initCallCount).toBe(1)
274
+
275
+ // Verify session in storage matches what backend returned
276
+ const storedSession = await sessionStorage.get()
277
+ expect(storedSession?.sessionId).toBe('existing-session-123')
278
+ })
279
+
280
+ it('handles challenge retry when upgrade fails', async () => {
281
+ // Set up to require challenge
282
+ mockEndpoints['/lux.platformservice.v1.SessionService/InitSession'] =
283
+ async (): Promise<InitSessionResponse> => {
284
+ return new InitSessionResponse({
285
+ sessionId: 'retry-session-123',
286
+ needChallenge: true,
287
+ extra: {},
288
+ })
289
+ }
290
+
291
+ // Make first verify attempt fail with retry
292
+ let verifyAttempts = 0
293
+ mockEndpoints['/lux.platformservice.v1.SessionService/Verify'] = async (): Promise<VerifyResponse> => {
294
+ verifyAttempts++
295
+ if (verifyAttempts === 1) {
296
+ return new VerifyResponse({ retry: true })
297
+ }
298
+ return createSuccessVerifyResponse()
299
+ }
300
+
301
+ // Track challenge calls
302
+ const challengeCalls: any[] = []
303
+ const originalChallenge = mockEndpoints['/lux.platformservice.v1.SessionService/Challenge']
304
+ mockEndpoints['/lux.platformservice.v1.SessionService/Challenge'] = async (
305
+ request,
306
+ headers,
307
+ ): Promise<ChallengeResponse> => {
308
+ challengeCalls.push({ request, headers })
309
+ return originalChallenge(request, headers)
310
+ }
311
+
312
+ // Execute flow
313
+ await sessionInitializationService.initialize()
314
+
315
+ // Should have made 2 challenge requests (initial + retry)
316
+ expect(challengeCalls).toHaveLength(2)
317
+ expect(verifyAttempts).toBe(2)
318
+
319
+ // Session should be stored after successful retry
320
+ const storedSession = await sessionStorage.get()
321
+ expect(storedSession?.sessionId).toBe('retry-session-123')
322
+ })
323
+
324
+ it('respects maximum retry limit for challenges', async () => {
325
+ // Set up to require challenge
326
+ mockEndpoints['/lux.platformservice.v1.SessionService/InitSession'] =
327
+ async (): Promise<InitSessionResponse> => {
328
+ return new InitSessionResponse({
329
+ sessionId: 'max-retry-session',
330
+ needChallenge: true,
331
+ extra: {},
332
+ })
333
+ }
334
+
335
+ // Always return retry: true
336
+ mockEndpoints['/lux.platformservice.v1.SessionService/Verify'] = async (): Promise<VerifyResponse> => {
337
+ return new VerifyResponse({ retry: true })
338
+ }
339
+
340
+ // Track attempts
341
+ const challengeCalls: any[] = []
342
+ const verifyCalls: any[] = []
343
+
344
+ const originalChallenge = mockEndpoints['/lux.platformservice.v1.SessionService/Challenge']
345
+ mockEndpoints['/lux.platformservice.v1.SessionService/Challenge'] = async (
346
+ request,
347
+ headers,
348
+ ): Promise<ChallengeResponse> => {
349
+ challengeCalls.push({ request, headers })
350
+ return originalChallenge(request, headers)
351
+ }
352
+
353
+ const originalVerify = mockEndpoints['/lux.platformservice.v1.SessionService/Verify']
354
+ mockEndpoints['/lux.platformservice.v1.SessionService/Verify'] = async (
355
+ request,
356
+ headers,
357
+ ): Promise<VerifyResponse> => {
358
+ verifyCalls.push({ request, headers })
359
+ return originalVerify(request, headers)
360
+ }
361
+
362
+ // Should throw after max retries
363
+ await expect(sessionInitializationService.initialize()).rejects.toThrow()
364
+
365
+ // Should have attempted 4 times (1 initial + 3 retries)
366
+ expect(challengeCalls).toHaveLength(4)
367
+ expect(verifyCalls).toHaveLength(4)
368
+ })
369
+
370
+ it('handles device ID in headers for challenge requests', async () => {
371
+ // Set device ID
372
+ await deviceIdService.setDeviceId('test-device-456')
373
+ await sessionService.initSession()
374
+
375
+ // Track challenge request to verify headers
376
+ let capturedHeaders: Record<string, string> = {}
377
+ const originalChallenge = mockEndpoints['/lux.platformservice.v1.SessionService/Challenge']
378
+ mockEndpoints['/lux.platformservice.v1.SessionService/Challenge'] = async (
379
+ request,
380
+ headers,
381
+ ): Promise<ChallengeResponse> => {
382
+ capturedHeaders = headers
383
+ return originalChallenge(request, headers)
384
+ }
385
+
386
+ // Request challenge
387
+ await sessionService.requestChallenge()
388
+
389
+ // Verify device ID was included in headers
390
+ expect(capturedHeaders['X-Device-ID']).toBe('test-device-456')
391
+ expect(capturedHeaders['X-Session-ID']).toBe('test-session-123')
392
+ })
393
+
394
+ it('handles missing device ID gracefully', async () => {
395
+ // Don't set device ID
396
+ await sessionService.initSession()
397
+
398
+ // Track challenge request to verify headers
399
+ let capturedHeaders: Record<string, string> = {}
400
+ const originalChallenge = mockEndpoints['/lux.platformservice.v1.SessionService/Challenge']
401
+ mockEndpoints['/lux.platformservice.v1.SessionService/Challenge'] = async (
402
+ request,
403
+ headers,
404
+ ): Promise<ChallengeResponse> => {
405
+ capturedHeaders = headers
406
+ return originalChallenge(request, headers)
407
+ }
408
+
409
+ // Request challenge
410
+ await sessionService.requestChallenge()
411
+
412
+ // Verify only session ID was included in headers
413
+ expect(capturedHeaders['X-Device-ID']).toBeUndefined()
414
+ expect(capturedHeaders['X-Session-ID']).toBe('test-session-123')
415
+ })
416
+
417
+ it('submits empty solution when solver throws, allowing verify-retry fallback', async () => {
418
+ // Set up to require challenge
419
+ mockEndpoints['/lux.platformservice.v1.SessionService/InitSession'] =
420
+ async (): Promise<InitSessionResponse> => {
421
+ return new InitSessionResponse({
422
+ sessionId: 'error-session-123',
423
+ needChallenge: true,
424
+ extra: {},
425
+ })
426
+ }
427
+
428
+ // Make Turnstile solver throw (e.g. domain not approved on Vercel preview)
429
+ mockTurnstileSolve.mockRejectedValue(new Error('Turnstile error: domain not allowed'))
430
+
431
+ // Verify endpoint accepts empty solution (no retry needed for this test)
432
+ const verifyCalls: Array<{ request: any }> = []
433
+ mockEndpoints['/lux.platformservice.v1.SessionService/Verify'] = async (request): Promise<VerifyResponse> => {
434
+ verifyCalls.push({ request })
435
+ return createSuccessVerifyResponse()
436
+ }
437
+
438
+ const challengeSolverService = createChallengeSolverService()
439
+ challengeSolverService.getSolver = (type: ChallengeType): ChallengeSolver | null => {
440
+ if (type === ChallengeType.TURNSTILE) {
441
+ return { solve: mockTurnstileSolve }
442
+ }
443
+ return null
444
+ }
445
+
446
+ const initService = createSessionInitializationService({
447
+ getSessionService: () => sessionService,
448
+ challengeSolverService,
449
+ performanceTracker: createMockPerformanceTracker(),
450
+ getIsSessionUpgradeAutoEnabled: () => true,
451
+ })
452
+
453
+ // Should NOT throw — empty solution is submitted instead
454
+ await initService.initialize()
455
+
456
+ // Solver was called once and threw
457
+ expect(mockTurnstileSolve).toHaveBeenCalledTimes(1)
458
+ // Verify was called with empty solution
459
+ expect(verifyCalls).toHaveLength(1)
460
+ expect(verifyCalls[0].request.solution).toBe('solver-failed')
461
+ })
462
+
463
+ it('Turnstile solver fails → empty verify → retry → Hashcash succeeds end-to-end', async () => {
464
+ // Set up to require challenge
465
+ mockEndpoints['/lux.platformservice.v1.SessionService/InitSession'] =
466
+ async (): Promise<InitSessionResponse> => {
467
+ return new InitSessionResponse({
468
+ sessionId: 'fallback-e2e-session',
469
+ needChallenge: true,
470
+ extra: {},
471
+ })
472
+ }
473
+
474
+ // First challenge returns Turnstile, second returns Hashcash
475
+ let challengeRequestCount = 0
476
+ mockEndpoints['/lux.platformservice.v1.SessionService/Challenge'] = async (): Promise<ChallengeResponse> => {
477
+ challengeRequestCount++
478
+ if (challengeRequestCount === 1) {
479
+ return new ChallengeResponse({
480
+ challengeId: 'turnstile-challenge-id',
481
+ challengeType: ChallengeType.TURNSTILE,
482
+ extra: {
483
+ challengeData: '{"siteKey":"0x4AAAAAABiAHneWOWZHzZtO","action":"session_verification"}',
484
+ },
485
+ })
486
+ }
487
+ return new ChallengeResponse({
488
+ challengeId: 'hashcash-challenge-id',
489
+ challengeType: ChallengeType.HASHCASH,
490
+ extra: {
491
+ challengeData: '{"difficulty":10}',
492
+ },
493
+ })
494
+ }
495
+
496
+ // First verify rejects empty solution, second succeeds
497
+ let verifyCount = 0
498
+ mockEndpoints['/lux.platformservice.v1.SessionService/Verify'] = async (): Promise<VerifyResponse> => {
499
+ verifyCount++
500
+ if (verifyCount === 1) {
501
+ return new VerifyResponse({ retry: true })
502
+ }
503
+ return createSuccessVerifyResponse()
504
+ }
505
+
506
+ // Turnstile solver throws (domain mismatch), Hashcash solver succeeds
507
+ mockTurnstileSolve.mockRejectedValue(new Error('Turnstile error 110200: domain not allowed'))
508
+ const mockHashcashSolve = vi.fn().mockResolvedValue('hashcash-solution-token')
509
+
510
+ const challengeSolverService = createChallengeSolverService()
511
+ challengeSolverService.getSolver = (type: ChallengeType): ChallengeSolver | null => {
512
+ if (type === ChallengeType.TURNSTILE) {
513
+ return { solve: mockTurnstileSolve }
514
+ }
515
+ if (type === ChallengeType.HASHCASH) {
516
+ return { solve: mockHashcashSolve }
517
+ }
518
+ return null
519
+ }
520
+
521
+ const initService = createSessionInitializationService({
522
+ getSessionService: () => sessionService,
523
+ challengeSolverService,
524
+ performanceTracker: createMockPerformanceTracker(),
525
+ getIsSessionUpgradeAutoEnabled: () => true,
526
+ })
527
+
528
+ // Full flow: Turnstile throws → empty verify → retry → Hashcash succeeds
529
+ await initService.initialize()
530
+
531
+ // Turnstile solver was called once (and threw)
532
+ expect(mockTurnstileSolve).toHaveBeenCalledTimes(1)
533
+ // Hashcash solver was called once (and succeeded)
534
+ expect(mockHashcashSolve).toHaveBeenCalledTimes(1)
535
+ // Two challenge requests: Turnstile then Hashcash
536
+ expect(challengeRequestCount).toBe(2)
537
+ // Two verify calls: first rejected empty solution, second accepted Hashcash
538
+ expect(verifyCount).toBe(2)
539
+
540
+ // Session should be stored
541
+ const storedSession = await sessionStorage.get()
542
+ expect(storedSession?.sessionId).toBe('fallback-e2e-session')
543
+ })
544
+
545
+ it('falls back to Hashcash via verify-retry when mock Turnstile token is rejected', async () => {
546
+ // Set up to require challenge
547
+ mockEndpoints['/lux.platformservice.v1.SessionService/InitSession'] =
548
+ async (): Promise<InitSessionResponse> => {
549
+ return new InitSessionResponse({
550
+ sessionId: 'fallback-session-123',
551
+ needChallenge: true,
552
+ extra: {},
553
+ })
554
+ }
555
+
556
+ // First challenge returns Turnstile, second returns Hashcash
557
+ // (backend switches after failed verification)
558
+ let challengeRequestCount = 0
559
+ mockEndpoints['/lux.platformservice.v1.SessionService/Challenge'] = async (): Promise<ChallengeResponse> => {
560
+ challengeRequestCount++
561
+ if (challengeRequestCount === 1) {
562
+ return new ChallengeResponse({
563
+ challengeId: 'turnstile-challenge-id',
564
+ challengeType: ChallengeType.TURNSTILE,
565
+ extra: {
566
+ challengeData: '{"siteKey":"0x4AAAAAABiAHneWOWZHzZtO","action":"session_verification"}',
567
+ },
568
+ })
569
+ }
570
+ return new ChallengeResponse({
571
+ challengeId: 'hashcash-challenge-id',
572
+ challengeType: ChallengeType.HASHCASH,
573
+ extra: {
574
+ challengeData: '{"difficulty":10}',
575
+ },
576
+ })
577
+ }
578
+
579
+ // First verify rejects mock token, second succeeds
580
+ let verifyCount = 0
581
+ mockEndpoints['/lux.platformservice.v1.SessionService/Verify'] = async (): Promise<VerifyResponse> => {
582
+ verifyCount++
583
+ if (verifyCount === 1) {
584
+ return new VerifyResponse({ retry: true })
585
+ }
586
+ return createSuccessVerifyResponse()
587
+ }
588
+
589
+ // Mock Turnstile returns a fake token (doesn't throw), Hashcash succeeds
590
+ const mockHashcashSolve = vi.fn().mockResolvedValue('hashcash-solution-token')
591
+ mockTurnstileSolve.mockResolvedValue('mock-turnstile-token')
592
+
593
+ const challengeSolverService = createChallengeSolverService()
594
+ challengeSolverService.getSolver = (type: ChallengeType): ChallengeSolver | null => {
595
+ if (type === ChallengeType.TURNSTILE) {
596
+ return { solve: mockTurnstileSolve }
597
+ }
598
+ if (type === ChallengeType.HASHCASH) {
599
+ return { solve: mockHashcashSolve }
600
+ }
601
+ return null
602
+ }
603
+
604
+ const initService = createSessionInitializationService({
605
+ getSessionService: () => sessionService,
606
+ challengeSolverService,
607
+ performanceTracker: createMockPerformanceTracker(),
608
+ getIsSessionUpgradeAutoEnabled: () => true,
609
+ })
610
+
611
+ // Flow: mock Turnstile token → verify rejects → retry → backend sends Hashcash → succeeds
612
+ await initService.initialize()
613
+
614
+ // Mock Turnstile was called (returned fake token)
615
+ expect(mockTurnstileSolve).toHaveBeenCalledTimes(1)
616
+ // Hashcash was used as fallback after verify rejected mock token
617
+ expect(mockHashcashSolve).toHaveBeenCalledTimes(1)
618
+ // Two challenge requests (Turnstile, then Hashcash after failed verify)
619
+ expect(challengeRequestCount).toBe(2)
620
+ // Two verify calls (first rejected mock token, second accepted Hashcash)
621
+ expect(verifyCount).toBe(2)
622
+
623
+ // Session should be stored
624
+ const storedSession = await sessionStorage.get()
625
+ expect(storedSession?.sessionId).toBe('fallback-session-123')
626
+ })
627
+ })
@@ -0,0 +1,19 @@
1
+ import type { DeviceIdService } from '@luxfi/sessions/src/device-id/types'
2
+
3
+ function createDeviceIdService(ctx: {
4
+ getDeviceId: () => Promise<string>
5
+ setDeviceId: (deviceId: string) => Promise<void>
6
+ removeDeviceId: () => Promise<void>
7
+ }): DeviceIdService {
8
+ const getDeviceId = ctx.getDeviceId
9
+ const setDeviceId = ctx.setDeviceId
10
+ const removeDeviceId = ctx.removeDeviceId
11
+
12
+ return {
13
+ getDeviceId,
14
+ setDeviceId,
15
+ removeDeviceId,
16
+ }
17
+ }
18
+
19
+ export { createDeviceIdService }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Device ID provider interface
3
+ * Platform-specific implementations handle device identification
4
+ */
5
+ interface DeviceIdService {
6
+ getDeviceId(): Promise<string | null>
7
+ setDeviceId(deviceId: string): Promise<void>
8
+ removeDeviceId(): Promise<void>
9
+ }
10
+
11
+ export type { DeviceIdService }