@prosopo/procaptcha-frictionless 2.8.31 → 2.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prosopo/procaptcha-frictionless",
3
- "version": "2.8.31",
3
+ "version": "2.11.0",
4
4
  "author": "PROSOPO LIMITED <info@prosopo.io>",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/index.js",
@@ -30,21 +30,22 @@
30
30
  },
31
31
  "browserslist": ["> 0.5%, last 2 versions, not dead"],
32
32
  "dependencies": {
33
- "@prosopo/api": "3.1.41",
34
- "@prosopo/common": "3.1.28",
35
- "@prosopo/detector": "3.4.0",
36
- "@prosopo/load-balancer": "2.8.17",
37
- "@prosopo/locale": "3.1.28",
38
- "@prosopo/procaptcha-common": "2.9.23",
39
- "@prosopo/procaptcha-pow": "2.8.31",
40
- "@prosopo/procaptcha-react": "2.9.27",
41
- "@prosopo/types": "3.8.0",
42
- "@prosopo/widget-skeleton": "2.7.13",
33
+ "@prosopo/api": "3.4.8",
34
+ "@prosopo/common": "3.1.38",
35
+ "@prosopo/detector": "3.4.36",
36
+ "@prosopo/load-balancer": "2.9.10",
37
+ "@prosopo/locale": "3.2.4",
38
+ "@prosopo/procaptcha-common": "2.10.17",
39
+ "@prosopo/procaptcha-pow": "2.9.4",
40
+ "@prosopo/procaptcha-puzzle": "2.10.8",
41
+ "@prosopo/procaptcha-react": "2.9.66",
42
+ "@prosopo/types": "4.3.0",
43
+ "@prosopo/widget-skeleton": "2.8.3",
43
44
  "dotenv": "16.4.5",
44
45
  "react": "18.3.1"
45
46
  },
46
47
  "devDependencies": {
47
- "@prosopo/config": "3.3.0",
48
+ "@prosopo/config": "3.3.1",
48
49
  "@types/node": "22.10.2",
49
50
  "@vitest/coverage-v8": "3.2.4",
50
51
  "concurrently": "9.0.1",
@@ -58,7 +59,8 @@
58
59
  },
59
60
  "repository": {
60
61
  "type": "git",
61
- "url": "git+https://github.com/prosopo/captcha.git"
62
+ "url": "git+https://github.com/prosopo/captcha.git",
63
+ "directory": "packages/procaptcha-frictionless"
62
64
  },
