@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.
- package/.depcheckrc +20 -0
- package/.eslintrc.js +21 -0
- package/LICENSE +122 -0
- package/README.md +1 -0
- package/env.d.ts +12 -0
- package/package.json +49 -1
- package/project.json +36 -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 +270 -0
- package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
- package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
- package/src/challenge-solvers/createTurnstileSolver.ts +357 -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 +139 -0
- package/src/lx-identifier/createLXIdentifierService.ts +1 -0
- package/src/lx-identifier/createUniswapIdentifierService.ts +19 -0
- package/src/lx-identifier/lxIdentifierQuery.ts +1 -0
- package/src/lx-identifier/types.ts +11 -0
- package/src/lx-identifier/uniswapIdentifierQuery.ts +20 -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 +184 -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 +516 -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/src/uniswap-identifier/createUniswapIdentifierService.ts +19 -0
- package/src/uniswap-identifier/types.ts +11 -0
- package/src/uniswap-identifier/uniswapIdentifierQuery.ts +20 -0
- package/tsconfig.json +26 -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
- package/index.d.ts +0 -1
- package/index.js +0 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkDifficulty,
|
|
3
|
+
computeHash,
|
|
4
|
+
findProof,
|
|
5
|
+
formatHashcashString,
|
|
6
|
+
type HashcashChallenge,
|
|
7
|
+
verifyProof,
|
|
8
|
+
} from '@l.x/sessions/src/challenge-solvers/hashcash/core'
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
describe('hashcash core', () => {
|
|
12
|
+
// Backend example data for testing
|
|
13
|
+
const backendExample: HashcashChallenge = {
|
|
14
|
+
difficulty: 1,
|
|
15
|
+
subject: 'Lx',
|
|
16
|
+
algorithm: 'sha256',
|
|
17
|
+
nonce: 'Qlquffem7d8RrL6fmveE68XK0KxcoczdiVpFrV1qeUk=',
|
|
18
|
+
max_proof_length: 1000,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Basic smoke test
|
|
22
|
+
it('exports all required functions', () => {
|
|
23
|
+
expect(checkDifficulty).toBeDefined()
|
|
24
|
+
expect(computeHash).toBeDefined()
|
|
25
|
+
expect(findProof).toBeDefined()
|
|
26
|
+
expect(verifyProof).toBeDefined()
|
|
27
|
+
expect(formatHashcashString).toBeDefined()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('checkDifficulty', () => {
|
|
31
|
+
it('validates difficulty 1 (1 full zero byte)', () => {
|
|
32
|
+
// Backend treats difficulty as number of zero BYTES
|
|
33
|
+
// difficulty=1 means first byte must be 0
|
|
34
|
+
const validHash = new Uint8Array([0, 255, 255, 255])
|
|
35
|
+
expect(checkDifficulty(validHash, 1)).toBe(true)
|
|
36
|
+
|
|
37
|
+
const invalidHash = new Uint8Array([1, 255, 255, 255])
|
|
38
|
+
expect(checkDifficulty(invalidHash, 1)).toBe(false)
|
|
39
|
+
|
|
40
|
+
const invalidHash2 = new Uint8Array([0b00000001, 255, 255, 255]) // Even 1 bit set fails
|
|
41
|
+
expect(checkDifficulty(invalidHash2, 1)).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('validates difficulty 2 (2 full zero bytes)', () => {
|
|
45
|
+
// First two bytes must be all zeros
|
|
46
|
+
const validHash = new Uint8Array([0, 0, 255, 255])
|
|
47
|
+
expect(checkDifficulty(validHash, 2)).toBe(true)
|
|
48
|
+
|
|
49
|
+
const invalidHash = new Uint8Array([0, 1, 255, 255]) // Second byte not zero
|
|
50
|
+
expect(checkDifficulty(invalidHash, 2)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('validates difficulty 0 (no requirement)', () => {
|
|
54
|
+
const anyHash = new Uint8Array([255, 255, 255, 255])
|
|
55
|
+
expect(checkDifficulty(anyHash, 0)).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('computeHash', () => {
|
|
60
|
+
it('produces consistent SHA-256 hashes', async () => {
|
|
61
|
+
const params = {
|
|
62
|
+
subject: 'test',
|
|
63
|
+
nonce: 'AQIDBA==', // Base64 string
|
|
64
|
+
counter: 42,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const hash1 = await computeHash(params)
|
|
68
|
+
const hash2 = await computeHash(params)
|
|
69
|
+
|
|
70
|
+
expect(hash1).toEqual(hash2)
|
|
71
|
+
expect(hash1.length).toBe(32) // SHA-256 is 32 bytes
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('works with real backend nonce', async () => {
|
|
75
|
+
const hash = await computeHash({
|
|
76
|
+
subject: backendExample.subject,
|
|
77
|
+
nonce: backendExample.nonce, // Use nonce string directly
|
|
78
|
+
counter: 0,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(hash).toBeDefined()
|
|
82
|
+
expect(hash.length).toBe(32)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('hashes colon-separated string format like backend', async () => {
|
|
86
|
+
// Test that we're using the backend's expected format: "${subject}:${nonce}:${counter}"
|
|
87
|
+
const nonceString = 'AQIDBA=='
|
|
88
|
+
|
|
89
|
+
const hash = await computeHash({
|
|
90
|
+
subject: 'Lx',
|
|
91
|
+
nonce: nonceString,
|
|
92
|
+
counter: 123,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// The hash should be of the string "Lx:AQIDBA==:123"
|
|
96
|
+
expect(hash).toBeDefined()
|
|
97
|
+
expect(hash.length).toBe(32)
|
|
98
|
+
|
|
99
|
+
// Verify the expected string format
|
|
100
|
+
const expectedString = `Lx:${nonceString}:123`
|
|
101
|
+
expect(expectedString).toBe('Lx:AQIDBA==:123')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('matches known SHA-256 test vector', async () => {
|
|
105
|
+
// computeHash("Lx:AQIDBA==:123") verified against @noble/hashes/webcrypto SHA-256
|
|
106
|
+
const hash = await computeHash({
|
|
107
|
+
subject: 'Lx',
|
|
108
|
+
nonce: 'AQIDBA==',
|
|
109
|
+
counter: 123,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const expectedHex = '222c2db479a1ff907a329fc3ff8e99b1f19f0695d938b74ccd95323b9c853510'
|
|
113
|
+
const actualHex = Array.from(hash)
|
|
114
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
115
|
+
.join('')
|
|
116
|
+
|
|
117
|
+
expect(actualHex).toBe(expectedHex)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('findProof', () => {
|
|
122
|
+
it('finds a valid proof for difficulty 1', async () => {
|
|
123
|
+
const challenge: HashcashChallenge = {
|
|
124
|
+
...backendExample,
|
|
125
|
+
difficulty: 1,
|
|
126
|
+
max_proof_length: 10000, // Increase range for testing
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const proof = await findProof({ challenge })
|
|
130
|
+
|
|
131
|
+
expect(proof).not.toBeNull()
|
|
132
|
+
if (proof) {
|
|
133
|
+
expect(proof.counter).toBeDefined()
|
|
134
|
+
expect(proof.hash).toBeDefined()
|
|
135
|
+
expect(proof.attempts).toBeGreaterThan(0)
|
|
136
|
+
expect(checkDifficulty(proof.hash, 1)).toBe(true)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('respects max_proof_length limit', async () => {
|
|
141
|
+
const challenge: HashcashChallenge = {
|
|
142
|
+
...backendExample,
|
|
143
|
+
difficulty: 20, // High difficulty unlikely to be found
|
|
144
|
+
max_proof_length: 100,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const proof = await findProof({
|
|
148
|
+
challenge,
|
|
149
|
+
rangeSize: challenge.max_proof_length,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Should return null since difficulty 20 (20 zero bytes) is impossible in 100 attempts
|
|
153
|
+
expect(proof).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns null when shouldStop signals cancellation', async () => {
|
|
157
|
+
let calls = 0
|
|
158
|
+
const shouldStop = (): boolean => {
|
|
159
|
+
calls++
|
|
160
|
+
// Stop after the first batch boundary check
|
|
161
|
+
return calls >= 2
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const challenge: HashcashChallenge = {
|
|
165
|
+
...backendExample,
|
|
166
|
+
difficulty: 20, // High difficulty so it won't find a proof naturally
|
|
167
|
+
max_proof_length: 100_000,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const proof = await findProof({
|
|
171
|
+
challenge,
|
|
172
|
+
shouldStop,
|
|
173
|
+
batchSize: 64,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
expect(proof).toBeNull()
|
|
177
|
+
// shouldStop was called at least twice (once to pass, once to cancel)
|
|
178
|
+
expect(calls).toBeGreaterThanOrEqual(2)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('finds proof with custom rangeStart', async () => {
|
|
182
|
+
// First, find a valid proof starting from 0
|
|
183
|
+
const challenge: HashcashChallenge = {
|
|
184
|
+
...backendExample,
|
|
185
|
+
difficulty: 1,
|
|
186
|
+
max_proof_length: 10000,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const baseProof = await findProof({ challenge })
|
|
190
|
+
expect(baseProof).not.toBeNull()
|
|
191
|
+
|
|
192
|
+
if (baseProof) {
|
|
193
|
+
const knownCounter = parseInt(baseProof.counter)
|
|
194
|
+
|
|
195
|
+
// Now search again starting from that known counter
|
|
196
|
+
const proof = await findProof({
|
|
197
|
+
challenge,
|
|
198
|
+
rangeStart: knownCounter,
|
|
199
|
+
rangeSize: 1, // Only check the one counter
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
expect(proof).not.toBeNull()
|
|
203
|
+
expect(proof!.counter).toBe(knownCounter.toString())
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('verifyProof', () => {
|
|
209
|
+
it('verifies a valid proof', async () => {
|
|
210
|
+
const challenge: HashcashChallenge = {
|
|
211
|
+
...backendExample,
|
|
212
|
+
difficulty: 1,
|
|
213
|
+
max_proof_length: 10000,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// First find a proof
|
|
217
|
+
const proof = await findProof({ challenge })
|
|
218
|
+
expect(proof).not.toBeNull()
|
|
219
|
+
|
|
220
|
+
if (proof) {
|
|
221
|
+
// Then verify it
|
|
222
|
+
const isValid = await verifyProof(challenge, proof.counter)
|
|
223
|
+
expect(isValid).toBe(true)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('rejects an invalid proof', async () => {
|
|
228
|
+
// Test with non-numeric counter (always invalid)
|
|
229
|
+
expect(await verifyProof(backendExample, 'not-a-number')).toBe(false)
|
|
230
|
+
|
|
231
|
+
// Test with higher difficulty where most counters are invalid
|
|
232
|
+
const higherDifficultyChallenge = {
|
|
233
|
+
...backendExample,
|
|
234
|
+
difficulty: 16, // 2 full zero bytes required
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// This counter is very unlikely to produce 2 zero bytes
|
|
238
|
+
const isValid = await verifyProof(higherDifficultyChallenge, '12345')
|
|
239
|
+
expect(isValid).toBe(false)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('formatHashcashString', () => {
|
|
244
|
+
it('formats hashcash string correctly', () => {
|
|
245
|
+
const proof = {
|
|
246
|
+
counter: '123',
|
|
247
|
+
hash: new Uint8Array(32).fill(0),
|
|
248
|
+
attempts: 123,
|
|
249
|
+
timeMs: 10,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const formatted = formatHashcashString(backendExample, proof)
|
|
253
|
+
|
|
254
|
+
// Check format: version:bits:date:resource:extension:counter:hash
|
|
255
|
+
const parts = formatted.split(':')
|
|
256
|
+
|
|
257
|
+
expect(parts.length).toBe(7)
|
|
258
|
+
expect(parts[0]).toBe('1') // Version
|
|
259
|
+
expect(parts[1]).toBe('1') // Difficulty
|
|
260
|
+
expect(parts[2]).toMatch(/^\d{6}$/) // Date format YYMMDD (always 6 digits)
|
|
261
|
+
expect(parts[3]).toBe('Lx') // Resource
|
|
262
|
+
expect(parts[4]).toBe('') // Extension (empty)
|
|
263
|
+
expect(parts[5]).toBe('123') // Counter
|
|
264
|
+
expect(parts[6]).toMatch(/^[A-Za-z0-9+/=]+$/) // Base64 hash
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('integration with backend example', () => {
|
|
269
|
+
it('completes full hashcash flow with real backend data', async () => {
|
|
270
|
+
const challenge: HashcashChallenge = {
|
|
271
|
+
...backendExample,
|
|
272
|
+
max_proof_length: 10000, // Give enough range to find solution
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Step 1: Find proof
|
|
276
|
+
const proof = await findProof({ challenge })
|
|
277
|
+
expect(proof).not.toBeNull()
|
|
278
|
+
|
|
279
|
+
if (proof) {
|
|
280
|
+
// Step 2: Verify the proof is valid
|
|
281
|
+
const isValid = await verifyProof(challenge, proof.counter)
|
|
282
|
+
expect(isValid).toBe(true)
|
|
283
|
+
|
|
284
|
+
// Step 3: Check the hash meets difficulty requirement
|
|
285
|
+
expect(checkDifficulty(proof.hash, challenge.difficulty)).toBe(true)
|
|
286
|
+
|
|
287
|
+
// Step 4: Format for submission
|
|
288
|
+
const hashcashString = formatHashcashString(challenge, proof)
|
|
289
|
+
expect(hashcashString).toBeTruthy()
|
|
290
|
+
|
|
291
|
+
// Verify format includes our subject
|
|
292
|
+
expect(hashcashString).toContain('Lx')
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('handles backend difficulty vs verifier discrepancy', async () => {
|
|
297
|
+
// The backend example shows difficulty: 1
|
|
298
|
+
// But the verifier checks: hash.slice(0,1).every(x => x === 0)
|
|
299
|
+
// which actually requires the first byte to be 0 (difficulty 8)
|
|
300
|
+
|
|
301
|
+
const challenge = backendExample
|
|
302
|
+
const proof = await findProof({
|
|
303
|
+
challenge,
|
|
304
|
+
rangeSize: 10000,
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
if (proof) {
|
|
308
|
+
// Check our difficulty 1 validation
|
|
309
|
+
const meetsSpecifiedDifficulty = checkDifficulty(proof.hash, 1)
|
|
310
|
+
expect(meetsSpecifiedDifficulty).toBe(true)
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
})
|