@l.x/sessions 1.0.3 → 1.0.4

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 (72) 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 +49 -1
  6. package/project.json +36 -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 +270 -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 +357 -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 +139 -0
  37. package/src/oauth-service/createOAuthService.ts +125 -0
  38. package/src/oauth-service/types.ts +104 -0
  39. package/src/performance/createNoopPerformanceTracker.ts +12 -0
  40. package/src/performance/createPerformanceTracker.ts +43 -0
  41. package/src/performance/index.ts +7 -0
  42. package/src/performance/types.ts +11 -0
  43. package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
  44. package/src/session-initialization/createSessionInitializationService.ts +184 -0
  45. package/src/session-initialization/sessionErrors.ts +32 -0
  46. package/src/session-repository/createSessionClient.ts +10 -0
  47. package/src/session-repository/createSessionRepository.test.ts +313 -0
  48. package/src/session-repository/createSessionRepository.ts +242 -0
  49. package/src/session-repository/errors.ts +22 -0
  50. package/src/session-repository/types.ts +289 -0
  51. package/src/session-service/createNoopSessionService.ts +24 -0
  52. package/src/session-service/createSessionService.test.ts +388 -0
  53. package/src/session-service/createSessionService.ts +61 -0
  54. package/src/session-service/types.ts +59 -0
  55. package/src/session-storage/createSessionStorage.ts +28 -0
  56. package/src/session-storage/types.ts +15 -0
  57. package/src/session.integration.test.ts +516 -0
  58. package/src/sessionLifecycle.integration.test.ts +264 -0
  59. package/src/test-utils/createLocalCookieTransport.ts +52 -0
  60. package/src/test-utils/createLocalHeaderTransport.ts +45 -0
  61. package/src/test-utils/mocks.ts +122 -0
  62. package/src/test-utils.ts +200 -0
  63. package/src/uniswap-identifier/createUniswapIdentifierService.ts +19 -0
  64. package/src/uniswap-identifier/types.ts +11 -0
  65. package/src/uniswap-identifier/uniswapIdentifierQuery.ts +20 -0
  66. package/tsconfig.json +26 -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
  71. package/index.d.ts +0 -1
  72. package/index.js +0 -1
@@ -0,0 +1,270 @@
1
+ import { findProof, type HashcashChallenge } from '@l.x/sessions/src/challenge-solvers/hashcash/core'
2
+ import type { HashcashWorkerChannelFactory } from '@l.x/sessions/src/challenge-solvers/hashcash/worker/types'
3
+ import type { ChallengeData, ChallengeSolver } from '@l.x/sessions/src/challenge-solvers/types'
4
+ import type { PerformanceTracker } from '@l.x/sessions/src/performance/types'
5
+ import type { Logger } from 'utilities/src/logger/logger'
6
+ import { z } from 'zod'
7
+
8
+ /** Error type for analytics classification */
9
+ type HashcashErrorType = 'validation' | 'no_proof' | 'worker_busy' | 'unknown'
10
+
11
+ /** Base class for hashcash errors with typed errorType for reliable analytics classification */
12
+ class HashcashError extends Error {
13
+ readonly errorType: HashcashErrorType
14
+
15
+ constructor(message: string, errorType: HashcashErrorType) {
16
+ super(message)
17
+ this.name = 'HashcashError'
18
+ this.errorType = errorType
19
+ }
20
+ }
21
+
22
+ /** Validation errors (parsing, missing data, invalid challenge format) */
23
+ class HashcashValidationError extends HashcashError {
24
+ constructor(message: string) {
25
+ super(message, 'validation')
26
+ this.name = 'HashcashValidationError'
27
+ }
28
+ }
29
+
30
+ /** Proof not found within allowed iterations */
31
+ class HashcashNoProofError extends HashcashError {
32
+ constructor(message: string) {
33
+ super(message, 'no_proof')
34
+ this.name = 'HashcashNoProofError'
35
+ }
36
+ }
37
+
38
+ /** Worker is busy processing another request */
39
+ class HashcashWorkerBusyError extends HashcashError {
40
+ constructor(message: string) {
41
+ super(message, 'worker_busy')
42
+ this.name = 'HashcashWorkerBusyError'
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Analytics data for Hashcash solve attempts.
48
+ * Reported via onSolveCompleted callback.
49
+ */
50
+ interface HashcashSolveAnalytics {
51
+ durationMs: number
52
+ success: boolean
53
+ errorType?: 'validation' | 'no_proof' | 'worker_busy' | 'unknown'
54
+ errorMessage?: string
55
+ /** The difficulty level of the challenge (number of leading zero bytes) */
56
+ difficulty: number
57
+ /** Number of hash iterations to find proof (undefined on failure) */
58
+ iterationCount?: number
59
+ /** Whether the worker was used for proof computation */
60
+ usedWorker: boolean
61
+ }
62
+
63
+ /**
64
+ * Context for creating a hashcash solver.
65
+ */
66
+ interface CreateHashcashSolverContext {
67
+ /**
68
+ * Required: Performance tracker for timing measurements.
69
+ * Must be injected - no implicit dependency on globalThis.performance.
70
+ */
71
+ performanceTracker: PerformanceTracker
72
+ /**
73
+ * Factory function to create a worker channel.
74
+ * If provided, proof-of-work runs in a Web Worker (non-blocking).
75
+ * If not provided, falls back to main-thread execution (blocking).
76
+ */
77
+ getWorkerChannel?: HashcashWorkerChannelFactory
78
+ /**
79
+ * Callback for analytics when solve completes (success or failure)
80
+ */
81
+ onSolveCompleted?: (data: HashcashSolveAnalytics) => void
82
+ /**
83
+ * Optional logger for operational observability (Datadog).
84
+ */
85
+ getLogger?: () => Logger
86
+ }
87
+
88
+ // Zod schema for hashcash challenge validation
89
+ const HashcashChallengeSchema = z.object({
90
+ difficulty: z.number().int().nonnegative(),
91
+ subject: z.string().min(1),
92
+ algorithm: z.literal('sha256'),
93
+ nonce: z.string().min(1),
94
+ max_proof_length: z.number().int().positive().default(1000000),
95
+ verifier: z.string().optional(),
96
+ })
97
+
98
+ /**
99
+ * Parses and validates hashcash challenge data from the backend.
100
+ * @param challengeDataStr - JSON string containing challenge data
101
+ * @returns Parsed and validated HashcashChallenge
102
+ * @throws {Error} If challenge data is invalid or missing required fields
103
+ */
104
+ function parseHashcashChallenge(challengeDataStr: string): HashcashChallenge {
105
+ let parsedData: unknown
106
+ try {
107
+ parsedData = JSON.parse(challengeDataStr)
108
+ } catch (error) {
109
+ throw new HashcashValidationError(`Failed to parse challenge JSON: ${error}`)
110
+ }
111
+
112
+ // Validate with Zod
113
+ const result = HashcashChallengeSchema.safeParse(parsedData)
114
+ if (!result.success) {
115
+ // Get unique field paths from errors
116
+ const fieldPaths = new Set(
117
+ result.error.issues.filter((issue) => issue.path.length > 0).map((issue) => issue.path[0]),
118
+ )
119
+ if (fieldPaths.size === 1) {
120
+ // Single field-specific error
121
+ const fieldName = String(Array.from(fieldPaths)[0])
122
+ throw new HashcashValidationError(`Invalid challenge data: ${fieldName}`)
123
+ }
124
+ // General validation error (multiple fields or form-level errors)
125
+ throw new HashcashValidationError('Invalid challenge data')
126
+ }
127
+
128
+ return result.data
129
+ }
130
+
131
+ /**
132
+ * Classifies error into analytics error type.
133
+ * Uses instanceof checks for typed errors (preferred), with string matching fallback for external errors.
134
+ */
135
+ function classifyError(error: unknown): HashcashSolveAnalytics['errorType'] {
136
+ // Prefer typed error classification via instanceof
137
+ if (error instanceof HashcashError) {
138
+ return error.errorType
139
+ }
140
+
141
+ // Fallback to string matching for external or legacy errors
142
+ if (error instanceof Error) {
143
+ if (error.message.includes('parse') || error.message.includes('Invalid challenge')) {
144
+ return 'validation'
145
+ }
146
+ if (error.message.includes('Missing challengeData')) {
147
+ return 'validation'
148
+ }
149
+ if (error.message.includes('Failed to find valid proof')) {
150
+ return 'no_proof'
151
+ }
152
+ if (error.message.includes('busy')) {
153
+ return 'worker_busy'
154
+ }
155
+ }
156
+ return 'unknown'
157
+ }
158
+
159
+ /**
160
+ * Creates a real hashcash challenge solver that performs proof-of-work
161
+ * to solve hashcash challenges from the backend.
162
+ *
163
+ * @param ctx - Required context with performanceTracker and optional getWorkerChannel
164
+ */
165
+ function createHashcashSolver(ctx: CreateHashcashSolverContext): ChallengeSolver {
166
+ const usedWorker = !!ctx.getWorkerChannel
167
+
168
+ async function solve(challengeData: ChallengeData): Promise<string> {
169
+ const startTime = ctx.performanceTracker.now()
170
+ let difficulty = 0 // Default, will be updated after parsing
171
+
172
+ try {
173
+ let challenge: HashcashChallenge
174
+
175
+ // Prefer typed challengeData over legacy extra field
176
+ if (challengeData.challengeData?.case === 'hashcash') {
177
+ const typed = challengeData.challengeData.value
178
+ challenge = {
179
+ difficulty: typed.difficulty,
180
+ subject: typed.subject,
181
+ algorithm: typed.algorithm as 'sha256',
182
+ nonce: typed.nonce,
183
+ max_proof_length: typed.maxProofLength,
184
+ verifier: typed.verifier,
185
+ }
186
+ } else {
187
+ // Fallback to legacy extra field
188
+ const challengeDataStr = challengeData.extra?.['challengeData']
189
+ if (!challengeDataStr) {
190
+ throw new HashcashValidationError('Missing challengeData in challenge extra field')
191
+ }
192
+ challenge = parseHashcashChallenge(challengeDataStr)
193
+ }
194
+
195
+ difficulty = challenge.difficulty
196
+
197
+ const findProofParams = {
198
+ challenge,
199
+ rangeStart: 0,
200
+ rangeSize: challenge.max_proof_length,
201
+ }
202
+
203
+ // Use worker if provided, otherwise fall back to main thread
204
+ let proof
205
+ if (ctx.getWorkerChannel) {
206
+ const workerChannel = ctx.getWorkerChannel()
207
+ try {
208
+ proof = await workerChannel.api.findProof(findProofParams)
209
+ } finally {
210
+ workerChannel.terminate()
211
+ }
212
+ } else {
213
+ // Fallback to main-thread execution (still async for Web Crypto)
214
+ proof = await findProof(findProofParams)
215
+ }
216
+
217
+ if (!proof) {
218
+ throw new HashcashNoProofError(
219
+ `Failed to find valid proof within allowed range (0-${challenge.max_proof_length}). ` +
220
+ 'Challenge may have expired or difficulty may be too high.',
221
+ )
222
+ }
223
+
224
+ // Report success
225
+ const data: HashcashSolveAnalytics = {
226
+ durationMs: ctx.performanceTracker.now() - startTime,
227
+ success: true,
228
+ difficulty,
229
+ iterationCount: proof.attempts,
230
+ usedWorker,
231
+ }
232
+ ctx.onSolveCompleted?.(data)
233
+ ctx.getLogger?.().info('sessions', 'hashcashSolved', 'Hashcash solve completed', data)
234
+
235
+ // Return the solution in the format expected by backend: "${subject}:${nonce}:${counter}"
236
+ return `${challenge.subject}:${challenge.nonce}:${proof.counter}`
237
+ } catch (error) {
238
+ // Report failure
239
+ const data: HashcashSolveAnalytics = {
240
+ durationMs: ctx.performanceTracker.now() - startTime,
241
+ success: false,
242
+ errorType: classifyError(error),
243
+ errorMessage: error instanceof Error ? error.message : String(error),
244
+ difficulty,
245
+ usedWorker,
246
+ }
247
+ ctx.onSolveCompleted?.(data)
248
+ ctx.getLogger?.().warn('sessions', 'hashcashSolved', 'Hashcash solve failed', data)
249
+ ctx.getLogger?.().error(error, {
250
+ tags: {
251
+ file: 'createHashcashSolver.ts',
252
+ function: 'solve',
253
+ },
254
+ })
255
+ throw error
256
+ }
257
+ }
258
+
259
+ return { solve }
260
+ }
261
+
262
+ export {
263
+ createHashcashSolver,
264
+ parseHashcashChallenge,
265
+ HashcashError,
266
+ HashcashValidationError,
267
+ HashcashNoProofError,
268
+ HashcashWorkerBusyError,
269
+ }
270
+ export type { HashcashSolveAnalytics, CreateHashcashSolverContext }
@@ -0,0 +1,11 @@
1
+ import type { ChallengeData, ChallengeSolver } from '@l.x/sessions/src/challenge-solvers/types'
2
+
3
+ function createNoneMockSolver(): ChallengeSolver {
4
+ async function solve(_challengeData: ChallengeData): Promise<string> {
5
+ return ''
6
+ }
7
+
8
+ return { solve }
9
+ }
10
+
11
+ export { createNoneMockSolver }
@@ -0,0 +1,30 @@
1
+ import type { ChallengeData, ChallengeSolver } from '@l.x/sessions/src/challenge-solvers/types'
2
+ import { sleep } from 'utilities/src/time/timing'
3
+
4
+ /**
5
+ * Creates a mock Turnstile challenge solver for development/testing
6
+ *
7
+ * In production, this would integrate with Cloudflare Turnstile:
8
+ * - Load Turnstile script
9
+ * - Render widget with sitekey from challengeData.extra
10
+ * - Return actual token from Turnstile API
11
+ */
12
+ function createTurnstileMockSolver(): ChallengeSolver {
13
+ async function solve(challengeData: ChallengeData): Promise<string> {
14
+ // Simulate widget render delay
15
+ await sleep(300)
16
+
17
+ // Simulate challenge solving time (random between 200-500ms)
18
+ const solvingTime = 200 + Math.random() * 300
19
+ await sleep(solvingTime)
20
+
21
+ // Return mock Turnstile token
22
+ const timestamp = Date.now()
23
+ const challengeIdPrefix = challengeData.challengeId.slice(0, 8)
24
+ return `mock_turnstile_token_${timestamp}_${challengeIdPrefix}`
25
+ }
26
+
27
+ return { solve }
28
+ }
29
+
30
+ export { createTurnstileMockSolver }
@@ -0,0 +1,357 @@
1
+ import {
2
+ TurnstileApiNotAvailableError,
3
+ TurnstileError,
4
+ TurnstileScriptLoadError,
5
+ TurnstileTimeoutError,
6
+ TurnstileTokenExpiredError,
7
+ } from '@l.x/sessions/src/challenge-solvers/turnstileErrors'
8
+ import { ensureTurnstileScript } from '@l.x/sessions/src/challenge-solvers/turnstileScriptLoader'
9
+ import type {
10
+ ChallengeData,
11
+ ChallengeSolver,
12
+ TurnstileScriptOptions,
13
+ } from '@l.x/sessions/src/challenge-solvers/types'
14
+ import type { PerformanceTracker } from '@l.x/sessions/src/performance/types'
15
+ import type { Logger } from 'utilities/src/logger/logger'
16
+
17
+ /**
18
+ * Analytics data for Turnstile solve attempts.
19
+ * Reported via onSolveCompleted callback.
20
+ */
21
+ interface TurnstileSolveAnalytics {
22
+ durationMs: number
23
+ success: boolean
24
+ errorType?: 'timeout' | 'script_load' | 'network' | 'validation' | 'unknown'
25
+ errorMessage?: string
26
+ }
27
+
28
+ // Declare Turnstile types inline to avoid import issues
29
+ interface TurnstileWidget {
30
+ render: (container: string | HTMLElement, options: TurnstileOptions) => string
31
+ remove: (widgetId: string) => void
32
+ reset: (widgetId: string) => void
33
+ getResponse: (widgetId: string) => string | undefined
34
+ ready: (callback: () => void) => void
35
+ }
36
+
37
+ interface TurnstileOptions {
38
+ sitekey: string
39
+ action?: string
40
+ theme?: 'light' | 'dark' | 'auto'
41
+ size?: 'normal' | 'compact' | 'flexible'
42
+ callback?: (token: string) => void
43
+ 'error-callback'?: (error: string) => void
44
+ 'expired-callback'?: () => void
45
+ }
46
+
47
+ // Extend the Window interface to include turnstile
48
+ declare global {
49
+ interface Window {
50
+ turnstile?: TurnstileWidget
51
+ }
52
+ }
53
+
54
+ interface CreateTurnstileSolverContext {
55
+ /**
56
+ * Required: Performance tracker for timing measurements.
57
+ * Must be injected - no implicit dependency on globalThis.performance.
58
+ */
59
+ performanceTracker: PerformanceTracker
60
+ /**
61
+ * Optional logger for debugging
62
+ */
63
+ getLogger?: () => Logger
64
+ /**
65
+ * Optional script injection options for CSP compliance
66
+ */
67
+ scriptOptions?: TurnstileScriptOptions
68
+ /**
69
+ * Widget rendering timeout in milliseconds
70
+ * @default 30000
71
+ */
72
+ timeoutMs?: number
73
+ /**
74
+ * Callback for analytics when solve completes (success or failure)
75
+ */
76
+ onSolveCompleted?: (data: TurnstileSolveAnalytics) => void
77
+ }
78
+
79
+ /**
80
+ * Classifies error into analytics error type
81
+ */
82
+ function classifyError(error: unknown): TurnstileSolveAnalytics['errorType'] {
83
+ if (error instanceof TurnstileTimeoutError || error instanceof TurnstileTokenExpiredError) {
84
+ return 'timeout'
85
+ }
86
+ if (error instanceof TurnstileScriptLoadError || error instanceof TurnstileApiNotAvailableError) {
87
+ return 'script_load'
88
+ }
89
+ if (error instanceof TurnstileError) {
90
+ return 'network'
91
+ }
92
+ if (error instanceof Error && error.message.includes('parse')) {
93
+ return 'validation'
94
+ }
95
+ if (error instanceof Error && error.message.includes('Missing')) {
96
+ return 'validation'
97
+ }
98
+ return 'unknown'
99
+ }
100
+
101
+ /**
102
+ * Creates a Turnstile challenge solver.
103
+ *
104
+ * This integrates with Cloudflare Turnstile using explicit rendering:
105
+ * - Dynamically loads Turnstile script if not present (with state management)
106
+ * - Creates a temporary DOM container
107
+ * - Renders widget with sitekey and action from challengeData.extra
108
+ * - Returns the verification token from Turnstile API
109
+ *
110
+ * Features:
111
+ * - Separation of concerns: Script loading separated from widget rendering
112
+ * - Dependency injection: Logger and script options injected via context
113
+ * - Contract-based design: Implements ChallengeSolver interface
114
+ * - Factory pattern: Returns solver instance, not component
115
+ */
116
+ function createTurnstileSolver(ctx: CreateTurnstileSolverContext): ChallengeSolver {
117
+ async function solve(challengeData: ChallengeData): Promise<string> {
118
+ const startTime = ctx.performanceTracker.now()
119
+
120
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Solving Turnstile challenge', { challengeData })
121
+
122
+ // Extract challenge data — prefer typed challengeData over legacy extra field
123
+ let siteKey: string
124
+ let action: string | undefined
125
+
126
+ if (challengeData.challengeData?.case === 'turnstile') {
127
+ siteKey = challengeData.challengeData.value.siteKey
128
+ action = challengeData.challengeData.value.action
129
+ } else {
130
+ // Fallback to legacy extra field
131
+ const challengeDataStr = challengeData.extra?.['challengeData']
132
+ if (!challengeDataStr) {
133
+ const error = new Error('Missing challengeData in challenge extra')
134
+ ctx.onSolveCompleted?.({
135
+ durationMs: ctx.performanceTracker.now() - startTime,
136
+ success: false,
137
+ errorType: 'validation',
138
+ errorMessage: error.message,
139
+ })
140
+ throw error
141
+ }
142
+
143
+ let parsedData: { siteKey: string; action: string }
144
+ try {
145
+ parsedData = JSON.parse(challengeDataStr)
146
+ } catch (error) {
147
+ const parseError = new Error('Failed to parse challengeData', { cause: error })
148
+ ctx.onSolveCompleted?.({
149
+ durationMs: ctx.performanceTracker.now() - startTime,
150
+ success: false,
151
+ errorType: 'validation',
152
+ errorMessage: parseError.message,
153
+ })
154
+ throw parseError
155
+ }
156
+
157
+ siteKey = parsedData.siteKey
158
+ action = parsedData.action
159
+ }
160
+
161
+ if (!siteKey) {
162
+ const error = new Error('Missing siteKey in challengeData')
163
+ ctx.onSolveCompleted?.({
164
+ durationMs: ctx.performanceTracker.now() - startTime,
165
+ success: false,
166
+ errorType: 'validation',
167
+ errorMessage: error.message,
168
+ })
169
+ throw error
170
+ }
171
+
172
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Parsed challengeData', { siteKey, action })
173
+
174
+ await ensureTurnstileScript(ctx.scriptOptions)
175
+
176
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Turnstile script loaded')
177
+
178
+ // Verify Turnstile API is available
179
+ if (!window.turnstile) {
180
+ const error = new TurnstileApiNotAvailableError()
181
+ ctx.onSolveCompleted?.({
182
+ durationMs: ctx.performanceTracker.now() - startTime,
183
+ success: false,
184
+ errorType: 'script_load',
185
+ errorMessage: error.message,
186
+ })
187
+ throw error
188
+ }
189
+
190
+ // Create temporary container for the widget
191
+ const containerId = `turnstile-${challengeData.challengeId}`
192
+ const container = document.createElement('div')
193
+ container.id = containerId
194
+ container.style.position = 'fixed'
195
+ container.style.top = '-9999px' // Hide off-screen
196
+ container.style.left = '-9999px'
197
+ container.setAttribute('aria-hidden', 'true') // Accessibility
198
+ document.body.appendChild(container)
199
+
200
+ const timeoutMs = ctx.timeoutMs ?? 30000
201
+ const cleanupState = {
202
+ timeoutId: null as ReturnType<typeof setTimeout> | null,
203
+ widgetId: null as string | null,
204
+ }
205
+
206
+ try {
207
+ // Wait for Turnstile to be ready and render widget
208
+ const token = await new Promise<string>((resolve, reject) => {
209
+ // Set up timeout with proper cleanup
210
+ cleanupState.timeoutId = setTimeout(() => {
211
+ if (cleanupState.widgetId && window.turnstile) {
212
+ try {
213
+ window.turnstile.remove(cleanupState.widgetId)
214
+ } catch {
215
+ // Ignore cleanup errors
216
+ }
217
+ }
218
+ reject(new TurnstileTimeoutError(timeoutMs))
219
+ }, timeoutMs)
220
+
221
+ // Helper function to render the widget
222
+ const renderWidget = (): void => {
223
+ if (!window.turnstile) {
224
+ reject(new TurnstileApiNotAvailableError())
225
+ return
226
+ }
227
+
228
+ try {
229
+ cleanupState.widgetId = window.turnstile.render(container, {
230
+ sitekey: siteKey,
231
+ action,
232
+ theme: 'light',
233
+ size: 'normal',
234
+ callback: (tokenValue: string) => {
235
+ if (cleanupState.timeoutId) {
236
+ clearTimeout(cleanupState.timeoutId)
237
+ cleanupState.timeoutId = null
238
+ }
239
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Turnstile token resolved', {
240
+ tokenValue,
241
+ })
242
+ resolve(tokenValue)
243
+ },
244
+ 'error-callback': (error: string) => {
245
+ if (cleanupState.timeoutId) {
246
+ clearTimeout(cleanupState.timeoutId)
247
+ cleanupState.timeoutId = null
248
+ }
249
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Turnstile error', { error })
250
+ reject(new TurnstileError(error))
251
+ },
252
+ 'expired-callback': () => {
253
+ if (cleanupState.timeoutId) {
254
+ clearTimeout(cleanupState.timeoutId)
255
+ cleanupState.timeoutId = null
256
+ }
257
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Turnstile token expired')
258
+ reject(new TurnstileTokenExpiredError())
259
+ },
260
+ })
261
+
262
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'Turnstile widget rendered', {
263
+ widgetId: cleanupState.widgetId,
264
+ })
265
+ } catch (error) {
266
+ if (cleanupState.timeoutId) {
267
+ clearTimeout(cleanupState.timeoutId)
268
+ cleanupState.timeoutId = null
269
+ }
270
+ ctx.getLogger?.().error(error, {
271
+ tags: {
272
+ file: 'createTurnstileSolver.ts',
273
+ function: 'solve',
274
+ },
275
+ })
276
+ reject(
277
+ new TurnstileError(`Failed to render widget: ${error instanceof Error ? error.message : String(error)}`),
278
+ )
279
+ }
280
+ }
281
+
282
+ if (!window.turnstile) {
283
+ reject(new TurnstileApiNotAvailableError())
284
+ return
285
+ }
286
+
287
+ try {
288
+ window.turnstile.ready(() => {
289
+ renderWidget()
290
+ })
291
+ } catch (error) {
292
+ // Fallback: render directly if ready() throws
293
+ ctx.getLogger?.().debug('createTurnstileSolver', 'solve', 'turnstile.ready() failed, rendering directly', {
294
+ error: error instanceof Error ? error.message : String(error),
295
+ })
296
+ renderWidget()
297
+ }
298
+ })
299
+
300
+ // Report success
301
+ const data: TurnstileSolveAnalytics = {
302
+ durationMs: ctx.performanceTracker.now() - startTime,
303
+ success: true,
304
+ }
305
+ ctx.onSolveCompleted?.(data)
306
+ ctx.getLogger?.().info('sessions', 'turnstileSolved', 'Turnstile solve completed', data)
307
+
308
+ return token
309
+ } catch (error) {
310
+ // Report failure
311
+ const data: TurnstileSolveAnalytics = {
312
+ durationMs: ctx.performanceTracker.now() - startTime,
313
+ success: false,
314
+ errorType: classifyError(error),
315
+ errorMessage: error instanceof Error ? error.message : String(error),
316
+ }
317
+ ctx.onSolveCompleted?.(data)
318
+ ctx.getLogger?.().warn('sessions', 'turnstileSolved', 'Turnstile solve failed', data)
319
+
320
+ ctx.getLogger?.().error(error, {
321
+ tags: {
322
+ file: 'createTurnstileSolver.ts',
323
+ function: 'solve',
324
+ },
325
+ })
326
+ throw error
327
+ } finally {
328
+ // Clean up timeout
329
+ if (cleanupState.timeoutId) {
330
+ clearTimeout(cleanupState.timeoutId)
331
+ }
332
+
333
+ // Clean up widget if it was created
334
+ // widgetId only exists if turnstile was successfully loaded and rendered
335
+ if (cleanupState.widgetId) {
336
+ try {
337
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
338
+ if (window.turnstile) {
339
+ window.turnstile.remove(cleanupState.widgetId)
340
+ }
341
+ } catch {
342
+ // Ignore cleanup errors
343
+ }
344
+ }
345
+
346
+ // Clean up container
347
+ if (container.parentNode) {
348
+ container.parentNode.removeChild(container)
349
+ }
350
+ }
351
+ }
352
+
353
+ return { solve }
354
+ }
355
+
356
+ export { createTurnstileSolver }
357
+ export type { CreateTurnstileSolverContext, TurnstileSolveAnalytics }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Native stub for hashcash core functions.
3
+ *
4
+ * Mobile does not use this file - it uses native Nitro modules
5
+ * (hashcash-native package) which bypass the JS hashcash implementation entirely.
6
+ *
7
+ * @see packages/hashcash-native for the native implementation
8
+ */
9
+
10
+ import { NotImplementedError } from 'utilities/src/errors'
11
+
12
+ export type { HashcashChallenge, ProofResult } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
13
+ // Re-export shared types and platform-agnostic functions
14
+ export { checkDifficulty, formatHashcashString } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
15
+
16
+ import type { HashcashChallenge, ProofResult } from '@l.x/sessions/src/challenge-solvers/hashcash/shared'
17
+
18
+ export async function computeHash(_params: { subject: string; nonce: string; counter: number }): Promise<Uint8Array> {
19
+ throw new NotImplementedError('computeHash - mobile uses native Nitro modules')
20
+ }
21
+
22
+ export async function findProof(_params: {
23
+ challenge: HashcashChallenge
24
+ rangeStart?: number
25
+ rangeSize?: number
26
+ shouldStop?: () => boolean
27
+ batchSize?: number
28
+ }): Promise<ProofResult | null> {
29
+ throw new NotImplementedError('findProof - mobile uses native Nitro modules')
30
+ }
31
+
32
+ export async function verifyProof(_challenge: HashcashChallenge, _proofCounter: string): Promise<boolean> {
33
+ throw new NotImplementedError('verifyProof - mobile uses native Nitro modules')
34
+ }