@l.x/sessions 1.0.3 → 1.0.5

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 (78) hide show
  1. package/.depcheckrc +20 -0
  2. package/.eslintrc.js +21 -0
  3. package/LICENSE +122 -0
  4. package/README.md +1 -0
  5. package/env.d.ts +12 -0
  6. package/package.json +49 -1
  7. package/project.json +36 -0
  8. package/src/challenge-solvers/createChallengeSolverService.ts +64 -0
  9. package/src/challenge-solvers/createHashcashMockSolver.ts +39 -0
  10. package/src/challenge-solvers/createHashcashSolver.test.ts +385 -0
  11. package/src/challenge-solvers/createHashcashSolver.ts +270 -0
  12. package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
  13. package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
  14. package/src/challenge-solvers/createTurnstileSolver.ts +357 -0
  15. package/src/challenge-solvers/hashcash/core.native.ts +34 -0
  16. package/src/challenge-solvers/hashcash/core.test.ts +314 -0
  17. package/src/challenge-solvers/hashcash/core.ts +35 -0
  18. package/src/challenge-solvers/hashcash/core.web.ts +123 -0
  19. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.test.ts +195 -0
  20. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.ts +120 -0
  21. package/src/challenge-solvers/hashcash/shared.ts +70 -0
  22. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.native.ts +22 -0
  23. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.ts +22 -0
  24. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.web.ts +212 -0
  25. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.native.ts +16 -0
  26. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.ts +16 -0
  27. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.web.ts +97 -0
  28. package/src/challenge-solvers/hashcash/worker/hashcash.worker.ts +91 -0
  29. package/src/challenge-solvers/hashcash/worker/types.ts +69 -0
  30. package/src/challenge-solvers/turnstileErrors.ts +49 -0
  31. package/src/challenge-solvers/turnstileScriptLoader.ts +325 -0
  32. package/src/challenge-solvers/turnstileSolver.integration.test.ts +331 -0
  33. package/src/challenge-solvers/types.ts +55 -0
  34. package/src/challengeFlow.integration.test.ts +627 -0
  35. package/src/device-id/createDeviceIdService.ts +19 -0
  36. package/src/device-id/types.ts +11 -0
  37. package/src/index.ts +139 -0
  38. package/src/lx-identifier/createLXIdentifierService.ts +1 -0
  39. package/src/lx-identifier/createUniswapIdentifierService.ts +19 -0
  40. package/src/lx-identifier/lxIdentifierQuery.ts +1 -0
  41. package/src/lx-identifier/types.ts +11 -0
  42. package/src/lx-identifier/uniswapIdentifierQuery.ts +20 -0
  43. package/src/oauth-service/createOAuthService.ts +125 -0
  44. package/src/oauth-service/types.ts +104 -0
  45. package/src/performance/createNoopPerformanceTracker.ts +12 -0
  46. package/src/performance/createPerformanceTracker.ts +43 -0
  47. package/src/performance/index.ts +7 -0
  48. package/src/performance/types.ts +11 -0
  49. package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
  50. package/src/session-initialization/createSessionInitializationService.ts +184 -0
  51. package/src/session-initialization/sessionErrors.ts +32 -0
  52. package/src/session-repository/createSessionClient.ts +10 -0
  53. package/src/session-repository/createSessionRepository.test.ts +313 -0
  54. package/src/session-repository/createSessionRepository.ts +242 -0
  55. package/src/session-repository/errors.ts +22 -0
  56. package/src/session-repository/types.ts +289 -0
  57. package/src/session-service/createNoopSessionService.ts +24 -0
  58. package/src/session-service/createSessionService.test.ts +388 -0
  59. package/src/session-service/createSessionService.ts +61 -0
  60. package/src/session-service/types.ts +59 -0
  61. package/src/session-storage/createSessionStorage.ts +28 -0
  62. package/src/session-storage/types.ts +15 -0
  63. package/src/session.integration.test.ts +516 -0
  64. package/src/sessionLifecycle.integration.test.ts +264 -0
  65. package/src/test-utils/createLocalCookieTransport.ts +52 -0
  66. package/src/test-utils/createLocalHeaderTransport.ts +45 -0
  67. package/src/test-utils/mocks.ts +122 -0
  68. package/src/test-utils.ts +200 -0
  69. package/src/uniswap-identifier/createUniswapIdentifierService.ts +19 -0
  70. package/src/uniswap-identifier/types.ts +11 -0
  71. package/src/uniswap-identifier/uniswapIdentifierQuery.ts +20 -0
  72. package/tsconfig.json +26 -0
  73. package/tsconfig.lint.json +8 -0
  74. package/tsconfig.spec.json +8 -0
  75. package/vitest.config.ts +20 -0
  76. package/vitest.integration.config.ts +14 -0
  77. package/index.d.ts +0 -1
  78. package/index.js +0 -1
