@prosopo/procaptcha-frictionless 2.15.0 → 2.16.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.
Files changed (52) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +11 -10
  2. package/.turbo/turbo-build$colon$tsc.log +24 -24
  3. package/.turbo/turbo-build.log +13 -12
  4. package/CHANGELOG.md +73 -0
  5. package/dist/AuthenticatedBadge.d.ts +12 -0
  6. package/dist/AuthenticatedBadge.d.ts.map +1 -0
  7. package/dist/AuthenticatedBadge.js +75 -0
  8. package/dist/AuthenticatedBadge.js.map +1 -0
  9. package/dist/ProcaptchaFrictionless.d.ts.map +1 -1
  10. package/dist/ProcaptchaFrictionless.js +35 -9
  11. package/dist/ProcaptchaFrictionless.js.map +1 -1
  12. package/dist/cjs/AuthenticatedBadge.cjs +75 -0
  13. package/dist/cjs/ProcaptchaFrictionless.cjs +35 -9
  14. package/dist/cjs/customDetectBot.cjs +3 -1
  15. package/dist/cjs/detectorLoader.cjs +42 -13
  16. package/dist/cjs/sessionInvalidatedRecovery.cjs +16 -9
  17. package/dist/customDetectBot.d.ts.map +1 -1
  18. package/dist/customDetectBot.js +3 -1
  19. package/dist/customDetectBot.js.map +1 -1
  20. package/dist/detectorLoader.d.ts +2 -1
  21. package/dist/detectorLoader.d.ts.map +1 -1
  22. package/dist/detectorLoader.js +42 -13
  23. package/dist/detectorLoader.js.map +1 -1
  24. package/dist/sessionInvalidatedRecovery.d.ts +3 -1
  25. package/dist/sessionInvalidatedRecovery.d.ts.map +1 -1
  26. package/dist/sessionInvalidatedRecovery.js +16 -9
  27. package/dist/sessionInvalidatedRecovery.js.map +1 -1
  28. package/dist/tests/detectorLoader.test.d.ts +2 -0
  29. package/dist/tests/detectorLoader.test.d.ts.map +1 -0
  30. package/dist/tests/detectorLoader.test.js +67 -0
  31. package/dist/tests/detectorLoader.test.js.map +1 -0
  32. package/dist/tests/escalationHandoff.integration.test.d.ts +5 -0
  33. package/dist/tests/escalationHandoff.integration.test.d.ts.map +1 -0
  34. package/dist/tests/escalationHandoff.integration.test.js +216 -0
  35. package/dist/tests/escalationHandoff.integration.test.js.map +1 -0
  36. package/dist/tests/sessionInvalidatedRecovery.test.js +41 -21
  37. package/dist/tests/sessionInvalidatedRecovery.test.js.map +1 -1
  38. package/dist/tests/sessionInvalidatedRemint.test.d.ts +5 -0
  39. package/dist/tests/sessionInvalidatedRemint.test.d.ts.map +1 -0
  40. package/dist/tests/sessionInvalidatedRemint.test.js +150 -0
  41. package/dist/tests/sessionInvalidatedRemint.test.js.map +1 -0
  42. package/package.json +8 -8
  43. package/src/AuthenticatedBadge.tsx +141 -0
  44. package/src/ProcaptchaFrictionless.tsx +85 -16
  45. package/src/customDetectBot.ts +2 -0
  46. package/src/detectorLoader.ts +52 -12
  47. package/src/sessionInvalidatedRecovery.ts +30 -10
  48. package/src/tests/detectorLoader.test.ts +108 -0
  49. package/src/tests/escalationHandoff.integration.test.tsx +354 -0
  50. package/src/tests/sessionInvalidatedRecovery.test.ts +49 -21
  51. package/src/tests/sessionInvalidatedRemint.test.tsx +245 -0
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,354 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ /**
16
+ * Post-PoW escalation, end to end on the client: the real
17
+ * ProcaptchaFrictionless wrapper hands off to the real image widget and its
18
+ * real Manager. Only the network is stubbed, and the stub enforces the
19
+ * provider's actual contract — `checkAndRemoveSession` consumes a session the
20
+ * moment it issues a challenge, so a second `/captcha/image` on the same
21
+ * sessionId is answered with 400 CAPTCHA.NO_SESSION_FOUND.
22
+ *
23
+ * Observed in production on provider 3.8.5:
24
+ *
25
+ * 11:27:49.910 POST /pow/solution 200 escalation envelope returned
26
+ * 11:27:50.263 POST /captcha/image 200 escalation session issued
27
+ * 11:27:55.030 POST /captcha/image 400 CAPTCHA.NO_SESSION_FOUND, same session
28
+ *
29
+ * The PoW manager fires `onEscalate` from inside its `providerRetry`-wrapped
30
+ * `submit()`, so a throw after the handoff re-runs submit and escalates a
31
+ * second time on the same envelope.
32
+ */
33
+
34
+ import type { Ti18n } from "@prosopo/locale";
35
+ import {
36
+ type Account,
37
+ ApiParams,
38
+ type BotDetectionFunction,
39
+ type CaptchaResponseBody,
40
+ CaptchaType,
41
+ ModeEnum,
42
+ type ProcaptchaClientConfigInput,
43
+ type ProcaptchaProps,
44
+ type RandomProvider,
45
+ } from "@prosopo/types";
46
+ import { type ReactElement, act, createElement } from "react";
47
+ import { type Root, createRoot } from "react-dom/client";
48
+ import {
49
+ type Mock,
50
+ afterEach,
51
+ beforeEach,
52
+ describe,
53
+ expect,
54
+ it,
55
+ vi,
56
+ } from "vitest";
57
+
58
+ declare global {
59
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
60
+ }
61
+
62
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
63
+
64
+ const ESCALATION_SESSION_ID = "provider-session-escalated";
65
+ const FIRST_SESSION_ID = "provider-session-1";
66
+
67
+ // The provider's one-shot session contract, as a stub: the first challenge
68
+ // fetch for a sessionId succeeds, every later one fails the way
69
+ // `checkAndRemoveSession` makes it fail.
70
+ const mocks = vi.hoisted(() => {
71
+ const challengeRequests: (string | undefined)[] = [];
72
+ const consumed = new Set<string>();
73
+
74
+ const body = (): CaptchaResponseBody => ({
75
+ captchas: [
76
+ {
77
+ captchaId: "captcha-id-1",
78
+ captchaContentId: "captcha-content-id-1",
79
+ datasetId: "dataset-id",
80
+ salt: "0xsalt",
81
+ target: "cars",
82
+ items: [],
83
+ },
84
+ ],
85
+ requestHash: "0xrequest-hash",
86
+ timestamp: "1700000000000",
87
+ signature: { provider: { requestHash: "0xprovider-request-hash" } },
88
+ status: "ok",
89
+ });
90
+
91
+ const getCaptchaChallenge = async (
92
+ _userAccount: string,
93
+ _provider: unknown,
94
+ sessionId?: string,
95
+ ): Promise<CaptchaResponseBody> => {
96
+ challengeRequests.push(sessionId);
97
+ if (sessionId && consumed.has(sessionId)) {
98
+ // A 400 carrying a JSON body is *returned*, not thrown —
99
+ // HttpClientBase only throws when the failure isn't JSON. This is
100
+ // the envelope the provider's ProsopoApiError serialises to, and
101
+ // `error.key` is what the widget's recovery path keys off.
102
+ return {
103
+ ...body(),
104
+ captchas: [],
105
+ error: {
106
+ key: "CAPTCHA.NO_SESSION_FOUND",
107
+ message: "No session found",
108
+ code: 400,
109
+ },
110
+ };
111
+ }
112
+ if (sessionId) consumed.add(sessionId);
113
+ return body();
114
+ };
115
+
116
+ class ProviderApiMock {
117
+ public getCaptchaChallenge = getCaptchaChallenge;
118
+ }
119
+
120
+ return { challengeRequests, consumed, ProviderApiMock };
121
+ });
122
+
123
+ vi.mock("@prosopo/api", async (importOriginal) => {
124
+ const actual = await importOriginal<typeof import("@prosopo/api")>();
125
+ return { ...actual, ProviderApi: mocks.ProviderApiMock };
126
+ });
127
+
128
+ // The image Manager constructs an extension before it can ask for a challenge
129
+ // (it then reads the account off the frictionless state, and nothing in this
130
+ // flow signs anything). `ExtensionLoader` resolves to the class, so the mock
131
+ // has to as well.
132
+ class ExtensionMock {
133
+ public getAccount = async (): Promise<Account> => ({
134
+ account: { address: "user-address" },
135
+ });
136
+ }
137
+
138
+ vi.mock("@prosopo/procaptcha-common", async (importOriginal) => {
139
+ const actual =
140
+ await importOriginal<typeof import("@prosopo/procaptcha-common")>();
141
+ return {
142
+ ...actual,
143
+ isSecureBrowserContext: () => true,
144
+ ExtensionLoader: async (_web2: boolean) => ExtensionMock,
145
+ };
146
+ });
147
+
148
+ vi.mock("@prosopo/locale", async (importOriginal) => {
149
+ const actual = await importOriginal<typeof import("@prosopo/locale")>();
150
+ return {
151
+ ...actual,
152
+ useTranslation: () => ({ t: (key: string) => key, ready: true }),
153
+ loadI18next: async () => undefined,
154
+ };
155
+ });
156
+
157
+ // The PoW widget is the escalation *source*, so it stays a stub — the test
158
+ // drives its `onEscalate` callback directly. The image widget below it is real.
159
+ const powMounts: { props: ProcaptchaProps }[] = [];
160
+ vi.mock("@prosopo/procaptcha-pow", () => ({
161
+ ProcaptchaPow: (props: ProcaptchaProps) => {
162
+ powMounts.push({ props });
163
+ return createElement("div", { "data-widget": "pow" });
164
+ },
165
+ }));
166
+ vi.mock("@prosopo/procaptcha-puzzle", () => ({
167
+ ProcaptchaPuzzle: () => createElement("div", { "data-widget": "puzzle" }),
168
+ }));
169
+
170
+ const { ProcaptchaFrictionless } = await import("../ProcaptchaFrictionless.js");
171
+
172
+ const provider: RandomProvider = {
173
+ providerAccount: "provider-account",
174
+ provider: { url: "https://provider.test" },
175
+ };
176
+
177
+ const userAccount: Account = { account: { address: "user-address" } };
178
+
179
+ const config = (): ProcaptchaClientConfigInput => ({
180
+ account: { address: "5siteKey" },
181
+ userAccountAddress: "",
182
+ web2: true,
183
+ mode: ModeEnum.visible,
184
+ });
185
+
186
+ const i18nStub = {
187
+ isInitialized: true,
188
+ language: "en",
189
+ t: (key: string) => key,
190
+ changeLanguage: vi.fn(),
191
+ } as unknown as Ti18n;
192
+
193
+ const detectBot: Mock<BotDetectionFunction> = vi.fn(async () => ({
194
+ captchaType: CaptchaType.pow,
195
+ sessionId: FIRST_SESSION_ID,
196
+ status: "ok",
197
+ provider,
198
+ userAccount,
199
+ }));
200
+
201
+ let container: HTMLDivElement;
202
+ let root: Root;
203
+
204
+ const lastPowMount = () => {
205
+ const mount = powMounts.at(-1);
206
+ if (!mount) throw new Error("expected the pow widget to have mounted");
207
+ return mount;
208
+ };
209
+
210
+ /**
211
+ * Flush React work until the challenge traffic has been quiet for four
212
+ * consecutive polls, so the negative assertions know nothing further is on its
213
+ * way. Paired with `waitForChallenge` below rather than used alone: starting
214
+ * from an already-quiet count, a pure quiescence check can declare "settled"
215
+ * before a request that is still coming has been issued.
216
+ */
217
+ const settle = async (): Promise<void> => {
218
+ let quiet = 0;
219
+ let previous = mocks.challengeRequests.length;
220
+ for (let poll = 0; poll < 40 && quiet < 4; poll++) {
221
+ await act(async () => {
222
+ await new Promise((resolve) => setTimeout(resolve, 60));
223
+ });
224
+ const current = mocks.challengeRequests.length;
225
+ quiet = current === previous ? quiet + 1 : 0;
226
+ previous = current;
227
+ }
228
+ };
229
+
230
+ const challengeRequestCount = (sessionId: string): number =>
231
+ mocks.challengeRequests.filter((id) => id === sessionId).length;
232
+
233
+ /** Flush until the image Manager has issued its challenge fetch. */
234
+ const waitForChallenge = async (sessionId: string): Promise<void> => {
235
+ for (
236
+ let poll = 0;
237
+ poll < 60 && challengeRequestCount(sessionId) === 0;
238
+ poll++
239
+ ) {
240
+ await act(async () => {
241
+ await new Promise((resolve) => setTimeout(resolve, 50));
242
+ });
243
+ }
244
+ };
245
+
246
+ /**
247
+ * The PoW solution came back with an escalation envelope. `expectRequest`
248
+ * distinguishes a handoff that should reach the network from one the wrapper
249
+ * is expected to swallow — waiting for the request in the first case is what
250
+ * keeps a late fetch from leaking into the next assertion.
251
+ */
252
+ const escalate = async (
253
+ sessionId: string,
254
+ expectRequest = true,
255
+ ): Promise<void> => {
256
+ const { onEscalate } = lastPowMount().props;
257
+ await act(async () => {
258
+ onEscalate?.(CaptchaType.image, sessionId, { x: 120, y: 340 });
259
+ });
260
+ if (expectRequest) await waitForChallenge(sessionId);
261
+ await settle();
262
+ };
263
+
264
+ beforeEach(async () => {
265
+ powMounts.length = 0;
266
+ mocks.challengeRequests.length = 0;
267
+ mocks.consumed.clear();
268
+ detectBot.mockClear();
269
+ container = document.createElement("div");
270
+ document.body.appendChild(container);
271
+ act(() => {
272
+ root = createRoot(container);
273
+ });
274
+ await act(async () => {
275
+ root.render(
276
+ createElement(ProcaptchaFrictionless, {
277
+ config: config(),
278
+ callbacks: {},
279
+ restart: vi.fn<() => void>(),
280
+ i18n: i18nStub,
281
+ detectBot,
282
+ }) as ReactElement,
283
+ );
284
+ });
285
+ });
286
+
287
+ afterEach(() => {
288
+ act(() => {
289
+ root.unmount();
290
+ });
291
+ container.remove();
292
+ vi.clearAllMocks();
293
+ });
294
+
295
+ describe("post-PoW escalation handoff", () => {
296
+ it("fetches the image challenge exactly once for the escalation session", async () => {
297
+ await escalate(ESCALATION_SESSION_ID);
298
+
299
+ expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
300
+ });
301
+
302
+ it("does not re-request the challenge when the same escalation fires twice", async () => {
303
+ // The PoW manager escalates from inside providerRetry, so a retried
304
+ // submit() replays the same envelope. Before the guard this mounted a
305
+ // second image widget against a session the first one had already
306
+ // spent, and the provider answered 400 CAPTCHA.NO_SESSION_FOUND.
307
+ await escalate(ESCALATION_SESSION_ID);
308
+ await escalate(ESCALATION_SESSION_ID, false);
309
+
310
+ expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
311
+ });
312
+
313
+ it("still follows a genuinely new escalation session", async () => {
314
+ await escalate(ESCALATION_SESSION_ID);
315
+ await escalate("provider-session-escalated-2");
316
+
317
+ expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
318
+ expect(challengeRequestCount("provider-session-escalated-2")).toBe(1);
319
+ });
320
+
321
+ it("recovers rather than stranding the user when the escalation session is already gone", async () => {
322
+ // Something else consumed the escalation session first (a duplicate
323
+ // POST from a mount storm). The wrapper must re-mint via /frictionless
324
+ // rather than leaving a dead "No session found" checkbox.
325
+ mocks.consumed.add(ESCALATION_SESSION_ID);
326
+ const detectBotCallsBefore = detectBot.mock.calls.length;
327
+
328
+ await escalate(ESCALATION_SESSION_ID);
329
+
330
+ expect(challengeRequestCount(ESCALATION_SESSION_ID)).toBe(1);
331
+ expect(detectBot.mock.calls.length).toBeGreaterThan(detectBotCallsBefore);
332
+ });
333
+ });
334
+
335
+ describe("the one-shot session contract this suite stubs", () => {
336
+ it("issues a challenge on the first fetch", async () => {
337
+ const api = new mocks.ProviderApiMock();
338
+
339
+ const challenge = await api.getCaptchaChallenge("user", provider, "fresh");
340
+
341
+ expect(challenge[ApiParams.captchas]).toHaveLength(1);
342
+ expect(challenge[ApiParams.error]).toBeUndefined();
343
+ });
344
+
345
+ it("matches the provider on a second fetch: NO_SESSION_FOUND in the body, not a throw", async () => {
346
+ const api = new mocks.ProviderApiMock();
347
+ await api.getCaptchaChallenge("user", provider, "session-x");
348
+
349
+ const repeat = await api.getCaptchaChallenge("user", provider, "session-x");
350
+
351
+ expect(repeat[ApiParams.error]?.key).toBe("CAPTCHA.NO_SESSION_FOUND");
352
+ expect(repeat[ApiParams.captchas]).toHaveLength(0);
353
+ });
354
+ });
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { describe, expect, it } from "vitest";
16
16
  import {
17
+ MAX_SESSION_INVALIDATED_RETRIES,
17
18
  type MutableRef,
18
19
  type RetryCoords,
19
20
  consumeRetryMountProps,
@@ -25,70 +26,97 @@ const ref = <T>(initial: T): MutableRef<T> => ({ current: initial });
25
26
 
26
27
  describe("handleSessionInvalidated", () => {
27
28
  it("records both coords and signals a restart on the first fire", () => {
28
- const firedRef = ref(false);
29
+ const attemptsRef = ref(0);
29
30
  const coordsRef = ref<RetryCoords | null>(null);
30
31
 
31
- const result = handleSessionInvalidated(120, 340, firedRef, coordsRef);
32
+ const result = handleSessionInvalidated(120, 340, attemptsRef, coordsRef);
32
33
 
33
- expect(result).toEqual({ shouldRestart: true });
34
- expect(firedRef.current).toBe(true);
34
+ expect(result).toEqual({ shouldRestart: true, exhausted: false });
35
+ expect(attemptsRef.current).toBe(1);
35
36
  expect(coordsRef.current).toEqual({ x: 120, y: 340 });
36
37
  });
37
38
 
38
39
  it("treats (0, 0) as 'no coords' — that's the autoStart / untrusted-event default, not a real click", () => {
39
- const firedRef = ref(false);
40
+ const attemptsRef = ref(0);
40
41
  const coordsRef = ref<RetryCoords | null>(null);
41
42
 
42
- const result = handleSessionInvalidated(0, 0, firedRef, coordsRef);
43
+ const result = handleSessionInvalidated(0, 0, attemptsRef, coordsRef);
43
44
 
44
- expect(result).toEqual({ shouldRestart: true });
45
+ expect(result).toEqual({ shouldRestart: true, exhausted: false });
45
46
  expect(coordsRef.current).toBeNull();
46
47
  });
47
48
 
48
49
  it("carries a real click even if only one axis is at the origin", () => {
49
- const firedRef = ref(false);
50
+ const attemptsRef = ref(0);
50
51
  const coordsRef = ref<RetryCoords | null>(null);
51
52
 
52
- handleSessionInvalidated(0, 340, firedRef, coordsRef);
53
+ handleSessionInvalidated(0, 340, attemptsRef, coordsRef);
53
54
 
54
55
  expect(coordsRef.current).toEqual({ x: 0, y: 340 });
55
56
  });
56
57
 
57
58
  it("stores no coords when x or y is undefined (autoStart / non-trusted event)", () => {
58
- const firedRef = ref(false);
59
+ const attemptsRef = ref(0);
59
60
  const coordsRef = ref<RetryCoords | null>(null);
60
61
 
61
62
  const result = handleSessionInvalidated(
62
63
  undefined,
63
64
  undefined,
64
- firedRef,
65
+ attemptsRef,
65
66
  coordsRef,
66
67
  );
67
68
 
68
- expect(result).toEqual({ shouldRestart: true });
69
- expect(firedRef.current).toBe(true);
69
+ expect(result).toEqual({ shouldRestart: true, exhausted: false });
70
+ expect(attemptsRef.current).toBe(1);
70
71
  expect(coordsRef.current).toBeNull();
71
72
  });
72
73
 
73
74
  it("stores no coords when only one axis is present — never emit NaN into the salt", () => {
74
- const firedRef = ref(false);
75
+ const attemptsRef = ref(0);
75
76
  const coordsRef = ref<RetryCoords | null>(null);
76
77
 
77
- handleSessionInvalidated(120, undefined, firedRef, coordsRef);
78
+ handleSessionInvalidated(120, undefined, attemptsRef, coordsRef);
78
79
 
79
80
  expect(coordsRef.current).toBeNull();
80
81
  });
81
82
 
82
- it("is one-shot per outer widget lifetime — a second call is a no-op", () => {
83
- const firedRef = ref(false);
83
+ it("keeps re-minting up to the retry budget — a reload press is a legitimate new session", () => {
84
+ const attemptsRef = ref(0);
84
85
  const coordsRef = ref<RetryCoords | null>(null);
85
86
 
86
- handleSessionInvalidated(100, 200, firedRef, coordsRef);
87
- const second = handleSessionInvalidated(500, 600, firedRef, coordsRef);
87
+ for (let i = 0; i < MAX_SESSION_INVALIDATED_RETRIES; i++) {
88
+ expect(
89
+ handleSessionInvalidated(100 + i, 200 + i, attemptsRef, coordsRef),
90
+ ).toEqual({ shouldRestart: true, exhausted: false });
91
+ }
88
92
 
89
- expect(second).toEqual({ shouldRestart: false });
90
- // The second call must not overwrite the first attempt's coords.
93
+ expect(attemptsRef.current).toBe(MAX_SESSION_INVALIDATED_RETRIES);
94
+ });
95
+
96
+ it("reports exhausted once the budget is spent so the caller can fall over visibly", () => {
97
+ const attemptsRef = ref(MAX_SESSION_INVALIDATED_RETRIES);
98
+ const coordsRef = ref<RetryCoords | null>({ x: 100, y: 200 });
99
+
100
+ const result = handleSessionInvalidated(500, 600, attemptsRef, coordsRef);
101
+
102
+ expect(result).toEqual({ shouldRestart: false, exhausted: true });
103
+ // An exhausted call must not overwrite the pending attempt's coords.
91
104
  expect(coordsRef.current).toEqual({ x: 100, y: 200 });
105
+ expect(attemptsRef.current).toBe(MAX_SESSION_INVALIDATED_RETRIES);
106
+ });
107
+
108
+ it("honours a caller-supplied budget", () => {
109
+ const attemptsRef = ref(0);
110
+ const coordsRef = ref<RetryCoords | null>(null);
111
+
112
+ expect(handleSessionInvalidated(1, 2, attemptsRef, coordsRef, 1)).toEqual({
113
+ shouldRestart: true,
114
+ exhausted: false,
115
+ });
116
+ expect(handleSessionInvalidated(3, 4, attemptsRef, coordsRef, 1)).toEqual({
117
+ shouldRestart: false,
118
+ exhausted: true,
119
+ });
92
120
  });
93
121
  });
94
122