From 5de818c843b120b10d6796d8c5a6086159169c0a Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 27 Aug 2026 15:32:53 +0100 Subject: [PATCH 1/4] feat(statistics): show the image challenge threshold The portal's frictionless flow gained a second score threshold: sessions past the frictionless threshold now get a puzzle, and only those at or above a higher threshold get an image captcha. The Statistics tab shows the first of those already, so it should show the second too. `frictionlessThreshold` keeps arriving as a plain number, so nothing about the existing row changes. It is now also read as the two-rung object the portal uses internally and collapsed back to its lower rung, so the tab cannot break if that shape ever reaches the plugin. The new row is blank on portals that predate the change rather than inventing a value for them. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/settings/procaptcha/procaptchaSite.ts | 75 ++++++++++++++++++- .../statistics/components/appComponent.tsx | 38 ++++++++++ assets/src/settings/statistics/config.ts | 4 + .../Statistics/Statistics_Settings_Tab.php | 1 + 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index d339ea6..1cbf09e 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -2,7 +2,7 @@ import { type ProcaptchaAccount, procaptchaAccountSchema, } from "#settings/procaptcha/procaptchaAccount.js"; -import { z, type ZodType } from "zod"; +import { z, type ZodType, type ZodTypeDef } from "zod"; export interface ProcaptchaSite { account: ProcaptchaAccount; @@ -16,7 +16,18 @@ export interface ProcaptchaSite { } export interface SiteSettings { + /** + * Lower rung of the frictionless score ladder: the score a session has to + * stay under to pass without being challenged. This is the value the + * plugin has always shown as "Frictionless Threshold". + */ frictionlessThreshold: number; + /** + * Upper rung: the score at or above which a session gets an image captcha + * rather than a puzzle. Optional because portals older than the ladder + * release do not send it. + */ + frictionlessImageThreshold?: number; powDifficulty: number; captchaType: string; domains: string[]; @@ -28,12 +39,68 @@ export interface CaptchaUsage { total: number; } +/** + * `SiteSettings` as it arrives on the wire, before the ladder is collapsed + * to its lower rung. Declared separately because the schema below transforms + * on parse, so its input and output types differ and `ZodType` needs both. + */ +export interface SiteSettingsInput extends Omit< + SiteSettings, + "frictionlessThreshold" +> { + frictionlessThreshold: + | number + | { + frictionlessPuzzleThreshold?: number; + frictionlessImageThreshold?: number; + }; +} + +export interface ProcaptchaSiteInput extends Omit { + settings: SiteSettingsInput; +} + +/** + * Fallback for a ladder object that arrives without its lower rung. Matches + * the portal's own default so the label the user sees does not change + * meaning between the two shapes. + */ +const DEFAULT_FRICTIONLESS_THRESHOLD = 0.5; + +/** + * The API sends `frictionlessThreshold` as a plain number, and will keep + * doing so — the plugin ships independently of the portal, so that field's + * type is part of a contract the portal cannot change from under an install + * that updates on its own schedule. + * + * It is nonetheless read here as "number, or the two-rung object", because + * internally the portal did move to the object and a future endpoint (or a + * self-hosted one) may pass it straight through. Both collapse to the puzzle + * rung, which is what this field has always meant, so the rest of the plugin + * keeps seeing a number. + */ +const frictionlessThresholdSchema = z + .union([ + z.number(), + z.object({ + frictionlessPuzzleThreshold: z.number().optional(), + frictionlessImageThreshold: z.number().optional(), + }), + ]) + .transform((value) => + "number" === typeof value + ? value + : (value.frictionlessPuzzleThreshold ?? + DEFAULT_FRICTIONLESS_THRESHOLD), + ); + export const siteSettingsSchema = z.object({ - frictionlessThreshold: z.number(), + frictionlessThreshold: frictionlessThresholdSchema, + frictionlessImageThreshold: z.number().optional(), powDifficulty: z.number(), captchaType: z.string(), domains: z.string().array(), -}) satisfies ZodType; +}) satisfies ZodType; export const captchaUsageSchema = z.object({ submissions: z.number(), @@ -50,4 +117,4 @@ export const procaptchaSiteSchema = z.object({ image: captchaUsageSchema, pow: captchaUsageSchema, }), -}) satisfies ZodType; +}) satisfies ZodType; diff --git a/assets/src/settings/statistics/components/appComponent.tsx b/assets/src/settings/statistics/components/appComponent.tsx index e2fc253..4e92d27 100644 --- a/assets/src/settings/statistics/components/appComponent.tsx +++ b/assets/src/settings/statistics/components/appComponent.tsx @@ -122,6 +122,11 @@ class AppComponent extends React.Component { .frictionlessThreshold, value: "...", }, + { + label: this.config.getCaptchaSettingsLabels() + .frictionlessImageThreshold, + value: "...", + }, { label: this.config.getCaptchaSettingsLabels() .powDifficulty, @@ -221,6 +226,32 @@ class AppComponent extends React.Component { return frictionlessThreshold < 0.4 ? levelLabels.high : levelLabels.low; } + /** + * Upper rung of the score ladder, shown as a level rather than a raw + * score to match its sibling above. A lower rung means more sessions + * reach an image captcha instead of a puzzle, so it reads as stricter. + * + * Portals older than the ladder release do not send this at all, in + * which case the row shows an em dash rather than inventing a value. + */ + protected getFrictionlessImageThresholdLabel( + frictionlessImageThreshold: number | undefined, + ): string { + if (undefined === frictionlessImageThreshold) { + return "—"; + } + + const levelLabels = this.config.getCaptchaSettingsLabels().level; + + if (frictionlessImageThreshold < 1) { + return levelLabels.high; + } + + return 1 === frictionlessImageThreshold + ? levelLabels.normal + : levelLabels.low; + } + protected getTypeLabel(type: string): string { const typeLabels = this.config.getCaptchaSettingsLabels().types; @@ -260,6 +291,13 @@ class AppComponent extends React.Component { siteSettings.frictionlessThreshold, ), }, + { + label: this.config.getCaptchaSettingsLabels() + .frictionlessImageThreshold, + value: this.getFrictionlessImageThresholdLabel( + siteSettings.frictionlessImageThreshold, + ), + }, { label: this.config.getCaptchaSettingsLabels() .powDifficulty, diff --git a/assets/src/settings/statistics/config.ts b/assets/src/settings/statistics/config.ts index 6e5d621..82c6ee1 100644 --- a/assets/src/settings/statistics/config.ts +++ b/assets/src/settings/statistics/config.ts @@ -24,6 +24,7 @@ interface CaptchaSettingsLabels { title: string; type: string; frictionlessThreshold: string; + frictionlessImageThreshold: string; powDifficulty: string; level: { low: string; @@ -151,6 +152,9 @@ class ConfigClass implements Config { frictionlessThreshold: captchaSettingsLabels.getString( "frictionlessThreshold", ), + frictionlessImageThreshold: captchaSettingsLabels.getString( + "frictionlessImageThreshold", + ), powDifficulty: captchaSettingsLabels.getString("powDifficulty"), level: { low: level.getString("low"), diff --git a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php index 25314cf..7cc6460 100644 --- a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php +++ b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php @@ -71,6 +71,7 @@ function ( Upgrade_Tier_Banner $model ) { ), 'callToUpgradeElementMarkup' => $call_to_upgrade_element_markup, 'captchaSettingsLabels' => array( + 'frictionlessImageThreshold' => __( 'Image Challenge Threshold:', 'prosopo-procaptcha' ), 'frictionlessThreshold' => __( 'Frictionless Threshold:', 'prosopo-procaptcha' ), 'level' => array( 'high' => __( 'High', 'prosopo-procaptcha' ), From 70dd75c2f00579383a96e376bbf5f7187d88a7ce Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 27 Aug 2026 16:53:52 +0100 Subject: [PATCH 2/4] style: satisfy prettier and phpcs on the ladder changes Prettier 3.4.1 (the version pinned in yarn.lock) collapses the SiteSettingsInput extends clause onto fewer lines, and the new frictionlessImageThreshold key widened the captchaSettingsLabels array enough that phpcs wanted the whole block realigned. Co-Authored-By: Claude Opus 5 (1M context) --- assets/src/settings/procaptcha/procaptchaSite.ts | 6 ++---- .../Settings/Statistics/Statistics_Settings_Tab.php | 12 ++++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index 1cbf09e..b68029e 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -44,10 +44,8 @@ export interface CaptchaUsage { * to its lower rung. Declared separately because the schema below transforms * on parse, so its input and output types differ and `ZodType` needs both. */ -export interface SiteSettingsInput extends Omit< - SiteSettings, - "frictionlessThreshold" -> { +export interface SiteSettingsInput + extends Omit { frictionlessThreshold: | number | { diff --git a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php index 7cc6460..b528499 100644 --- a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php +++ b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php @@ -72,16 +72,16 @@ function ( Upgrade_Tier_Banner $model ) { 'callToUpgradeElementMarkup' => $call_to_upgrade_element_markup, 'captchaSettingsLabels' => array( 'frictionlessImageThreshold' => __( 'Image Challenge Threshold:', 'prosopo-procaptcha' ), - 'frictionlessThreshold' => __( 'Frictionless Threshold:', 'prosopo-procaptcha' ), - 'level' => array( + 'frictionlessThreshold' => __( 'Frictionless Threshold:', 'prosopo-procaptcha' ), + 'level' => array( 'high' => __( 'High', 'prosopo-procaptcha' ), 'low' => __( 'Low', 'prosopo-procaptcha' ), 'normal' => __( 'Normal', 'prosopo-procaptcha' ), ), - 'powDifficulty' => __( 'Proof of Work Difficulty:', 'prosopo-procaptcha' ), - 'title' => __( 'Captcha Settings', 'prosopo-procaptcha' ), - 'type' => __( 'Type:', 'prosopo-procaptcha' ), - 'types' => array( + 'powDifficulty' => __( 'Proof of Work Difficulty:', 'prosopo-procaptcha' ), + 'title' => __( 'Captcha Settings', 'prosopo-procaptcha' ), + 'type' => __( 'Type:', 'prosopo-procaptcha' ), + 'types' => array( 'frictionless' => __( 'Frictionless', 'prosopo-procaptcha' ), 'image' => __( 'Image', 'prosopo-procaptcha' ), 'proofOfWork' => __( 'Proof of Work', 'prosopo-procaptcha' ), From 611b68faf09ab2476aef01dfd108565bddedbe15 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 27 Aug 2026 18:12:39 +0100 Subject: [PATCH 3/4] fix(statistics): read the image threshold under its real wire name The portal's ClientSettingsSchema names this field imageThreshold, not frictionlessImageThreshold, so the row this feature adds was reading a key that is never present and always rendered an em dash. The level bucketing was wrong for the same reason: the score is constrained to 0..1, so the old "< 1 means High" test matched every real value and the Normal and Low labels were unreachable. Banded around the portal's 0.8 default the way the sibling threshold is banded around its 0.5 one. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/settings/procaptcha/procaptchaSite.ts | 9 ++++---- .../statistics/components/appComponent.tsx | 22 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index b68029e..f52eb35 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -24,10 +24,11 @@ export interface SiteSettings { frictionlessThreshold: number; /** * Upper rung: the score at or above which a session gets an image captcha - * rather than a puzzle. Optional because portals older than the ladder - * release do not send it. + * rather than a puzzle. Named `imageThreshold` on the wire, which is the + * name the portal's own settings schema uses. Optional because portals + * older than the ladder release do not send it. */ - frictionlessImageThreshold?: number; + imageThreshold?: number; powDifficulty: number; captchaType: string; domains: string[]; @@ -94,7 +95,7 @@ const frictionlessThresholdSchema = z export const siteSettingsSchema = z.object({ frictionlessThreshold: frictionlessThresholdSchema, - frictionlessImageThreshold: z.number().optional(), + imageThreshold: z.number().optional(), powDifficulty: z.number(), captchaType: z.string(), domains: z.string().array(), diff --git a/assets/src/settings/statistics/components/appComponent.tsx b/assets/src/settings/statistics/components/appComponent.tsx index 4e92d27..0f4a7ee 100644 --- a/assets/src/settings/statistics/components/appComponent.tsx +++ b/assets/src/settings/statistics/components/appComponent.tsx @@ -231,25 +231,27 @@ class AppComponent extends React.Component { * score to match its sibling above. A lower rung means more sessions * reach an image captcha instead of a puzzle, so it reads as stricter. * + * Banded around the portal's 0.8 default the same way the sibling is + * banded around its 0.5 one. The score is constrained to 0..1, so the + * bands have to sit inside that range to stay reachable. + * * Portals older than the ladder release do not send this at all, in * which case the row shows an em dash rather than inventing a value. */ - protected getFrictionlessImageThresholdLabel( - frictionlessImageThreshold: number | undefined, + protected getImageThresholdLabel( + imageThreshold: number | undefined, ): string { - if (undefined === frictionlessImageThreshold) { + if (undefined === imageThreshold) { return "—"; } const levelLabels = this.config.getCaptchaSettingsLabels().level; - if (frictionlessImageThreshold < 1) { - return levelLabels.high; + if (imageThreshold >= 0.7 && imageThreshold <= 0.9) { + return levelLabels.normal; } - return 1 === frictionlessImageThreshold - ? levelLabels.normal - : levelLabels.low; + return imageThreshold < 0.7 ? levelLabels.high : levelLabels.low; } protected getTypeLabel(type: string): string { @@ -294,8 +296,8 @@ class AppComponent extends React.Component { { label: this.config.getCaptchaSettingsLabels() .frictionlessImageThreshold, - value: this.getFrictionlessImageThresholdLabel( - siteSettings.frictionlessImageThreshold, + value: this.getImageThresholdLabel( + siteSettings.imageThreshold, ), }, { From d22bc0d0dc8cab6d55a2b88cf368514fd8b83018 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 27 Aug 2026 20:53:55 +0100 Subject: [PATCH 4/4] fix(statistics): read the image rung from inside the ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upper rung arrives nested inside frictionlessThreshold, not as a sibling of it. The row was reading a top-level field that is never sent, so it always rendered an em dash. An earlier attempt pointed it at the settings' own imageThreshold, which does exist on the wire but is an unrelated image-captcha setting on a 0..1 scale — not the ladder rung the row is labelled for. Reverted. The union that tolerates both wire shapes is load-bearing rather than defensive: the portal has moved frictionlessThreshold to the ladder, but records migrate in the background and the WordPress endpoint still emits the bare number today, so an install can meet either. The transform now splits the ladder into two flat fields instead of collapsing it and discarding the image rung. Banding restored to the 1.0 default: unlike the lower rung this one is deliberately allowed above 1, because the score compared against it is a total that server-side penalties add to. The 0.7-0.9 band from the earlier attempt belonged to imageThreshold's 0..1 scale. Verified against the live endpoint: settings.frictionlessThreshold is still 0.5, so the row shows an em dash until the portal API ships the ladder, then fills in with no further plugin change. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/settings/procaptcha/procaptchaSite.ts | 89 +++++++++++-------- .../statistics/components/appComponent.tsx | 29 +++--- 2 files changed, 66 insertions(+), 52 deletions(-) diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index f52eb35..f7982a0 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -24,11 +24,14 @@ export interface SiteSettings { frictionlessThreshold: number; /** * Upper rung: the score at or above which a session gets an image captcha - * rather than a puzzle. Named `imageThreshold` on the wire, which is the - * name the portal's own settings schema uses. Optional because portals - * older than the ladder release do not send it. + * rather than a puzzle. Arrives nested inside `frictionlessThreshold`, not + * as a sibling of it, and is absent whenever that field is still the bare + * number the pre-ladder API sends. + * + * Not to be confused with the settings' own `imageThreshold`, which is an + * unrelated image-captcha setting on a 0..1 scale. */ - imageThreshold?: number; + frictionlessImageThreshold?: number; powDifficulty: number; captchaType: string; domains: string[]; @@ -41,12 +44,16 @@ export interface CaptchaUsage { } /** - * `SiteSettings` as it arrives on the wire, before the ladder is collapsed - * to its lower rung. Declared separately because the schema below transforms - * on parse, so its input and output types differ and `ZodType` needs both. + * `SiteSettings` as it arrives on the wire, before the ladder is split into + * the two flat fields the rest of the plugin reads. Declared separately + * because the schema below transforms on parse, so its input and output + * types differ and `ZodType` needs both. */ export interface SiteSettingsInput - extends Omit { + extends Omit< + SiteSettings, + "frictionlessThreshold" | "frictionlessImageThreshold" + > { frictionlessThreshold: | number | { @@ -67,39 +74,43 @@ export interface ProcaptchaSiteInput extends Omit { const DEFAULT_FRICTIONLESS_THRESHOLD = 0.5; /** - * The API sends `frictionlessThreshold` as a plain number, and will keep - * doing so — the plugin ships independently of the portal, so that field's - * type is part of a contract the portal cannot change from under an install - * that updates on its own schedule. - * - * It is nonetheless read here as "number, or the two-rung object", because - * internally the portal did move to the object and a future endpoint (or a - * self-hosted one) may pass it straight through. Both collapse to the puzzle - * rung, which is what this field has always meant, so the rest of the plugin - * keeps seeing a number. + * `frictionlessThreshold` is read as "number, or the two-rung ladder object" + * because both are live at once: the portal moved the field to the ladder, + * but records migrate in the background and the plugin ships independently + * of the portal, so an install can be talking to either shape. A bare number + * means what it always meant — the puzzle rung — and carries no image rung. */ -const frictionlessThresholdSchema = z - .union([ - z.number(), - z.object({ - frictionlessPuzzleThreshold: z.number().optional(), - frictionlessImageThreshold: z.number().optional(), - }), - ]) - .transform((value) => - "number" === typeof value - ? value - : (value.frictionlessPuzzleThreshold ?? - DEFAULT_FRICTIONLESS_THRESHOLD), - ); +const frictionlessThresholdSchema = z.union([ + z.number(), + z.object({ + frictionlessPuzzleThreshold: z.number().optional(), + frictionlessImageThreshold: z.number().optional(), + }), +]); -export const siteSettingsSchema = z.object({ - frictionlessThreshold: frictionlessThresholdSchema, - imageThreshold: z.number().optional(), - powDifficulty: z.number(), - captchaType: z.string(), - domains: z.string().array(), -}) satisfies ZodType; +/** + * Split the ladder into the two flat fields the rest of the plugin reads, so + * nothing downstream has to know which of the two wire shapes arrived. + */ +export const siteSettingsSchema = z + .object({ + frictionlessThreshold: frictionlessThresholdSchema, + powDifficulty: z.number(), + captchaType: z.string(), + domains: z.string().array(), + }) + .transform(({ frictionlessThreshold, ...settings }) => ({ + ...settings, + frictionlessThreshold: + "number" === typeof frictionlessThreshold + ? frictionlessThreshold + : (frictionlessThreshold.frictionlessPuzzleThreshold ?? + DEFAULT_FRICTIONLESS_THRESHOLD), + frictionlessImageThreshold: + "number" === typeof frictionlessThreshold + ? undefined + : frictionlessThreshold.frictionlessImageThreshold, + })) satisfies ZodType; export const captchaUsageSchema = z.object({ submissions: z.number(), diff --git a/assets/src/settings/statistics/components/appComponent.tsx b/assets/src/settings/statistics/components/appComponent.tsx index 0f4a7ee..9f25eca 100644 --- a/assets/src/settings/statistics/components/appComponent.tsx +++ b/assets/src/settings/statistics/components/appComponent.tsx @@ -231,27 +231,30 @@ class AppComponent extends React.Component { * score to match its sibling above. A lower rung means more sessions * reach an image captcha instead of a puzzle, so it reads as stricter. * - * Banded around the portal's 0.8 default the same way the sibling is - * banded around its 0.5 one. The score is constrained to 0..1, so the - * bands have to sit inside that range to stay reachable. + * Banded on the portal's 1.0 default rather than a range: unlike the + * lower rung this one is deliberately allowed above 1, because the score + * it is compared against is a total that server-side penalties add to. * - * Portals older than the ladder release do not send this at all, in - * which case the row shows an em dash rather than inventing a value. + * An API still sending the pre-ladder bare `frictionlessThreshold` gives + * no upper rung at all, in which case the row shows an em dash rather + * than inventing a value. */ - protected getImageThresholdLabel( - imageThreshold: number | undefined, + protected getFrictionlessImageThresholdLabel( + frictionlessImageThreshold: number | undefined, ): string { - if (undefined === imageThreshold) { + if (undefined === frictionlessImageThreshold) { return "—"; } const levelLabels = this.config.getCaptchaSettingsLabels().level; - if (imageThreshold >= 0.7 && imageThreshold <= 0.9) { - return levelLabels.normal; + if (frictionlessImageThreshold < 1) { + return levelLabels.high; } - return imageThreshold < 0.7 ? levelLabels.high : levelLabels.low; + return 1 === frictionlessImageThreshold + ? levelLabels.normal + : levelLabels.low; } protected getTypeLabel(type: string): string { @@ -296,8 +299,8 @@ class AppComponent extends React.Component { { label: this.config.getCaptchaSettingsLabels() .frictionlessImageThreshold, - value: this.getImageThresholdLabel( - siteSettings.imageThreshold, + value: this.getFrictionlessImageThresholdLabel( + siteSettings.frictionlessImageThreshold, ), }, {