@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,385 @@
|
|
|
1
|
+
import { ChallengeType } from '@luxamm/client-platform-service/dist/lx/platformservice/v1/sessionService_pb'
|
|
2
|
+
import { createHashcashSolver } from '@l.x/sessions/src/challenge-solvers/createHashcashSolver'
|
|
3
|
+
import type { HashcashWorkerChannel } from '@l.x/sessions/src/challenge-solvers/hashcash/worker/types'
|
|
4
|
+
import type { ChallengeData } from '@l.x/sessions/src/challenge-solvers/types'
|
|
5
|
+
import type { PerformanceTracker } from '@l.x/sessions/src/performance/types'
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
// Mock performance tracker for testing
|
|
9
|
+
function createMockPerformanceTracker(): PerformanceTracker {
|
|
10
|
+
let time = 0
|
|
11
|
+
return {
|
|
12
|
+
now: (): number => {
|
|
13
|
+
time += 100
|
|
14
|
+
return time
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('createHashcashSolver', () => {
|
|
20
|
+
const mockPerformanceTracker = createMockPerformanceTracker()
|
|
21
|
+
const solver = createHashcashSolver({ performanceTracker: mockPerformanceTracker })
|
|
22
|
+
|
|
23
|
+
// Real backend example data
|
|
24
|
+
const backendExample = {
|
|
25
|
+
difficulty: 1,
|
|
26
|
+
subject: 'Lx',
|
|
27
|
+
algorithm: 'sha256' as const,
|
|
28
|
+
nonce: 'Qlquffem7d8RrL6fmveE68XK0KxcoczdiVpFrV1qeUk=',
|
|
29
|
+
max_proof_length: 10000,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('successfully solves a valid challenge', async () => {
|
|
33
|
+
const challengeData: ChallengeData = {
|
|
34
|
+
challengeId: 'test-challenge-123',
|
|
35
|
+
challengeType: ChallengeType.HASHCASH,
|
|
36
|
+
extra: {
|
|
37
|
+
challengeData: JSON.stringify(backendExample),
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const solution = await solver.solve(challengeData)
|
|
42
|
+
|
|
43
|
+
// Check format: "${subject}:${nonce}:${counter}"
|
|
44
|
+
expect(solution).toMatch(/^Lx:[A-Za-z0-9+/=]+:\d+$/)
|
|
45
|
+
expect(solution.startsWith('Lx:')).toBe(true)
|
|
46
|
+
expect(solution).toContain(backendExample.nonce) // Should contain the base64 nonce
|
|
47
|
+
|
|
48
|
+
// Verify the solution has all three parts
|
|
49
|
+
const parts = solution.split(':')
|
|
50
|
+
expect(parts.length).toBe(3)
|
|
51
|
+
expect(parts[0]).toBe('Lx')
|
|
52
|
+
expect(parts[1]).toBe(backendExample.nonce)
|
|
53
|
+
expect(Number.parseInt(parts[2], 10)).toBeGreaterThanOrEqual(0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('throws error when challengeData is missing', async () => {
|
|
57
|
+
const challengeData: ChallengeData = {
|
|
58
|
+
challengeId: 'test-challenge-123',
|
|
59
|
+
challengeType: ChallengeType.HASHCASH,
|
|
60
|
+
extra: {}, // Missing challengeData
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Missing challengeData in challenge extra field')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('throws error when challengeData is not valid JSON', async () => {
|
|
67
|
+
const challengeData: ChallengeData = {
|
|
68
|
+
challengeId: 'test-challenge-123',
|
|
69
|
+
challengeType: ChallengeType.HASHCASH,
|
|
70
|
+
extra: {
|
|
71
|
+
challengeData: 'not-valid-json{',
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Failed to parse challenge JSON')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('throws error when required fields are missing', async () => {
|
|
79
|
+
const invalidChallenge = {
|
|
80
|
+
difficulty: 1,
|
|
81
|
+
// Missing: nonce, subject, algorithm
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const challengeData: ChallengeData = {
|
|
85
|
+
challengeId: 'test-challenge-123',
|
|
86
|
+
challengeType: ChallengeType.HASHCASH,
|
|
87
|
+
extra: {
|
|
88
|
+
challengeData: JSON.stringify(invalidChallenge),
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Zod will report all missing fields at once
|
|
93
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Invalid challenge data')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('throws error when algorithm is not sha256', async () => {
|
|
97
|
+
const invalidChallenge = {
|
|
98
|
+
...backendExample,
|
|
99
|
+
algorithm: 'sha512' as any, // Wrong algorithm
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const challengeData: ChallengeData = {
|
|
103
|
+
challengeId: 'test-challenge-123',
|
|
104
|
+
challengeType: ChallengeType.HASHCASH,
|
|
105
|
+
extra: {
|
|
106
|
+
challengeData: JSON.stringify(invalidChallenge),
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Zod will report the literal mismatch
|
|
111
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Invalid challenge data: algorithm')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('handles high difficulty that cannot be solved', async () => {
|
|
115
|
+
const impossibleChallenge = {
|
|
116
|
+
...backendExample,
|
|
117
|
+
difficulty: 30, // Very high difficulty
|
|
118
|
+
max_proof_length: 10, // Very small search space
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const challengeData: ChallengeData = {
|
|
122
|
+
challengeId: 'test-challenge-123',
|
|
123
|
+
challengeType: ChallengeType.HASHCASH,
|
|
124
|
+
extra: {
|
|
125
|
+
challengeData: JSON.stringify(impossibleChallenge),
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Failed to find valid proof within allowed range')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('handles missing extra field gracefully', async () => {
|
|
133
|
+
const challengeData: ChallengeData = {
|
|
134
|
+
challengeId: 'test-challenge-123',
|
|
135
|
+
challengeType: ChallengeType.HASHCASH,
|
|
136
|
+
// No extra field at all
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await expect(solver.solve(challengeData)).rejects.toThrow('Missing challengeData in challenge extra field')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('analytics callback', () => {
|
|
143
|
+
it('reports success with difficulty and iteration count', async () => {
|
|
144
|
+
const onSolveCompleted = vi.fn()
|
|
145
|
+
const solverWithAnalytics = createHashcashSolver({
|
|
146
|
+
performanceTracker: createMockPerformanceTracker(),
|
|
147
|
+
onSolveCompleted,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const challengeData: ChallengeData = {
|
|
151
|
+
challengeId: 'test-challenge-123',
|
|
152
|
+
challengeType: ChallengeType.HASHCASH,
|
|
153
|
+
extra: {
|
|
154
|
+
challengeData: JSON.stringify(backendExample),
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await solverWithAnalytics.solve(challengeData)
|
|
159
|
+
|
|
160
|
+
expect(onSolveCompleted).toHaveBeenCalledWith(
|
|
161
|
+
expect.objectContaining({
|
|
162
|
+
success: true,
|
|
163
|
+
difficulty: 1,
|
|
164
|
+
usedWorker: false,
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
expect(onSolveCompleted.mock.calls[0][0].iterationCount).toBeGreaterThan(0)
|
|
168
|
+
expect(onSolveCompleted.mock.calls[0][0].durationMs).toBeGreaterThanOrEqual(0)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('reports failure with validation error type', async () => {
|
|
172
|
+
const onSolveCompleted = vi.fn()
|
|
173
|
+
const solverWithAnalytics = createHashcashSolver({
|
|
174
|
+
performanceTracker: createMockPerformanceTracker(),
|
|
175
|
+
onSolveCompleted,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const challengeData: ChallengeData = {
|
|
179
|
+
challengeId: 'test-challenge-123',
|
|
180
|
+
challengeType: ChallengeType.HASHCASH,
|
|
181
|
+
extra: {}, // Missing challengeData
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await expect(solverWithAnalytics.solve(challengeData)).rejects.toThrow()
|
|
185
|
+
|
|
186
|
+
expect(onSolveCompleted).toHaveBeenCalledWith(
|
|
187
|
+
expect.objectContaining({
|
|
188
|
+
success: false,
|
|
189
|
+
errorType: 'validation',
|
|
190
|
+
usedWorker: false,
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('reports failure when proof cannot be found', async () => {
|
|
196
|
+
const onSolveCompleted = vi.fn()
|
|
197
|
+
const solverWithAnalytics = createHashcashSolver({
|
|
198
|
+
performanceTracker: createMockPerformanceTracker(),
|
|
199
|
+
onSolveCompleted,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const impossibleChallenge = {
|
|
203
|
+
...backendExample,
|
|
204
|
+
difficulty: 30,
|
|
205
|
+
max_proof_length: 10,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const challengeData: ChallengeData = {
|
|
209
|
+
challengeId: 'test-challenge-123',
|
|
210
|
+
challengeType: ChallengeType.HASHCASH,
|
|
211
|
+
extra: {
|
|
212
|
+
challengeData: JSON.stringify(impossibleChallenge),
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await expect(solverWithAnalytics.solve(challengeData)).rejects.toThrow()
|
|
217
|
+
|
|
218
|
+
expect(onSolveCompleted).toHaveBeenCalledWith(
|
|
219
|
+
expect.objectContaining({
|
|
220
|
+
success: false,
|
|
221
|
+
errorType: 'no_proof',
|
|
222
|
+
difficulty: 30,
|
|
223
|
+
}),
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('reports usedWorker: true when worker is provided', async () => {
|
|
228
|
+
const onSolveCompleted = vi.fn()
|
|
229
|
+
const mockWorkerChannel: HashcashWorkerChannel = {
|
|
230
|
+
api: {
|
|
231
|
+
findProof: vi.fn().mockResolvedValue({ counter: 42, hash: new Uint8Array([0]), attempts: 100 }),
|
|
232
|
+
cancel: vi.fn(),
|
|
233
|
+
},
|
|
234
|
+
terminate: vi.fn(),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const solverWithWorker = createHashcashSolver({
|
|
238
|
+
performanceTracker: createMockPerformanceTracker(),
|
|
239
|
+
getWorkerChannel: () => mockWorkerChannel,
|
|
240
|
+
onSolveCompleted,
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
const challengeData: ChallengeData = {
|
|
244
|
+
challengeId: 'test-challenge-123',
|
|
245
|
+
challengeType: ChallengeType.HASHCASH,
|
|
246
|
+
extra: {
|
|
247
|
+
challengeData: JSON.stringify(backendExample),
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await solverWithWorker.solve(challengeData)
|
|
252
|
+
|
|
253
|
+
expect(onSolveCompleted).toHaveBeenCalledWith(
|
|
254
|
+
expect.objectContaining({
|
|
255
|
+
success: true,
|
|
256
|
+
usedWorker: true,
|
|
257
|
+
}),
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('with worker channel', () => {
|
|
263
|
+
it('uses worker channel when provided', async () => {
|
|
264
|
+
const mockFindProof = vi.fn().mockResolvedValue({ counter: 42, hash: new Uint8Array([0]) })
|
|
265
|
+
const mockTerminate = vi.fn()
|
|
266
|
+
|
|
267
|
+
const mockWorkerChannel: HashcashWorkerChannel = {
|
|
268
|
+
api: {
|
|
269
|
+
findProof: mockFindProof,
|
|
270
|
+
cancel: vi.fn(),
|
|
271
|
+
},
|
|
272
|
+
terminate: mockTerminate,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const solverWithWorker = createHashcashSolver({
|
|
276
|
+
performanceTracker: createMockPerformanceTracker(),
|
|
277
|
+
getWorkerChannel: () => mockWorkerChannel,
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const challengeData: ChallengeData = {
|
|
281
|
+
challengeId: 'test-challenge-123',
|
|
282
|
+
challengeType: ChallengeType.HASHCASH,
|
|
283
|
+
extra: {
|
|
284
|
+
challengeData: JSON.stringify(backendExample),
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const solution = await solverWithWorker.solve(challengeData)
|
|
289
|
+
|
|
290
|
+
expect(mockFindProof).toHaveBeenCalledWith({
|
|
291
|
+
challenge: backendExample,
|
|
292
|
+
rangeStart: 0,
|
|
293
|
+
rangeSize: backendExample.max_proof_length,
|
|
294
|
+
})
|
|
295
|
+
expect(mockTerminate).toHaveBeenCalled()
|
|
296
|
+
expect(solution).toBe(`Lx:${backendExample.nonce}:42`)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('terminates worker channel even on error', async () => {
|
|
300
|
+
const mockTerminate = vi.fn()
|
|
301
|
+
|
|
302
|
+
const mockWorkerChannel: HashcashWorkerChannel = {
|
|
303
|
+
api: {
|
|
304
|
+
findProof: vi.fn().mockRejectedValue(new Error('Worker error')),
|
|
305
|
+
cancel: vi.fn(),
|
|
306
|
+
},
|
|
307
|
+
terminate: mockTerminate,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const solverWithWorker = createHashcashSolver({
|
|
311
|
+
performanceTracker: createMockPerformanceTracker(),
|
|
312
|
+
getWorkerChannel: () => mockWorkerChannel,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const challengeData: ChallengeData = {
|
|
316
|
+
challengeId: 'test-challenge-123',
|
|
317
|
+
challengeType: ChallengeType.HASHCASH,
|
|
318
|
+
extra: {
|
|
319
|
+
challengeData: JSON.stringify(backendExample),
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await expect(solverWithWorker.solve(challengeData)).rejects.toThrow('Worker error')
|
|
324
|
+
expect(mockTerminate).toHaveBeenCalled()
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('throws error when worker returns null', async () => {
|
|
328
|
+
const mockTerminate = vi.fn()
|
|
329
|
+
|
|
330
|
+
const mockWorkerChannel: HashcashWorkerChannel = {
|
|
331
|
+
api: {
|
|
332
|
+
findProof: vi.fn().mockResolvedValue(null),
|
|
333
|
+
cancel: vi.fn(),
|
|
334
|
+
},
|
|
335
|
+
terminate: mockTerminate,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const solverWithWorker = createHashcashSolver({
|
|
339
|
+
performanceTracker: createMockPerformanceTracker(),
|
|
340
|
+
getWorkerChannel: () => mockWorkerChannel,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
const challengeData: ChallengeData = {
|
|
344
|
+
challengeId: 'test-challenge-123',
|
|
345
|
+
challengeType: ChallengeType.HASHCASH,
|
|
346
|
+
extra: {
|
|
347
|
+
challengeData: JSON.stringify(backendExample),
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
await expect(solverWithWorker.solve(challengeData)).rejects.toThrow('Failed to find valid proof')
|
|
352
|
+
expect(mockTerminate).toHaveBeenCalled()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('throws error when worker is busy with another operation', async () => {
|
|
356
|
+
const mockTerminate = vi.fn()
|
|
357
|
+
|
|
358
|
+
const mockWorkerChannel: HashcashWorkerChannel = {
|
|
359
|
+
api: {
|
|
360
|
+
findProof: vi
|
|
361
|
+
.fn()
|
|
362
|
+
.mockRejectedValue(new Error('Worker is busy - another findProof operation is in progress')),
|
|
363
|
+
cancel: vi.fn(),
|
|
364
|
+
},
|
|
365
|
+
terminate: mockTerminate,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const solverWithWorker = createHashcashSolver({
|
|
369
|
+
performanceTracker: createMockPerformanceTracker(),
|
|
370
|
+
getWorkerChannel: () => mockWorkerChannel,
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
const challengeData: ChallengeData = {
|
|
374
|
+
challengeId: 'test-challenge-123',
|
|
375
|
+
challengeType: ChallengeType.HASHCASH,
|
|
376
|
+
extra: {
|
|
377
|
+
challengeData: JSON.stringify(backendExample),
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
await expect(solverWithWorker.solve(challengeData)).rejects.toThrow('Worker is busy')
|
|
382
|
+
expect(mockTerminate).toHaveBeenCalled()
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
})
|
|
@@ -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 }
|