Skip to content

feat(types,provider): per-sitekey image round floor, and a decay curve that decays - #3136

Draft
HughParry wants to merge 1 commit into
mainfrom
feat/image-round-bounds
Draft

feat(types,provider): per-sitekey image round floor, and a decay curve that decays#3136
HughParry wants to merge 1 commit into
mainfrom
feat/image-round-bounds

Conversation

@HughParry

Copy link
Copy Markdown
Contributor

Closes half of prosopo/captcha-private#3099 — the provider/schema half. The portal UI lands in the companion PR on captcha-private.

What was missing

imageMaxRounds has capped image challenges since #2434, but there has never been a floor. The fewest rounds anyone could be asked to solve was whatever the provider's env config happened to say (captchas.solved.count, default 2), plus a hard-coded Math.max(2, …) buried in the staleness function. A sitekey could not raise it, and a decision machine asking for 1 round got 1 round.

imageMinRounds

New optional field on ClientSettingsSchema, number().int().min(1), defaulting to 2 — the floor that was already hard-coded, so existing sitekeys are unaffected. A cross-field refinement rejects imageMinRounds > imageMaxRounds on write.

Every round-count source now goes through one clampImageRounds(requested, settings):

Source Where
Access-policy rules getFrictionlessCaptchaChallenge/accessPolicy.ts
Traffic-filter categories trafficFilterRequestTime.ts
Routing / decision machines frictionlessTasks.ts
Provider heuristics (missing token, failed decrypt, webview, bot score, context-aware sim score) getFrictionlessCaptchaChallenge/decisionMachine.ts, shortCircuit.ts
Serve time — the last word on what the user sees getImageCaptchaChallenge.ts

That is what the issue's "overrides rules" asks for: the sitekey's settings now bound its rules in both directions instead of only capping them.

The routing machine was the one source nothing clamped. Its solvedImagesCount reached the session record unbounded, and that record is what billing and analytics read. It now passes through the same clamp, so the stored count agrees with what getImageCaptchaChallenge actually serves.

timestampDecayFunction

The old implementation used new Date().getTime() — epoch milliseconds, ~1.76e12 — as both the score ceiling and the decay denominator, so decay = log10(2000) / max ≈ 1.9e-12 and the exponential term never moved. Measured:

session age old new
0–10 min 3 3
20 min 3 5
35 min 3 7
50 min 3 10
60 min+ 12 12

A two-value step function wearing a logarithm costume. It now interpolates linearly from DECAY_FLOOR_ROUNDS (3) at 10 minutes — where FrictionlessManager.timestampTooOld starts calling a timestamp old — to DECAY_CEILING_ROUNDS (12) at an hour, clamped into the sitekey's bounds at every step. Both endpoints are preserved; only the middle changes, and only upward.

NaN in used to mean NaN out, which the caller wrote straight onto the session record. An unreadable timestamp is now treated as fully decayed.

Behaviour changes to be aware of

  • Sub-hour stale sessions get more rounds. The table above. This is the intended fix, but it lands on the timestamp_too_old and user_agent_mismatch paths in production, so worth watching solve rates on rollout rather than folding it in silently.
  • imageMinRounds defaults to 2. A decision machine that returns solvedImagesCount: 1 will now serve 2. Nothing in the repo does this today.
  • Nothing else moves. Every other path was already floored at 2 or higher.

Also

  • image_min_rounds on site_key_register / site_key_register_api.
  • Mongoose field on UserSettingsSchema — without it the value is silently stripped on write, as EndpointRule.enforcement was.
  • Cypress MIN_IMAGE_CAPTCHA_ROUNDS with a matching lengthOf.gte assertion, so the e2e covers the floor and not just the cap.

Testing

  • npm run build:all:tsc — clean.
  • Provider unit suite: 73 files, 1099 tests pass. Types: 177 pass.
  • timestampDecayFunction tests rewritten — endpoints, monotonicity in age, both clamps, pinned min == max, and the unreadable-timestamp case. The old tests only asserted typeof === "number" and 2 <= x <= 12, so they passed just as happily against the broken version.
  • New clampImageRounds / resolveImageRoundsBounds tests cover the legacy-record case where a defaulted floor sits above a since-lowered cap (cap wins).
  • Integration suites needing Mongo/Redis were not run locally; assignDetectorBundle.test.ts fails identically on main.

https://claude.ai/code/session_017pFGMWxTGoqe2fBe54TYLx

…e that decays

`imageMaxRounds` has capped image challenges since #2434, but there has never
been a floor: the fewest rounds anyone could be asked for was whatever the
provider's env config happened to say, plus a hard-coded `Math.max(2, …)` in
the staleness function. Adds `imageMinRounds` (default 2, so nothing moves for
existing sitekeys) and routes every round-count source through a single
`clampImageRounds`, so access-policy rules, traffic-filter categories, routing
machines and the provider's own heuristics all land inside the sitekey's
bounds. The settings now override the rules in both directions rather than
only capping them.

The routing machine was the one source nothing clamped — its count reached the
session record unbounded, which is what billing and analytics read. It now
goes through the same clamp, so the record agrees with what
`getImageCaptchaChallenge` actually serves.

`timestampDecayFunction` used `new Date().getTime()` — epoch milliseconds — as
both the score ceiling and the decay denominator, which made the exponential
term a rounding error. Measured, it returned 3 rounds for every session under
an hour and `min(imageMaxRounds, 12)` beyond: a two-value step function
wearing a logarithm costume. It now interpolates linearly from 3 rounds at
10 minutes (where the frictionless flow starts calling a timestamp old) to 12
at an hour, so both endpoints are preserved and only the middle changes. An
unreadable timestamp is treated as fully decayed rather than returning `NaN`
for the caller to write onto the session record.

Refs #3099

Claude-Session: https://claude.ai/code/session_017pFGMWxTGoqe2fBe54TYLx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant