@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.
- package/.depcheckrc +20 -0
- package/.eslintrc.js +21 -0
- package/README.md +1 -0
- package/env.d.ts +12 -0
- package/package.json +50 -0
- package/project.json +42 -0
- package/src/challenge-solvers/createChallengeSolverService.ts +64 -0
- package/src/challenge-solvers/createHashcashMockSolver.ts +39 -0
- package/src/challenge-solvers/createHashcashSolver.test.ts +385 -0
- package/src/challenge-solvers/createHashcashSolver.ts +255 -0
- package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
- package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
- package/src/challenge-solvers/createTurnstileSolver.ts +353 -0
- package/src/challenge-solvers/hashcash/core.native.ts +34 -0
- package/src/challenge-solvers/hashcash/core.test.ts +314 -0
- package/src/challenge-solvers/hashcash/core.ts +35 -0
- package/src/challenge-solvers/hashcash/core.web.ts +123 -0
- package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.test.ts +195 -0
- package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.ts +120 -0
- package/src/challenge-solvers/hashcash/shared.ts +70 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.native.ts +22 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.ts +22 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.web.ts +212 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.native.ts +16 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.ts +16 -0
- package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.web.ts +97 -0
- package/src/challenge-solvers/hashcash/worker/hashcash.worker.ts +91 -0
- package/src/challenge-solvers/hashcash/worker/types.ts +69 -0
- package/src/challenge-solvers/turnstileErrors.ts +49 -0
- package/src/challenge-solvers/turnstileScriptLoader.ts +325 -0
- package/src/challenge-solvers/turnstileSolver.integration.test.ts +331 -0
- package/src/challenge-solvers/types.ts +55 -0
- package/src/challengeFlow.integration.test.ts +627 -0
- package/src/device-id/createDeviceIdService.ts +19 -0
- package/src/device-id/types.ts +11 -0
- package/src/index.ts +137 -0
- package/src/lux-identifier/createLuxIdentifierService.ts +19 -0
- package/src/lux-identifier/luxIdentifierQuery.ts +20 -0
- package/src/lux-identifier/types.ts +11 -0
- package/src/oauth-service/createOAuthService.ts +125 -0
- package/src/oauth-service/types.ts +104 -0
- package/src/performance/createNoopPerformanceTracker.ts +12 -0
- package/src/performance/createPerformanceTracker.ts +43 -0
- package/src/performance/index.ts +7 -0
- package/src/performance/types.ts +11 -0
- package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
- package/src/session-initialization/createSessionInitializationService.ts +193 -0
- package/src/session-initialization/sessionErrors.ts +32 -0
- package/src/session-repository/createSessionClient.ts +10 -0
- package/src/session-repository/createSessionRepository.test.ts +313 -0
- package/src/session-repository/createSessionRepository.ts +242 -0
- package/src/session-repository/errors.ts +22 -0
- package/src/session-repository/types.ts +289 -0
- package/src/session-service/createNoopSessionService.ts +24 -0
- package/src/session-service/createSessionService.test.ts +388 -0
- package/src/session-service/createSessionService.ts +61 -0
- package/src/session-service/types.ts +59 -0
- package/src/session-storage/createSessionStorage.ts +28 -0
- package/src/session-storage/types.ts +15 -0
- package/src/session.integration.test.ts +480 -0
- package/src/sessionLifecycle.integration.test.ts +264 -0
- package/src/test-utils/createLocalCookieTransport.ts +52 -0
- package/src/test-utils/createLocalHeaderTransport.ts +45 -0
- package/src/test-utils/mocks.ts +122 -0
- package/src/test-utils.ts +200 -0
- package/tsconfig.json +19 -0
- package/tsconfig.lint.json +8 -0
- package/tsconfig.spec.json +8 -0
- package/vitest.config.ts +20 -0
- package/vitest.integration.config.ts +14 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { findProof, type HashcashChallenge } from '@luxexchange/sessions/src/challenge-solvers/hashcash/core'
|
|
2
|
+
import type { HashcashWorkerChannelFactory } from '@luxexchange/sessions/src/challenge-solvers/hashcash/worker/types'
|
|
3
|
+
import type { ChallengeData, ChallengeSolver } from '@luxexchange/sessions/src/challenge-solvers/types'
|
|
4
|
+
import type { PerformanceTracker } from '@luxexchange/sessions/src/performance/types'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
/** Error type for analytics classification */
|
|
8
|
+
type HashcashErrorType = 'validation' | 'no_proof' | 'worker_busy' | 'unknown'
|
|
9
|
+
|
|
10
|
+
/** Base class for hashcash errors with typed errorType for reliable analytics classification */
|
|
11
|
+
class HashcashError extends Error {
|
|
12
|
+
readonly errorType: HashcashErrorType
|
|
13
|
+
|
|
14
|
+
constructor(message: string, errorType: HashcashErrorType) {
|
|
15
|
+
super(message)
|
|
16
|
+
this.name = 'HashcashError'
|
|
17
|
+
this.errorType = errorType
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Validation errors (parsing, missing data, invalid challenge format) */
|
|
22
|
+
class HashcashValidationError extends HashcashError {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message, 'validation')
|
|
25
|
+
this.name = 'HashcashValidationError'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Proof not found within allowed iterations */
|
|
30
|
+
class HashcashNoProofError extends HashcashError {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message, 'no_proof')
|
|
33
|
+
this.name = 'HashcashNoProofError'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Worker is busy processing another request */
|
|
38
|
+
class HashcashWorkerBusyError extends HashcashError {
|
|
39
|
+
constructor(message: string) {
|
|
40
|
+
super(message, 'worker_busy')
|
|
41
|
+
this.name = 'HashcashWorkerBusyError'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Analytics data for Hashcash solve attempts.
|
|
47
|
+
* Reported via onSolveCompleted callback.
|
|
48
|
+
*/
|
|
49
|
+
interface HashcashSolveAnalytics {
|
|
50
|
+
durationMs: number
|
|
51
|
+
success: boolean
|
|
52
|
+
errorType?: 'validation' | 'no_proof' | 'worker_busy' | 'unknown'
|
|
53
|
+
errorMessage?: string
|
|
54
|
+
/** The difficulty level of the challenge (number of leading zero bytes) */
|
|
55
|
+
difficulty: number
|
|
56
|
+
/** Number of hash iterations to find proof (undefined on failure) */
|
|
57
|
+
iterationCount?: number
|
|
58
|
+
/** Whether the worker was used for proof computation */
|
|
59
|
+
usedWorker: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Context for creating a hashcash solver.
|
|
64
|
+
*/
|
|
65
|
+
interface CreateHashcashSolverContext {
|
|
66
|
+
/**
|
|
67
|
+
* Required: Performance tracker for timing measurements.
|
|
68
|
+
* Must be injected - no implicit dependency on globalThis.performance.
|
|
69
|
+
*/
|
|
70
|
+
performanceTracker: PerformanceTracker
|
|
71
|
+
/**
|
|
72
|
+
* Factory function to create a worker channel.
|
|
73
|
+
* If provided, proof-of-work runs in a Web Worker (non-blocking).
|
|
74
|
+
* If not provided, falls back to main-thread execution (blocking).
|
|
75
|
+
*/
|
|
76
|
+
getWorkerChannel?: HashcashWorkerChannelFactory
|
|
77
|
+
/**
|
|
78
|
+
* Callback for analytics when solve completes (success or failure)
|
|
79
|
+
*/
|
|
80
|
+
onSolveCompleted?: (data: HashcashSolveAnalytics) => void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Zod schema for hashcash challenge validation
|
|
84
|
+
const HashcashChallengeSchema = z.object({
|
|
85
|
+
difficulty: z.number().int().nonnegative(),
|
|
86
|
+
subject: z.string().min(1),
|
|
87
|
+
algorithm: z.literal('sha256'),
|
|
88
|
+
nonce: z.string().min(1),
|
|
89
|
+
max_proof_length: z.number().int().positive().default(1000000),
|
|
90
|
+
verifier: z.string().optional(),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parses and validates hashcash challenge data from the backend.
|
|
95
|
+
* @param challengeDataStr - JSON string containing challenge data
|
|
96
|
+
* @returns Parsed and validated HashcashChallenge
|
|
97
|
+
* @throws {Error} If challenge data is invalid or missing required fields
|
|
98
|
+
*/
|
|
99
|
+
function parseHashcashChallenge(challengeDataStr: string): HashcashChallenge {
|
|
100
|
+
let parsedData: unknown
|
|
101
|
+
try {
|
|
102
|
+
parsedData = JSON.parse(challengeDataStr)
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new HashcashValidationError(`Failed to parse challenge JSON: ${error}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Validate with Zod
|
|
108
|
+
const result = HashcashChallengeSchema.safeParse(parsedData)
|
|
109
|
+
if (!result.success) {
|
|
110
|
+
// Get unique field paths from errors
|
|
111
|
+
const fieldPaths = new Set(
|
|
112
|
+
result.error.issues.filter((issue) => issue.path.length > 0).map((issue) => issue.path[0]),
|
|
113
|
+
)
|
|
114
|
+
if (fieldPaths.size === 1) {
|
|
115
|
+
// Single field-specific error
|
|
116
|
+
const fieldName = String(Array.from(fieldPaths)[0])
|
|
117
|
+
throw new HashcashValidationError(`Invalid challenge data: ${fieldName}`)
|
|
118
|
+
}
|
|
119
|
+
// General validation error (multiple fields or form-level errors)
|
|
120
|
+
throw new HashcashValidationError('Invalid challenge data')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return result.data
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Classifies error into analytics error type.
|
|
128
|
+
* Uses instanceof checks for typed errors (preferred), with string matching fallback for external errors.
|
|
129
|
+
*/
|
|
130
|
+
function classifyError(error: unknown): HashcashSolveAnalytics['errorType'] {
|
|
131
|
+
// Prefer typed error classification via instanceof
|
|
132
|
+
if (error instanceof HashcashError) {
|
|
133
|
+
return error.errorType
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Fallback to string matching for external or legacy errors
|
|
137
|
+
if (error instanceof Error) {
|
|
138
|
+
if (error.message.includes('parse') || error.message.includes('Invalid challenge')) {
|
|
139
|
+
return 'validation'
|
|
140
|
+
}
|
|
141
|
+
if (error.message.includes('Missing challengeData')) {
|
|
142
|
+
return 'validation'
|
|
143
|
+
}
|
|
144
|
+
if (error.message.includes('Failed to find valid proof')) {
|
|
145
|
+
return 'no_proof'
|
|
146
|
+
}
|
|
147
|
+
if (error.message.includes('busy')) {
|
|
148
|
+
return 'worker_busy'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return 'unknown'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Creates a real hashcash challenge solver that performs proof-of-work
|
|
156
|
+
* to solve hashcash challenges from the backend.
|
|
157
|
+
*
|
|
158
|
+
* @param ctx - Required context with performanceTracker and optional getWorkerChannel
|
|
159
|
+
*/
|
|
160
|
+
function createHashcashSolver(ctx: CreateHashcashSolverContext): ChallengeSolver {
|
|
161
|
+
const usedWorker = !!ctx.getWorkerChannel
|
|
162
|
+
|
|
163
|
+
async function solve(challengeData: ChallengeData): Promise<string> {
|
|
164
|
+
const startTime = ctx.performanceTracker.now()
|
|
165
|
+
let difficulty = 0 // Default, will be updated after parsing
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
let challenge: HashcashChallenge
|
|
169
|
+
|
|
170
|
+
// Prefer typed challengeData over legacy extra field
|
|
171
|
+
if (challengeData.challengeData?.case === 'hashcash') {
|
|
172
|
+
const typed = challengeData.challengeData.value
|
|
173
|
+
challenge = {
|
|
174
|
+
difficulty: typed.difficulty,
|
|
175
|
+
subject: typed.subject,
|
|
176
|
+
algorithm: typed.algorithm as 'sha256',
|
|
177
|
+
nonce: typed.nonce,
|
|
178
|
+
max_proof_length: typed.maxProofLength,
|
|
179
|
+
verifier: typed.verifier,
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// Fallback to legacy extra field
|
|
183
|
+
const challengeDataStr = challengeData.extra?.['challengeData']
|
|
184
|
+
if (!challengeDataStr) {
|
|
185
|
+
throw new HashcashValidationError('Missing challengeData in challenge extra field')
|
|
186
|
+
}
|
|
187
|
+
challenge = parseHashcashChallenge(challengeDataStr)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
difficulty = challenge.difficulty
|
|
191
|
+
|
|
192
|
+
const findProofParams = {
|
|
193
|
+
challenge,
|
|
194
|
+
rangeStart: 0,
|
|
195
|
+
rangeSize: challenge.max_proof_length,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Use worker if provided, otherwise fall back to main thread
|
|
199
|
+
let proof
|
|
200
|
+
if (ctx.getWorkerChannel) {
|
|
201
|
+
const workerChannel = ctx.getWorkerChannel()
|
|
202
|
+
try {
|
|
203
|
+
proof = await workerChannel.api.findProof(findProofParams)
|
|
204
|
+
} finally {
|
|
205
|
+
workerChannel.terminate()
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
// Fallback to main-thread execution (still async for Web Crypto)
|
|
209
|
+
proof = await findProof(findProofParams)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!proof) {
|
|
213
|
+
throw new HashcashNoProofError(
|
|
214
|
+
`Failed to find valid proof within allowed range (0-${challenge.max_proof_length}). ` +
|
|
215
|
+
'Challenge may have expired or difficulty may be too high.',
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Report success
|
|
220
|
+
ctx.onSolveCompleted?.({
|
|
221
|
+
durationMs: ctx.performanceTracker.now() - startTime,
|
|
222
|
+
success: true,
|
|
223
|
+
difficulty,
|
|
224
|
+
iterationCount: proof.attempts,
|
|
225
|
+
usedWorker,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Return the solution in the format expected by backend: "${subject}:${nonce}:${counter}"
|
|
229
|
+
return `${challenge.subject}:${challenge.nonce}:${proof.counter}`
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// Report failure
|
|
232
|
+
ctx.onSolveCompleted?.({
|
|
233
|
+
durationMs: ctx.performanceTracker.now() - startTime,
|
|
234
|
+
success: false,
|
|
235
|
+
errorType: classifyError(error),
|
|
236
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
237
|
+
difficulty,
|
|
238
|
+
usedWorker,
|
|
239
|
+
})
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { solve }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export {
|
|
248
|
+
createHashcashSolver,
|
|
249
|
+
parseHashcashChallenge,
|
|
250
|
+
HashcashError,
|
|
251
|
+
HashcashValidationError,
|
|
252
|
+
HashcashNoProofError,
|
|
253
|
+
HashcashWorkerBusyError,
|
|
254
|
+
}
|
|
255
|
+
export type { HashcashSolveAnalytics, CreateHashcashSolverContext }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ChallengeData, ChallengeSolver } from '@luxfi/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 '@luxfi/sessions/src/challenge-solvers/types'
|
|
2
|
+
import { sleep } from '@luxfi/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,353 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TurnstileApiNotAvailableError,
|
|
3
|
+
TurnstileError,
|
|
4
|
+
TurnstileScriptLoadError,
|
|
5
|
+
TurnstileTimeoutError,
|
|
6
|
+
TurnstileTokenExpiredError,
|
|
7
|
+
} from '@luxexchange/sessions/src/challenge-solvers/turnstileErrors'
|
|
8
|
+
import { ensureTurnstileScript } from '@luxexchange/sessions/src/challenge-solvers/turnstileScriptLoader'
|
|
9
|
+
import type {
|
|
10
|
+
ChallengeData,
|
|
11
|
+
ChallengeSolver,
|
|
12
|
+
TurnstileScriptOptions,
|
|
13
|
+
} from '@luxexchange/sessions/src/challenge-solvers/types'
|
|
14
|
+
import type { PerformanceTracker } from '@luxexchange/sessions/src/performance/types'
|
|
15
|
+
import type { Logger } from '@luxfi/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
|
+
ctx.onSolveCompleted?.({
|
|
302
|
+
durationMs: ctx.performanceTracker.now() - startTime,
|
|
303
|
+
success: true,
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
return token
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// Report failure
|
|
309
|
+
ctx.onSolveCompleted?.({
|
|
310
|
+
durationMs: ctx.performanceTracker.now() - startTime,
|
|
311
|
+
success: false,
|
|
312
|
+
errorType: classifyError(error),
|
|
313
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
ctx.getLogger?.().error(error, {
|
|
317
|
+
tags: {
|
|
318
|
+
file: 'createTurnstileSolver.ts',
|
|
319
|
+
function: 'solve',
|
|
320
|
+
},
|
|
321
|
+
})
|
|
322
|
+
throw error
|
|
323
|
+
} finally {
|
|
324
|
+
// Clean up timeout
|
|
325
|
+
if (cleanupState.timeoutId) {
|
|
326
|
+
clearTimeout(cleanupState.timeoutId)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Clean up widget if it was created
|
|
330
|
+
// widgetId only exists if turnstile was successfully loaded and rendered
|
|
331
|
+
if (cleanupState.widgetId) {
|
|
332
|
+
try {
|
|
333
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
334
|
+
if (window.turnstile) {
|
|
335
|
+
window.turnstile.remove(cleanupState.widgetId)
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
// Ignore cleanup errors
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Clean up container
|
|
343
|
+
if (container.parentNode) {
|
|
344
|
+
container.parentNode.removeChild(container)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { solve }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export { createTurnstileSolver }
|
|
353
|
+
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 '@luxfi/utilities/src/errors'
|
|
11
|
+
|
|
12
|
+
export type { HashcashChallenge, ProofResult } from '@luxexchange/sessions/src/challenge-solvers/hashcash/shared'
|
|
13
|
+
// Re-export shared types and platform-agnostic functions
|
|
14
|
+
export { checkDifficulty, formatHashcashString } from '@luxexchange/sessions/src/challenge-solvers/hashcash/shared'
|
|
15
|
+
|
|
16
|
+
import type { HashcashChallenge, ProofResult } from '@luxexchange/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
|
+
}
|