@prosopo/provider 5.6.2 → 5.6.4

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 (90) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +16 -13
  2. package/.turbo/turbo-build$colon$tsc.log +25 -25
  3. package/.turbo/turbo-build.log +17 -14
  4. package/CHANGELOG.md +92 -0
  5. package/dist/api/captcha/contextAwareValidation.d.ts +3 -2
  6. package/dist/api/captcha/contextAwareValidation.d.ts.map +1 -1
  7. package/dist/api/captcha/contextAwareValidation.js +32 -13
  8. package/dist/api/captcha/contextAwareValidation.js.map +1 -1
  9. package/dist/api/captcha/getFrictionlessCaptchaChallenge/accessPolicy.d.ts.map +1 -1
  10. package/dist/api/captcha/getFrictionlessCaptchaChallenge/accessPolicy.js +8 -5
  11. package/dist/api/captcha/getFrictionlessCaptchaChallenge/accessPolicy.js.map +1 -1
  12. package/dist/api/captcha/getFrictionlessCaptchaChallenge/decisionMachine.d.ts.map +1 -1
  13. package/dist/api/captcha/getFrictionlessCaptchaChallenge/decisionMachine.js +14 -20
  14. package/dist/api/captcha/getFrictionlessCaptchaChallenge/decisionMachine.js.map +1 -1
  15. package/dist/api/captcha/getFrictionlessCaptchaChallenge/handler.d.ts.map +1 -1
  16. package/dist/api/captcha/getFrictionlessCaptchaChallenge/handler.js +3 -0
  17. package/dist/api/captcha/getFrictionlessCaptchaChallenge/handler.js.map +1 -1
  18. package/dist/api/captcha/getFrictionlessCaptchaChallenge/shortCircuit.d.ts.map +1 -1
  19. package/dist/api/captcha/getFrictionlessCaptchaChallenge/shortCircuit.js +2 -2
  20. package/dist/api/captcha/getFrictionlessCaptchaChallenge/shortCircuit.js.map +1 -1
  21. package/dist/api/captcha/getImageCaptchaChallenge.d.ts.map +1 -1
  22. package/dist/api/captcha/getImageCaptchaChallenge.js +2 -3
  23. package/dist/api/captcha/getImageCaptchaChallenge.js.map +1 -1
  24. package/dist/api/captcha/submitPoWCaptchaSolution.d.ts +4 -1
  25. package/dist/api/captcha/submitPoWCaptchaSolution.d.ts.map +1 -1
  26. package/dist/api/captcha/submitPoWCaptchaSolution.js +23 -6
  27. package/dist/api/captcha/submitPoWCaptchaSolution.js.map +1 -1
  28. package/dist/api/captcha/trafficFilterRequestTime.d.ts.map +1 -1
  29. package/dist/api/captcha/trafficFilterRequestTime.js +2 -2
  30. package/dist/api/captcha/trafficFilterRequestTime.js.map +1 -1
  31. package/dist/cjs/api/captcha/contextAwareValidation.cjs +31 -11
  32. package/dist/cjs/api/captcha/getFrictionlessCaptchaChallenge/accessPolicy.cjs +7 -4
  33. package/dist/cjs/api/captcha/getFrictionlessCaptchaChallenge/decisionMachine.cjs +12 -18
  34. package/dist/cjs/api/captcha/getFrictionlessCaptchaChallenge/handler.cjs +3 -0
  35. package/dist/cjs/api/captcha/getFrictionlessCaptchaChallenge/shortCircuit.cjs +1 -1
  36. package/dist/cjs/api/captcha/getImageCaptchaChallenge.cjs +1 -1
  37. package/dist/cjs/api/captcha/submitPoWCaptchaSolution.cjs +22 -5
  38. package/dist/cjs/api/captcha/trafficFilterRequestTime.cjs +1 -1
  39. package/dist/cjs/tasks/captchaTypeSelection.cjs +53 -0
  40. package/dist/cjs/tasks/frictionless/frictionlessTasks.cjs +19 -8
  41. package/dist/cjs/tasks/puzzle/puzzleDifficulty.cjs +208 -0
  42. package/dist/cjs/tasks/puzzle/puzzleRenderer.cjs +13 -61
  43. package/dist/cjs/tasks/puzzle/stratifiedSampler.cjs +61 -0
  44. package/dist/tasks/captchaTypeSelection.d.ts +5 -0
  45. package/dist/tasks/captchaTypeSelection.d.ts.map +1 -0
  46. package/dist/tasks/captchaTypeSelection.js +53 -0
  47. package/dist/tasks/captchaTypeSelection.js.map +1 -0
  48. package/dist/tasks/frictionless/frictionlessTasks.d.ts.map +1 -1
  49. package/dist/tasks/frictionless/frictionlessTasks.js +20 -9
  50. package/dist/tasks/frictionless/frictionlessTasks.js.map +1 -1
  51. package/dist/tasks/frictionless/routingMachine.d.ts +4 -1
  52. package/dist/tasks/frictionless/routingMachine.d.ts.map +1 -1
  53. package/dist/tasks/frictionless/routingMachine.js.map +1 -1
  54. package/dist/tasks/puzzle/puzzleDifficulty.d.ts +25 -0
  55. package/dist/tasks/puzzle/puzzleDifficulty.d.ts.map +1 -0
  56. package/dist/tasks/puzzle/puzzleDifficulty.js +204 -0
  57. package/dist/tasks/puzzle/puzzleDifficulty.js.map +1 -0
  58. package/dist/tasks/puzzle/puzzleRenderer.d.ts +1 -3
  59. package/dist/tasks/puzzle/puzzleRenderer.d.ts.map +1 -1
  60. package/dist/tasks/puzzle/puzzleRenderer.js +15 -62
  61. package/dist/tasks/puzzle/puzzleRenderer.js.map +1 -1
  62. package/dist/tasks/puzzle/stratifiedSampler.d.ts +7 -0
  63. package/dist/tasks/puzzle/stratifiedSampler.d.ts.map +1 -0
  64. package/dist/tasks/puzzle/stratifiedSampler.js +61 -0
  65. package/dist/tasks/puzzle/stratifiedSampler.js.map +1 -0
  66. package/dist/tests/integration/api/admin/apiRegisterSiteKeyEndpoint.integration.test.js +4 -0
  67. package/dist/tests/integration/api/admin/apiRegisterSiteKeyEndpoint.integration.test.js.map +1 -1
  68. package/dist/tests/integration/clientSettingsPersistence.integration.test.js +3 -0
  69. package/dist/tests/integration/clientSettingsPersistence.integration.test.js.map +1 -1
  70. package/dist/tests/unit/api/admin/apiRegisterSiteKeyEndpoint.unit.test.js +2 -0
  71. package/dist/tests/unit/api/admin/apiRegisterSiteKeyEndpoint.unit.test.js.map +1 -1
  72. package/dist/tests/unit/api/captcha/submitPoWCaptchaSolution.escalation.unit.test.js +3 -0
  73. package/dist/tests/unit/api/captcha/submitPoWCaptchaSolution.escalation.unit.test.js.map +1 -1
  74. package/dist/tests/unit/api/contextAwareValidation.unit.test.js +88 -67
  75. package/dist/tests/unit/api/contextAwareValidation.unit.test.js.map +1 -1
  76. package/dist/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.js +55 -105
  77. package/dist/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.js.map +1 -1
  78. package/dist/tests/unit/tasks/captchaManager.unit.test.js +2 -0
  79. package/dist/tests/unit/tasks/captchaManager.unit.test.js.map +1 -1
  80. package/dist/tests/unit/tasks/captchaTypeSelection.unit.test.d.ts +2 -0
  81. package/dist/tests/unit/tasks/captchaTypeSelection.unit.test.d.ts.map +1 -0
  82. package/dist/tests/unit/tasks/captchaTypeSelection.unit.test.js +54 -0
  83. package/dist/tests/unit/tasks/captchaTypeSelection.unit.test.js.map +1 -0
  84. package/dist/tests/unit/tasks/frictionless/routerImageRoundsClamp.unit.test.js +24 -5
  85. package/dist/tests/unit/tasks/frictionless/routerImageRoundsClamp.unit.test.js.map +1 -1
  86. package/dist/tests/unit/tasks/puzzleDifficulty.unit.test.d.ts +2 -0
  87. package/dist/tests/unit/tasks/puzzleDifficulty.unit.test.d.ts.map +1 -0
  88. package/dist/tests/unit/tasks/puzzleDifficulty.unit.test.js +137 -0
  89. package/dist/tests/unit/tasks/puzzleDifficulty.unit.test.js.map +1 -0
  90. package/package.json +13 -13
