feat(types,provider): per-sitekey image round floor, and a decay curve that decays - #3136
Draft
HughParry wants to merge 1 commit into
Draft
feat(types,provider): per-sitekey image round floor, and a decay curve that decays#3136HughParry wants to merge 1 commit into
HughParry wants to merge 1 commit into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
imageMaxRoundshas 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-codedMath.max(2, …)buried in the staleness function. A sitekey could not raise it, and a decision machine asking for 1 round got 1 round.imageMinRoundsNew 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 rejectsimageMinRounds > imageMaxRoundson write.Every round-count source now goes through one
clampImageRounds(requested, settings):getFrictionlessCaptchaChallenge/accessPolicy.tstrafficFilterRequestTime.tsfrictionlessTasks.tsgetFrictionlessCaptchaChallenge/decisionMachine.ts,shortCircuit.tsgetImageCaptchaChallenge.tsThat 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
solvedImagesCountreached 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 whatgetImageCaptchaChallengeactually serves.timestampDecayFunctionThe old implementation used
new Date().getTime()— epoch milliseconds, ~1.76e12 — as both the score ceiling and the decay denominator, sodecay = log10(2000) / max ≈ 1.9e-12and the exponential term never moved. Measured:A two-value step function wearing a logarithm costume. It now interpolates linearly from
DECAY_FLOOR_ROUNDS(3) at 10 minutes — whereFrictionlessManager.timestampTooOldstarts calling a timestamp old — toDECAY_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.NaNin used to meanNaNout, which the caller wrote straight onto the session record. An unreadable timestamp is now treated as fully decayed.Behaviour changes to be aware of
timestamp_too_oldanduser_agent_mismatchpaths in production, so worth watching solve rates on rollout rather than folding it in silently.imageMinRoundsdefaults to 2. A decision machine that returnssolvedImagesCount: 1will now serve 2. Nothing in the repo does this today.Also
image_min_roundsonsite_key_register/site_key_register_api.UserSettingsSchema— without it the value is silently stripped on write, asEndpointRule.enforcementwas.MIN_IMAGE_CAPTCHA_ROUNDSwith a matchinglengthOf.gteassertion, so the e2e covers the floor and not just the cap.Testing
npm run build:all:tsc— clean.timestampDecayFunctiontests rewritten — endpoints, monotonicity in age, both clamps, pinned min == max, and the unreadable-timestamp case. The old tests only assertedtypeof === "number"and2 <= x <= 12, so they passed just as happily against the broken version.clampImageRounds/resolveImageRoundsBoundstests cover the legacy-record case where a defaulted floor sits above a since-lowered cap (cap wins).assignDetectorBundle.test.tsfails identically onmain.https://claude.ai/code/session_017pFGMWxTGoqe2fBe54TYLx