@prosopo/procaptcha-frictionless 2.12.23 → 2.13.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.
@@ -25,7 +25,16 @@ import type {
25
25
  ProviderSelectRetryContext,
26
26
  } from "@prosopo/types";
27
27
  import type { BotDetectionFunctionResult } from "@prosopo/types";
28
- import { DetectorLoader } from "./detectorLoader.js";
28
+ import {
29
+ DetectorLoaderFromScript,
30
+ type DetectorType,
31
+ } from "./detectorLoader.js";
32
+
33
+ // Upper bound on the detector-bundle assignment + load probe. The detector
34
+ // lives ONLY in the provider-served pool bundles, so if the provider is
35
+ // slow/unreachable we abandon the probe and signal `detectorUnavailable`, and
36
+ // the provider serves a PoW challenge instead of running detection.
37
+ const ASSIGN_TIMEOUT_MS = 2000;
29
38
 
30
39
  // The page(s) the widget is rendered on, reduced to origin + path.
31
40
  // Deliberately built from `origin` + `pathname` (never `href`) so the query
@@ -129,69 +138,36 @@ const customDetectBot: BotDetectionFunction = async (
129
138
  restartFn: () => void,
130
139
  retryContext?: ProviderSelectRetryContext,
131
140
  ): Promise<BotDetectionFunctionResult> => {
132
- // Kick off the provider-pin resolution (which hits `/healthz` on the
133
- // load-balancer DNS endpoint) in parallel with the module loaders and
134
- // the whole `detect()` flow. `getProcaptchaRandomActiveProvider` is
135
- // memoised via `pinPromiseCache` keyed on `(env, ipMode)`, so the
136
- // in-flight promise is reused when catcher's internal provider selector
137
- // and the awaited `getProcaptchaRandomActiveProvider(...)` below reach
138
- // for it — no duplicate healthz request. Before this ordering change
139
- // the healthz round-trip was fully serial with `detect()`, adding
140
- // ~100–200ms to the frictionless critical path on cold DNS/TLS.
141
- //
142
- // We warm both cache keys because the catcher-internal selector call
143
- // (line ~145 below) passes no ipMode, while the awaited call further
144
- // down passes `pickIpMode(config)`. Same key when the dapp opts into
145
- // neither ipv4 nor ipv6 (dual stack — the common case); different
146
- // keys when the dapp is pinned to a single stack. Fire-and-forget on
147
- // both — the real synchronisation happens at the awaits below.
148
- const ipMode = pickIpMode(config);
149
- void getProcaptchaRandomActiveProvider(config.defaultEnvironment).catch(
150
- () => undefined,
151
- );
152
- if (ipMode !== undefined) {
153
- void getProcaptchaRandomActiveProvider(
154
- config.defaultEnvironment,
155
- ipMode,
156
- retryContext,
157
- ).catch(() => undefined);
158
- }
159
-
160
- const [ExtClass, detect] = await Promise.all([
161
- ExtensionLoader(config.web2),
162
- DetectorLoader(),
163
- ]);
164
- const ext = new ExtClass();
165
-
166
- // The detector bundle still expects the legacy 5-arg signature
167
- // `(env, randomProviderSelectorFn, container, restart, accountGenerator)`.
168
- // Until the bundle is rebuilt without the provider selector, hand it a
169
- // noop selector that resolves to the static DNS endpoint — the detector
170
- // no longer uses the returned RandomProvider for routing.
171
- const detectionResult = await detect(
172
- config.defaultEnvironment,
173
- async () => getProcaptchaRandomActiveProvider(config.defaultEnvironment),
174
- container,
175
- restartFn,
176
- () => ext.getAccount(config),
177
- );
178
-
179
- const userAccount = detectionResult.userAccount;
180
-
181
141
  if (!config.account.address) {
182
142
  throw new ProsopoEnvError("GENERAL.SITE_KEY_MISSING");
183
143
  }
184
144
 
145
+ const ipMode = pickIpMode(config);
146
+
147
+ // Start the extension/account module load now rather than after the bundle
148
+ // assignment. It depends on nothing above, so it overlaps the provider-pin
149
+ // `/healthz` round-trip and the assign request for free; it is awaited below
150
+ // at the point `ExtClass` is first needed.
151
+ //
152
+ // This is what survives of main's parallel-pin optimisation. That change
153
+ // warmed BOTH `pinPromiseCache` keys — with and without ipMode — because the
154
+ // catcher-internal provider selector called with no ipMode while the awaited
155
+ // call passed one. The 3-arg detector has no provider selector at all, so
156
+ // only the `(env, ipMode)` key is ever used now and warming the bare-env key
157
+ // would just issue a healthz request nothing consumes. Nor can the pin be
158
+ // moved off the critical path the way it was before: the detector now comes
159
+ // FROM the provider, so resolving the provider is a hard prerequisite of
160
+ // having anything to run, not something that can proceed alongside it.
161
+ const extClassPromise = ExtensionLoader(config.web2);
162
+
185
163
  // On the first attempt this resolves the static DNS endpoint for this env —
186
164
  // the DNS layer load-balances across the pronode fleet. On a retry
187
165
  // (`retryContext.attempt > 1`) the previously used pronode errored, so we
188
166
  // pick a random provider straight from the list instead of re-pinning the
189
167
  // same one. `pickIpMode(config)` honours the dapp's data-ipv4 / data-ipv6
190
168
  // preference so frictionless and the subsequent captcha hops stay on the
191
- // same stack.
192
- //
193
- // This await usually hits the in-flight/resolved promise warmed at the
194
- // top of this function — see the prefetch call there.
169
+ // same stack. Resolved up front — before detection rather than alongside it —
170
+ // because the detector bundle is served BY this provider.
195
171
  const provider = await getProcaptchaRandomActiveProvider(
196
172
  config.defaultEnvironment,
197
173
  ipMode,
@@ -203,6 +179,87 @@ const customDetectBot: BotDetectionFunction = async (
203
179
  config.account.address,
204
180
  );
205
181
 
182
+ // Ask the provider for a per-session detector bundle. The detector lives ONLY
183
+ // in the provider-served pool: when the provider has a populated pool it
184
+ // returns the obfuscated detector (each with its own keys + inner cipher)
185
+ // plus a detectorSessionId. When it cannot (no pool, network, decode, or
186
+ // timeout) we have NO detector to run — there is no bundled fallback — so we
187
+ // signal `detectorUnavailable` and the provider serves a PoW challenge.
188
+ let detectorSessionId: string | undefined;
189
+ let providerDetect: DetectorType | undefined;
190
+ try {
191
+ const assigned = await withTimeout(
192
+ providerApi.assignDetectorBundle(config.account.address),
193
+ ASSIGN_TIMEOUT_MS,
194
+ );
195
+ if (assigned.useProviderBundle && assigned.detectorScript) {
196
+ detectorSessionId = assigned.detectorSessionId;
197
+ providerDetect = await withTimeout(
198
+ DetectorLoaderFromScript(assigned.detectorScript),
199
+ ASSIGN_TIMEOUT_MS,
200
+ );
201
+ }
202
+ } catch {
203
+ // No detector available — fall through to the PoW request below.
204
+ }
205
+
206
+ const ExtClass = await extClassPromise;
207
+ const ext = new ExtClass();
208
+
209
+ // No provider detector ⇒ no detection is possible. Request a PoW challenge
210
+ // directly: send no token and the detectorUnavailable flag so the provider
211
+ // serves PoW rather than attempting to score an absent payload.
212
+ if (providerDetect === undefined) {
213
+ const userAccount = await ext.getAccount(config);
214
+ const { currentUrl: fallbackUrl, iframeUrl: fallbackIframeUrl } =
215
+ getCurrentPageUrls();
216
+ const powCaptcha = await withTimeout(
217
+ providerApi.getFrictionlessCaptcha(
218
+ undefined,
219
+ undefined,
220
+ config.account.address,
221
+ userAccount.account.address,
222
+ config.mode,
223
+ undefined,
224
+ undefined,
225
+ true,
226
+ fallbackUrl,
227
+ fallbackIframeUrl,
228
+ ),
229
+ 10000,
230
+ );
231
+ if (powCaptcha.dns_url) {
232
+ try {
233
+ void fetch(powCaptcha.dns_url, {
234
+ method: "GET",
235
+ mode: "no-cors",
236
+ credentials: "omit",
237
+ keepalive: true,
238
+ cache: "no-store",
239
+ }).catch(() => undefined);
240
+ } catch {
241
+ /* swallow */
242
+ }
243
+ }
244
+ return {
245
+ captchaType: powCaptcha.captchaType,
246
+ sessionId: powCaptcha.sessionId,
247
+ provider: provider,
248
+ status: powCaptcha.status,
249
+ userAccount: userAccount,
250
+ error: powCaptcha.error,
251
+ hp: powCaptcha.hp,
252
+ };
253
+ }
254
+
255
+ const detect: DetectorType = providerDetect;
256
+
257
+ const detectionResult = await detect(container, restartFn, () =>
258
+ ext.getAccount(config),
259
+ );
260
+
261
+ const userAccount = detectionResult.userAccount;
262
+
206
263
  // SIMD readings deliberately omitted from the frictionless hop. The WASM
207
264
  // benchmark is a CPU-bound loop that contends with BotScoreWorker if it
208
265
  // runs during detection; deferring it until after the POST is in flight
@@ -217,6 +274,8 @@ const customDetectBot: BotDetectionFunction = async (
217
274
  userAccount.account.address,
218
275
  config.mode,
219
276
  undefined,
277
+ detectorSessionId,
278
+ undefined,
220
279
  currentUrl,
221
280
  iframeUrl,
222
281
  );
@@ -12,7 +12,69 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- type DetectorType = typeof import("@prosopo/detector").default;
15
+ import type {
16
+ Account,
17
+ BehavioralData,
18
+ ClickEventPoint,
19
+ MouseMovementPoint,
20
+ PackedBehavioralData,
21
+ TouchEventPoint,
22
+ } from "@prosopo/types";
16
23
 
17
- export const DetectorLoader = async (): Promise<DetectorType> =>
18
- (await import("@prosopo/detector")).default;
24
+ // The detector is NOT bundled into the widget — it lives only in the
25
+ // provider-served pool bundles, loaded at runtime via a blob URL below. So the
26
+ // signature is declared locally here (mirroring `@prosopo/detector`'s default
27
+ // export) from shared @prosopo/types primitives, rather than importing the
28
+ // detector package as a build-time type dependency.
29
+ export type DetectorType = (
30
+ container: HTMLElement | undefined,
31
+ restart: () => void,
32
+ accountGenerator: () => Promise<Account>,
33
+ ) => Promise<{
34
+ token: string;
35
+ shadowDomCleanup: () => void;
36
+ encryptHeadHash: string;
37
+ mouseTracker?: {
38
+ start: () => void;
39
+ stop: () => void;
40
+ getData: () => MouseMovementPoint[];
41
+ clear: () => void;
42
+ };
43
+ touchTracker?: {
44
+ start: () => void;
45
+ stop: () => void;
46
+ getData: () => TouchEventPoint[];
47
+ clear: () => void;
48
+ };
49
+ clickTracker?: {
50
+ start: () => void;
51
+ stop: () => void;
52
+ getData: () => ClickEventPoint[];
53
+ clear: () => void;
54
+ };
55
+ hasTouchSupport?: string;
56
+ encryptBehavioralData?: (data: string) => Promise<string>;
57
+ packBehavioralData?: (behavioralData: BehavioralData) => PackedBehavioralData;
58
+ getSimdReadings?: (timeoutMs?: number) => Promise<string | undefined>;
59
+ userAccount: Account;
60
+ }>;
61
+
62
+ /**
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.
66
+ */
67
+ export const DetectorLoaderFromScript = async (
68
+ script: string,
69
+ ): 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);
79
+ }
80
+ };
@@ -19,6 +19,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
19
19
  const mocks = vi.hoisted(() => ({
20
20
  getFrictionlessCaptcha: vi.fn(),
21
21
  getProcaptchaRandomActiveProvider: vi.fn(),
22
+ assignDetectorBundle: vi.fn(),
22
23
  detect: vi.fn(),
23
24
  }));