@@ -1,27 +1,47 @@
1
1
  let _prosopo_types = require("@prosopo/types");
2
2
  //#region src/api/captcha/contextAwareValidation.ts
3
3
  /**
4
- * Determines the context type based on the webView flag
4
+ * Determines the context a request belongs to — its device family crossed
5
+ * with whether it is running in a webview.
6
+ *
7
+ * `userAgent` is the raw header value, not the hashed one carried on the
8
+ * decrypted payload. The entropy sweep classifies stored sessions from the
9
+ * same header through the same function, so the two sides agree on which
10
+ * bucket a session lands in.
11
+ *
12
+ * @param userAgent - Raw `user-agent` request header
5
13
  * @param webView - Whether the request is from a WebView
6
- * @returns The context type (Webview or Default)
7
14
  */
8
- function determineContextType(webView) {
9
- return webView ? _prosopo_types.ContextType.Webview : _prosopo_types.ContextType.Default;
15
+ function determineContextType(userAgent, webView) {
16
+ return (0, _prosopo_types.contextTypeFromSession)(userAgent, webView);
10
17
  }
11
18
  /**
12
- * Gets the threshold for a specific context type from client settings
19
+ * Whether the client has this context configured.
20
+ *
21
+ * A context the customer has not enabled is not validated at all — the
22
+ * request passes through this stage untouched. That is a change from the
23
+ * pre-device-type behaviour, where configuring a single context validated
24
+ * *every* request against it: with six contexts, applying a tablet baseline
25
+ * to desktop traffic would reject real users wholesale.
26
+ */
27
+ function isContextConfigured(settings, contextType) {
28
+ return (0, _prosopo_types.expandContexts)(settings.contextAware?.contexts)[contextType] !== void 0;
29
+ }
30
+ /**
31
+ * Gets the threshold for a specific context type from client settings.
32
+ *
33
+ * Falls back to the global default when the context is unconfigured, so a
34
+ * caller that skipped `isContextConfigured` still gets a sane number rather
35
+ * than NaN.
36
+ *
13
37
  * @param settings - Client settings
14
38
  * @param contextType - The context type to get the threshold for
15
39
  * @returns The threshold for the context type, or the global threshold if not configured
16
40
  */
17
41
  function getContextThreshold(settings, contextType) {
18
- const contextAware = settings.contextAware;
19
- if (contextAware === void 0) return _prosopo_types.contextAwareThresholdDefault;
20
- const contexts = contextAware.contexts;
21
- let contextConfig;
22
- if (contexts !== void 0) contextConfig = contexts[contextType];
23
- return contextConfig?.threshold ?? _prosopo_types.contextAwareThresholdDefault;
42
+ return (0, _prosopo_types.expandContexts)(settings.contextAware?.contexts)[contextType]?.threshold ?? _prosopo_types.contextAwareThresholdDefault;
24
43
  }
25
44
  //#endregion
26
45
  exports.determineContextType = determineContextType;
27
46
  exports.getContextThreshold = getContextThreshold;
47
+ exports.isContextConfigured = isContextConfigured;
@@ -31,7 +31,7 @@ var handleAccessPolicy = async (input, res) => {
31
31
  }
32
32
  }));
33
33
  await tasks.frictionlessManager.registerBlockedSession({
34
- solvedImagesCount: clientRecord.settings.imageMaxRounds,
34
+ solvedImagesCount: (0, _prosopo_types.resolveImageRoundsBounds)(clientRecord.settings).max,
35
35
  userSitekeyIpHash: input.userSitekeyIpHash,
36
36
  reason: _prosopo_types.FrictionlessReason.ACCESS_POLICY_BLOCK,
37
37
  siteKey: input.dapp,
@@ -55,7 +55,7 @@ var handleAccessPolicy = async (input, res) => {
55
55
  }
56
56
  }));
