@prosopo/procaptcha-frictionless 2.12.4 → 2.12.6

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.
@@ -30,6 +30,11 @@ import {
30
30
  import { darkTheme, lightTheme } from "@prosopo/widget-skeleton";
31
31
  import { useEffect, useRef, useState } from "react";
32
32
  import customDetectBot from "./customDetectBot.js";
33
+ import {
34
+ type RetryCoords,
35
+ consumeRetryMountProps,
36
+ handleSessionInvalidated,
37
+ } from "./sessionInvalidatedRecovery.js";
33
38
 
34
39
  // Each session uses exactly one solver — chosen by the /frictionless response.
35
40
  const ProcaptchaLoader = async () =>
@@ -89,6 +94,16 @@ export const ProcaptchaFrictionless = ({
89
94
  }: ProcaptchaFrictionlessProps) => {
90
95
  const stateRef = useRef(defaultLoadingState(0));
91
96
  const events = getDefaultEvents(callbacks);
97
+ // Coords carried over from an `onSessionInvalidated` event on the inner
98
+ // widget. Consumed once by the next `renderForCaptchaType` — the resumed
99
+ // widget mounts with `autoStart` + `startCoords` so the user doesn't have
100
+ // to click the checkbox a second time and the checkbox click position is
101
+ // preserved in the eventual solution salt.
102
+ const pendingRetryCoordsRef = useRef<RetryCoords | null>(null);
103
+ // One-shot outer guard so a persistently broken session doesn't loop.
104
+ // After we've retried once, a second NO_SESSION_FOUND falls back to the
105
+ // inner widget's own `frictionlessState.restart()` path.
106
+ const sessionInvalidatedFiredRef = useRef(false);
92
107
 
93
108
  useEffect(() => {
94
109
  if (config.language) {
@@ -175,6 +190,35 @@ export const ProcaptchaFrictionless = ({
175
190
  );
176
191
  };
177
192
 
193
+ // The provider returned NO_SESSION_FOUND on the inner widget's
194
+ // challenge fetch — the sessionId minted upstream is no longer usable
195
+ // (usually because a duplicate /captcha/{type} POST from a WebView
196
+ // mount storm consumed it first). Re-run the frictionless flow to
197
+ // mint a fresh session, then re-mount the inner widget with the
198
+ // preserved checkbox click coords so the user is not asked to click a
199
+ // second time. One-shot per outer widget lifetime — if the retry
200
+ // also fails, fall through to the inner widget's existing
201
+ // `frictionlessState.restart()` path.
202
+ const onSessionInvalidated = (x?: number, y?: number) => {
203
+ const { shouldRestart } = handleSessionInvalidated(
204
+ x,
205
+ y,
206
+ sessionInvalidatedFiredRef,
207
+ pendingRetryCoordsRef,
208
+ );
209
+ if (!shouldRestart) return;
210
+ resetState(0);
211
+ void start();
212
+ };
213
+
214
+ // Consume any pending retry coords now — the resumed widget owns them
215
+ // for exactly one auto-fired `manager.start(x, y)`. Cleared so a
216
+ // subsequent escalation/re-render doesn't accidentally re-inject.
217
+ const { autoStart: resumedAutoStart, startCoords } = consumeRetryMountProps(
218
+ pendingRetryCoordsRef,
219
+ autoStart,
220
+ );
221
+
178
222
  if (captchaType === CaptchaType.image) {
179
223
  const Procaptcha = await ProcaptchaLoader();
180
224
  setComponentToRender(
@@ -183,7 +227,9 @@ export const ProcaptchaFrictionless = ({
183
227
  callbacks={callbacks}
184
228
  frictionlessState={frictionlessState}
185
229
  i18n={i18n}
186
- autoStart={autoStart}
230
+ autoStart={resumedAutoStart}
231
+ startCoords={startCoords}
232
+ onSessionInvalidated={onSessionInvalidated}
187
233
  />,
188
234
  );
189
235
  } else if (captchaType === CaptchaType.puzzle) {
@@ -194,7 +240,9 @@ export const ProcaptchaFrictionless = ({
194
240
  callbacks={callbacks}
195
241
  frictionlessState={frictionlessState}
196
242
  i18n={i18n}
197
- autoStart={autoStart}
243
+ autoStart={resumedAutoStart}
244
+ startCoords={startCoords}
245
+ onSessionInvalidated={onSessionInvalidated}
198
246
  />,
199
247
  );
200
248
  } else {
@@ -206,6 +254,9 @@ export const ProcaptchaFrictionless = ({
206
254
  frictionlessState={frictionlessState}
207
255
  i18n={i18n}
208
256
  onEscalate={onEscalate}
257
+ autoStart={resumedAutoStart}
258
+ startCoords={startCoords}
259
+ onSessionInvalidated={onSessionInvalidated}
209
260
  />,
210
261
  );
211
262
  }
@@ -0,0 +1,78 @@
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
+ // Shared, unit-testable pieces of the ProcaptchaFrictionless recovery path
16
+ // for CAPTCHA.NO_SESSION_FOUND on the inner widget. The React component
17
+ // mutates refs directly; these helpers isolate the logic that decides how a
18
+ // re-mount should be parameterised so it can be exercised without a renderer.
19
+
20
+ export type RetryCoords = { x: number; y: number };
21
+
22
+ /**
23
+ * Ref-like container used by ProcaptchaFrictionless. Extracted so tests
24
+ * can pass a plain `{current}` object.
25
+ */
26
+ export type MutableRef<T> = { current: T };
27
+
28
+ /**
29
+ * Semantics of the outer recovery handler. Returns whether the caller
30
+ * should proceed to re-run the frictionless flow (`start()`), and mutates
31
+ * the passed refs to record the one-shot fire + pending coords.
32
+ *
33
+ * - Second calls are ignored (one-shot per outer widget lifetime) so a
34
+ * persistently broken session doesn't loop.
35
+ * - Coords are recorded only for a real trusted checkbox click. A partial
36
+ * pair (only x or only y numeric) is treated as "no coords" so we
37
+ * never accidentally embed `NaN` into the solution salt.
38
+ * - `(0, 0)` is treated as "no coords" too — that's what the widgets
39
+ * emit for an `autoStart` mount (post-PoW escalation) or an untrusted
40
+ * pointer event on the checkbox, neither of which is a real click. The
41
+ * resumed widget re-uses the same default and the outcome on the wire
42
+ * is identical; we discard the pair here so future readers can tell
43
+ * the two apart.
44
+ */
45
+ export const handleSessionInvalidated = (
46
+ x: number | undefined,
47
+ y: number | undefined,
48
+ firedRef: MutableRef<boolean>,
49
+ pendingCoordsRef: MutableRef<RetryCoords | null>,
50
+ ): { shouldRestart: boolean } => {
51
+ if (firedRef.current) return { shouldRestart: false };
52
+ firedRef.current = true;
53
+ const bothNumeric = typeof x === "number" && typeof y === "number";
54
+ const isRealClick = bothNumeric && (x !== 0 || y !== 0);
55
+ pendingCoordsRef.current = isRealClick ? { x, y } : null;
56
+ return { shouldRestart: true };
57
+ };
58
+
59
+ /**
60
+ * Compute the props a resumed inner widget mounts with. Consumes the
61
+ * pending coords ref (sets it back to `null`) so the next render doesn't
62
+ * accidentally re-inject stale coords into a fresh escalation.
63
+ *
64
+ * `escalationAutoStart` reflects the caller's own `autoStart` argument to
65
+ * `renderForCaptchaType` — post-PoW escalations keep the historic
66
+ * autoStart=true behaviour when no retry coords are pending.
67
+ */
68
+ export const consumeRetryMountProps = (
69
+ pendingCoordsRef: MutableRef<RetryCoords | null>,
70
+ escalationAutoStart: boolean,
71
+ ): { autoStart: boolean; startCoords: RetryCoords | undefined } => {
72
+ const startCoords = pendingCoordsRef.current ?? undefined;
73
+ pendingCoordsRef.current = null;
74
+ return {
75
+ autoStart: escalationAutoStart || Boolean(startCoords),
76
+ startCoords,
77
+ };
78
+ };
@@ -0,0 +1,129 @@
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
+ import { describe, expect, it } from "vitest";
16
+ import {
17
+ type MutableRef,
18
+ type RetryCoords,
19
+ consumeRetryMountProps,
20
+ handleSessionInvalidated,
21
+ } from "../sessionInvalidatedRecovery.js";
22
+
23
+ const ref = <T>(initial: T): MutableRef<T> => ({ current: initial });
24
+
25
+ describe("handleSessionInvalidated", () => {
26
+ it("records both coords and signals a restart on the first fire", () => {
27
+ const firedRef = ref(false);
28
+ const coordsRef = ref<RetryCoords | null>(null);
29
+
30
+ const result = handleSessionInvalidated(120, 340, firedRef, coordsRef);
31
+
32
+ expect(result).toEqual({ shouldRestart: true });
33
+ expect(firedRef.current).toBe(true);
34
+ expect(coordsRef.current).toEqual({ x: 120, y: 340 });
35
+ });
36
+
37
+ it("treats (0, 0) as 'no coords' — that's the autoStart / untrusted-event default, not a real click", () => {
38
+ const firedRef = ref(false);
39
+ const coordsRef = ref<RetryCoords | null>(null);
40
+
41
+ const result = handleSessionInvalidated(0, 0, firedRef, coordsRef);
42
+
43
+ expect(result).toEqual({ shouldRestart: true });
44
+ expect(coordsRef.current).toBeNull();
45
+ });
46
+
47
+ it("carries a real click even if only one axis is at the origin", () => {
48
+ const firedRef = ref(false);
49
+ const coordsRef = ref<RetryCoords | null>(null);
50
+
51
+ handleSessionInvalidated(0, 340, firedRef, coordsRef);
52
+
53
+ expect(coordsRef.current).toEqual({ x: 0, y: 340 });
54
+ });
55
+
56
+ it("stores no coords when x or y is undefined (autoStart / non-trusted event)", () => {
57
+ const firedRef = ref(false);
58
+ const coordsRef = ref<RetryCoords | null>(null);
59
+
60
+ const result = handleSessionInvalidated(
61
+ undefined,
62
+ undefined,
63
+ firedRef,
64
+ coordsRef,
65
+ );
66
+
67
+ expect(result).toEqual({ shouldRestart: true });
68
+ expect(firedRef.current).toBe(true);
69
+ expect(coordsRef.current).toBeNull();
70
+ });
71
+
72
+ it("stores no coords when only one axis is present — never emit NaN into the salt", () => {
73
+ const firedRef = ref(false);
74
+ const coordsRef = ref<RetryCoords | null>(null);
75
+
76
+ handleSessionInvalidated(120, undefined, firedRef, coordsRef);
77
+
78
+ expect(coordsRef.current).toBeNull();
79
+ });
80
+
81
+ it("is one-shot per outer widget lifetime — a second call is a no-op", () => {
82
+ const firedRef = ref(false);
83
+ const coordsRef = ref<RetryCoords | null>(null);
84
+
85
+ handleSessionInvalidated(100, 200, firedRef, coordsRef);
86
+ const second = handleSessionInvalidated(500, 600, firedRef, coordsRef);
87
+
88
+ expect(second).toEqual({ shouldRestart: false });
89
+ // The second call must not overwrite the first attempt's coords.
90
+ expect(coordsRef.current).toEqual({ x: 100, y: 200 });
91
+ });
92
+ });
93
+
94
+ describe("consumeRetryMountProps", () => {
95
+ it("returns pending retry coords and forces autoStart", () => {
96
+ const coordsRef = ref<RetryCoords | null>({ x: 42, y: 99 });
97
+
98
+ const mount = consumeRetryMountProps(coordsRef, false);
99
+
100
+ expect(mount).toEqual({
101
+ autoStart: true,
102
+ startCoords: { x: 42, y: 99 },
103
+ });
104
+ });
105
+
106
+ it("clears the coords ref so a subsequent render doesn't re-inject stale values", () => {
107
+ const coordsRef = ref<RetryCoords | null>({ x: 42, y: 99 });
108
+
109
+ consumeRetryMountProps(coordsRef, false);
110
+
111
+ expect(coordsRef.current).toBeNull();
112
+ });
113
+
114
+ it("propagates escalationAutoStart when no retry is pending (post-PoW escalation path)", () => {
115
+ const coordsRef = ref<RetryCoords | null>(null);
116
+
117
+ const mount = consumeRetryMountProps(coordsRef, true);
118
+
119
+ expect(mount).toEqual({ autoStart: true, startCoords: undefined });
120
+ });
121
+
122
+ it("returns autoStart=false, startCoords=undefined for the initial checkbox mount", () => {
123
+ const coordsRef = ref<RetryCoords | null>(null);
124
+
125
+ const mount = consumeRetryMountProps(coordsRef, false);
126
+
127
+ expect(mount).toEqual({ autoStart: false, startCoords: undefined });
128
+ });
129
+ });