24
25
 
@@ -29,6 +30,7 @@ vi.mock("@prosopo/api", () => ({
29
30
  ProviderApi: vi.fn(function () {
30
31
  return {
31
32
  getFrictionlessCaptcha: mocks.getFrictionlessCaptcha,
33
+ assignDetectorBundle: mocks.assignDetectorBundle,
32
34
  };
33
35
  }),
34
36
  }));
@@ -48,7 +50,7 @@ vi.mock("@prosopo/procaptcha-common", () => ({
48
50
  }));
49
51
 
50
52
  vi.mock("../detectorLoader.js", () => ({
51
- DetectorLoader: vi.fn(async () => mocks.detect),
53
+ DetectorLoaderFromScript: vi.fn(async () => mocks.detect),
52
54
  }));
53
55
 
54
56
  import customDetectBot from "../customDetectBot.js";
@@ -81,11 +83,19 @@ const captchaResponse = {
81
83
  beforeEach(() => {
82
84
  mocks.getFrictionlessCaptcha.mockReset();
83
85
  mocks.getProcaptchaRandomActiveProvider.mockReset();
86
+ mocks.assignDetectorBundle.mockReset();
84
87
  mocks.detect.mockReset();
85
88
  mocks.getProcaptchaRandomActiveProvider.mockResolvedValue({
86
89
  providerAccount: "dns-routed",
87
90
  provider: { url: "https://provider.test" },
88
91
  });
92
+ // Provider serves a per-session detector bundle (the only detector source).
93
+ mocks.assignDetectorBundle.mockResolvedValue({
94
+ useProviderBundle: true,
95
+ detectorSessionId: "det-1",
96
+ detectorScript: "SELF_CONTAINED_ESM",
97
+ status: "ok",
98
+ });
89
99
  mocks.getFrictionlessCaptcha.mockResolvedValue(captchaResponse);
90
100
  });
91
101
 
package/tsconfig.cjs.json CHANGED
@@ -11,9 +11,6 @@
11
11
  {
12
12
  "path": "../../dev/config/tsconfig.cjs.json"
13
13
  },
14
- {
15
- "path": "../detector/tsconfig.cjs.json"
16
- },
17
14
  {
18
15
  "path": "../locale/tsconfig.cjs.json"
19
16
  },
package/tsconfig.json CHANGED
@@ -18,9 +18,6 @@
18
18
  {
19
19
  "path": "../../dev/config/tsconfig.json"
20
20
  },
21
- {
22
- "path": "../detector"
23
- },
24
21
  {
25
22
  "path": "../locale"
26
23
  },