@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,141 @@
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
+ // Widget for the `authenticated` captcha outcome — no user-facing challenge,
16
+ // just a visible acknowledgement that Web Bot Auth verification succeeded.
17
+ // Mounts once, immediately encodes a ProcaptchaToken with the sessionId as
18
+ // its commitmentId (matching what /client/authenticated/verify decodes) and
19
+ // fires `onHuman`. The badge itself is purely presentational; the token
20
+ // submission happens in the mount effect.
21
+ //
22
+ // The token carries empty signature bags (`{provider: {}, user: {}}`) — the
23
+ // authenticated verify endpoint does not check the token's user or provider
24
+ // signatures because there was no captcha challenge to sign. Only the dApp
25
+ // server's signature on the timestamp is checked at verify time, and that
26
+ // is applied by the dApp on the way to /verify (not by the widget here).
27
+
28
+ import { getDefaultEvents } from "@prosopo/procaptcha-common";
29
+ import {
30
+ type Account,
31
+ ApiParams,
32
+ CaptchaType,
33
+ type ProcaptchaCallbacks,
34
+ type RandomProvider,
35
+ encodeProcaptchaOutput,
36
+ } from "@prosopo/types";
37
+ import { type FC, useEffect, useRef } from "react";
38
+
39
+ export type AuthenticatedBadgeProps = {
40
+ sessionId: string;
41
+ agent?: string;
42
+ dapp: string;
43
+ userAccount: Account;
44
+ provider: RandomProvider;
45
+ callbacks: ProcaptchaCallbacks;
46
+ };
47
+
48
+ const displayHost = (agent?: string): string => {
49
+ if (!agent) return "unknown";
50
+ try {
51
+ return new URL(agent).hostname;
52
+ } catch {
53
+ return agent;
54
+ }
55
+ };
56
+
57
+ export const AuthenticatedBadge: FC<AuthenticatedBadgeProps> = ({
58
+ sessionId,
59
+ agent,
60
+ dapp,
61
+ userAccount,
62
+ provider,
63
+ callbacks,
64
+ }) => {
65
+ // One-shot: React 18 StrictMode double-invokes effects in development;
66
+ // this guard makes sure the token is emitted exactly once even under
67
+ // double-mount. Production doesn't double-invoke, so this is a
68
+ // belt-and-braces against local-dev confusion.
69
+ const emittedRef = useRef(false);
70
+
71
+ useEffect(() => {
72
+ if (emittedRef.current) return;
73
+ emittedRef.current = true;
74
+ const events = getDefaultEvents(callbacks);
75
+ const token = encodeProcaptchaOutput({
76
+ [ApiParams.providerUrl]: provider.provider.url,
77
+ [ApiParams.user]: userAccount.account.address,
78
+ [ApiParams.dapp]: dapp,
79
+ // Verify endpoint reads commitmentId as the sessionId — reusing
80
+ // the existing slot avoids a token codec bump for this one flow.
81
+ [ApiParams.commitmentId]: sessionId,
82
+ [ApiParams.timestamp]: Date.now().toString(),
83
+ [ApiParams.signature]: {
84
+ [ApiParams.provider]: {},
85
+ [ApiParams.user]: {},
86
+ },
87
+ [ApiParams.captchaType]: CaptchaType.authenticated,
88
+ });
89
+ events.onHuman(token);
90
+ }, [sessionId, dapp, userAccount, provider, callbacks]);
91
+
92
+ return (
93
+ <div
94
+ style={{
95
+ display: "inline-flex",
96
+ alignItems: "center",
97
+ gap: 8,
98
+ padding: "8px 12px",
99
+ borderRadius: 6,
100
+ background: "#eef2ff",
101
+ border: "1px solid #c7d2fe",
102
+ color: "#3730a3",
103
+ fontFamily:
104
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
105
+ fontSize: 13,
106
+ lineHeight: 1.4,
107
+ }}
108
+ // biome-ignore lint/a11y/useSemanticElements: doesn't make sense
109
+ role="status"
110
+ aria-label="Verified agent"
111
+ >
112
+ <svg
113
+ width="16"
114
+ height="16"
115
+ viewBox="0 0 24 24"
116
+ fill="none"
117
+ stroke="currentColor"
118
+ strokeWidth="2.5"
119
+ strokeLinecap="round"
120
+ strokeLinejoin="round"
121
+ aria-hidden="true"
122
+ >
123
+ <title>Verified</title>
124
+ <path d="M20 6L9 17l-5-5" />
125
+ </svg>
126
+ <span>
127
+ {agent ? (
128
+ <>
129
+ <strong>Verified agent</strong>: {displayHost(agent)}
130
+ </>
131
+ ) : (
132
+ // No Signature-Agent URL on the response — the operator's Allow
133
+ // rule matched on a non-Web-Bot-Auth condition (IP CIDR, JA4,
134
+ // UA substring, ASN, country). Fall back to generic copy so
135
+ // the operator isn't misled about which qualifier fired.
136
+ <strong>Trusted request</strong>
137
+ )}
138
+ </span>
139
+ </div>
140
+ );
141
+ };
@@ -33,6 +33,7 @@ import {
33
33
  import { darkTheme, lightTheme } from "@prosopo/widget-skeleton";
34
34
  import type { KeyboardEvent, MouseEvent, TouchEvent } from "react";
35
35
  import { useEffect, useRef, useState } from "react";
36
+ import { AuthenticatedBadge } from "./AuthenticatedBadge.js";
36
37
  import customDetectBot from "./customDetectBot.js";
37
38
  import { evaluateFrictionlessResult } from "./frictionlessResultGuard.js";
38
39
  import {
@@ -42,6 +43,8 @@ import {
42
43
  normaliseRetryCoords,
43
44
  } from "./sessionInvalidatedRecovery.js";
44
45
 
46
+ const NO_SESSION_FOUND_KEY = "CAPTCHA.NO_SESSION_FOUND";
47
+
45
48
  // Each session uses exactly one solver — chosen by the /frictionless response.
46
49
  const ProcaptchaLoader = async () =>
47
50
  (await import("@prosopo/procaptcha-react")).Procaptcha;
@@ -94,7 +97,7 @@ const defaultLoadingState = (
94
97
  attemptCount: number,
95
98
  ): FrictionlessLoadingState => ({
96
99
  loading: false,
97
- attemptCount: attemptCount || 0,
100
+ attemptCount,
98
101
  });
99
102
 
100
103
  export const ProcaptchaFrictionless = ({
@@ -113,10 +116,22 @@ export const ProcaptchaFrictionless = ({
113
116
  // to click the checkbox a second time and the checkbox click position is
114
117
  // preserved in the eventual solution salt.
115
118
  const pendingRetryCoordsRef = useRef<RetryCoords | null>(null);
116
- // One-shot outer guard so a persistently broken session doesn't loop.
117
- // After we've retried once, a second NO_SESSION_FOUND falls back to the
118
- // inner widget's own `frictionlessState.restart()` path.
119
- const sessionInvalidatedFiredRef = useRef(false);
119
+ // Bounded outer guard so a persistently broken session doesn't loop. Each
120
+ // re-mint costs a /frictionless round trip, so the count is capped but
121
+ // it is a count rather than a one-shot, because a widget legitimately
122
+ // mints a new session every time the user presses reload. `onReload`
123
+ // clears it for the same reason. When the budget is spent we fall over
124
+ // visibly (see `onSessionInvalidated`) instead of leaving the user on a
125
+ // dead "No session found" checkbox.
126
+ const sessionInvalidatedAttemptsRef = useRef(0);
127
+ // Escalation sessions we have already mounted a widget for. The provider
128
+ // mints exactly one escalation session per PoW solution and consumes it on
129
+ // the first challenge fetch, so a repeat handoff for the same id can only
130
+ // produce a widget that 400s with NO_SESSION_FOUND. The PoW manager fires
131
+ // `onEscalate` from inside its `providerRetry`-wrapped `submit()`, so a
132
+ // throw anywhere after the handoff re-runs submit and escalates a second
133
+ // time on the same envelope.
134
+ const escalatedSessionIdsRef = useRef(new Set<string>());
120
135
  // Bumped on every mount so the replacement widget gets a fresh React
121
136
  // `key`. Without it a re-render for the same captcha type reconciles onto
122
137
  // the existing element, and the inner widget keeps the manager it built on
@@ -161,9 +176,14 @@ export const ProcaptchaFrictionless = ({
161
176
  ),
162
177
  );
163
178
 
179
+ // `??`, not `||`: every caller that wants the counter back at zero passes
180
+ // literal 0, and `0 || current` silently kept the old count. `start()` then
181
+ // tripped its own `attemptCount >= 5` fall-over after five *cumulative*
182
+ // runs in a widget lifetime — five reload presses were enough to strand the
183
+ // user on the error placeholder even though every one of them succeeded.
164
184
  const resetState = (attemptCount?: number) => {
165
185
  stateRef.current = defaultLoadingState(
166
- attemptCount || stateRef.current.attemptCount,
186
+ attemptCount ?? stateRef.current.attemptCount,
167
187
  );
168
188
  };
169
189
 
@@ -212,6 +232,12 @@ export const ProcaptchaFrictionless = ({
212
232
  newSessionId: string,
213
233
  coords?: RetryCoords,
214
234
  ) => {
235
+ // Idempotent per escalation session — see `escalatedSessionIdsRef`.
236
+ // Without this a re-run of the PoW widget's `submit()` mounts a
237
+ // second widget against the session the first one already spent,
238
+ // which the provider answers with 400 CAPTCHA.NO_SESSION_FOUND.
239
+ if (escalatedSessionIdsRef.current.has(newSessionId)) return;
240
+ escalatedSessionIdsRef.current.add(newSessionId);
215
241
  void renderForCaptchaType(
216
242
  next,
217
243
  {
@@ -225,23 +251,38 @@ export const ProcaptchaFrictionless = ({
225
251
 
226
252
  // The provider returned NO_SESSION_FOUND on the inner widget's
227
253
  // challenge fetch — the sessionId minted upstream is no longer usable
228
- // (usually because a duplicate /captcha/{type} POST from a WebView
229
- // mount storm consumed it first). Re-run the frictionless flow to
254
+ // (a duplicate /captcha/{type} POST consumed it first, or the widget
255
+ // re-sent an id it had already spent). Re-run the frictionless flow to
230
256
  // mint a fresh session, then re-mount the inner widget with the
231
257
  // preserved checkbox click coords so the user is not asked to click a
232
- // second time. One-shot per outer widget lifetime — if the retry
233
- // also fails, fall through to the inner widget's existing
234
- // `frictionlessState.restart()` path.
258
+ // second time.
259
+ //
260
+ // The inner widget cannot recover on its own here: it always takes
261
+ // this branch and returns before its own `frictionlessState.restart()`
262
+ // fallback, and its guard ref is fresh on every re-mount. So whatever
263
+ // this handler declines to do, nothing else does — hence the terminal
264
+ // `fallOverWithStyle` rather than a silent return once the retry
265
+ // budget is spent.
235
266
  const onSessionInvalidated = (x?: number, y?: number) => {
236
267
  const { shouldRestart } = handleSessionInvalidated(
237
268
  x,
238
269
  y,
239
- sessionInvalidatedFiredRef,
270
+ sessionInvalidatedAttemptsRef,
240
271
  pendingRetryCoordsRef,
241
272
  );
242
- if (!shouldRestart) return;
243
- resetState(0);
244
- void start();
273
+ if (shouldRestart) {
274
+ resetState(0);
275
+ void start();
276
+ return;
277
+ }
278
+ // Budget spent. Surface the error on the checkbox and let
279
+ // `fallOverWithStyle`'s NO_SESSION_FOUND branch schedule the
280
+ // 10-second full restart, so the user always has a way back.
281
+ const message = i18n.isInitialized
282
+ ? i18n.t(NO_SESSION_FOUND_KEY)
283
+ : "No session found";
284
+ events.onError(new Error(message));
285
+ fallOverWithStyle(message, NO_SESSION_FOUND_KEY);
245
286
  };
246
287
 
247
288
  // The user pressed reload on the challenge. The provider consumed this
@@ -253,6 +294,9 @@ export const ProcaptchaFrictionless = ({
253
294
  const onReload = (x?: number, y?: number) => {
254
295
  pendingRetryCoordsRef.current = normaliseRetryCoords(x, y);
255
296
  nextMountAutoStartRef.current = true;
297
+ // A reload mints a genuinely new session, so the invalidation
298
+ // budget for the *previous* one shouldn't count against it.
299
+ sessionInvalidatedAttemptsRef.current = 0;
256
300
  resetState(0);
257
301
  void start();
258
302
  };
@@ -275,7 +319,31 @@ export const ProcaptchaFrictionless = ({
275
319
  mountCountRef.current += 1;
276
320
  const mountKey = mountCountRef.current;
277
321
 
278
- if (captchaType === CaptchaType.image) {
322
+ if (captchaType === CaptchaType.authenticated) {
323
+ // Web Bot Auth pre-verified pass-through. No challenge, no
324
+ // interaction — the badge component encodes and fires the token
325
+ // on mount. Skip the loader chain the other branches use because
326
+ // there is no captcha module to lazy-import here.
327
+ if (!frictionlessState.sessionId) {
328
+ events.onError(
329
+ new Error(
330
+ "authenticated captcha response missing sessionId — provider is misbehaving",
331
+ ),
332
+ );
333
+ fallOverWithStyle();
334
+ return;
335
+ }
336
+ setComponentToRender(
337
+ <AuthenticatedBadge
338
+ sessionId={frictionlessState.sessionId}
339
+ agent={frictionlessState.agent}
340
+ dapp={config.account.address ?? ""}
341
+ userAccount={frictionlessState.userAccount}
342
+ provider={frictionlessState.provider}
343
+ callbacks={callbacks}
344
+ />,
345
+ );
346
+ } else if (captchaType === CaptchaType.image) {
279
347
  const Procaptcha = await ProcaptchaLoader();
280
348
  setComponentToRender(
281
349
  <Procaptcha
@@ -374,6 +442,7 @@ export const ProcaptchaFrictionless = ({
374
442
  encryptBehavioralData: result.encryptBehavioralData,
375
443
  getSimdReadings: result.getSimdReadings,
376
444
  hp: result.hp,
445
+ agent: result.agent,
377
446
  };
378
447
 
379
448
  await renderForCaptchaType(result.captchaType, frictionlessState);
@@ -297,6 +297,7 @@ const customDetectBot: BotDetectionFunction = async (
297
297
  userAccount: userAccount,
298
298
  error: captcha.error,
299
299
  hp: captcha.hp,
300
+ agent: captcha.agent,
300
301
  };
301
302
  }
302
303
 
@@ -357,6 +358,7 @@ const customDetectBot: BotDetectionFunction = async (
357
358
  userAccount: userAccount,
358
359
  error: captcha.error,
359
360
  hp: captcha.hp,
361
+ agent: captcha.agent,
360
362
  // Map specific trackers to generic behavioral collectors
361
363
  behaviorCollector1: detectionResult.mouseTracker,
362
364
  behaviorCollector2: detectionResult.touchTracker,
@@ -59,22 +59,62 @@ export type DetectorType = (
59
59
  userAccount: Account;
60
60
  }>;
61
61
 
62
+ interface LoaderAttempt {
63
+ readonly url: string;
64
+ readonly release: () => void;
65
+ }
66
+
67
+ const blobAttempt = (script: string): LoaderAttempt => {
68
+ const url = URL.createObjectURL(
69
+ new Blob([script], { type: "text/javascript" }),
70
+ );
71
+ return { url, release: (): void => URL.revokeObjectURL(url) };
72
+ };
73
+
74
+ const dataAttempt = (script: string): LoaderAttempt => ({
75
+ url: `data:text/javascript;charset=utf-8,${encodeURIComponent(script)}`,
76
+ release: (): void => undefined,
77
+ });
78
+
79
+ /** Injection point for tests; real callers never pass this. */
80
+ export type ModuleImporter = (url: string) => Promise<unknown>;
81
+
82
+ const defaultImporter: ModuleImporter = (url) =>
83
+ import(/* @vite-ignore */ /* webpackIgnore: true */ url);
84
+
62
85
  /**
63
- * Loads a detector from a provider-served obfuscated ESM string (the per-session
64
- * pool bundle). Evaluated via a blob URL — the served module is self-contained
65
- * (no imports), so dynamic `import()` of the blob yields its default export.
86
+ * Loads a detector from a provider-served obfuscated ESM string (the
87
+ * per-session pool bundle). The module is self-contained, so a dynamic
88
+ * `import()` yields its default export.
89
+ *
90
+ * A blob URL is tried first, then a `data:` URL. The two are gated by
91
+ * different CSP directives and sites commonly allow one without the other — a
92
+ * `script-src` of `*` matches neither, and a policy listing `data:` for
93
+ * scripts and `blob:` only for workers blocks the blob path outright. Without
94
+ * the fallback every session on such a site loses the detector, sends an empty
95
+ * token, and can never pass frictionlessly.
96
+ *
97
+ * A blocked attempt logs a CSP violation before the import rejects. That noise
98
+ * is the cost of recovering the session.
66
99
  */
67
100
  export const DetectorLoaderFromScript = async (
68
101
  script: string,
102
+ importModule: ModuleImporter = defaultImporter,
69
103
  ): Promise<DetectorType> => {
70
- const blob = new Blob([script], { type: "text/javascript" });
71
- const url = URL.createObjectURL(blob);
72
- try {
73
- const mod = (await import(/* @vite-ignore */ url)) as {
74
- default: DetectorType;
75
- };
76
- return mod.default;
77
- } finally {
78
- URL.revokeObjectURL(url);
104
+ let lastError: unknown = new Error("no loader attempt ran");
105
+ for (const build of [blobAttempt, dataAttempt]) {
106
+ let attempt: LoaderAttempt | undefined;
107
+ try {
108
+ attempt = build(script);
109
+ const mod = (await importModule(attempt.url)) as {
110
+ default: DetectorType;
111
+ };
112
+ return mod.default;
113
+ } catch (err) {
114
+ lastError = err;
115
+ } finally {
116
+ attempt?.release();
117
+ }
79
118
  }
119
+ throw lastError;
80
120
  };
@@ -49,23 +49,43 @@ export const normaliseRetryCoords = (
49
49
  };
50
50
 
51
51
  /**
52
- * Semantics of the outer recovery handler. Returns whether the caller
53
- * should proceed to re-run the frictionless flow (`start()`), and mutates
54
- * the passed refs to record the one-shot fire + pending coords.
52
+ * How many times a single outer widget will re-mint a session in response to
53
+ * `CAPTCHA.NO_SESSION_FOUND` on the inner widget before giving up and handing
54
+ * over to the terminal fallback.
55
55
  *
56
- * Second calls are ignored (one-shot per outer widget lifetime) so a
57
- * persistently broken session doesn't loop.
56
+ * This used to be one-shot per outer widget lifetime, which stranded users:
57
+ * the inner widget always takes the `onSessionInvalidated` branch (its own
58
+ * guard ref is fresh on every re-mount, because the outer widget bumps its
59
+ * mount key) and returns without touching its own `restart()` fallback. Once
60
+ * the outer one-shot was spent nothing at all handled the second failure, so
61
+ * the checkbox sat on "No session found" forever. A widget legitimately mints
62
+ * many sessions over its lifetime — every reload press is a new one — so a
63
+ * single lifetime-wide attempt is far too coarse a bound.
64
+ */
65
+ export const MAX_SESSION_INVALIDATED_RETRIES = 3;
66
+
67
+ /**
68
+ * Semantics of the outer recovery handler. Returns whether the caller should
69
+ * proceed to re-run the frictionless flow (`start()`), and mutates the passed
70
+ * refs to record the attempt + pending coords.
71
+ *
72
+ * Bounded rather than one-shot: a persistently broken session still stops
73
+ * looping, but the caller is told (`exhausted`) so it can fall back visibly
74
+ * instead of silently doing nothing.
58
75
  */
59
76
  export const handleSessionInvalidated = (
60
77
  x: number | undefined,
61
78
  y: number | undefined,
62
- firedRef: MutableRef<boolean>,
79
+ attemptsRef: MutableRef<number>,
63
80
  pendingCoordsRef: MutableRef<RetryCoords | null>,
64
- ): { shouldRestart: boolean } => {
65
- if (firedRef.current) return { shouldRestart: false };
66
- firedRef.current = true;
81
+ maxAttempts: number = MAX_SESSION_INVALIDATED_RETRIES,
82
+ ): { shouldRestart: boolean; exhausted: boolean } => {
83
+ if (attemptsRef.current >= maxAttempts) {
84
+ return { shouldRestart: false, exhausted: true };
85
+ }
86
+ attemptsRef.current += 1;
67
87
  pendingCoordsRef.current = normaliseRetryCoords(x, y);
68
- return { shouldRestart: true };
88
+ return { shouldRestart: true, exhausted: false };
69
89
  };
70
90
 
71
91
  /**
@@ -0,0 +1,108 @@
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
+ // @vitest-environment jsdom
16
+
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+ import {
19
+ DetectorLoaderFromScript,
20
+ type DetectorType,
21
+ type ModuleImporter,
22
+ } from "../detectorLoader.js";
23
+
24
+ const SCRIPT = "export default () => ({ token: 'x' });";
25
+
26
+ const detector = (() => undefined) as unknown as DetectorType;
27
+
28
+ describe("DetectorLoaderFromScript", () => {
29
+ const created: string[] = [];
30
+ const revoked: string[] = [];
31
+
32
+ beforeEach(() => {
33
+ created.length = 0;
34
+ revoked.length = 0;
35
+ let counter = 0;
36
+ URL.createObjectURL = vi.fn((): string => {
37
+ const url = `blob:https://example.test/${counter++}`;
38
+ created.push(url);
39
+ return url;
40
+ });
41
+ URL.revokeObjectURL = vi.fn((url: string): void => {
42
+ revoked.push(url);
43
+ });
44
+ });
45
+
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ it("uses a blob URL when the import succeeds", async () => {
51
+ const seen: string[] = [];
52
+ const importer: ModuleImporter = async (url) => {
53
+ seen.push(url);
54
+ return { default: detector };
55
+ };
56
+
57
+ await expect(DetectorLoaderFromScript(SCRIPT, importer)).resolves.toBe(
58
+ detector,
59
+ );
60
+
61
+ expect(seen).toHaveLength(1);
62
+ expect(seen[0]).toMatch(/^blob:/);
63
+ expect(revoked).toEqual(created);
64
+ });
65
+
66
+ it("falls back to a data URL when the blob import is blocked by CSP", async () => {
67
+ const seen: string[] = [];
68
+ const importer: ModuleImporter = async (url) => {
69
+ seen.push(url);
70
+ if (url.startsWith("blob:")) {
71
+ // What Chrome throws when script-src omits blob:
72
+ throw new TypeError("Failed to fetch dynamically imported module");
73
+ }
74
+ return { default: detector };
75
+ };
76
+
77
+ await expect(DetectorLoaderFromScript(SCRIPT, importer)).resolves.toBe(
78
+ detector,
79
+ );
80
+
81
+ expect(seen).toHaveLength(2);
82
+ expect(seen[0]).toMatch(/^blob:/);
83
+ expect(seen[1]).toMatch(/^data:text\/javascript;charset=utf-8,/);
84
+ expect(decodeURIComponent(seen[1]?.split(",")[1] ?? "")).toBe(SCRIPT);
85
+ });
86
+
87
+ it("revokes the blob URL even when its import fails", async () => {
88
+ const importer: ModuleImporter = async (url) => {
89
+ if (url.startsWith("blob:")) throw new Error("blocked");
90
+ return { default: detector };
91
+ };
92
+
93
+ await DetectorLoaderFromScript(SCRIPT, importer);
94
+
95
+ expect(revoked).toEqual(created);
96
+ });
97
+
98
+ it("rethrows the last error when both schemes are blocked", async () => {
99
+ const importer: ModuleImporter = async (url) => {
100
+ throw new Error(`blocked: ${url.slice(0, 5)}`);
101
+ };
102
+
103
+ await expect(DetectorLoaderFromScript(SCRIPT, importer)).rejects.toThrow(
104
+ "blocked: data:",
105
+ );
106
+ expect(revoked).toEqual(created);
107
+ });
108
+ });