@l.x/sessions 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.depcheckrc +20 -0
  2. package/.eslintrc.js +21 -0
  3. package/LICENSE +122 -0
  4. package/README.md +1 -0
  5. package/env.d.ts +12 -0
  6. package/package.json +49 -1
  7. package/project.json +36 -0
  8. package/src/challenge-solvers/createChallengeSolverService.ts +64 -0
  9. package/src/challenge-solvers/createHashcashMockSolver.ts +39 -0
  10. package/src/challenge-solvers/createHashcashSolver.test.ts +385 -0
  11. package/src/challenge-solvers/createHashcashSolver.ts +270 -0
  12. package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
  13. package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
  14. package/src/challenge-solvers/createTurnstileSolver.ts +357 -0
  15. package/src/challenge-solvers/hashcash/core.native.ts +34 -0
  16. package/src/challenge-solvers/hashcash/core.test.ts +314 -0
  17. package/src/challenge-solvers/hashcash/core.ts +35 -0
  18. package/src/challenge-solvers/hashcash/core.web.ts +123 -0
  19. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.test.ts +195 -0
  20. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.ts +120 -0
  21. package/src/challenge-solvers/hashcash/shared.ts +70 -0
  22. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.native.ts +22 -0
  23. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.ts +22 -0
  24. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.web.ts +212 -0
  25. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.native.ts +16 -0
  26. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.ts +16 -0
  27. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.web.ts +97 -0
  28. package/src/challenge-solvers/hashcash/worker/hashcash.worker.ts +91 -0
  29. package/src/challenge-solvers/hashcash/worker/types.ts +69 -0
  30. package/src/challenge-solvers/turnstileErrors.ts +49 -0
  31. package/src/challenge-solvers/turnstileScriptLoader.ts +325 -0
  32. package/src/challenge-solvers/turnstileSolver.integration.test.ts +331 -0
  33. package/src/challenge-solvers/types.ts +55 -0
  34. package/src/challengeFlow.integration.test.ts +627 -0
  35. package/src/device-id/createDeviceIdService.ts +19 -0
  36. package/src/device-id/types.ts +11 -0
  37. package/src/index.ts +139 -0
  38. package/src/lx-identifier/createLXIdentifierService.ts +1 -0
  39. package/src/lx-identifier/createUniswapIdentifierService.ts +19 -0
  40. package/src/lx-identifier/lxIdentifierQuery.ts +1 -0
  41. package/src/lx-identifier/types.ts +11 -0
  42. package/src/lx-identifier/uniswapIdentifierQuery.ts +20 -0
  43. package/src/oauth-service/createOAuthService.ts +125 -0
  44. package/src/oauth-service/types.ts +104 -0
  45. package/src/performance/createNoopPerformanceTracker.ts +12 -0
  46. package/src/performance/createPerformanceTracker.ts +43 -0
  47. package/src/performance/index.ts +7 -0
  48. package/src/performance/types.ts +11 -0
  49. package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
  50. package/src/session-initialization/createSessionInitializationService.ts +184 -0
  51. package/src/session-initialization/sessionErrors.ts +32 -0
  52. package/src/session-repository/createSessionClient.ts +10 -0
  53. package/src/session-repository/createSessionRepository.test.ts +313 -0
  54. package/src/session-repository/createSessionRepository.ts +242 -0
  55. package/src/session-repository/errors.ts +22 -0
  56. package/src/session-repository/types.ts +289 -0
  57. package/src/session-service/createNoopSessionService.ts +24 -0
  58. package/src/session-service/createSessionService.test.ts +388 -0
  59. package/src/session-service/createSessionService.ts +61 -0
  60. package/src/session-service/types.ts +59 -0
  61. package/src/session-storage/createSessionStorage.ts +28 -0
  62. package/src/session-storage/types.ts +15 -0
  63. package/src/session.integration.test.ts +516 -0
  64. package/src/sessionLifecycle.integration.test.ts +264 -0
  65. package/src/test-utils/createLocalCookieTransport.ts +52 -0
  66. package/src/test-utils/createLocalHeaderTransport.ts +45 -0
  67. package/src/test-utils/mocks.ts +122 -0
  68. package/src/test-utils.ts +200 -0
  69. package/src/uniswap-identifier/createUniswapIdentifierService.ts +19 -0
  70. package/src/uniswap-identifier/types.ts +11 -0
  71. package/src/uniswap-identifier/uniswapIdentifierQuery.ts +20 -0
  72. package/tsconfig.json +26 -0
  73. package/tsconfig.lint.json +8 -0
  74. package/tsconfig.spec.json +8 -0
  75. package/vitest.config.ts +20 -0
  76. package/vitest.integration.config.ts +14 -0
  77. package/index.d.ts +0 -1
  78. package/index.js +0 -1
