@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,325 @@
1
+ import { TurnstileScriptLoadError } from '@l.x/sessions/src/challenge-solvers/turnstileErrors'
2
+ import type { TurnstileScriptOptions } from '@l.x/sessions/src/challenge-solvers/types'
3
+
4
+ type TurnstileState = 'unloaded' | 'loading' | 'ready'
5
+
6
+ interface TurnstileLoadPromise {
7
+ resolve: (value?: void | PromiseLike<void>) => void
8
+ reject: (reason?: unknown) => void
9
+ }
10
+
11
+ /**
12
+ * Global state machine for Turnstile script loading
13
+ * Singleton pattern ensures shared state across solver instances
14
+ */
15
+ class TurnstileScriptState {
16
+ private state: TurnstileState = 'unloaded'
17
+ private loadPromise: Promise<void>
18
+ private loadResolvers: TurnstileLoadPromise | null = null
19
+
20
+ constructor() {
21
+ this.loadPromise = new Promise((resolve, reject) => {
22
+ this.loadResolvers = { resolve, reject }
23
+ // If already ready, resolve immediately
24
+ if (this.state === 'ready' && window.turnstile) {
25
+ resolve(undefined)
26
+ }
27
+ })
28
+ }
29
+
30
+ getState(): TurnstileState {
31
+ return this.state
32
+ }
33
+
34
+ setState(newState: TurnstileState): void {
35
+ this.state = newState
36
+ }
37
+
38
+ getLoadPromise(): Promise<void> {
39
+ return this.loadPromise
40
+ }
41
+
42
+ resolveLoad(): void {
43
+ if (this.loadResolvers) {
44
+ this.loadResolvers.resolve(undefined)
45
+ this.loadResolvers = null
46
+ }
47
+ }
48
+
49
+ rejectLoad(reason: unknown): void {
50
+ if (this.loadResolvers) {
51
+ this.loadResolvers.reject(reason)
52
+ this.loadResolvers = null
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Reset state for testing purposes
58
+ */
59
+ reset(): void {
60
+ this.state = 'unloaded'
61
+ this.loadPromise = new Promise((resolve, reject) => {
62
+ this.loadResolvers = { resolve, reject }
63
+ })
64
+ }
65
+ }
66
+
67
+ // Singleton instance
68
+ const turnstileState = new TurnstileScriptState()
69
+
70
+ const DEFAULT_SCRIPT_ID = 'cf-turnstile-script'
71
+ const DEFAULT_ONLOAD_NAME = 'onloadTurnstileCallback'
72
+ const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
73
+
74
+ /**
75
+ * Observes script loading using MutationObserver
76
+ * More reliable than polling for detecting when script is loaded
77
+ */
78
+ function observeScriptLoad(scriptId: string, timeoutMs = 10000): Promise<void> {
79
+ return new Promise((resolve, reject) => {
80
+ // Check if script already exists and is loaded
81
+ const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
82
+ if (existingScript && window.turnstile) {
83
+ resolve(undefined)
84
+ return
85
+ }
86
+
87
+ // If script exists but not loaded yet, set up load listener
88
+ if (existingScript && !window.turnstile) {
89
+ // Track intervals so we can clean them up from any code path
90
+ let loadCheckIntervalId: ReturnType<typeof setInterval> | null = null
91
+ let raceCheckIntervalId: ReturnType<typeof setInterval> | null = null
92
+
93
+ const cleanup = (): void => {
94
+ if (loadCheckIntervalId) {
95
+ clearInterval(loadCheckIntervalId)
96
+ }
97
+ if (raceCheckIntervalId) {
98
+ clearInterval(raceCheckIntervalId)
99
+ }
100
+ existingScript.removeEventListener('load', onScriptLoad)
101
+ existingScript.removeEventListener('error', onScriptError)
102
+ }
103
+
104
+ const onScriptLoad = (): void => {
105
+ // Wait a bit for turnstile to initialize
106
+ loadCheckIntervalId = setInterval(() => {
107
+ if (window.turnstile) {
108
+ cleanup()
109
+ resolve(undefined)
110
+ }
111
+ }, 50)
112
+
113
+ // Timeout if turnstile doesn't initialize
114
+ setTimeout(() => {
115
+ cleanup()
116
+ if (!window.turnstile) {
117
+ reject(new TurnstileScriptLoadError('Turnstile did not initialize after script load'))
118
+ } else {
119
+ resolve(undefined)
120
+ }
121
+ }, timeoutMs)
122
+ }
123
+
124
+ const onScriptError = (): void => {
125
+ cleanup()
126
+ reject(new TurnstileScriptLoadError('Failed to load Turnstile script'))
127
+ }
128
+
129
+ // Script exists but not loaded yet, listen for load event
130
+ // For scripts with defer/async, the load event will fire when ready
131
+ existingScript.addEventListener('load', onScriptLoad)
132
+ existingScript.addEventListener('error', onScriptError)
133
+
134
+ // Also check periodically in case the script loaded before we attached listeners
135
+ // (race condition with defer/async scripts)
136
+ raceCheckIntervalId = setInterval(() => {
137
+ if (window.turnstile) {
138
+ cleanup()
139
+ resolve(undefined)
140
+ }
141
+ }, 50)
142
+
143
+ // Timeout fallback
144
+ setTimeout(() => {
145
+ cleanup()
146
+ if (!window.turnstile) {
147
+ reject(new TurnstileScriptLoadError('Turnstile script loaded but API not available'))
148
+ } else {
149
+ resolve(undefined)
150
+ }
151
+ }, timeoutMs)
152
+ return
153
+ }
154
+
155
+ let resolved = false
156
+ const timeoutId = setTimeout(() => {
157
+ if (!resolved) {
158
+ resolved = true
159
+ reject(new TurnstileScriptLoadError(`Script observation timeout after ${timeoutMs}ms`))
160
+ }
161
+ }, timeoutMs)
162
+
163
+ const observer = new MutationObserver(() => {
164
+ const script = document.getElementById(scriptId) as HTMLScriptElement | null
165
+ if (script && window.turnstile) {
166
+ if (!resolved) {
167
+ resolved = true
168
+ clearTimeout(timeoutId)
169
+ observer.disconnect()
170
+ resolve(undefined)
171
+ }
172
+ }
173
+ })
174
+
175
+ // Start observing
176
+ observer.observe(document.head, {
177
+ childList: true,
178
+ subtree: true,
179
+ })
180
+
181
+ // Also check immediately in case script was added synchronously
182
+ const scriptCheck = document.getElementById(scriptId) as HTMLScriptElement | null
183
+ if (scriptCheck && window.turnstile) {
184
+ resolved = true
185
+ clearTimeout(timeoutId)
186
+ observer.disconnect()
187
+ resolve(undefined)
188
+ return
189
+ }
190
+ })
191
+ }
192
+
193
+ /**
194
+ * Injects Turnstile script with CSP-compliant options
195
+ */
196
+ function injectTurnstileScript(options: TurnstileScriptOptions = {}): void {
197
+ const scriptId = options.id || DEFAULT_SCRIPT_ID
198
+ const onLoadCallbackName = options.onLoadCallbackName || DEFAULT_ONLOAD_NAME
199
+
200
+ // Check if script already exists
201
+ if (document.getElementById(scriptId)) {
202
+ return
203
+ }
204
+
205
+ // Set up global onload callback
206
+ // Use unknown first to avoid type errors
207
+ const windowWithCallback = window as unknown as Record<string, unknown>
208
+ windowWithCallback[onLoadCallbackName] = (): void => {
209
+ turnstileState.setState('ready')
210
+ turnstileState.resolveLoad()
211
+ // Clean up callback
212
+ delete windowWithCallback[onLoadCallbackName]
213
+ }
214
+
215
+ // Create script element
216
+ const script = document.createElement('script')
217
+ script.id = scriptId
218
+ script.src = TURNSTILE_SCRIPT_URL
219
+
220
+ // Apply CSP-compliant options
221
+ if (options.nonce) {
222
+ script.nonce = options.nonce
223
+ }
224
+ if (options.defer !== undefined) {
225
+ script.defer = options.defer
226
+ }
227
+ if (options.async !== undefined) {
228
+ script.async = options.async
229
+ }
230
+ if (options.crossOrigin) {
231
+ script.crossOrigin = options.crossOrigin
232
+ }
233
+
234
+ // Track interval so it can be cleaned up from timeout
235
+ let checkIntervalId: ReturnType<typeof setInterval> | null = null
236
+
237
+ // Set onload callback via script attribute for CSP compliance
238
+ script.onload = (): void => {
239
+ // Wait for turnstile to actually be available
240
+ // The global callback will handle state transition
241
+ checkIntervalId = setInterval(() => {
242
+ if (window.turnstile) {
243
+ if (checkIntervalId) {
244
+ clearInterval(checkIntervalId)
245
+ }
246
+ // If global callback hasn't fired, resolve manually
247
+ if (turnstileState.getState() !== 'ready') {
248
+ turnstileState.setState('ready')
249
+ turnstileState.resolveLoad()
250
+ }
251
+ }
252
+ }, 50)
253
+
254
+ // Fallback timeout
255
+ setTimeout(() => {
256
+ if (checkIntervalId) {
257
+ clearInterval(checkIntervalId)
258
+ }
259
+ // Only reject if BOTH conditions are true: state isn't ready AND turnstile doesn't exist.
260
+ // If turnstile exists but state isn't ready, the interval will handle it.
261
+ // If state is ready, the global callback already resolved.
262
+ if (turnstileState.getState() !== 'ready' && !window.turnstile) {
263
+ turnstileState.rejectLoad(new TurnstileScriptLoadError('Turnstile did not initialize after script load'))
264
+ }
265
+ }, 5000)
266
+ }
267
+
268
+ script.onerror = (): void => {
269
+ turnstileState.setState('unloaded')
270
+ turnstileState.rejectLoad(new TurnstileScriptLoadError('Failed to load Turnstile script'))
271
+ }
272
+
273
+ // Inject script
274
+ document.head.appendChild(script)
275
+ turnstileState.setState('loading')
276
+ }
277
+
278
+ /**
279
+ * Ensures Turnstile script is loaded, using state machine for coordination
280
+ */
281
+ async function ensureTurnstileScript(scriptOptions: TurnstileScriptOptions = {}): Promise<void> {
282
+ // If already ready, return immediately
283
+ if (turnstileState.getState() === 'ready' && window.turnstile) {
284
+ return Promise.resolve()
285
+ }
286
+
287
+ // Check if script exists in DOM (including preloaded scripts)
288
+ const scriptId = scriptOptions.id || DEFAULT_SCRIPT_ID
289
+ const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
290
+
291
+ if (existingScript) {
292
+ // Script exists in DOM - check if it's already loaded
293
+ if (window.turnstile) {
294
+ turnstileState.setState('ready')
295
+ turnstileState.resolveLoad()
296
+ return Promise.resolve()
297
+ }
298
+
299
+ // Script exists but not loaded yet - observe for loading
300
+ // This handles preloaded scripts with defer/async
301
+ turnstileState.setState('loading')
302
+ return observeScriptLoad(scriptId).then(() => {
303
+ turnstileState.setState('ready')
304
+ turnstileState.resolveLoad()
305
+ })
306
+ }
307
+
308
+ // If already loading (from a previous call), wait for existing promise
309
+ if (turnstileState.getState() === 'loading') {
310
+ return turnstileState.getLoadPromise()
311
+ }
312
+
313
+ // Script doesn't exist - inject it
314
+ injectTurnstileScript(scriptOptions)
315
+ return turnstileState.getLoadPromise()
316
+ }
317
+
318
+ /**
319
+ * Reset turnstile state for testing purposes
320
+ */
321
+ function resetTurnstileState(): void {
322
+ turnstileState.reset()
323
+ }
324
+
325
+ export { ensureTurnstileScript, observeScriptLoad, turnstileState, resetTurnstileState }
@@ -0,0 +1,331 @@
1
+ /// @vitest-environment happy-dom
2
+ import { ChallengeType } from '@luxamm/client-platform-service/dist/lx/platformservice/v1/sessionService_pb'
3
+ import { createTurnstileSolver } from '@l.x/sessions/src/challenge-solvers/createTurnstileSolver'
4
+ import { resetTurnstileState } from '@l.x/sessions/src/challenge-solvers/turnstileScriptLoader'
5
+ import type { PerformanceTracker } from '@l.x/sessions/src/performance/types'
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ // Mock performance tracker for testing
9
+ function createMockPerformanceTracker(): PerformanceTracker {
10
+ let time = 0
11
+ return {
12
+ now: (): number => {
13
+ time += 100
14
+ return time
15
+ },
16
+ }
17
+ }
18
+
19
+ // Mock window.turnstile API
20
+ const mockTurnstileAPI = {
21
+ render: vi.fn(),
22
+ remove: vi.fn(),
23
+ reset: vi.fn(),
24
+ getResponse: vi.fn(),
25
+ ready: vi.fn(),
26
+ }
27
+
28
+ // Setup DOM mocks
29
+ beforeAll(() => {
30
+ const originalCreateElement = document.createElement.bind(document)
31
+ vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
32
+ const element = originalCreateElement(tagName)
33
+ if (tagName === 'div') {
34
+ // Track created divs for assertions
35
+ // eslint-disable-next-line no-extra-semi
36
+ ;(element as any)._testCreated = true
37
+ }
38
+ return element
39
+ })
40
+
41
+ vi.spyOn(document.head, 'appendChild').mockImplementation((node) => {
42
+ if (node instanceof HTMLScriptElement && node.src.includes('challenges.cloudflare.com')) {
43
+ // Simulate script load immediately
44
+ setTimeout(() => {
45
+ // Set up the mock turnstile API
46
+ // eslint-disable-next-line no-extra-semi
47
+ ;(window as any).turnstile = mockTurnstileAPI
48
+ if (node.onload) {
49
+ node.onload({} as Event)
50
+ }
51
+ }, 0)
52
+ }
53
+ return node
54
+ })
55
+
56
+ const originalBodyAppendChild = document.body.appendChild.bind(document.body)
57
+ vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
58
+ // eslint-disable-next-line no-extra-semi
59
+ ;(node as any)._testAppended = true
60
+ // Actually append to the DOM so we can query it later
61
+ return originalBodyAppendChild(node)
62
+ })
63
+
64
+ vi.spyOn(Element.prototype, 'removeChild').mockImplementation(function (this: Element, child: Node) {
65
+ // eslint-disable-next-line no-extra-semi
66
+ ;(child as any)._testRemoved = true
67
+ return child
68
+ })
69
+ })
70
+
71
+ afterAll(() => {
72
+ vi.restoreAllMocks()
73
+ })
74
+
75
+ describe('Turnstile Solver Integration Tests', () => {
76
+ beforeEach(() => {
77
+ // Configure mock Turnstile API behavior
78
+ mockTurnstileAPI.ready.mockImplementation((callback: () => void) => {
79
+ // Call the callback immediately
80
+ callback()
81
+ })
82
+
83
+ mockTurnstileAPI.render.mockImplementation((container: string | HTMLElement, options: any) => {
84
+ // Simulate successful render and call the callback with a test token
85
+ if (options.callback) {
86
+ setTimeout(() => {
87
+ options.callback('test-turnstile-solution-token')
88
+ }, 10) // Small delay to simulate async behavior
89
+ }
90
+ return 'widget-123' // Return a mock widget ID
91
+ })
92
+ })
93
+
94
+ afterEach(() => {
95
+ // Clean up DOM
96
+ document.querySelectorAll('div[id^="turnstile-"]').forEach((el) => el.remove())
97
+
98
+ // Reset mocks to default successful behavior
99
+ vi.clearAllMocks()
100
+ ;(window as any).turnstile = undefined
101
+
102
+ // Reset turnstile script loader state for next test
103
+ resetTurnstileState()
104
+ })
105
+
106
+ it('verifies Turnstile solver basic functionality', async () => {
107
+ // Create a challenge solver directly to test
108
+ const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
109
+
110
+ // Create challenge data with proper structure
111
+ const challengeData = {
112
+ challengeId: 'dom-test-challenge-123',
113
+ challengeType: ChallengeType.TURNSTILE,
114
+ extra: {
115
+ challengeData: JSON.stringify({
116
+ siteKey: '0x4AAAAAABiAHneWOWZHzZtO',
117
+ action: 'session_verification',
118
+ }),
119
+ },
120
+ }
121
+
122
+ // Execute the solver and wait for solution
123
+ const solution = await turnstileSolver.solve(challengeData)
124
+
125
+ // Verify solution was returned
126
+ expect(solution).toBe('test-turnstile-solution-token')
127
+
128
+ // Verify script injection was attempted
129
+ expect(document.head.appendChild).toHaveBeenCalledWith(
130
+ expect.objectContaining({
131
+ src: 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit',
132
+ }),
133
+ )
134
+ })
135
+
136
+ it('handles Turnstile solver errors properly', async () => {
137
+ // Configure mock to simulate an error with proper timing
138
+ mockTurnstileAPI.render.mockImplementation(async (container: string | HTMLElement, options: any) => {
139
+ if (options['error-callback']) {
140
+ // Use microtask to ensure promise handlers are set up
141
+ await Promise.resolve().then(() => {
142
+ options['error-callback']('NETWORK_ERROR')
143
+ })
144
+ }
145
+ return 'widget-error-123'
146
+ })
147
+
148
+ const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
149
+ const challengeData = {
150
+ challengeId: 'error-test-123',
151
+ challengeType: ChallengeType.TURNSTILE,
152
+ extra: {
153
+ challengeData: JSON.stringify({
154
+ siteKey: 'test-site-key',
155
+ action: 'test-action',
156
+ }),
157
+ },
158
+ }
159
+
160
+ // Should reject with Turnstile error
161
+ await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Turnstile error: NETWORK_ERROR')
162
+ })
163
+
164
+ it('handles expired tokens', async () => {
165
+ // Configure mock to simulate token expiration
166
+ mockTurnstileAPI.render.mockImplementation(async (container: string | HTMLElement, options: any) => {
167
+ if (options['expired-callback']) {
168
+ // Use microtask to ensure promise handlers are set up
169
+ await Promise.resolve().then(() => {
170
+ options['expired-callback']()
171
+ })
172
+ }
173
+ return 'widget-expired-123'
174
+ })
175
+
176
+ const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
177
+ const challengeData = {
178
+ challengeId: 'expired-test-123',
179
+ challengeType: ChallengeType.TURNSTILE,
180
+ extra: {
181
+ challengeData: JSON.stringify({
182
+ siteKey: 'test-site-key',
183
+ action: 'test-action',
184
+ }),
185
+ },
186
+ }
187
+
188
+ // Should reject with expiration error
189
+ await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Turnstile token expired')
190
+ })
191
+
192
+ it('handles timeout scenarios', async () => {
193
+ // Configure mock to never call any callbacks
194
+ mockTurnstileAPI.render.mockImplementation(() => {
195
+ // Don't call any callbacks - simulate timeout
196
+ return 'widget-timeout-123'
197
+ })
198
+
199
+ // Use a short timeout for testing (100ms instead of 30s)
200
+ const turnstileSolver = createTurnstileSolver({
201
+ performanceTracker: createMockPerformanceTracker(),
202
+ timeoutMs: 100,
203
+ })
204
+ const challengeData = {
205
+ challengeId: 'timeout-test-123',
206
+ challengeType: ChallengeType.TURNSTILE,
207
+ extra: {
208
+ challengeData: JSON.stringify({
209
+ siteKey: 'test-site-key',
210
+ action: 'test-action',
211
+ }),
212
+ },
213
+ }
214
+
215
+ // Should reject with timeout error after 100ms
216
+ await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Turnstile challenge timed out after')
217
+ })
218
+
219
+ it('handles missing challenge data', async () => {
220
+ const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
221
+ const challengeData = {
222
+ challengeId: 'missing-data-123',
223
+ challengeType: ChallengeType.TURNSTILE,
224
+ extra: {}, // Missing challengeData
225
+ }
226
+
227
+ // Should reject with missing data error
228
+ await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Missing challengeData in challenge extra')
229
+ })
230
+
231
+ it('handles invalid challenge data JSON', async () => {
232
+ const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
233
+ const challengeData = {
234
+ challengeId: 'invalid-json-123',
235
+ challengeType: ChallengeType.TURNSTILE,
236
+ extra: {
237
+ challengeData: 'invalid-json-{',
238
+ },
239
+ }
240
+
241
+ // Should reject with JSON parse error
242
+ await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Failed to parse challengeData')
243
+ })
244
+
245
+ it('handles missing siteKey in challenge data', async () => {
246
+ const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
247
+ const challengeData = {
248
+ challengeId: 'missing-sitekey-123',
249
+ challengeType: ChallengeType.TURNSTILE,
250
+ extra: {
251
+ challengeData: JSON.stringify({
252
+ action: 'test-action',
253
+ // Missing siteKey
254
+ }),
255
+ },
256
+ }
257
+
258
+ // Should reject with missing siteKey error
259
+ await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Missing siteKey in challengeData')
260
+ })
261
+
262
+ it('handles script loading failures', async () => {
263
+ // Mock script loading failure
264
+ vi.spyOn(document.head, 'appendChild').mockImplementationOnce((node) => {
265
+ if (node instanceof HTMLScriptElement && node.src.includes('challenges.cloudflare.com')) {
266
+ setTimeout(() => {
267
+ if (node.onerror) {
268
+ node.onerror({} as Event)
269
+ }
270
+ }, 0)
271
+ }
272
+ return node
273
+ })
274
+
275
+ const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
276
+ const challengeData = {
277
+ challengeId: 'script-fail-123',
278
+ challengeType: ChallengeType.TURNSTILE,
279
+ extra: {
280
+ challengeData: JSON.stringify({
281
+ siteKey: 'test-site-key',
282
+ action: 'test-action',
283
+ }),
284
+ },
285
+ }
286
+
287
+ // Should reject with script loading error
288
+ await expect(turnstileSolver.solve(challengeData)).rejects.toThrow('Failed to load Turnstile script')
289
+ })
290
+
291
+ it('handles multiple concurrent solve requests', async () => {
292
+ const turnstileSolver = createTurnstileSolver({ performanceTracker: createMockPerformanceTracker() })
293
+
294
+ // Create multiple challenge data objects
295
+ const challenges = Array.from({ length: 3 }, (_, i) => ({
296
+ challengeId: `concurrent-test-${i}`,
297
+ challengeType: ChallengeType.TURNSTILE,
298
+ extra: {
299
+ challengeData: JSON.stringify({
300
+ siteKey: `test-site-key-${i}`,
301
+ action: 'test-action',
302
+ }),
303
+ },
304
+ }))
305
+
306
+ // Configure mock to return different tokens for each widget
307
+ let widgetCounter = 0
308
+ mockTurnstileAPI.render.mockImplementation((container: string | HTMLElement, options: any) => {
309
+ const widgetId = `widget-${widgetCounter++}`
310
+ if (options.callback) {
311
+ setTimeout(() => {
312
+ options.callback(`solution-for-${widgetId}`)
313
+ }, 10)
314
+ }
315
+ return widgetId
316
+ })
317
+
318
+ // Execute all solvers concurrently
319
+ const solutionPromises = challenges.map((challenge) => turnstileSolver.solve(challenge))
320
+
321
+ // Wait for all solutions
322
+ const solutions = await Promise.all(solutionPromises)
323
+
324
+ // Verify all solutions are unique
325
+ expect(solutions).toHaveLength(3)
326
+ expect(new Set(solutions).size).toBe(3)
327
+ solutions.forEach((solution) => {
328
+ expect(solution).toMatch(/^solution-for-widget-\d+$/)
329
+ })
330
+ })
331
+ })
@@ -0,0 +1,55 @@
1
+ import type { TypedChallengeData } from '@l.x/sessions/src/session-repository/types'
2
+ import type { ChallengeType } from '@l.x/sessions/src/session-service/types'
3
+
4
+ interface ChallengeData {
5
+ challengeId: string
6
+ challengeType: ChallengeType
7
+ /** @deprecated Use challengeData instead */
8
+ extra?: Record<string, string>
9
+ /** Type-safe challenge-specific data (replaces extra) */
10
+ challengeData?: TypedChallengeData
11
+ }
12
+
13
+ interface ChallengeSolver {
14
+ solve(challengeData: ChallengeData): Promise<string>
15
+ }
16
+
17
+ interface ChallengeSolverService {
18
+ getSolver(type: ChallengeType): ChallengeSolver | null
19
+ }
20
+
21
+ /**
22
+ * Script injection options for CSP compliance and customization
23
+ */
24
+ interface TurnstileScriptOptions {
25
+ /**
26
+ * Custom nonce for the injected script (for CSP compliance)
27
+ */
28
+ nonce?: string
29
+ /**
30
+ * Whether to set the script as defer
31
+ * @default false (Turnstile requires synchronous loading for ready())
32
+ */
33
+ defer?: boolean
34
+ /**
35
+ * Whether to set the script as async
36
+ * @default false (Turnstile requires synchronous loading for ready())
37
+ */
38
+ async?: boolean
39
+ /**
40
+ * Custom crossOrigin for the injected script
41
+ */
42
+ crossOrigin?: string
43
+ /**
44
+ * Custom ID for the injected script
45
+ * @default "cf-turnstile-script"
46
+ */
47
+ id?: string
48
+ /**
49
+ * Custom name for the onload callback
50
+ * @default "onloadTurnstileCallback"
51
+ */
52
+ onLoadCallbackName?: string
53
+ }
54
+
55
+ export type { ChallengeData, ChallengeSolver, ChallengeSolverService, TurnstileScriptOptions }