57
57
  await tasks.frictionlessManager.registerBlockedSession({
58
- solvedImagesCount: clientRecord.settings.imageMaxRounds,
58
+ solvedImagesCount: (0, _prosopo_types.resolveImageRoundsBounds)(clientRecord.settings).max,
59
59
  userSitekeyIpHash: input.userSitekeyIpHash,
60
60
  reason: _prosopo_types.FrictionlessReason.AUTO_BAN_SCORE,
61
61
  siteKey: input.dapp,
@@ -87,7 +87,7 @@ var handleAccessPolicy = async (input, res) => {
87
87
  handled: true,
88
88
  response: res.json(await tasks.frictionlessManager.sendImageCaptcha({
89
89
  ...captchaTypeBaseParams,
90
- solvedImagesCount: userAccessPolicy.solvedImagesCount ? Math.min(userAccessPolicy.solvedImagesCount, clientRecord.settings.imageMaxRounds) : clientRecord.settings.imageMaxRounds
90
+ solvedImagesCount: userAccessPolicy.solvedImagesCount ? (0, _prosopo_types.clampImageRounds)(userAccessPolicy.solvedImagesCount, clientRecord.settings) : (0, _prosopo_types.resolveImageRoundsBounds)(clientRecord.settings).max
91
91
  }))
92
92
  };
93
93
  }
@@ -116,7 +116,10 @@ var handleAccessPolicy = async (input, res) => {
116
116
  require_honeypotResponse.attachHoneypot(res, clientRecord);
117
117
  return {
118
118
  handled: true,
119
- response: res.json(await tasks.frictionlessManager.sendPuzzleCaptcha(captchaTypeBaseParams))
119
+ response: res.json(await tasks.frictionlessManager.sendPuzzleCaptcha({
120
+ ...captchaTypeBaseParams,
121
+ ...userAccessPolicy.solvedImagesCount !== void 0 && { solvedImagesCount: userAccessPolicy.solvedImagesCount }
122
+ }))
120
123
  };
121
124
  }
122
125
  return {
@@ -24,7 +24,7 @@ var runDecisionMachine = async (input, handle) => {
24
24
  require_metrics.recordFrictionlessDecision("missing_token");
25
25
  require_honeypotResponse.attachHoneypot(res, clientRecord);
26
26
  return res.json(await tasks.frictionlessManager.sendImageCaptcha({
27
- solvedImagesCount: Math.min(3, clientRecord.settings.imageMaxRounds),
27
+ solvedImagesCount: (0, _prosopo_types.clampImageRounds)(3, clientRecord.settings),
28
28
  userSitekeyIpHash,
29
29
  reason: _prosopo_types.FrictionlessReason.MISSING_TOKEN,
30
30
  siteKey: dapp,
@@ -43,7 +43,7 @@ var runDecisionMachine = async (input, handle) => {
43
43
  require_metrics.recordFrictionlessDecision("missing_head_hash");
44
44
  require_honeypotResponse.attachHoneypot(res, clientRecord);
45
45
  return res.json(await tasks.frictionlessManager.sendImageCaptcha({
46
- solvedImagesCount: Math.min(2, clientRecord.settings.imageMaxRounds),
46
+ solvedImagesCount: (0, _prosopo_types.clampImageRounds)(2, clientRecord.settings),
47
47
  userSitekeyIpHash,
48
48
  reason: _prosopo_types.FrictionlessReason.MISSING_HEAD_HASH,
49
49
  siteKey: dapp,
@@ -62,7 +62,7 @@ var runDecisionMachine = async (input, handle) => {
62
62
  require_metrics.recordFrictionlessDecision("decryption_failed");
63
63
  require_honeypotResponse.attachHoneypot(res, clientRecord);
64
64
  return res.json(await tasks.frictionlessManager.sendImageCaptcha({
65
- solvedImagesCount: Math.min(3, clientRecord.settings.imageMaxRounds),
65
+ solvedImagesCount: (0, _prosopo_types.clampImageRounds)(3, clientRecord.settings),
66
66
  userSitekeyIpHash,
67
67
  reason: _prosopo_types.FrictionlessReason.DECRYPTION_FAILED,
68
68
  siteKey: dapp,
@@ -102,7 +102,7 @@ var runDecisionMachine = async (input, handle) => {
102
102
  }));
103
103
  require_metrics.recordFrictionlessDecision("auto_ban_score");
104
104
  await tasks.frictionlessManager.registerBlockedSession({
105
- solvedImagesCount: clientRecord.settings.imageMaxRounds,
105
+ solvedImagesCount: (0, _prosopo_types.resolveImageRoundsBounds)(clientRecord.settings).max,
106
106
  userSitekeyIpHash,
107
107
  reason: _prosopo_types.FrictionlessReason.AUTO_BAN_SCORE,
108
108
  siteKey: dapp,
@@ -122,7 +122,7 @@ var runDecisionMachine = async (input, handle) => {
122
122
  require_metrics.recordFrictionlessDecision("webview_detected");
123
123
  require_honeypotResponse.attachHoneypot(res, clientRecord);
124
124
  return res.json(await tasks.frictionlessManager.sendImageCaptcha({
125
- solvedImagesCount: Math.min(env.config.captchas.solved.count * 2, clientRecord.settings.imageMaxRounds),
125
+ solvedImagesCount: (0, _prosopo_types.clampImageRounds)(env.config.captchas.solved.count * 2, clientRecord.settings),
126
126
  userSitekeyIpHash,
127
127
  reason: _prosopo_types.FrictionlessReason.WEBVIEW_DETECTED,
128
128
  siteKey: dapp,
@@ -141,7 +141,7 @@ var runDecisionMachine = async (input, handle) => {
141
141
  require_metrics.recordFrictionlessDecision("timestamp_too_old");
142
142
  require_honeypotResponse.attachHoneypot(res, clientRecord);
143
143
  return res.json(await tasks.frictionlessManager.sendImageCaptcha({
144
- solvedImagesCount: require_frictionlessTasksUtils.timestampDecayFunction(input.timestamp, clientRecord.settings.imageMaxRounds),
144
+ solvedImagesCount: (0, _prosopo_types.clampImageRounds)(require_frictionlessTasksUtils.timestampDecayFunction(input.timestamp, clientRecord.settings.imageMaxRounds), clientRecord.settings),
145
145
  userSitekeyIpHash,
146
146
  reason: _prosopo_types.FrictionlessReason.OLD_TIMESTAMP,
147
147
  siteKey: dapp,
@@ -149,7 +149,7 @@ var runDecisionMachine = async (input, handle) => {
149
149
  headers: flatHeaders
150
150
  }));
151
151
  }
152
- const scoredImageRounds = Math.min(require_constants.getRoundsFromTriggeredDetectors(env.config.captchas.solved.count, input.triggeredDetectors), clientRecord.settings.imageMaxRounds);
152
+ const scoredImageRounds = (0, _prosopo_types.clampImageRounds)(require_constants.getRoundsFromTriggeredDetectors(env.config.captchas.solved.count, input.triggeredDetectors), clientRecord.settings);
153
153
  const botImageThreshold = input.botImageThreshold;
154
154
  if (botImageThreshold > input.botThreshold && Number(botScore) > input.botThreshold && Number(botScore) < botImageThreshold) {
155
155
  req.logger.info(() => ({
@@ -215,7 +215,7 @@ var runDecisionMachine = async (input, handle) => {
215
215
  require_metrics.recordFrictionlessDecision("missing_current_url");
216
216
  require_honeypotResponse.attachHoneypot(res, clientRecord);
217
217
  return res.json(await tasks.frictionlessManager.sendImageCaptcha({
218
- solvedImagesCount: Math.min(env.config.captchas.solved.count, clientRecord.settings.imageMaxRounds),
218
+ solvedImagesCount: (0, _prosopo_types.clampImageRounds)(env.config.captchas.solved.count, clientRecord.settings),
219
219
  userSitekeyIpHash,
220
220
  reason: _prosopo_types.FrictionlessReason.MISSING_CURRENT_URL,
221
221
  siteKey: dapp,
@@ -265,7 +265,7 @@ var runUserAgentMismatchCheck = async (input, handle) => {
265
265
  require_metrics.recordFrictionlessDecision("user_agent_mismatch");
266
266
  require_honeypotResponse.attachHoneypot(res, input.clientRecord);
267
267
  return res.json(await input.tasks.frictionlessManager.sendImageCaptcha({
268
- solvedImagesCount: require_frictionlessTasksUtils.timestampDecayFunction(input.timestamp, input.clientRecord.settings.imageMaxRounds),
268
+ solvedImagesCount: (0, _prosopo_types.clampImageRounds)(require_frictionlessTasksUtils.timestampDecayFunction(input.timestamp, input.clientRecord.settings.imageMaxRounds), input.clientRecord.settings),
269
269
  userSitekeyIpHash: input.userSitekeyIpHash,
270
270
  reason: _prosopo_types.FrictionlessReason.USER_AGENT_MISMATCH,
271
271
  siteKey: input.dapp,
@@ -277,14 +277,8 @@ var runContextAwareValidation = async (input, handle) => {
277
277
  const { tasks, clientRecord, dapp, user } = input;
278
278
  const { req, res, next } = handle;
279
279
  if (!clientRecord.settings.contextAware?.enabled) return null;
280
- const contexts = clientRecord.settings.contextAware?.contexts || {};
281
- const hasDefault = contexts[_prosopo_types.ContextType.Default] !== void 0;
282
- const hasWebview = contexts[_prosopo_types.ContextType.Webview] !== void 0;
283
- let contextType;
284
- if (hasDefault && hasWebview) contextType = require_contextAwareValidation.determineContextType(input.webView);
285
- else if (hasDefault) contextType = _prosopo_types.ContextType.Default;
286
- else if (hasWebview) contextType = _prosopo_types.ContextType.Webview;
287
- if (!contextType) return null;
280
+ const contextType = require_contextAwareValidation.determineContextType(req.headers["user-agent"], input.webView);
281
+ if (!require_contextAwareValidation.isContextConfigured(clientRecord.settings, contextType)) return null;
288
282
  const clientEntropy = await tasks.frictionlessManager.getClientContextEntropy(clientRecord.account, contextType);
289
283
  if (!clientEntropy) return null;
290
284
  if (!input.decryptedHeadHash) {
@@ -314,7 +308,7 @@ var runContextAwareValidation = async (input, handle) => {
314
308
  require_metrics.recordFrictionlessDecision("context_aware_failed");
315
309
  require_honeypotResponse.attachHoneypot(res, clientRecord);
316
310
  return res.json(await tasks.frictionlessManager.sendImageCaptcha({
317
- solvedImagesCount: Math.min(require_constants.getRoundsFromSimScore(sim), clientRecord.settings.imageMaxRounds),
311
+ solvedImagesCount: (0, _prosopo_types.clampImageRounds)(require_constants.getRoundsFromSimScore(sim), clientRecord.settings),
318
312
  userSitekeyIpHash: input.userSitekeyIpHash,
319
313
  reason: _prosopo_types.FrictionlessReason.CONTEXT_AWARE_VALIDATION_FAILED,
320
314
  siteKey: dapp,
@@ -316,6 +316,9 @@ var handler_default = (env, userAccessRulesStorage) => async (req, res, next) =>
316
316
  countryCode,
317
317
  score: botScore,
318
318
  imageMaxRounds: clientRecord.settings.imageMaxRounds,
319
+ imageMinRounds: clientRecord.settings.imageMinRounds,
320
+ frictionlessTypes: clientRecord.settings.frictionlessTypes,
321
+ baseImageRounds: env.config.captchas.solved.count,
319
322
  platform: require_devicePlatform.derivePlatform(safeUserAgent, webView, { ...typeof ipInfoMobile === "boolean" && { isMobile: ipInfoMobile } }),
320
323
  raw: {
321
324
  headers: flatHeaders,
@@ -64,7 +64,7 @@ var runConfiguredCaptchaTypeShortCircuit = async (input, res) => {
64
64
  switch (configuredType) {
65
65
  case _prosopo_types.CaptchaType.image: return res.json(await input.tasks.frictionlessManager.sendImageCaptcha({
66
66
  ...sessionParams,
67
- solvedImagesCount: Math.min(input.env.config.captchas.solved.count, input.clientRecord.settings.imageMaxRounds)
67
+ solvedImagesCount: (0, _prosopo_types.clampImageRounds)(input.env.config.captchas.solved.count, input.clientRecord.settings)
68
68
  }));
69
69
  case _prosopo_types.CaptchaType.pow: return res.json(await input.tasks.frictionlessManager.sendPowCaptcha(sessionParams));
70
70
  case _prosopo_types.CaptchaType.puzzle: return res.json(await input.tasks.frictionlessManager.sendPuzzleCaptcha(sessionParams));
@@ -101,7 +101,7 @@ var getImageCaptchaChallenge_default = (env, userAccessRulesStorage) => async (r
101
101
  const trafficVerdict = require_trafficFilterRequestTime.applyTrafficFilterAtRequestTime(req.ipInfo, clientRecord.settings?.trafficFilter, req.logger);
102
102
  const trafficSolvedImagesCount = trafficVerdict.kind === "challenge" ? trafficVerdict.solvedImagesCount : void 0;
103
103
  const captchaConfig = {
104
- solved: { count: Math.min(trafficSolvedImagesCount || solvedImagesCount || userAccessPolicy?.solvedImagesCount || deferredParamsPolicy?.solvedImagesCount || env.config.captchas.solved.count, clientRecord.settings.imageMaxRounds ?? _prosopo_types.imageMaxRoundsDefault) },
104
+ solved: { count: (0, _prosopo_types.clampImageRounds)(trafficSolvedImagesCount || solvedImagesCount || userAccessPolicy?.solvedImagesCount || deferredParamsPolicy?.solvedImagesCount || env.config.captchas.solved.count, clientRecord.settings) },
105
105
  unsolved: { count: userAccessPolicy?.unsolvedImagesCount || deferredParamsPolicy?.unsolvedImagesCount || env.config.captchas.unsolved.count }
106
106
  };
107
107
  if (validSessionId && simdReadings) await tasks.frictionlessManager.decryptAndAttachSimdReadingsIfAbsent(validSessionId, simdReadings, _prosopo_types.SimdReadingsStage.challenge).catch((updateErr) => {
@@ -1,5 +1,6 @@
1
1
  const require_validateAddress = require("../validateAddress.cjs");
2
- const require_puzzleRenderer = require("../../tasks/puzzle/puzzleRenderer.cjs");
2
+ const require_puzzleDifficulty = require("../../tasks/puzzle/puzzleDifficulty.cjs");
3
+ const require_captchaTypeSelection = require("../../tasks/captchaTypeSelection.cjs");
3
4
  const require_devicePlatform = require("../../utils/devicePlatform.cjs");
4
5
  const require_tasks = require("../../tasks/tasks.cjs");
5
6
  const require_apiToggleMaintenanceModeEndpoint = require("../admin/apiToggleMaintenanceModeEndpoint.cjs");
@@ -8,6 +9,7 @@ const require_rawTlsSignalsMiddleware = require("../rawTlsSignalsMiddleware.cjs"
8
9
  let _prosopo_common = require("@prosopo/common");
9
10
  let _prosopo_types = require("@prosopo/types");
10
11
  let _prosopo_util = require("@prosopo/util");
12
+ let _prosopo_puzzle_assets = require("@prosopo/puzzle-assets");
11
13
  //#region src/api/captcha/submitPoWCaptchaSolution.ts
12
14
  var submitPoWCaptchaSolution_default = (env) => async (req, res, next) => {
13
15
  if (require_apiToggleMaintenanceModeEndpoint.getMaintenanceMode()) {
@@ -95,6 +97,9 @@ var submitPoWCaptchaSolution_default = (env) => async (req, res, next) => {
95
97
  tcpToChelloUs: req.tcpToChelloUs,
96
98
  chelloToHandshakeUs: req.chelloToHandshakeUs,
97
99
  ...require_rawTlsSignalsMiddleware.rawTlsSignalsForSession(req)
100
+ }, {
101
+ frictionlessTypes: clientRecord.settings?.frictionlessTypes,
102
+ imageMaxRounds: clientRecord.settings?.imageMaxRounds
98
103
  });
99
104
  const response = {
100
105
  status: "ok",
@@ -128,7 +133,7 @@ var submitPoWCaptchaSolution_default = (env) => async (req, res, next) => {
128
133
  * Exported for unit testing only — the handler above is the production entry
129
134
  * point.
130
135
  */
131
- var buildEscalation = async (tasks, result, challenge, perConnectionSignals) => {
136
+ var buildEscalation = async (tasks, result, challenge, perConnectionSignals, siteConstraints) => {
132
137
  if (!result.verified || !result.routingOutput) return void 0;
133
138
  const routedType = result.routingOutput.captchaType;
134
139
  if (routedType !== _prosopo_types.CaptchaType.image && routedType !== _prosopo_types.CaptchaType.puzzle) return;
@@ -137,9 +142,21 @@ var buildEscalation = async (tasks, result, challenge, perConnectionSignals) =>
137
142
  const originSession = await tasks.db.getSessionRecordBySessionId(powRecord.sessionId);
138
143
  if (!originSession) return void 0;
139
144
  const routed = result.routingOutput;
140
- const escalatedType = require_puzzleRenderer.downgradePuzzleIfUnavailable(routed.captchaType);
145
+ const escalatedType = require_captchaTypeSelection.coerceToEnabledCaptchaType(routed.captchaType, siteConstraints?.frictionlessTypes);
146
+ if (escalatedType === _prosopo_types.CaptchaType.pow) return void 0;
147
+ const imageMaxRounds = siteConstraints?.imageMaxRounds ?? _prosopo_types.imageMaxRoundsDefault;
148
+ const escalationPuzzleOverrides = (() => {
149
+ if (escalatedType !== _prosopo_types.CaptchaType.puzzle) return void 0;
150
+ const level = require_puzzleDifficulty.severityToPuzzleDifficulty(routed.solvedImagesCount ?? originSession.solvedImagesCount, tasks.config.captchas.solved.count);
151
+ if (level === 0) return void 0;
152
+ const difficulty = require_puzzleDifficulty.samplePuzzleDifficulty(level, _prosopo_puzzle_assets.DEFAULT_RENDER_SETTINGS.holeDarken);
153
+ return {
154
+ puzzleTolerance: difficulty.tolerance,
155
+ puzzle: difficulty.puzzle
156
+ };
157
+ })();
141
158
  const selectionReason = routed.reason ?? originSession.reason;
142
- const newSession = await tasks.frictionlessManager.createSession(originSession.token, originSession.score, originSession.threshold, originSession.scoreComponents, originSession.ipAddress, escalatedType, originSession.siteKey ?? powRecord.dappAccount, escalatedType === _prosopo_types.CaptchaType.image ? routed.solvedImagesCount ?? originSession.solvedImagesCount : void 0, void 0, originSession.userSitekeyIpHash, originSession.webView, originSession.iFrame, originSession.decryptedHeadHash, selectionReason, void 0, void 0, originSession.ipInfo, originSession.headers, originSession.mode, originSession.simdReadings, originSession.entropyMathRandomFingerprint, originSession.entropyCryptoFingerprint, originSession.entropyWallClockOffsetMs, originSession.entropyMathRandomFirst, originSession.bundleId, originSession.currentUrl, perConnectionSignals?.tcpToChelloUs, perConnectionSignals?.chelloToHandshakeUs, true, originSession.iframeUrl, originSession.isProtect, originSession.sessionId, originSession.g, void 0, originSession.i, originSession.sw, originSession.md, originSession.bn, originSession.fs, perConnectionSignals && {
159
+ const newSession = await tasks.frictionlessManager.createSession(originSession.token, originSession.score, originSession.threshold, originSession.scoreComponents, originSession.ipAddress, escalatedType, originSession.siteKey ?? powRecord.dappAccount, escalatedType === _prosopo_types.CaptchaType.image ? Math.min(routed.solvedImagesCount ?? originSession.solvedImagesCount ?? tasks.config.captchas.solved.count, imageMaxRounds) : void 0, void 0, originSession.userSitekeyIpHash, originSession.webView, originSession.iFrame, originSession.decryptedHeadHash, selectionReason, void 0, void 0, originSession.ipInfo, originSession.headers, originSession.mode, originSession.simdReadings, originSession.entropyMathRandomFingerprint, originSession.entropyCryptoFingerprint, originSession.entropyWallClockOffsetMs, originSession.entropyMathRandomFirst, originSession.bundleId, originSession.currentUrl, perConnectionSignals?.tcpToChelloUs, perConnectionSignals?.chelloToHandshakeUs, true, originSession.iframeUrl, originSession.isProtect, originSession.sessionId, originSession.g, void 0, originSession.i, originSession.sw, originSession.md, originSession.bn, originSession.fs, perConnectionSignals && {
143
160
  synNs: perConnectionSignals.synNs,
144
161
  synackNs: perConnectionSignals.synackNs,
145
162
  ackNs: perConnectionSignals.ackNs,
@@ -149,7 +166,7 @@ var buildEscalation = async (tasks, result, challenge, perConnectionSignals) =>
149
166
  tcpOptsFlags: perConnectionSignals.tcpOptsFlags,
150
167
  tcpOptsOrder: perConnectionSignals.tcpOptsOrder,
151
168
  tcpWindow: perConnectionSignals.tcpWindow
152
- });
169
+ }, escalationPuzzleOverrides);
153
170
  if (tasks.writeQueue) await tasks.writeQueue.cacheSessionEscalation(powRecord.sessionId, newSession.sessionId);
154
171
  return {
155
172
  captchaType: escalatedType,
@@ -72,7 +72,7 @@ var handleFrictionlessTrafficFilter = async (input, res) => {
72
72
  logDispatch(_prosopo_types.CaptchaType.image);
73
73
  response = await input.frictionlessManager.sendImageCaptcha({
74
74
  ...baseParams,
75
- solvedImagesCount: verdict.solvedImagesCount ? Math.min(verdict.solvedImagesCount, input.clientRecord.settings.imageMaxRounds) : input.clientRecord.settings.imageMaxRounds
75
+ solvedImagesCount: verdict.solvedImagesCount ? (0, _prosopo_types.clampImageRounds)(verdict.solvedImagesCount, input.clientRecord.settings) : (0, _prosopo_types.resolveImageRoundsBounds)(input.clientRecord.settings).max
76
76
  });
77
77
  } else if (verdict.captchaType === _prosopo_types.CaptchaType.pow) {
78
78
  logDispatch(_prosopo_types.CaptchaType.pow);
@@ -0,0 +1,53 @@
1
+ const require_puzzleRenderer = require("./puzzle/puzzleRenderer.cjs");
2
+ let _prosopo_types = require("@prosopo/types");
3
+ //#region src/tasks/captchaTypeSelection.ts
4
+ /**
5
+ * Resolve a requested captcha type against what the site permits and what
6
+ * this provider can render.
7
+ *
8
+ * Call this at every point a session's captchaType is DECIDED, never at the
9
+ * point one is served: `/captcha/puzzle` answers with a puzzle-shaped
10
+ * response and nothing else, so a session minted as a type we cannot fulfil
11
+ * strands the user on INCORRECT_CAPTCHA_TYPE. There are two such mint sites —
12
+ * `sendCaptcha` in frictionlessTasks (which every frictionless path funnels
13
+ * through, after the routing machine has had its say) and `buildEscalation`
14
+ * in submitPoWCaptchaSolution.
15
+ *
16
+ * Because it sits after routing, this covers every upstream selector
17
+ * transitively: the score ladder, the no-measurement gates, access-policy
18
+ * Restrict rules, traffic-filter category policies, routing-machine actions
19
+ * and detector-generated rules.
20
+ *
21
+ * Coercion only ever narrows. A disabled type falls back to the other
22
+ * interactive type if that is permitted, and to PoW otherwise — never the
23
+ * reverse, so this can't hand a user a harder challenge than was asked for.
24
+ */
25
+ var coerceToEnabledCaptchaType = (requested, frictionlessTypes, logger) => {
26
+ const types = (0, _prosopo_types.resolveFrictionlessTypes)(frictionlessTypes);
27
+ const puzzleAllowed = types.puzzle && require_puzzleRenderer.isPuzzleRenderAvailable();
28
+ const imageAllowed = types.image;
29
+ const resolved = (() => {
30
+ switch (requested) {
31
+ case _prosopo_types.CaptchaType.pow: return _prosopo_types.CaptchaType.pow;
32
+ case _prosopo_types.CaptchaType.puzzle:
33
+ if (puzzleAllowed) return _prosopo_types.CaptchaType.puzzle;
34
+ return imageAllowed ? _prosopo_types.CaptchaType.image : _prosopo_types.CaptchaType.pow;
35
+ case _prosopo_types.CaptchaType.image:
36
+ if (imageAllowed) return _prosopo_types.CaptchaType.image;
37
+ return puzzleAllowed ? _prosopo_types.CaptchaType.puzzle : _prosopo_types.CaptchaType.pow;
38
+ }
39
+ })();
40
+ if (resolved !== requested) logger?.info(() => ({
41
+ msg: "Captcha type coerced to an enabled type",
42
+ data: {
43
+ requested,
44
+ served: resolved,
45
+ imageEnabled: types.image,
46
+ puzzleEnabled: types.puzzle,
47
+ puzzleRenderable: require_puzzleRenderer.isPuzzleRenderAvailable()
48
+ }
49
+ }));
50
+ return resolved;
51
+ };
52
+ //#endregion
53
+ exports.coerceToEnabledCaptchaType = coerceToEnabledCaptchaType;
@@ -3,10 +3,12 @@ const require_decisionMachineRunner = require("../decisionMachine/decisionMachin
3
3
  const require_dnsEventUrl = require("../../api/dnsEventUrl.cjs");
4
4
  const require_lang = require("../../rules/lang.cjs");
5
5
  const require_captchaManager = require("../captchaManager.cjs");
6
+ const require_puzzleDifficulty = require("../puzzle/puzzleDifficulty.cjs");
7
+ const require_captchaTypeSelection = require("../captchaTypeSelection.cjs");
6
8
  const require_getBotScore = require("../detection/getBotScore.cjs");
7
- const require_puzzleRenderer = require("../puzzle/puzzleRenderer.cjs");
8
9
  const require_routingMachine = require("./routingMachine.cjs");
9
10
  let _prosopo_types = require("@prosopo/types");
11
+ let _prosopo_puzzle_assets = require("@prosopo/puzzle-assets");
10
12
  let uuid = require("uuid");
11
13
  //#region src/tasks/frictionless/frictionlessTasks.ts
12
14
  var DEFAULT_MAX_TIMESTAMP_AGE = 600 * 1e3;
@@ -190,15 +192,24 @@ var FrictionlessManager = class extends require_captchaManager.CaptchaManager {
190
192
  powDifficulty: captchaType === _prosopo_types.CaptchaType.pow ? effectiveParams.powDifficulty : void 0
191
193
  };
192
194
  const routed = this.routingContext ? await require_routingMachine.applyRouter(this.decisionMachineRunner, this.usageCounters, baseline, this.routingContext, this.logger) : baseline;
193
- const finalCaptchaType = require_puzzleRenderer.downgradePuzzleIfUnavailable(routed.captchaType, this.logger);
195
+ const finalCaptchaType = require_captchaTypeSelection.coerceToEnabledCaptchaType(routed.captchaType, this.routingContext?.frictionlessTypes, this.logger);
194
196
  const requestedSolvedImagesCount = routed.solvedImagesCount ?? effectiveParams.solvedImagesCount;
195
- const imageRoundsCeiling = this.routingContext?.imageMaxRounds;
196
- const finalSolvedImagesCount = finalCaptchaType === _prosopo_types.CaptchaType.image ? requestedSolvedImagesCount !== void 0 && imageRoundsCeiling !== void 0 ? Math.min(requestedSolvedImagesCount, imageRoundsCeiling) : requestedSolvedImagesCount : void 0;
197
+ const imageRoundsBounds = this.routingContext ?? {};
198
+ const finalSolvedImagesCount = finalCaptchaType === _prosopo_types.CaptchaType.image ? requestedSolvedImagesCount !== void 0 ? (0, _prosopo_types.clampImageRounds)(requestedSolvedImagesCount, imageRoundsBounds) : requestedSolvedImagesCount : void 0;
197
199
  const finalPowDifficulty = finalCaptchaType === _prosopo_types.CaptchaType.pow ? routed.powDifficulty ?? effectiveParams.powDifficulty : void 0;
198
- const finalPuzzleOverrides = finalCaptchaType === _prosopo_types.CaptchaType.puzzle ? {
199
- ...routed.puzzleTolerance !== void 0 && { puzzleTolerance: routed.puzzleTolerance },
200
- ...routed.puzzle && { puzzle: routed.puzzle }
201
- } : {};
200
+ const finalPuzzleOverrides = finalCaptchaType === _prosopo_types.CaptchaType.puzzle ? (() => {
201
+ const level = require_puzzleDifficulty.severityToPuzzleDifficulty(requestedSolvedImagesCount, this.routingContext?.baseImageRounds ?? this.config.captchas.solved.count);
202
+ const difficulty = level > 0 ? require_puzzleDifficulty.samplePuzzleDifficulty(level, _prosopo_puzzle_assets.DEFAULT_RENDER_SETTINGS.holeDarken) : void 0;
203
+ const tolerance = routed.puzzleTolerance ?? difficulty?.tolerance;
204
+ const puzzle = routed.puzzle || difficulty ? {
205
+ ...difficulty?.puzzle ?? {},
206
+ ...routed.puzzle ?? {}
207
+ } : void 0;
208
+ return {
209
+ ...tolerance !== void 0 && { puzzleTolerance: tolerance },
210
+ ...puzzle !== void 0 && { puzzle }
211
+ };
212
+ })() : {};
202
213
  const finalReason = routed.reason ?? effectiveParams.reason;
203
214
  const blocked = finalCaptchaType === _prosopo_types.CaptchaType.image ? effectiveParams.blocked : void 0;
204
215
  const sessionRecord = await this.createSession(effectiveParams.token, effectiveParams.score, effectiveParams.threshold, effectiveParams.scoreComponents, effectiveParams.ipAddress, finalCaptchaType, effectiveParams.siteKey, finalSolvedImagesCount, finalPowDifficulty, effectiveParams.userSitekeyIpHash, effectiveParams.webView ?? false, effectiveParams.iFrame ?? false, effectiveParams.decryptedHeadHash, finalReason, blocked, void 0, effectiveParams.ipInfo, effectiveParams.headers, effectiveParams.mode, effectiveParams.simdReadings, effectiveParams.entropyMathRandomFingerprint, effectiveParams.entropyCryptoFingerprint, effectiveParams.entropyWallClockOffsetMs, effectiveParams.entropyMathRandomFirst, effectiveParams.bundleId, effectiveParams.currentUrl, effectiveParams.tcpToChelloUs, effectiveParams.chelloToHandshakeUs, void 0, effectiveParams.iframeUrl, effectiveParams.isProtect, void 0, effectiveParams.g, effectiveParams.matchedRule, effectiveParams.i, effectiveParams.sw, effectiveParams.md, effectiveParams.bn, effectiveParams.fs, {
@@ -0,0 +1,208 @@
1
+ const require_stratifiedSampler = require("./stratifiedSampler.cjs");
2
+ //#region src/tasks/puzzle/puzzleDifficulty.ts
3
+ /**
4
+ * Minimum gap that must remain between `decoyHoleDarken` and `holeDarken`.
5
+ *
6
+ * The real cutout has to stay the deepest region on the frame or a human
7
+ * cannot tell target from decoy at all. But pushed too far apart, a solver
8
+ * simply thresholds on brightness and ignores shape. The ladder walks
9
+ * `decoyHoleDarken` down toward `holeDarken` as difficulty rises; this floor
10
+ * is what stops it crossing over. Enforced by clamping every sampled value,
11
+ * not by trusting the band table to be authored correctly.
12
+ */
13
+ var MIN_DECOY_HOLE_DARKEN_MARGIN = .04;
14
+ /**
15
+ * L0 reproduces the shipped defaults (decoyCount 5, decoyEdgeDarkness 20,
16
+ * decoyHoleDarken 0.7, tolerance 15, pieceScale 0.15-0.45) so a site that
17
+ * never escalates sees no change in behaviour.
18
+ *
19
+ * The numbers above L0 are STARTING POINTS, not calibrated values. They need
20
+ * validating against observed human first-attempt solve rate per level,
21
+ * segmented by device class — see the shadow-calibration note on
22
+ * `severityToPuzzleDifficulty`.
23
+ */
24
+ var PUZZLE_DIFFICULTY_LEVELS = [
25
+ {
26
+ level: 0,
27
+ tolerance: {
28
+ min: 14,
29
+ max: 16
30
+ },
31
+ decoyCount: {
32
+ min: 4,
33
+ max: 6
34
+ },
35
+ decoyHoleDarken: {
36
+ min: .68,
37
+ max: .72
38
+ },
39
+ decoyEdgeDarkness: {
40
+ min: 18,
41
+ max: 22
42
+ },
43
+ pieceScale: {
44
+ min: .15,
45
+ max: .45
46
+ }
47
+ },
48
+ {
49
+ level: 1,
50
+ tolerance: {
51
+ min: 12,
52
+ max: 15
53
+ },
54
+ decoyCount: {
55
+ min: 6,
56
+ max: 10
57
+ },
58
+ decoyHoleDarken: {
59
+ min: .66,
60
+ max: .7
61
+ },
62
+ decoyEdgeDarkness: {
63
+ min: 20,
64
+ max: 24
65
+ },
66
+ pieceScale: {
67
+ min: .14,
68
+ max: .4
69
+ }
70
+ },
71
+ {
72
+ level: 2,
73
+ tolerance: {
74
+ min: 10,
75
+ max: 13
76
+ },
77
+ decoyCount: {
78
+ min: 10,
79
+ max: 16
80
+ },
81
+ decoyHoleDarken: {
82
+ min: .64,
83
+ max: .68
84
+ },
85
+ decoyEdgeDarkness: {
86
+ min: 22,
87
+ max: 27
88
+ },
89
+ pieceScale: {
90
+ min: .12,
91
+ max: .34
92
+ }
93
+ },
94
+ {
95
+ level: 3,
96
+ tolerance: {
97
+ min: 8,
98
+ max: 11
99
+ },
100
+ decoyCount: {
101
+ min: 16,
102
+ max: 26
103
+ },
104
+ decoyHoleDarken: {
105
+ min: .62,
106
+ max: .66
107
+ },
108
+ decoyEdgeDarkness: {
109
+ min: 25,
110
+ max: 30
111
+ },
112
+ pieceScale: {
113
+ min: .1,
114
+ max: .28
115
+ }
116
+ },
117
+ {
118
+ level: 4,
119
+ tolerance: {
120
+ min: 6,
121
+ max: 9
122
+ },
123
+ decoyCount: {
124
+ min: 26,
125
+ max: 40
126
+ },
127
+ decoyHoleDarken: {
128
+ min: .6,
129
+ max: .64
130
+ },
131
+ decoyEdgeDarkness: {
132
+ min: 28,
133
+ max: 34
134
+ },
135
+ pieceScale: {
136
+ min: .09,
137
+ max: .22
138
+ }
139
+ }
140
+ ];
141
+ var toleranceSampler = require_stratifiedSampler.createStratifiedSampler();
142
+ var decoyCountSampler = require_stratifiedSampler.createStratifiedSampler();
143
+ var decoyHoleDarkenSampler = require_stratifiedSampler.createStratifiedSampler();
144
+ var decoyEdgeDarknessSampler = require_stratifiedSampler.createStratifiedSampler();
145
+ var clampDifficultyLevel = (level, maxLevel = 3) => {
146
+ const ceiling = Math.min(maxLevel, PUZZLE_DIFFICULTY_LEVELS.length - 1);
147
+ if (!Number.isFinite(level)) return 0;
148
+ return Math.max(0, Math.min(Math.floor(level), ceiling));
149
+ };
150
+ /**
151
+ * Map the severity currency every existing call-site already speaks — image
152
+ * rounds — onto a difficulty level.
153
+ *
154
+ * Severity is expressed as rounds ABOVE the site's ordinary count rather than
155
+ * an absolute, because the baseline (`env.config.captchas.solved.count`, or a
156
+ * rule's own `solvedImagesCount`) varies per site. "Two more rounds than
157
+ * normal" means the same thing everywhere; "four rounds" does not.
158
+ *
159
+ * Keeping one ordering here means the score ladder, the no-measurement gates,
160
+ * detector rules and traffic-filter policies cannot drift apart on what
161
+ * counts as more severe.
162
+ */
163
+ var severityToPuzzleDifficulty = (requestedImageRounds, baseImageRounds, maxLevel = 3) => {
164
+ if (requestedImageRounds === void 0) return 0;
165
+ const excess = requestedImageRounds - baseImageRounds;
166
+ if (!Number.isFinite(excess) || excess <= 0) return clampDifficultyLevel(0, maxLevel);
167
+ if (excess <= 2) return clampDifficultyLevel(1, maxLevel);
168
+ if (excess <= 4) return clampDifficultyLevel(2, maxLevel);
169
+ if (excess <= 6) return clampDifficultyLevel(3, maxLevel);
170
+ return clampDifficultyLevel(4, maxLevel);
171
+ };
172
+ /**
173
+ * Draw a concrete puzzle config from a level's bands.
174
+ *
175
+ * Sampling is server-side and per-challenge, and is deliberately NOT derived
176
+ * from anything the client supplies (sessionId, token, ip, timestamp). A
177
+ * client-derived seed would let a bot replay a request to obtain the same
178
+ * render, or precompute one offline.
179
+ *
180
+ * `pieceScale` is passed through as a range rather than drawn here: the
181
+ * renderer's own stratified piece-size draw consumes it in
182
+ * `resolvePuzzlePieceSize`, and sampling twice would narrow the distribution.
183
+ */
184
+ var samplePuzzleDifficulty = (level, holeDarken, maxLevel = 3) => {
185
+ const index = clampDifficultyLevel(level, maxLevel);
186
+ const band = PUZZLE_DIFFICULTY_LEVELS[index];
187
+ const decoyHoleDarkenFloor = holeDarken + MIN_DECOY_HOLE_DARKEN_MARGIN;
188
+ const sampledDecoyHoleDarken = decoyHoleDarkenSampler.sample(band.decoyHoleDarken.min, band.decoyHoleDarken.max);
189
+ return {
190
+ level: index,
191
+ tolerance: toleranceSampler.sampleInt(band.tolerance.min, band.tolerance.max),
192
+ puzzle: {
193
+ decoyCount: decoyCountSampler.sampleInt(band.decoyCount.min, band.decoyCount.max),
194
+ decoyEdgeDarkness: decoyEdgeDarknessSampler.sampleInt(band.decoyEdgeDarkness.min, band.decoyEdgeDarkness.max),
195
+ decoyHoleDarken: Math.min(1, Math.max(decoyHoleDarkenFloor, sampledDecoyHoleDarken)),
196
+ pieceScale: {
197
+ min: band.pieceScale.min,
198
+ max: band.pieceScale.max
199
+ }
200
+ }
201
+ };
202
+ };
203
+ //#endregion
204
+ exports.MIN_DECOY_HOLE_DARKEN_MARGIN = MIN_DECOY_HOLE_DARKEN_MARGIN;
205
+ exports.PUZZLE_DIFFICULTY_LEVELS = PUZZLE_DIFFICULTY_LEVELS;
206
+ exports.clampDifficultyLevel = clampDifficultyLevel;
207
+ exports.samplePuzzleDifficulty = samplePuzzleDifficulty;
208
+ exports.severityToPuzzleDifficulty = severityToPuzzleDifficulty;