diff --git a/shared/captcha.ts b/shared/captcha.ts index f7ad320..7effb18 100644 --- a/shared/captcha.ts +++ b/shared/captcha.ts @@ -93,6 +93,11 @@ export interface SlidePayload { game: "slide"; target: number; // 0–100 along the track color: string; + /** Base acceptance half-width (same units as target) the server validates + * against, so the client's "feels aligned" gate matches the server exactly + * instead of guessing. Not a secret: a bot submits the exact target anyway — + * this only governs how forgiving imprecise human input is. */ + tol: number; } export interface TapMatchPayload { @@ -105,6 +110,9 @@ export interface RotatePayload { arrow: { pos: ScenePoint; size: number; angle: number; color: string }; /** Marker the arrow must point at, on a ring around the arrow's center. */ dot: { angle: number; radius: number; size: number; color: string }; + /** Base acceptance half-angle (degrees) the server validates against, so the + * client gate matches the server exactly. Not a secret (see SlidePayload). */ + tol: number; } export interface ConnectPayload { @@ -235,6 +243,11 @@ export interface CaptchaChallengeDTO { gamesTotal: number; gameIndex: number; // 0-based limits: { maxEvents: number }; + /** Geometry-forgiveness multiplier (the admin touch-tolerance profile). The + * client mirrors it in its "feels aligned" gates so a lenient setting feels + * lenient on the client too — the server still holds the authoritative + * tolerance and re-validates every answer. Not a secret. */ + tolerance: number; } export interface CaptchaVerifyRequestDTO { diff --git a/src/components/HumanCheck.tsx b/src/components/HumanCheck.tsx index 45a87d4..147bfcf 100644 --- a/src/components/HumanCheck.tsx +++ b/src/components/HumanCheck.tsx @@ -247,6 +247,7 @@ export function HumanCheck({ rec={recRef.current} disabled={phase !== "playing"} onAnswer={handleAnswer} + tolerance={chRef.current?.tolerance ?? 1} /> diff --git a/src/components/captcha/games/ConnectGame.tsx b/src/components/captcha/games/ConnectGame.tsx index 7a634d4..b737839 100644 --- a/src/components/captcha/games/ConnectGame.tsx +++ b/src/components/captcha/games/ConnectGame.tsx @@ -6,7 +6,7 @@ import type { GameProps } from "./types"; /** "Draw a line between the two diamonds" — one stroke, or select both pieces * with the keyboard. */ -export function ConnectGame({ game, rec, disabled, onAnswer }: GameProps) { +export function ConnectGame({ game, rec, disabled, onAnswer, tolerance }: GameProps) { const payload = game.payload as ConnectPayload; const { toScene, surfaceProps } = useGameSurface(rec); const [fromId, setFromId] = useState(null); @@ -28,7 +28,7 @@ export function ConnectGame({ game, rec, disabled, onAnswer }: GameProps) { setLineEnd(null); if (!start) return; const target = payload.objects.find( - (o) => o.id !== start && distance(p, o.pos) <= Math.max(o.size * 1.8, 8), + (o) => o.id !== start && distance(p, o.pos) <= Math.max(o.size * 1.8, 8) * tolerance, ); if (target) onAnswer({ a: start, b: target.id }); else shake(); diff --git a/src/components/captcha/games/DragTargetGame.tsx b/src/components/captcha/games/DragTargetGame.tsx index f8a2159..b57dd9b 100644 --- a/src/components/captcha/games/DragTargetGame.tsx +++ b/src/components/captcha/games/DragTargetGame.tsx @@ -9,7 +9,7 @@ const clampPos = (v: number) => Math.min(96, Math.max(4, v)); /** "Drag the star into the dashed ring" — one drag, or pick-up/move/drop with * the keyboard (Enter grabs, arrows move, Enter drops). */ -export function DragTargetGame({ game, rec, disabled, onAnswer }: GameProps) { +export function DragTargetGame({ game, rec, disabled, onAnswer, tolerance }: GameProps) { const payload = game.payload as DragTargetPayload; const { toScene, surfaceProps } = useGameSurface(rec); const [dragId, setDragId] = useState(null); @@ -27,7 +27,7 @@ export function DragTargetGame({ game, rec, disabled, onAnswer }: GameProps) { }; const drop = (id: string, p: ScenePoint) => { - if (distance(p, ring.pos) <= ring.size) { + if (distance(p, ring.pos) <= ring.size * tolerance) { onAnswer({ objectId: id }); } else { shake(); diff --git a/src/components/captcha/games/PathTraceGame.tsx b/src/components/captcha/games/PathTraceGame.tsx index e7ee11b..0c78077 100644 --- a/src/components/captcha/games/PathTraceGame.tsx +++ b/src/components/captcha/games/PathTraceGame.tsx @@ -7,7 +7,7 @@ import type { GameProps } from "./types"; /** "Drag through the dots in order" — one continuous stroke 1→2→3(→4), or * activate the dots in order with the keyboard. Releasing early just resets * the visual progress; nothing is submitted until the trace completes. */ -export function PathTraceGame({ game, rec, disabled, onAnswer }: GameProps) { +export function PathTraceGame({ game, rec, disabled, onAnswer, tolerance }: GameProps) { const payload = game.payload as PathTracePayload; const { toScene, surfaceProps } = useGameSurface(rec); const [progress, setProgress] = useState(0); @@ -21,7 +21,7 @@ export function PathTraceGame({ game, rec, disabled, onAnswer }: GameProps) { [...payload.dots].sort((a, b) => Number(a.label ?? 0) - Number(b.label ?? 0)), [payload.dots], ); - const hitR = (d: SceneObject) => Math.max(d.size * 2, 8); + const hitR = (d: SceneObject) => Math.max(d.size * 2, 8) * tolerance; const done = Math.max(progress, kbProgress); diff --git a/src/components/captcha/games/RotateGame.tsx b/src/components/captcha/games/RotateGame.tsx index 7d66a10..d6e729c 100644 --- a/src/components/captcha/games/RotateGame.tsx +++ b/src/components/captcha/games/RotateGame.tsx @@ -4,16 +4,13 @@ import { cn } from "@/lib/utils"; import { useGameSurface, usePieceColor } from "../scene"; import type { GameProps } from "./types"; -const ALIGN_DEG = 12; // client-side "feels aligned" check (UX only — the -// server holds the real tolerance and validates the submitted angle itself) - function angDiff(a: number, b: number): number { return Math.abs(((a - b + 540) % 360) - 180); } /** "Turn the arrow to point at the dot" — drag around the pivot, or arrow keys * then Enter. Submits when the arrow is released roughly on target. */ -export function RotateGame({ game, rec, disabled, onAnswer }: GameProps) { +export function RotateGame({ game, rec, disabled, onAnswer, tolerance }: GameProps) { const payload = game.payload as RotatePayload; const { toScene, surfaceProps } = useGameSurface(rec); const { arrow, dot } = payload; @@ -35,7 +32,8 @@ export function RotateGame({ game, rec, disabled, onAnswer }: GameProps) { }; const submitIfAligned = (a: number) => { - if (angDiff(a, dot.angle) <= ALIGN_DEG) { + // Mirror the server's acceptance angle (payload.tol) exactly. + if (angDiff(a, dot.angle) <= payload.tol * tolerance) { onAnswer({ angle: ((a % 360) + 360) % 360 }); } else { setMiss(true); diff --git a/src/components/captcha/games/SlideGame.tsx b/src/components/captcha/games/SlideGame.tsx index eac1d8b..bb29ae8 100644 --- a/src/components/captcha/games/SlideGame.tsx +++ b/src/components/captcha/games/SlideGame.tsx @@ -6,14 +6,13 @@ import { darken, lighten } from "@/lib/pixel"; import { useGameSurface, usePieceColor } from "../scene"; import type { GameProps } from "./types"; -const ALIGN = 7; // client "looks aligned" feel (server holds the real tolerance) const KB_STEP = 3; const clampPos = (v: number) => Math.min(100, Math.max(0, v)); /** A chunky pixel handle on a track; slide it into the notch. Pointer drag or * arrow keys. The visible style is pixel-art; the maths is the same 0–100 the * server validated. */ -export function SlideGame({ game, rec, disabled, onAnswer }: GameProps) { +export function SlideGame({ game, rec, disabled, onAnswer, tolerance }: GameProps) { const payload = game.payload as SlidePayload; const { ref, toScene, surfaceProps } = useGameSurface(rec); const [pos, setPos] = useState(8); @@ -25,7 +24,9 @@ export function SlideGame({ game, rec, disabled, onAnswer }: GameProps) { const release = (p: number) => { setDrag(false); - if (Math.abs(p - payload.target) <= ALIGN) onAnswer({ pos: p }); + // Mirror the server's acceptance (payload.tol) so a gesture the client + // accepts is one the server accepts — no client-only over/under-shoot. + if (Math.abs(p - payload.target) <= payload.tol * tolerance) onAnswer({ pos: p }); else { setMiss(true); setTimeout(() => { diff --git a/src/components/captcha/games/types.ts b/src/components/captcha/games/types.ts index b245941..237c345 100644 --- a/src/components/captcha/games/types.ts +++ b/src/components/captcha/games/types.ts @@ -8,4 +8,10 @@ export interface GameProps { /** Submit the answer for server-side validation (the client never knows * whether it is correct — it only knows the gesture finished). */ onAnswer: (answer: unknown) => void; + /** The admin tolerance profile (geometry-forgiveness multiplier) the game + * applies to its acceptance gate, so the gate mirrors the server's and a + * lenient setting feels lenient on the client too. The server holds the + * authoritative tolerance and re-validates. Games without an alignment gate + * (tap-match, sort) simply ignore it. */ + tolerance: number; } diff --git a/src/pages/admin/settings/HumanCheckCard.tsx b/src/pages/admin/settings/HumanCheckCard.tsx index c683eff..7356a5c 100644 --- a/src/pages/admin/settings/HumanCheckCard.tsx +++ b/src/pages/admin/settings/HumanCheckCard.tsx @@ -67,7 +67,7 @@ function HumanCheckForm({ initial, patch }: { initial: SettingsDTO; patch: Setti const [captchaRiskMedium, setCaptchaRiskMedium] = useState(initial.captchaRiskMedium ?? 30); const [captchaRiskHigh, setCaptchaRiskHigh] = useState(initial.captchaRiskHigh ?? 60); const [captchaTolerance, setCaptchaTolerance] = useState<"lenient" | "standard" | "strict">( - initial.captchaTolerance ?? "standard", + initial.captchaTolerance ?? "lenient", ); const [captchaCreateLimit, setCaptchaCreateLimit] = useState(initial.captchaCreateLimit ?? 10); const [captchaVerifyLimit, setCaptchaVerifyLimit] = useState(initial.captchaVerifyLimit ?? 30); @@ -300,7 +300,7 @@ function HumanCheckForm({ initial, patch }: { initial: SettingsDTO; patch: Setti } className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" > - + diff --git a/worker/lib/captcha/games/rotate.ts b/worker/lib/captcha/games/rotate.ts index b371b45..4eac170 100644 --- a/worker/lib/captcha/games/rotate.ts +++ b/worker/lib/captcha/games/rotate.ts @@ -28,6 +28,7 @@ export const rotate: GamePlugin = { let initial = targetAngle + randFloat(70, 290); initial = ((initial % 360) + 360) % 360; + const tol = TOLERANCE[difficulty]; const payload: RotatePayload = { game: "rotate", arrow: { @@ -42,10 +43,12 @@ export const rotate: GamePlugin = { size: randFloat(3.5, 4.5), color: pick(COLORS), }, + // tol mirrors the secret so the client gate matches server acceptance. + tol, }; const secret: Secret = { targetAngle, - tolerance: TOLERANCE[difficulty], + tolerance: tol, }; return { type: "rotate", diff --git a/worker/lib/captcha/games/slide.ts b/worker/lib/captcha/games/slide.ts index cd568d7..aefa2dc 100644 --- a/worker/lib/captcha/games/slide.ts +++ b/worker/lib/captcha/games/slide.ts @@ -27,8 +27,11 @@ export const slide: GamePlugin = { generate({ difficulty }) { // Keep the notch away from the edges so "do nothing" never lands it. const target = randInt(22, 82); - const payload: SlidePayload = { game: "slide", target, color: pick(COLORS) }; - const secret: Secret = { target, tolerance: TOLERANCE[difficulty] }; + const tol = TOLERANCE[difficulty]; + // tol travels in the public payload too so the client's auto-submit gate + // matches the server's acceptance exactly (the secret stays authoritative). + const payload: SlidePayload = { game: "slide", target, color: pick(COLORS), tol }; + const secret: Secret = { target, tolerance: tol }; return { type: "slide", prompt: "Slide the handle into the notch", diff --git a/worker/lib/captcha/service.ts b/worker/lib/captcha/service.ts index 9582dcf..b4a55aa 100644 --- a/worker/lib/captcha/service.ts +++ b/worker/lib/captcha/service.ts @@ -138,6 +138,7 @@ export async function createChallenge( gamesTotal: 1, gameIndex: 0, limits: { maxEvents: cfg.maxEvents }, + tolerance: cfg.toleranceMult, ...generateDecoys(), } as CaptchaChallengeDTO, }; @@ -194,6 +195,7 @@ export async function createChallenge( gamesTotal, gameIndex: 0, limits: { maxEvents: cfg.maxEvents }, + tolerance: cfg.toleranceMult, ...generateDecoys(), } as CaptchaChallengeDTO, }; diff --git a/worker/lib/settings.ts b/worker/lib/settings.ts index 5bbeded..470b485 100644 --- a/worker/lib/settings.ts +++ b/worker/lib/settings.ts @@ -475,7 +475,11 @@ export function captchaToleranceFrom( map: Record, ): CaptchaTolerance { const v = map[SETTING_KEYS.captchaTolerance]; - return v === "lenient" || v === "strict" ? v : "standard"; + // Default LENIENT: this is a public link shortener whose audience includes + // older / less dexterous users, and the captcha's security never rested on + // tight geometry — it's the PoW economics + single-use + interaction risk + + // bindings (see docs/human-check-v3.md). An admin can still pick strict. + return v === "lenient" || v === "strict" || v === "standard" ? v : "lenient"; } const TOLERANCE_MULT: Record = {