@@ -0,0 +1,516 @@
1
+ import { createChallengeSolverService } from '@l.x/sessions/src/challenge-solvers/createChallengeSolverService'
2
+ import { createHashcashSolver } from '@l.x/sessions/src/challenge-solvers/createHashcashSolver'
3
+ import { createNoneMockSolver } from '@l.x/sessions/src/challenge-solvers/createNoneMockSolver'
4
+ import { createTurnstileMockSolver } from '@l.x/sessions/src/challenge-solvers/createTurnstileMockSolver'
5
+ import type { PerformanceTracker } from '@l.x/sessions/src/performance/types'
6
+ import { createSessionInitializationService } from '@l.x/sessions/src/session-initialization/createSessionInitializationService'
7
+ import { createSessionClient } from '@l.x/sessions/src/session-repository/createSessionClient'
8
+ import { createSessionRepository } from '@l.x/sessions/src/session-repository/createSessionRepository'
9
+ import { createSessionService } from '@l.x/sessions/src/session-service/createSessionService'
10
+ import type { SessionService } from '@l.x/sessions/src/session-service/types'
11
+ import { ChallengeType } from '@l.x/sessions/src/session-service/types'
12
+ import {
13
+ InMemoryDeviceIdService,
14
+ InMemorySessionStorage,
15
+ InMemoryLuxIdentifierService,
16
+ } from '@l.x/sessions/src/test-utils'
17
+ import {
18
+ createCookieJar,
19
+ createLocalCookieTransport,
20
+ } from '@l.x/sessions/src/test-utils/createLocalCookieTransport'
21
+ import { createLocalHeaderTransport } from '@l.x/sessions/src/test-utils/createLocalHeaderTransport'
22
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
23
+
24
+ // Mock performance tracker for testing
25
+ function createMockPerformanceTracker(): PerformanceTracker {
26
+ let time = 0
27
+ return {
28
+ now: (): number => {
29
+ time += 100
30
+ return time
31
+ },
32
+ }
33
+ }
34
+
35
+ const BACKEND_URL = 'https://entry-gateway.backend-staging.api.lux.org'
36
+ // const BACKEND_URL = 'http://localhost:3000'
37
+
38
+ // =============================================================================
39
+ // Web Platform Tests (Turnstile + Hashcash)
40
+ // =============================================================================
41
+ // Web uses Turnstile (browser CAPTCHA) first, then falls back to Hashcash
42
+ // These tests hit a staging backend that occasionally returns transient 502s.
43
+ describe('Real Backend Integration - Web (Turnstile + Hashcash)', () => {
44
+ let sessionService: SessionService
45
+ let sessionStorage: InMemorySessionStorage
46
+ let cookieJar: Map<string, string>
47
+ let challengeSolverService: ReturnType<typeof createChallengeSolverService>
48
+
49
+ beforeAll(() => {
50
+ sessionStorage = new InMemorySessionStorage()
51
+ cookieJar = createCookieJar()
52
+
53
+ // Web uses Turnstile first (which will fail with mock), then falls back to hashcash
54
+ const solvers = new Map([
55
+ [ChallengeType.UNSPECIFIED, createNoneMockSolver()],
56
+ [ChallengeType.TURNSTILE, createTurnstileMockSolver()],
57
+ [ChallengeType.HASHCASH, createHashcashSolver({ performanceTracker: createMockPerformanceTracker() })],
58
+ ])
59
+
60
+ const transport = createLocalCookieTransport({ baseUrl: BACKEND_URL, cookieJar })
61
+ const sessionClient = createSessionClient({ transport })
62
+ const sessionRepository = createSessionRepository({ client: sessionClient })
63
+
64
+ sessionService = createSessionService({
65
+ sessionStorage,
66
+ deviceIdService: new InMemoryDeviceIdService(),
67
+ luxIdentifierService: new InMemoryLuxIdentifierService(),
68
+ sessionRepository,
69
+ })
70
+
71
+ challengeSolverService = createChallengeSolverService({ solvers })
72
+ })
73
+
74
+ beforeEach(async () => {
75
+ await sessionService.removeSession()
76
+ cookieJar.clear()
77
+ await sessionStorage.clear()
78
+ })
79
+
80
+ it('initializes session with cookie, empty response sessionId', { timeout: 30000, retry: 5 }, async () => {
81
+ const manualInitService = createSessionInitializationService({
82
+ getSessionService: () => sessionService,
83
+ challengeSolverService,
84
+ performanceTracker: createMockPerformanceTracker(),
85
+ getIsSessionUpgradeAutoEnabled: () => false,
86
+ })
87
+
88
+ const result = await manualInitService.initialize()
89
+
90
+ // Web: Session ID is in the cookie, not in response body
91
+ expect(cookieJar.has('x-session-id')).toBe(true)
92
+ expect(cookieJar.get('x-session-id')).toBeTruthy()
93
+
94
+ // Web platform: sessionId is null (stored in cookie, not response body)
95
+ expect(result.sessionId).toBeNull()
96
+
97
+ // Session is NOT stored locally yet because challenge is needed
98
+ const sessionState = await sessionService.getSessionState()
99
+ expect(sessionState).toBeNull()
100
+ })
101
+
102
+ it('receives Turnstile challenge first', { timeout: 30000, retry: 5 }, async () => {
103
+ const manualInitService = createSessionInitializationService({
104
+ getSessionService: () => sessionService,
105
+ challengeSolverService,
106
+ performanceTracker: createMockPerformanceTracker(),
107
+ getIsSessionUpgradeAutoEnabled: () => false,
108
+ })
109
+
110
+ await manualInitService.initialize()
111
+
112
+ const challenge = await sessionService.requestChallenge()
113
+
114
+ // Web gets Turnstile first (browser-based CAPTCHA)
115
+ expect(challenge.challengeType).toBe(ChallengeType.TURNSTILE)
116
+ expect(challenge.challengeId).toBeTruthy()
117
+ })
118
+
119
+ it('falls back to Hashcash after Turnstile fails', { timeout: 30000, retry: 5 }, async () => {
120
+ const manualInitService = createSessionInitializationService({
121
+ getSessionService: () => sessionService,
122
+ challengeSolverService,
123
+ performanceTracker: createMockPerformanceTracker(),
124
+ getIsSessionUpgradeAutoEnabled: () => false,
125
+ })
126
+
127
+ await manualInitService.initialize()
128
+
129
+ // Get Turnstile challenge
130
+ const turnstileChallenge = await sessionService.requestChallenge()
131
+ expect(turnstileChallenge.challengeType).toBe(ChallengeType.TURNSTILE)
132
+
133
+ // Solve with mock Turnstile (will fail)
134
+ const turnstileSolver = challengeSolverService.getSolver(ChallengeType.TURNSTILE)
135
+ const turnstileSolution = await turnstileSolver?.solve({
136
+ challengeId: turnstileChallenge.challengeId,
137
+ challengeType: turnstileChallenge.challengeType,
138
+ extra: turnstileChallenge.extra,
139
+ })
140
+
141
+ // Submit Turnstile mock solution
142
+ const turnstileResult = await sessionService.verifySession({
143
+ solution: turnstileSolution || '',
144
+ challengeId: turnstileChallenge.challengeId,
145
+ challengeType: turnstileChallenge.challengeType,
146
+ })
147
+
148
+ // Turnstile failed, retry requested
149
+ expect(turnstileResult.retry).toBe(true)
150
+
151
+ // Request challenge again - now we get Hashcash
152
+ const hashcashChallenge = await sessionService.requestChallenge()
153
+ expect(hashcashChallenge.challengeType).toBe(ChallengeType.HASHCASH)
154
+ expect(hashcashChallenge.challengeId).toBeTruthy()
155
+ })
156
+
157
+ it('successfully upgrades session with Hashcash after Turnstile fails', { timeout: 60000, retry: 5 }, async () => {
158
+ let lastError: unknown
159
+
160
+ // Some staging responses intermittently return HTTP 502 and leave session state invalid.
161
+ // Retry the full flow from a clean state so we don't carry over a broken session cookie.
162
+ for (let attempt = 0; attempt < 3; attempt++) {
163
+ try {
164
+ const manualInitService = createSessionInitializationService({
165
+ getSessionService: () => sessionService,
166
+ challengeSolverService,
167
+ performanceTracker: createMockPerformanceTracker(),
168
+ getIsSessionUpgradeAutoEnabled: () => false,
169
+ })
170
+
171
+ await manualInitService.initialize()
172
+
173
+ // Turnstile attempt (fails)
174
+ const turnstileChallenge = await sessionService.requestChallenge()
175
+ const turnstileSolver = challengeSolverService.getSolver(ChallengeType.TURNSTILE)
176
+ const turnstileSolution = await turnstileSolver?.solve({
177
+ challengeId: turnstileChallenge.challengeId,
178
+ challengeType: turnstileChallenge.challengeType,
179
+ extra: turnstileChallenge.extra,
180
+ })
181
+
182
+ const turnstileResult = await sessionService.verifySession({
183
+ solution: turnstileSolution || '',
184
+ challengeId: turnstileChallenge.challengeId,
185
+ challengeType: turnstileChallenge.challengeType,
186
+ })
187
+ expect(turnstileResult.retry).toBe(true)
188
+
189
+ // Hashcash attempt
190
+ const hashcashChallenge = await sessionService.requestChallenge()
191
+ expect(hashcashChallenge.challengeType).toBe(ChallengeType.HASHCASH)
192
+
193
+ const hashcashSolver = challengeSolverService.getSolver(ChallengeType.HASHCASH)
194
+ const hashcashSolution = await hashcashSolver?.solve({
195
+ challengeId: hashcashChallenge.challengeId,
196
+ challengeType: hashcashChallenge.challengeType,
197
+ extra: hashcashChallenge.extra,
198
+ })
199
+
200
+ const hashcashResult = await sessionService.verifySession({
201
+ solution: hashcashSolution || '',
202
+ challengeId: hashcashChallenge.challengeId,
203
+ challengeType: hashcashChallenge.challengeType,
204
+ })
205
+
206
+ // Success!
207
+ expect(hashcashResult.retry).toBe(false)
208
+ return
209
+ } catch (error) {
210
+ lastError = error
211
+ await sessionService.removeSession()
212
+ cookieJar.clear()
213
+ await sessionStorage.clear()
214
+ }
215
+ }
216
+
217
+ throw lastError instanceof Error ? lastError : new Error('Failed to complete Hashcash upgrade flow')
218
+ })
219
+
220
+ it('completes auto-upgrade flow (Turnstile fail → Hashcash success)', { timeout: 60000, retry: 5 }, async () => {
221
+ const autoInitService = createSessionInitializationService({
222
+ getSessionService: () => sessionService,
223
+ challengeSolverService,
224
+ performanceTracker: createMockPerformanceTracker(),
225
+ getIsSessionUpgradeAutoEnabled: () => true,
226
+ })
227
+
228
+ const result = await autoInitService.initialize()
229
+
230
+ // Web: Session ID is in cookie, not returned by initSession when challenge is needed
231
+ // After challenge completion, the session is valid but sessionId in result may be empty
232
+ // because initSession originally returned empty sessionId
233
+ expect(cookieJar.has('x-session-id')).toBe(true)
234
+ expect(cookieJar.get('x-session-id')).toBeTruthy()
235
+ })
236
+
237
+ it(
238
+ 'calls initSession on reinit - backend handles session reuse via cookie',
239
+ { timeout: 60000, retry: 5 },
240
+ async () => {
241
+ let lastError: unknown
242
+
243
+ // Re-run the full reinit flow from a clean cookie/session state when staging is unstable.
244
+ for (let attempt = 0; attempt < 3; attempt++) {
245
+ try {
246
+ await sessionService.removeSession()
247
+ cookieJar.clear()
248
+ await sessionStorage.clear()
249
+
250
+ const autoInitService = createSessionInitializationService({
251
+ getSessionService: () => sessionService,
252
+ challengeSolverService,
253
+ performanceTracker: createMockPerformanceTracker(),
254
+ getIsSessionUpgradeAutoEnabled: () => true,
255
+ })
256
+
257
+ await autoInitService.initialize()
258
+
259
+ // Cookie should be set from first init
260
+ expect(cookieJar.has('x-session-id')).toBe(true)
261
+ const originalSessionId = cookieJar.get('x-session-id')
262
+
263
+ // Simulate page refresh - call initialize again
264
+ // Backend receives cookie and decides to reuse session
265
+ const reinitService = createSessionInitializationService({
266
+ getSessionService: () => sessionService,
267
+ challengeSolverService,
268
+ performanceTracker: createMockPerformanceTracker(),
269
+ getIsSessionUpgradeAutoEnabled: () => true,
270
+ })
271
+
272
+ await reinitService.initialize()
273
+
274
+ // Backend should reuse session - cookie remains the same
275
+ expect(cookieJar.get('x-session-id')).toBe(originalSessionId)
276
+ return
277
+ } catch (error) {
278
+ lastError = error
279
+ await sessionService.removeSession()
280
+ cookieJar.clear()
281
+ await sessionStorage.clear()
282
+ }
283
+ }
284
+
285
+ throw lastError instanceof Error ? lastError : new Error('Failed to verify cookie-based session reuse flow')
286
+ },
287
+ )
288
+
289
+ it('fires analytics callbacks during auto-upgrade flow', { timeout: 60000, retry: 5 }, async () => {
290
+ const analytics = {
291
+ onInitStarted: vi.fn(),
292
+ onInitCompleted: vi.fn(),
293
+ onChallengeReceived: vi.fn(),
294
+ onVerifyCompleted: vi.fn(),
295
+ }
296
+
297
+ const autoInitService = createSessionInitializationService({
298
+ getSessionService: () => sessionService,
299
+ challengeSolverService,
300
+ performanceTracker: createMockPerformanceTracker(),
301
+ getIsSessionUpgradeAutoEnabled: () => true,
302
+ analytics,
303
+ })
304
+
305
+ await autoInitService.initialize()
306
+
307
+ // Verify analytics flow
308
+ expect(analytics.onInitStarted).toHaveBeenCalledTimes(1)
309
+ expect(analytics.onInitCompleted).toHaveBeenCalledWith(expect.objectContaining({ needChallenge: true }))
310
+ expect(analytics.onChallengeReceived).toHaveBeenCalled()
311
+ // Verification may be called multiple times due to retries
312
+ expect(analytics.onVerifyCompleted).toHaveBeenCalled()
313
+ // Last call should be success
314
+ const lastVerificationCall = analytics.onVerifyCompleted.mock.calls.at(-1)?.[0]
315
+ expect(lastVerificationCall?.success).toBe(true)
316
+ })
317
+
318
+ afterAll(async () => {
319
+ await sessionService.removeSession()
320
+ cookieJar.clear()
321
+ await sessionStorage.clear()
322
+ })
323
+ })
324
+
325
+ // =============================================================================
326
+ // Non-Web Platform Tests (Hashcash only)
327
+ // =============================================================================
328
+ // iOS, Android, and Extension skip Turnstile and go straight to Hashcash
329
+ type NonWebPlatform = 'ios' | 'android' | 'extension'
330
+ type NonWebRequestSource = 'lux-ios' | 'lux-android' | 'lux-extension'
331
+
332
+ interface NonWebPlatformConfig {
333
+ platform: NonWebPlatform
334
+ requestSource: NonWebRequestSource
335
+ }
336
+
337
+ const NON_WEB_PLATFORMS: NonWebPlatformConfig[] = [
338
+ { platform: 'ios', requestSource: 'lux-ios' },
339
+ { platform: 'android', requestSource: 'lux-android' },
340
+ { platform: 'extension', requestSource: 'lux-extension' },
341
+ ]
342
+
343
+ describe.each(NON_WEB_PLATFORMS)(
344
+ 'Real Backend Integration - $platform (Hashcash only)',
345
+ ({ platform: _platform, requestSource }) => {
346
+ let sessionService: SessionService
347
+ let sessionStorage: InMemorySessionStorage
348
+ let deviceIdService: InMemoryDeviceIdService
349
+ let luxIdentifierService: InMemoryLuxIdentifierService
350
+ let challengeSolverService: ReturnType<typeof createChallengeSolverService>
351
+
352
+ beforeAll(() => {
353
+ sessionStorage = new InMemorySessionStorage()
354
+ deviceIdService = new InMemoryDeviceIdService()
355
+ luxIdentifierService = new InMemoryLuxIdentifierService()
356
+
357
+ // Non-web platforms only use Hashcash (no Turnstile)
358
+ const solvers = new Map([
359
+ [ChallengeType.UNSPECIFIED, createNoneMockSolver()],
360
+ [ChallengeType.HASHCASH, createHashcashSolver({ performanceTracker: createMockPerformanceTracker() })],
361
+ ])
362
+
363
+ const transport = createLocalHeaderTransport({
364
+ baseUrl: BACKEND_URL,
365
+ requestSource,
366
+ getSessionId: async () => (await sessionStorage.get())?.sessionId ?? null,
367
+ getDeviceId: async () => deviceIdService.getDeviceId(),
368
+ })
369
+
370
+ const sessionClient = createSessionClient({ transport })
371
+ const sessionRepository = createSessionRepository({ client: sessionClient })
372
+
373
+ sessionService = createSessionService({
374
+ sessionStorage,
375
+ deviceIdService,
376
+ luxIdentifierService,
377
+ sessionRepository,
378
+ })
379
+
380
+ challengeSolverService = createChallengeSolverService({ solvers })
381
+ })
382
+
383
+ beforeEach(async () => {
384
+ await sessionService.removeSession()
385
+ await sessionStorage.clear()
386
+ await deviceIdService.removeDeviceId()
387
+ await luxIdentifierService.removeLuxIdentifier()
388
+ })
389
+
390
+ it('initializes session with session ID and device ID stored locally', { timeout: 30000, retry: 5 }, async () => {
391
+ const manualInitService = createSessionInitializationService({
392
+ getSessionService: () => sessionService,
393
+ challengeSolverService,
394
+ performanceTracker: createMockPerformanceTracker(),
395
+ getIsSessionUpgradeAutoEnabled: () => false,
396
+ })
397
+
398
+ const result = await manualInitService.initialize()
399
+
400
+ // Non-web: Backend returns session ID in response, which gets stored locally
401
+ // sessionId is returned regardless of challenge status
402
+ expect(result.sessionId).toBeTruthy()
403
+
404
+ // Session IS stored locally (unlike web which uses cookies)
405
+ const sessionState = await sessionService.getSessionState()
406
+ expect(sessionState?.sessionId).toBe(result.sessionId)
407
+
408
+ // Device ID should also be returned and stored
409
+ const storedDeviceId = await deviceIdService.getDeviceId()
410
+ expect(storedDeviceId).toBeTruthy()
411
+ })
412
+
413
+ it('receives Hashcash challenge directly (no Turnstile)', { timeout: 30000, retry: 5 }, async () => {
414
+ const manualInitService = createSessionInitializationService({
415
+ getSessionService: () => sessionService,
416
+ challengeSolverService,
417
+ performanceTracker: createMockPerformanceTracker(),
418
+ getIsSessionUpgradeAutoEnabled: () => false,
419
+ })
420
+
421
+ await manualInitService.initialize()
422
+
423
+ const challenge = await sessionService.requestChallenge()
424
+
425
+ // Non-web gets Hashcash directly (Turnstile is browser-only)
426
+ expect(challenge.challengeType).toBe(ChallengeType.HASHCASH)
427
+ expect(challenge.challengeId).toBeTruthy()
428
+ })
429
+
430
+ it('successfully upgrades session with Hashcash', { timeout: 60000, retry: 5 }, async () => {
431
+ const manualInitService = createSessionInitializationService({
432
+ getSessionService: () => sessionService,
433
+ challengeSolverService,
434
+ performanceTracker: createMockPerformanceTracker(),
435
+ getIsSessionUpgradeAutoEnabled: () => false,
436
+ })
437
+
438
+ await manualInitService.initialize()
439
+
440
+ // Get Hashcash challenge directly
441
+ const hashcashChallenge = await sessionService.requestChallenge()
442
+ expect(hashcashChallenge.challengeType).toBe(ChallengeType.HASHCASH)
443
+
444
+ const hashcashSolver = challengeSolverService.getSolver(ChallengeType.HASHCASH)
445
+ const hashcashSolution = await hashcashSolver?.solve({
446
+ challengeId: hashcashChallenge.challengeId,
447
+ challengeType: hashcashChallenge.challengeType,
448
+ extra: hashcashChallenge.extra,
449
+ })
450
+
451
+ const hashcashResult = await sessionService.verifySession({
452
+ solution: hashcashSolution || '',
453
+ challengeId: hashcashChallenge.challengeId,
454
+ challengeType: hashcashChallenge.challengeType,
455
+ })
456
+
457
+ // Success!
458
+ expect(hashcashResult.retry).toBe(false)
459
+ })
460
+
461
+ it('completes auto-upgrade flow', { timeout: 60000, retry: 5 }, async () => {
462
+ const autoInitService = createSessionInitializationService({
463
+ getSessionService: () => sessionService,
464
+ challengeSolverService,
465
+ performanceTracker: createMockPerformanceTracker(),
466
+ getIsSessionUpgradeAutoEnabled: () => true,
467
+ })
468
+
469
+ const result = await autoInitService.initialize()
470
+
471
+ expect(result.sessionId).toBeTruthy()
472
+
473
+ const sessionState = await sessionService.getSessionState()
474
+ expect(sessionState?.sessionId).toBe(result.sessionId)
475
+ })
476
+
477
+ it(
478
+ 'calls initSession on reinit - backend handles session reuse via X-Session-ID header',
479
+ { timeout: 60000, retry: 5 },
480
+ async () => {
481
+ // First: Complete auto-upgrade flow
482
+ const autoInitService = createSessionInitializationService({
483
+ getSessionService: () => sessionService,
484
+ challengeSolverService,
485
+ performanceTracker: createMockPerformanceTracker(),
486
+ getIsSessionUpgradeAutoEnabled: () => true,
487
+ })
488
+
489
+ const firstResult = await autoInitService.initialize()
490
+ expect(firstResult.sessionId).toBeTruthy()
491
+ const originalSessionId = firstResult.sessionId
492
+
493
+ // Simulate app refresh - call initialize again
494
+ // Backend receives X-Session-ID header and decides to reuse session
495
+ const reinitService = createSessionInitializationService({
496
+ getSessionService: () => sessionService,
497
+ challengeSolverService,
498
+ performanceTracker: createMockPerformanceTracker(),
499
+ getIsSessionUpgradeAutoEnabled: () => true,
500
+ })
501
+
502
+ const secondResult = await reinitService.initialize()
503
+
504
+ // Backend should reuse session - session ID remains the same
505
+ expect(secondResult.sessionId).toBe(originalSessionId)
506
+ },
507
+ )
508
+
509
+ afterAll(async () => {
510
+ await sessionService.removeSession()
511
+ await sessionStorage.clear()
512
+ await deviceIdService.removeDeviceId()
513
+ await luxIdentifierService.removeLuxIdentifier()
514
+ })
515
+ },
516
+ )