63
65
  "bugs": {
64
66
  "url": "https://github.com/prosopo/captcha/issues"
@@ -0,0 +1,286 @@
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 { loadI18next } from "@prosopo/locale";
16
+ import {
17
+ Checkbox,
18
+ TestModeBanner,
19
+ getDefaultEvents,
20
+ isSecureBrowserContext,
21
+ providerRetry,
22
+ } from "@prosopo/procaptcha-common";
23
+ import {
24
+ CaptchaType,
25
+ type FrictionlessState,
26
+ type ModeType,
27
+ ProcaptchaConfigSchema,
28
+ type ProcaptchaFrictionlessProps,
29
+ } from "@prosopo/types";
30
+ import { darkTheme, lightTheme } from "@prosopo/widget-skeleton";
31
+ import { useEffect, useRef, useState } from "react";
32
+ import customDetectBot from "./customDetectBot.js";
33
+
34
+ // Each session uses exactly one solver — chosen by the /frictionless response.
35
+ const ProcaptchaLoader = async () =>
36
+ (await import("@prosopo/procaptcha-react")).Procaptcha;
37
+ const ProcaptchaPuzzleLoader = async () =>
38
+ (await import("@prosopo/procaptcha-puzzle")).ProcaptchaPuzzle;
39
+ const ProcaptchaPowLoader = async () =>
40
+ (await import("@prosopo/procaptcha-pow")).ProcaptchaPow;
41
+
42
+ const renderPlaceholder = (
43
+ theme: string | undefined,
44
+ mode: ModeType,
45
+ errorMessage: string | undefined,
46
+ isTranslationLoaded: boolean,
47
+ translationFn: (key: string) => string,
48
+ loading: boolean,
49
+ ) => {
50
+ const checkboxTheme = "light" === theme ? lightTheme : darkTheme;
51
+
52
+ if (mode === "invisible") {
53
+ return null;
54
+ }
55
+
56
+ return (
57
+ <Checkbox
58
+ theme={checkboxTheme}
59
+ onChange={async () => {}}
60
+ checked={false}
61
+ labelText={isTranslationLoaded ? translationFn("WIDGET.I_AM_HUMAN") : ""}
62
+ error={errorMessage}
63
+ aria-label="human checkbox"
64
+ loading={loading}
65
+ />
66
+ );
67
+ };
68
+
69
+ type FrictionlessLoadingState = {
70
+ loading: boolean;
71
+ attemptCount: number;
72
+ errorMessage?: string;
73
+ };
74
+
75
+ const defaultLoadingState = (
76
+ attemptCount: number,
77
+ ): FrictionlessLoadingState => ({
78
+ loading: false,
79
+ attemptCount: attemptCount || 0,
80
+ });
81
+
82
+ export const ProcaptchaFrictionless = ({
83
+ config,
84
+ callbacks,
85
+ restart,
86
+ i18n,
87
+ detectBot = customDetectBot,
88
+ container,
89
+ }: ProcaptchaFrictionlessProps) => {
90
+ const stateRef = useRef(defaultLoadingState(0));
91
+ const events = getDefaultEvents(callbacks);
92
+
93
+ useEffect(() => {
94
+ if (config.language) {
95
+ if (i18n) {
96
+ if (i18n.language !== config.language) {
97
+ i18n.changeLanguage(config.language).then((r) => r);
98
+ }
99
+ } else {
100
+ loadI18next(false).then((i18n) => {
101
+ if (i18n.language !== config.language)
102
+ i18n.changeLanguage(config.language).then((r) => r);
103
+ });
104
+ }
105
+ }
106
+ }, [i18n, config.language]);
107
+
108
+ const [componentToRender, setComponentToRender] = useState(
109
+ renderPlaceholder(
110
+ config.theme,
111
+ config.mode,
112
+ stateRef.current.errorMessage,
113
+ i18n.isInitialized,
114
+ i18n.t,
115
+ true,
116
+ ),
117
+ );
118
+
119
+ const resetState = (attemptCount?: number) => {
120
+ stateRef.current = defaultLoadingState(
121
+ attemptCount || stateRef.current.attemptCount,
122
+ );
123
+ };
124
+
125
+ const fallOverWithStyle = (errorMessage?: string, errorKey?: string) => {
126
+ // We could always re-render here after a period but this will result in never-ending requests to Providers when
127
+ // settings are incorrect, or the user is not human. We need to selectively re-render for events like
128
+ // `no session found` but not for other errors.
129
+ if (errorKey === "CAPTCHA.NO_SESSION_FOUND") {
130
+ setTimeout(() => {
131
+ restartComponentTimeout();
132
+ }, 0);
133
+ }
134
+ setComponentToRender(
135
+ renderPlaceholder(
136
+ config.theme,
137
+ config.mode,
138
+ errorMessage || "Cannot load CAPTCHA",
139
+ i18n.isInitialized,
140
+ i18n.t,
141
+ false,
142
+ ),
143
+ );
144
+ };
145
+
146
+ const restartComponentTimeout = () => {
147
+ setTimeout(() => {
148
+ resetState(0);
149
+ events.onReset();
150
+ // `restart` frictionless widget after 10 seconds
151
+ restart();
152
+ }, 10000);
153
+ };
154
+
155
+ // Mount the captcha widget that matches the chosen type. Used both for the
156
+ // initial frictionless decision and for the post-pow escalation handoff —
157
+ // in the latter case the FrictionlessState carries the new sessionId minted
158
+ // by the provider when it decided PoW alone wasn't enough.
159
+ const renderForCaptchaType = async (
160
+ captchaType: string,
161
+ frictionlessState: FrictionlessState,
162
+ ) => {
163
+ const onEscalate = (
164
+ next: CaptchaType.image | CaptchaType.puzzle,
165
+ newSessionId: string,
166
+ ) => {
167
+ void renderForCaptchaType(next, {
168
+ ...frictionlessState,
169
+ sessionId: newSessionId,
170
+ });
171
+ };
172
+
173
+ if (captchaType === CaptchaType.image) {
174
+ const Procaptcha = await ProcaptchaLoader();
175
+ setComponentToRender(
176
+ <Procaptcha
177
+ config={config}
178
+ callbacks={callbacks}
179
+ frictionlessState={frictionlessState}
180
+ i18n={i18n}
181
+ />,
182
+ );
183
+ } else if (captchaType === CaptchaType.puzzle) {
184
+ const ProcaptchaPuzzle = await ProcaptchaPuzzleLoader();
185
+ setComponentToRender(
186
+ <ProcaptchaPuzzle
187
+ config={config}
188
+ callbacks={callbacks}
189
+ frictionlessState={frictionlessState}
190
+ i18n={i18n}
191
+ />,
192
+ );
193
+ } else {
194
+ const ProcaptchaPow = await ProcaptchaPowLoader();
195
+ setComponentToRender(
196
+ <ProcaptchaPow
197
+ config={config}
198
+ callbacks={callbacks}
199
+ frictionlessState={frictionlessState}
200
+ i18n={i18n}
201
+ onEscalate={onEscalate}
202
+ />,
203
+ );
204
+ }
205
+ };
206
+
207
+ const start = async () => {
208
+ // Procaptcha cannot run over plain HTTP (no SubtleCrypto etc.), which
209
+ // would otherwise fail later with a cryptic provider-selection error.
210
+ // Surface a clear, non-retrying message instead.
211
+ if (!isSecureBrowserContext()) {
212
+ const errorMessage = i18n.isInitialized
213
+ ? i18n.t("WIDGET.INSECURE_CONTEXT")
214
+ : "Procaptcha requires a secure (HTTPS) connection";
215
+ events.onError(new Error(errorMessage));
216
+ fallOverWithStyle(errorMessage, "WIDGET.INSECURE_CONTEXT");
217
+ return;
218
+ }
219
+
220
+ await providerRetry(
221
+ async () => {
222
+ stateRef.current.attemptCount += 1;
223
+
224
+ const configOutput = ProcaptchaConfigSchema.parse(config);
225
+ const result = await detectBot(configOutput, container, restart);
226
+
227
+ if (result.error?.message) {
228
+ stateRef.current = {
229
+ ...stateRef.current,
230
+ loading: false,
231
+ errorMessage: result.error?.message,
232
+ };
233
+ events.onError(new Error(result.error?.message));
234
+ fallOverWithStyle(result.error?.message, result.error?.key);
235
+ return;
236
+ }
237
+
238
+ const frictionlessState: FrictionlessState = {
239
+ provider: result.provider,
240
+ sessionId: result.sessionId,
241
+ userAccount: result.userAccount,
242
+ restart, // Pass restart function
243
+ behaviorCollector1: result.behaviorCollector1,
244
+ behaviorCollector2: result.behaviorCollector2,
245
+ behaviorCollector3: result.behaviorCollector3,
246
+ deviceCapability: result.deviceCapability,
247
+ encryptBehavioralData: result.encryptBehavioralData,
248
+ getSimdReadings: result.getSimdReadings,
249
+ hp: result.hp,
250
+ };
251
+
252
+ await renderForCaptchaType(result.captchaType, frictionlessState);
253
+
254
+ stateRef.current = {
255
+ ...stateRef.current,
256
+ loading: false,
257
+ };
258
+ },
259
+ start,
260
+ resetState,
261
+ stateRef.current.attemptCount,
262
+ 5,
263
+ ).finally(() => {
264
+ if (stateRef.current.attemptCount >= 5) {
265
+ fallOverWithStyle();
266
+ restartComponentTimeout();
267
+ }
268
+ });
269
+ };
270
+
271
+ // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
272
+ useEffect(() => {
273
+ const detectAndSetComponent = async () => {
274
+ await start();
275
+ };
276
+
277
+ detectAndSetComponent();
278
+ }, [config, callbacks, detectBot, config.language]);
279
+
280
+ return (
281
+ <>
282
+ <TestModeBanner siteKey={config.account?.address ?? ""} />
283
+ {componentToRender}
284
+ </>
285
+ );
286
+ };
@@ -0,0 +1,160 @@
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 { ProviderApi } from "@prosopo/api";
16
+ import { ProsopoEnvError } from "@prosopo/common";
17
+ import {
18
+ getRandomActiveProvider,
19
+ prefetchProviders,
20
+ } from "@prosopo/load-balancer";
21
+ import { ExtensionLoader } from "@prosopo/procaptcha-common";
22
+ import { EnvironmentTypesSchema } from "@prosopo/types";
23
+ import type {
24
+ BotDetectionFunction,
25
+ ProcaptchaClientConfigOutput,
26
+ } from "@prosopo/types";
27
+ import type { BotDetectionFunctionResult } from "@prosopo/types";
28
+ import { DetectorLoader } from "./detectorLoader.js";
29
+
30
+ if (typeof window !== "undefined") {
31
+ const envHint =
32
+ typeof process !== "undefined"
33
+ ? process.env?.PROSOPO_DEFAULT_ENVIRONMENT
34
+ : undefined;
35
+ const parsedEnv = EnvironmentTypesSchema.safeParse(envHint);
36
+ if (parsedEnv.success) {
37
+ prefetchProviders(parsedEnv.data).catch(() => undefined);
38
+ }
39
+ }
40
+
41
+ export const withTimeout = async <T>(
42
+ promise: Promise<T>,
43
+ ms: number,
44
+ ): Promise<T> => {
45
+ let timeoutId: NodeJS.Timeout | undefined;
46
+ const timeoutPromise = new Promise<never>((_, reject) => {
47
+ timeoutId = setTimeout(() => {
48
+ reject(new ProsopoEnvError("API.UNKNOWN"));
49
+ }, ms);
50
+ });
51
+
52
+ try {
53
+ const result = await Promise.race([promise, timeoutPromise]);
54
+ if (timeoutId) {
55
+ clearTimeout(timeoutId);
56
+ }
57
+ return result;
58
+ } catch (error) {
59
+ if (timeoutId) {
60
+ clearTimeout(timeoutId);
61
+ }
62
+ throw error;
63
+ }
64
+ };
65
+
66
+ const customDetectBot: BotDetectionFunction = async (
67
+ config: ProcaptchaClientConfigOutput,
68
+ container: HTMLElement | undefined,
69
+ restartFn: () => void,
70
+ ): Promise<BotDetectionFunctionResult> => {
71
+ const [ExtClass, detect] = await Promise.all([
72
+ ExtensionLoader(config.web2),
73
+ DetectorLoader(),
74
+ prefetchProviders(config.defaultEnvironment),
75
+ ]);
76
+ const ext = new ExtClass();
77
+
78
+ const detectionResult = await detect(
79
+ config.defaultEnvironment,
80
+ getRandomActiveProvider,
81
+ container,
82
+ restartFn,
83
+ () => ext.getAccount(config),
84
+ );
85
+
86
+ const userAccount = detectionResult.userAccount;
87
+
88
+ if (!config.account.address) {
89
+ throw new ProsopoEnvError("GENERAL.SITE_KEY_MISSING");
90
+ }
91
+
92
+ // Get random active provider with timeout
93
+ const provider = detectionResult.provider;
94
+
95
+ if (!provider) {
96
+ throw new Error("Provider Selection Failed");
97
+ }
98
+
99
+ const providerApi = new ProviderApi(
100
+ provider.provider.url,
101
+ config.account.address,
102
+ );
103
+
104
+ // SIMD readings deliberately omitted from the frictionless hop. The WASM
105
+ // benchmark is a CPU-bound loop that contends with BotScoreWorker if it
106
+ // runs during detection; deferring it until after the POST is in flight
107
+ // lets it complete in the worker thread while the network round-trip
108
+ // burns. Readings still attach on the challenge GET and on solution
109
+ // submit (first-hop-wins server-side).
110
+ const captchaPromise = providerApi.getFrictionlessCaptcha(
111
+ detectionResult.token,
112
+ detectionResult.encryptHeadHash,
113
+ config.account.address,
114
+ userAccount.account.address,
115
+ config.mode,
116
+ undefined,
117
+ );
118
+ if (detectionResult.getSimdReadings) {
119
+ // Fire-and-forget: triggers the memoised prefetch inside the catcher
120
+ // so the next hop sees a hot benchmark. We never await the result here.
121
+ void detectionResult.getSimdReadings(60_000).catch(() => undefined);
122
+ }
123
+ const captcha = await withTimeout(captchaPromise, 10000);
124
+
125
+ // Fire-and-forget DNS observation beacon. Failures swallowed —
126
+ // observation must never break the captcha flow.
127
+ if (captcha.dns_url) {
128
+ try {
129
+ void fetch(captcha.dns_url, {
130
+ method: "GET",
131
+ mode: "no-cors",
132
+ credentials: "omit",
133
+ keepalive: true,
134
+ cache: "no-store",
135
+ }).catch(() => undefined);
136
+ } catch {
137
+ /* swallow */
138
+ }
139
+ }
140
+
141
+ return {
142
+ captchaType: captcha.captchaType,
143
+ sessionId: captcha.sessionId,
144
+ provider: provider,
145
+ status: captcha.status,
146
+ userAccount: userAccount,
147
+ error: captcha.error,
148
+ hp: captcha.hp,
149
+ // Map specific trackers to generic behavioral collectors
150
+ behaviorCollector1: detectionResult.mouseTracker,
151
+ behaviorCollector2: detectionResult.touchTracker,
152
+ behaviorCollector3: detectionResult.clickTracker,
153
+ deviceCapability: detectionResult.hasTouchSupport,
154
+ encryptBehavioralData: detectionResult.encryptBehavioralData,
155
+ packBehavioralData: detectionResult.packBehavioralData,
156
+ getSimdReadings: detectionResult.getSimdReadings,
157
+ };
158
+ };
159
+
160
+ export default customDetectBot;
@@ -0,0 +1,18 @@
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
+ type DetectorType = typeof import("@prosopo/detector").default;
16
+
17
+ export const DetectorLoader = async (): Promise<DetectorType> =>
18
+ (await import("@prosopo/detector")).default;
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
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
+ export * from "./ProcaptchaFrictionless.js";
@@ -0,0 +1,77 @@
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 { ProsopoEnvError } from "@prosopo/common";
16
+ import { describe, expect, it, vi } from "vitest";
17
+ import { withTimeout } from "../customDetectBot.js";
18
+
19
+ describe("withTimeout", () => {
20
+ it("should resolve with the promise result when promise resolves before timeout", async () => {
21
+ const result = "success";
22
+ const promise = Promise.resolve(result);
23
+
24
+ const response = await withTimeout(promise, 1000);
25
+
26
+ expect(response).toBe(result);
27
+ });
28
+
29
+ it("should reject with original error when promise rejects before timeout", async () => {
30
+ const errorMessage = "Original error";
31
+ const promise = Promise.reject(new Error(errorMessage));
32
+
33
+ await expect(withTimeout(promise, 1000)).rejects.toThrow(errorMessage);
34
+ });
35
+
36
+ it("should reject with timeout error when promise does not resolve within the timeout", async () => {
37
+ const promise = new Promise((resolve) => {
38
+ setTimeout(resolve, 500);
39
+ });
40
+
41
+ vi.useFakeTimers();
42
+
43
+ const timeoutPromise = withTimeout(promise, 100);
44
+
45
+ vi.advanceTimersByTime(200);
46
+
47
+ await expect(timeoutPromise).rejects.toThrow(ProsopoEnvError);
48
+ await expect(timeoutPromise).rejects.toEqual(
49
+ expect.objectContaining({
50
+ message: "API.UNKNOWN",
51
+ translationKey: "API.UNKNOWN",
52
+ }),
53
+ );
54
+
55
+ vi.useRealTimers();
56
+ });
57
+
58
+ it("should respect the provided timeout duration", async () => {
59
+ vi.useFakeTimers();
60
+
61
+ const fastResolve = Promise.resolve("success");
62
+ const fastResult = withTimeout(fastResolve, 1000);
63
+
64
+ const slowPromise = new Promise((resolve) => {
65
+ setTimeout(() => resolve("slow"), 2000);
66
+ });
67
+ const slowWithTimeout = withTimeout(slowPromise, 1000);
68
+
69
+ vi.advanceTimersByTime(500);
70
+ expect(await fastResult).toBe("success");
71
+
72
+ vi.advanceTimersByTime(600);
73
+ await expect(slowWithTimeout).rejects.toThrow(ProsopoEnvError);
74
+
75
+ vi.useRealTimers();
76
+ });
77
+ });