@@ -0,0 +1,184 @@
1
+ import type { ChallengeSolverService } from '@l.x/sessions/src/challenge-solvers/types'
2
+ import type { PerformanceTracker } from '@l.x/sessions/src/performance/types'
3
+ import {
4
+ MaxChallengeRetriesError,
5
+ NoSolverAvailableError,
6
+ } from '@l.x/sessions/src/session-initialization/sessionErrors'
7
+ import type { SessionService } from '@l.x/sessions/src/session-service/types'
8
+ import type { Logger } from 'utilities/src/logger/logger'
9
+
10
+ interface SessionInitResult {
11
+ sessionId: string | null
12
+ }
13
+
14
+ /**
15
+ * Callbacks for session initialization lifecycle events.
16
+ * Each callback is optional and focused on one event.
17
+ */
18
+ export interface SessionInitAnalytics {
19
+ /** Called when session initialization starts */
20
+ onInitStarted?: () => void
21
+ /** Called when session initialization completes (before challenge flow) */
22
+ onInitCompleted?: (data: { needChallenge: boolean; durationMs: number }) => void
23
+ /** Called when a challenge is received from the backend */
24
+ onChallengeReceived?: (data: { challengeType: string; challengeId: string }) => void
25
+ /** Called when session verification completes (success or retry) */
26
+ onVerifyCompleted?: (data: { success: boolean; attemptNumber: number; totalDurationMs: number }) => void
27
+ }
28
+
29
+ interface SessionInitOptions {
30
+ /**
31
+ * If provided, skips the initSession() RPC call and uses this value directly.
32
+ * Useful when the caller already knows the answer (e.g. from a prior init call).
33
+ */
34
+ needChallenge?: boolean
35
+ }
36
+
37
+ interface SessionInitializationService {
38
+ /**
39
+ * Orchestrates the complete session initialization flow:
40
+ * 1. Calls initSession (backend decides whether to create new or reuse existing)
41
+ * 2. Handles challenge solving if required
42
+ *
43
+ * Pass `options.needChallenge` to skip step 1 when the answer is already known.
44
+ *
45
+ * @throws Error if initialization fails
46
+ */
47
+ initialize(options?: SessionInitOptions): Promise<SessionInitResult>
48
+ }
49
+
50
+ function createSessionInitializationService(ctx: {
51
+ getSessionService: () => SessionService
52
+ challengeSolverService: ChallengeSolverService
53
+ /**
54
+ * Required: Performance tracker for timing measurements.
55
+ * Must be injected - no implicit dependency on globalThis.performance.
56
+ */
57
+ performanceTracker: PerformanceTracker
58
+ getIsSessionUpgradeAutoEnabled?: () => boolean
59
+ maxChallengeRetries?: number
60
+ getLogger?: () => Logger
61
+ /** Analytics callbacks for tracking session initialization lifecycle */
62
+ analytics?: SessionInitAnalytics
63
+ }): SessionInitializationService {
64
+ const log = ctx.getLogger?.()
65
+
66
+ async function handleChallengeFlow(attemptCount = 0, flowStartTime?: number): Promise<void> {
67
+ const startTime = flowStartTime ?? ctx.performanceTracker.now()
68
+ const maxRetries = ctx.maxChallengeRetries ?? 3
69
+
70
+ const challenge = await ctx.getSessionService().requestChallenge()
71
+
72
+ log?.debug('createSessionInitializationService', 'handleChallengeFlow', 'Requesting challenge', {
73
+ challenge,
74
+ })
75
+
76
+ // Report challenge received (only on first attempt)
77
+ if (attemptCount === 0) {
78
+ const data = { challengeType: String(challenge.challengeType), challengeId: challenge.challengeId }
79
+ ctx.analytics?.onChallengeReceived?.(data)
80
+ log?.info('sessions', 'challengeReceived', 'Challenge received', data)
81
+ }
82
+
83
+ // get our solver for the challenge type
84
+ const solver = ctx.challengeSolverService.getSolver(challenge.challengeType)
85
+ if (!solver) {
86
+ throw new NoSolverAvailableError(challenge.challengeType)
87
+ }
88
+
89
+ // Solve the challenge — if the solver throws (e.g. Turnstile domain mismatch on
90
+ // Vercel previews), submit a placeholder solution so verifySession can reject it
91
+ // and the retry loop can request a different challenge type (typically Hashcash).
92
+ // Note: we use a non-empty placeholder because proto3 omits empty strings from the
93
+ // wire, which means the backend wouldn't see the solution field at all.
94
+ let solution: string
95
+ try {
96
+ solution = await solver.solve({
97
+ challengeId: challenge.challengeId,
98
+ challengeType: challenge.challengeType,
99
+ extra: challenge.extra,
100
+ challengeData: challenge.challengeData,
101
+ })
102
+ } catch (solverError) {
103
+ log?.warn(
104
+ 'createSessionInitializationService',
105
+ 'handleChallengeFlow',
106
+ 'Solver failed, submitting placeholder solution to trigger fallback',
107
+ { error: solverError, challengeType: challenge.challengeType },
108
+ )
109
+ solution = 'solver-failed'
110
+ }
111
+
112
+ log?.debug('createSessionInitializationService', 'handleChallengeFlow', 'Solved challenge', { solution })
113
+
114
+ // Verify session with the solution
115
+ const result = await ctx.getSessionService().verifySession({
116
+ solution,
117
+ challengeId: challenge.challengeId,
118
+ challengeType: challenge.challengeType,
119
+ })
120
+
121
+ const verifyData = {
122
+ success: !result.retry,
123
+ attemptNumber: attemptCount + 1,
124
+ totalDurationMs: ctx.performanceTracker.now() - startTime,
125
+ }
126
+ ctx.analytics?.onVerifyCompleted?.(verifyData)
127
+ log?.info('sessions', 'verifyCompleted', 'Verify completed', verifyData)
128
+
129
+ if (!result.retry) {
130
+ return
131
+ }
132
+
133
+ // Handle server retry request
134
+ if (attemptCount >= maxRetries) {
135
+ throw new MaxChallengeRetriesError(maxRetries, attemptCount + 1)
136
+ }
137
+
138
+ await handleChallengeFlow(attemptCount + 1, startTime) // Recursive call with incremented count
139
+ }
140
+
141
+ async function initialize(options?: SessionInitOptions): Promise<SessionInitResult> {
142
+ const initStartTime = ctx.performanceTracker.now()
143
+
144
+ let needChallenge: boolean
145
+ let sessionId: string | undefined
146
+
147
+ if (options?.needChallenge !== undefined) {
148
+ // Caller already knows — skip the initSession() RPC
149
+ needChallenge = options.needChallenge
150
+ sessionId = undefined
151
+
152
+ const data = { needChallenge, durationMs: 0 }
153
+ ctx.analytics?.onInitCompleted?.(data)
154
+ log?.info('sessions', 'initCompleted', 'Session init completed', data)
155
+ } else {
156
+ // Discover from backend
157
+ ctx.analytics?.onInitStarted?.()
158
+ log?.info('sessions', 'initStarted', 'Session init started')
159
+
160
+ const initResponse = await ctx.getSessionService().initSession()
161
+ needChallenge = initResponse.needChallenge
162
+ sessionId = initResponse.sessionId
163
+
164
+ const data = { needChallenge, durationMs: ctx.performanceTracker.now() - initStartTime }
165
+ ctx.analytics?.onInitCompleted?.(data)
166
+ log?.info('sessions', 'initCompleted', 'Session init completed', data)
167
+ }
168
+
169
+ // Handle challenge if required and enabled
170
+ if (needChallenge && ctx.getIsSessionUpgradeAutoEnabled?.()) {
171
+ await handleChallengeFlow()
172
+ }
173
+
174
+ // sessionId is null for web (stored in cookie), real ID for non-web platforms
175
+ return {
176
+ sessionId: sessionId ?? null,
177
+ }
178
+ }
179
+
180
+ return { initialize }
181
+ }
182
+
183
+ export { createSessionInitializationService }
184
+ export type { SessionInitializationService, SessionInitOptions, SessionInitResult }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Base class for session-related errors that should not trigger retries at higher levels.
3
+ */
4
+ export abstract class SessionError extends Error {
5
+ constructor(message: string, name: string) {
6
+ super(message)
7
+ this.name = name
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Error thrown when maximum challenge retry attempts are exceeded.
13
+ * This error should not trigger additional retries at higher levels.
14
+ */
15
+ export class MaxChallengeRetriesError extends SessionError {
16
+ constructor(maxRetries: number, actualAttempts: number) {
17
+ super(
18
+ `Maximum challenge retry attempts (${maxRetries}) exceeded after ${actualAttempts} attempts`,
19
+ 'MaxChallengeRetriesError',
20
+ )
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Error thrown when no solver is available for a challenge type.
26
+ * This error should not trigger additional retries at higher levels.
27
+ */
28
+ export class NoSolverAvailableError extends SessionError {
29
+ constructor(challengeType: number) {
30
+ super(`No solver available for challenge type: ${challengeType}`, 'NoSolverAvailableError')
31
+ }
32
+ }
@@ -0,0 +1,10 @@
1
+ import { createPromiseClient, type PromiseClient, type Transport } from '@connectrpc/connect'
2
+ import { SessionService } from '@luxamm/client-platform-service/dist/lx/platformservice/v1/sessionService_connect'
3
+
4
+ type SessionServiceClient = PromiseClient<typeof SessionService>
5
+
6
+ function createSessionClient(ctx: { transport: Transport }): PromiseClient<typeof SessionService> {
7
+ return createPromiseClient(SessionService, ctx.transport)
8
+ }
9
+
10
+ export { type SessionServiceClient, createSessionClient }
@@ -0,0 +1,313 @@
1
+ import type { PromiseClient } from '@connectrpc/connect'
2
+ import type { SessionService } from '@luxamm/client-platform-service/dist/lx/platformservice/v1/sessionService_connect'
3
+ import {
4
+ ChallengeFailure,
5
+ ChallengeFailure_Reason,
6
+ ChallengeResponse,
7
+ GetChallengeTypesResponse,
8
+ InitSessionResponse,
9
+ SignoutResponse,
10
+ } from '@luxamm/client-platform-service/dist/lx/platformservice/v1/sessionService_pb'
11
+ import { createSessionRepository } from '@l.x/sessions/src/session-repository/createSessionRepository'
12
+ import { ChallengeRejectedError } from '@l.x/sessions/src/session-repository/errors'
13
+ import { ChallengeType } from '@l.x/sessions/src/session-service/types'
14
+ import { describe, expect, it, type MockedFunction, vi } from 'vitest'
15
+
16
+ type MockedClient = {
17
+ [K in keyof PromiseClient<typeof SessionService>]: MockedFunction<PromiseClient<typeof SessionService>[K]>
18
+ }
19
+
20
+ describe('createSessionRepository', () => {
21
+ const createMockClient = (): MockedClient => ({
22
+ initSession: vi.fn().mockResolvedValue({
23
+ sessionId: 'test-session-123',
24
+ deviceId: 'test-device-123',
25
+ needChallenge: false,
26
+ extra: {},
27
+ }),
28
+ challenge: vi.fn().mockResolvedValue({
29
+ challengeId: 'challenge-123',
30
+ challengeType: 1,
31
+ extra: { sitekey: 'test-key' },
32
+ challengeData: { case: undefined },
33
+ }),
34
+ verify: vi.fn().mockResolvedValue({
35
+ retry: false,
36
+ outcome: { case: 'success' as const, value: {} },
37
+ }),
38
+ updateSession: vi.fn().mockResolvedValue({}),
39
+ deleteSession: vi.fn().mockResolvedValue({}),
40
+ introspectSession: vi.fn().mockResolvedValue({}), // Required by proto but not used
41
+ getChallengeTypes: vi.fn().mockResolvedValue(new GetChallengeTypesResponse({ challengeTypes: [] })),
42
+ signout: vi.fn().mockResolvedValue(new SignoutResponse({})),
43
+ })
44
+
45
+ describe('session initialization behaviors', () => {
46
+ it('initializes a session and returns session data', async () => {
47
+ const mockClient = createMockClient()
48
+ const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
49
+
50
+ const result = await repository.initSession()
51
+
52
+ expect(result).toEqual({
53
+ sessionId: 'test-session-123',
54
+ deviceId: 'test-device-123',
55
+ needChallenge: false,
56
+ extra: {},
57
+ })
58
+ })
59
+
60
+ it('handles web sessions without session ID', async () => {
61
+ const mockClient = createMockClient()
62
+ mockClient.initSession.mockResolvedValue(
63
+ new InitSessionResponse({
64
+ sessionId: undefined,
65
+ deviceId: undefined,
66
+ needChallenge: true,
67
+ extra: { sitekey: 'turnstile-key' },
68
+ }),
69
+ )
70
+
71
+ const repository = createSessionRepository({ client: mockClient })
72
+ const result = await repository.initSession()
73
+
74
+ // Web sessions don't return sessionId or deviceId (managed by cookies)
75
+ expect(result.sessionId).toBeUndefined()
76
+ expect(result.deviceId).toBeUndefined()
77
+ expect(result.needChallenge).toBe(true)
78
+ expect(result.extra).toEqual({ sitekey: 'turnstile-key' })
79
+ })
80
+
81
+ it('provides meaningful error when initialization fails', async () => {
82
+ const mockClient = createMockClient()
83
+ mockClient.initSession.mockRejectedValue(new Error('Network error'))
84
+
85
+ const repository = createSessionRepository({ client: mockClient })
86
+
87
+ await expect(repository.initSession()).rejects.toThrow('Failed to initialize session')
88
+ })
89
+ })
90
+
91
+ describe('bot detection behaviors', () => {
92
+ it('retrieves challenge information for bot detection', async () => {
93
+ const mockClient = createMockClient()
94
+ const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
95
+
96
+ const result = await repository.challenge({})
97
+
98
+ expect(result).toEqual({
99
+ challengeId: 'challenge-123',
100
+ challengeType: 1,
101
+ extra: { sitekey: 'test-key' },
102
+ challengeData: { case: undefined },
103
+ authorizeUrl: undefined,
104
+ })
105
+ })
106
+
107
+ it('throws ChallengeRejectedError when response has failure field', async () => {
108
+ const mockClient = createMockClient()
109
+ mockClient.challenge.mockResolvedValue(
110
+ new ChallengeResponse({
111
+ failure: new ChallengeFailure({ reason: ChallengeFailure_Reason.UNSPECIFIED }),
112
+ }),
113
+ )
114
+
115
+ const repository = createSessionRepository({ client: mockClient })
116
+
117
+ await expect(repository.challenge({})).rejects.toThrow(ChallengeRejectedError)
118
+ await expect(repository.challenge({})).rejects.toThrow('REASON_UNSPECIFIED')
119
+ })
120
+
121
+ it('throws ChallengeRejectedError with BOT_DETECTION_REQUIRED reason', async () => {
122
+ const mockClient = createMockClient()
123
+ mockClient.challenge.mockResolvedValue(
124
+ new ChallengeResponse({
125
+ failure: new ChallengeFailure({ reason: ChallengeFailure_Reason.BOT_DETECTION_REQUIRED }),
126
+ }),
127
+ )
128
+
129
+ const repository = createSessionRepository({ client: mockClient })
130
+
131
+ await expect(repository.challenge({})).rejects.toThrow(ChallengeRejectedError)
132
+ await expect(repository.challenge({})).rejects.toThrow('REASON_BOT_DETECTION_REQUIRED')
133
+ })
134
+
135
+ it('does NOT throw ChallengeRejectedError when no failure field is present', async () => {
136
+ const mockClient = createMockClient()
137
+ mockClient.challenge.mockResolvedValue(new ChallengeResponse({ challengeId: 'some-id', challengeType: 0 }))
138
+
139
+ const repository = createSessionRepository({ client: mockClient })
140
+ const result = await repository.challenge({})
141
+
142
+ expect(result.challengeId).toBe('some-id')
143
+ })
144
+
145
+ it('ChallengeRejectedError has typed reason and rawFailure fields', async () => {
146
+ const mockClient = createMockClient()
147
+ mockClient.challenge.mockResolvedValue(
148
+ new ChallengeResponse({
149
+ failure: new ChallengeFailure({ reason: ChallengeFailure_Reason.BOT_DETECTION_REQUIRED }),
150
+ }),
151
+ )
152
+
153
+ const repository = createSessionRepository({ client: mockClient })
154
+
155
+ try {
156
+ await repository.challenge({})
157
+ expect.fail('Should have thrown')
158
+ } catch (error) {
159
+ expect(error).toBeInstanceOf(ChallengeRejectedError)
160
+ const rejected = error as ChallengeRejectedError
161
+ expect(rejected.reason).toBe('REASON_BOT_DETECTION_REQUIRED')
162
+ expect(rejected.rawFailure).toBeDefined()
163
+ expect(rejected.name).toBe('ChallengeRejectedError')
164
+ }
165
+ })
166
+
167
+ it('provides meaningful error when challenge request fails', async () => {
168
+ const mockClient = createMockClient()
169
+ mockClient.challenge.mockRejectedValue(new Error('API error'))
170
+
171
+ const repository = createSessionRepository({ client: mockClient })
172
+
173
+ await expect(repository.challenge({})).rejects.toThrow('Failed to get challenge')
174
+ })
175
+ })
176
+
177
+ describe('session verify behaviors', () => {
178
+ it('submits bot detection solution', async () => {
179
+ const mockClient = createMockClient()
180
+ const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
181
+
182
+ const result = await repository.verifySession({
183
+ solution: 'solution-token',
184
+ challengeId: 'challenge-123',
185
+ challengeType: ChallengeType.TURNSTILE,
186
+ })
187
+
188
+ // Verify the client was called correctly (challengeType maps to 'type' in the backend API)
189
+ expect(mockClient.verify).toHaveBeenCalledWith({
190
+ solution: 'solution-token',
191
+ challengeId: 'challenge-123',
192
+ type: ChallengeType.TURNSTILE,
193
+ })
194
+
195
+ // Should return retry status
196
+ expect(result).toEqual({ retry: false })
197
+ })
198
+
199
+ it('handles verify with additional parameters', async () => {
200
+ const mockClient = createMockClient()
201
+ const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
202
+
203
+ const result = await repository.verifySession({
204
+ solution: 'solution-token',
205
+ challengeId: 'challenge-123',
206
+ challengeType: ChallengeType.TURNSTILE,
207
+ })
208
+
209
+ // Should succeed with required params
210
+ expect(result).toEqual({ retry: false })
211
+ })
212
+
213
+ it('treats undefined outcome with retry=false as success (proto3 dropped empty VerifySuccess)', async () => {
214
+ const mockClient = createMockClient()
215
+ mockClient.verify.mockResolvedValue({
216
+ retry: false,
217
+ outcome: { case: undefined, value: undefined },
218
+ })
219
+
220
+ const repository = createSessionRepository({ client: mockClient })
221
+
222
+ const result = await repository.verifySession({
223
+ solution: 'solution-token',
224
+ challengeId: 'challenge-123',
225
+ challengeType: ChallengeType.TURNSTILE,
226
+ })
227
+
228
+ // Proto3 drops empty VerifySuccess — retry: false signals success
229
+ expect(result.retry).toBe(false)
230
+ })
231
+
232
+ it('allows undefined outcome when retry is true (valid retry-only response)', async () => {
233
+ const mockClient = createMockClient()
234
+ mockClient.verify.mockResolvedValue({
235
+ retry: true,
236
+ outcome: { case: undefined, value: undefined },
237
+ })
238
+
239
+ const repository = createSessionRepository({ client: mockClient })
240
+
241
+ const result = await repository.verifySession({
242
+ solution: 'solution-token',
243
+ challengeId: 'challenge-123',
244
+ challengeType: ChallengeType.TURNSTILE,
245
+ })
246
+
247
+ expect(result.retry).toBe(true)
248
+ })
249
+
250
+ it('returns failure info from verify failure outcome', async () => {
251
+ const mockClient = createMockClient()
252
+ mockClient.verify.mockResolvedValue({
253
+ retry: true,
254
+ outcome: {
255
+ case: 'failure' as const,
256
+ value: {
257
+ reason: 1, // INVALID_SOLUTION
258
+ message: 'Bad code',
259
+ waitSeconds: 30,
260
+ },
261
+ },
262
+ })
263
+
264
+ const repository = createSessionRepository({ client: mockClient })
265
+
266
+ const result = await repository.verifySession({
267
+ solution: 'bad-code',
268
+ challengeId: 'challenge-123',
269
+ challengeType: ChallengeType.TURNSTILE,
270
+ })
271
+
272
+ expect(result.retry).toBe(true)
273
+ expect(result.failureReason).toBe('REASON_INVALID_SOLUTION')
274
+ expect(result.failureMessage).toBe('Bad code')
275
+ expect(result.waitSeconds).toBe(30)
276
+ })
277
+
278
+ it('provides meaningful error when verify fails', async () => {
279
+ const mockClient = createMockClient()
280
+ mockClient.verify.mockRejectedValue(new Error('Invalid solution'))
281
+
282
+ const repository = createSessionRepository({ client: mockClient })
283
+
284
+ await expect(
285
+ repository.verifySession({
286
+ solution: 'bad-token',
287
+ challengeId: 'challenge-123',
288
+ challengeType: ChallengeType.TURNSTILE,
289
+ }),
290
+ ).rejects.toThrow('Failed to verify session')
291
+ })
292
+ })
293
+
294
+ describe('session cleanup behaviors', () => {
295
+ it('deletes session successfully', async () => {
296
+ const mockClient = createMockClient()
297
+ const repository = createSessionRepository({ client: mockClient as PromiseClient<typeof SessionService> })
298
+
299
+ const result = await repository.deleteSession({})
300
+
301
+ expect(result).toEqual({})
302
+ })
303
+
304
+ it('provides meaningful error when deletion fails', async () => {
305
+ const mockClient = createMockClient()
306
+ mockClient.signout.mockRejectedValue(new Error('Server error'))
307
+
308
+ const repository = createSessionRepository({ client: mockClient })
309
+
310
+ await expect(repository.deleteSession({})).rejects.toThrow('Failed to delete session')
311
+ })
312
+ })
313
+ })