Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/separate-image-puzzle-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@prosopo/types": minor
"@prosopo/types-database": patch
"@prosopo/provider": patch
"@prosopo/server": patch
"@prosopo/procaptcha-frictionless": patch
---

Name the captcha-type sets and group per-challenge session settings.

`ChallengeCaptchaType` (pow | image | puzzle) and `InteractiveCaptchaType`
(image | puzzle) replace the anonymous unions that were spelled out across the
escalation path, so adding a challenge type is a compile error at each site
rather than a grep exercise. `DecisionMachineCaptchaTypeSchema` stays as an
alias for stored decision-machine artefacts.

Challenge dispatch now goes through one exhaustive table
(`sendChallenge` in the provider, `VERIFY_RECENCY` + the verifier record in
`@prosopo/server`) instead of per-type if/switch chains in the configured-type
short-circuit, the access-policy handler and the client verify path.

Sessions additionally record `challengeParams`, a discriminated view of
`solvedImagesCount` / `powDifficulty` / `blocked` keyed on the challenge type.
This is dual-written alongside the existing flat fields, which remain the
source of truth — no reader changes and no backfill is required by this
release. `ClientSettingsSchema` likewise exposes derived `image`, `pow` and
`puzzle` groups on parse while the flat keys stay authoritative.

`registerBlockedSession` now takes the captcha type the request would have
been served instead of hardcoding `image`. Blocked sessions arising from an
access rule that pins pow or puzzle are recorded against that type, matching
what the same code path already logs.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import {
CaptchaType,
type FrictionlessState,
type InteractiveCaptchaType,
type ModeType,
ProcaptchaConfigSchema,
type ProcaptchaFrictionlessProps,
Expand Down Expand Up @@ -178,7 +179,7 @@ export const ProcaptchaFrictionless = ({
escalationCoords?: RetryCoords,
) => {
const onEscalate = (
next: CaptchaType.image | CaptchaType.puzzle,
next: InteractiveCaptchaType,
newSessionId: string,
coords?: RetryCoords,
) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import {
type IPInfoResponse,
type RequestHeaders,
type ScoreComponents,
isChallengeCaptchaType,
} from "@prosopo/types";
import type { ClientRecord } from "@prosopo/types-database";
import type { AccessPolicy, UserScope } from "@prosopo/user-access-policy";
import type { Response } from "express";
import { sendChallenge } from "../../../tasks/frictionless/challengeDispatch.js";
import { FrictionlessReason } from "../../../tasks/frictionless/frictionlessTasks.js";
import type { Tasks } from "../../../tasks/index.js";
import { attachHoneypot } from "./honeypotResponse.js";
Expand Down Expand Up @@ -92,7 +94,7 @@ export const handleAccessPolicy = async (
captchaType: CaptchaType.image,
},
}));
await tasks.frictionlessManager.registerBlockedSession({
await tasks.frictionlessManager.registerBlockedSession(CaptchaType.image, {
solvedImagesCount: clientRecord.settings.imageMaxRounds,
userSitekeyIpHash: input.userSitekeyIpHash,
reason: FrictionlessReason.ACCESS_POLICY_BLOCK,
Expand Down Expand Up @@ -123,14 +125,19 @@ export const handleAccessPolicy = async (
captchaType: userAccessPolicy.captchaType,
},
}));
await tasks.frictionlessManager.registerBlockedSession({
solvedImagesCount: clientRecord.settings.imageMaxRounds,
userSitekeyIpHash: input.userSitekeyIpHash,
reason: FrictionlessReason.AUTO_BAN_SCORE,
siteKey: input.dapp,
ipInfo: input.ipInfo,
headers: input.flatHeaders,
});
await tasks.frictionlessManager.registerBlockedSession(
isChallengeCaptchaType(userAccessPolicy.captchaType)
? userAccessPolicy.captchaType
: CaptchaType.image,
{
solvedImagesCount: clientRecord.settings.imageMaxRounds,
userSitekeyIpHash: input.userSitekeyIpHash,
reason: FrictionlessReason.AUTO_BAN_SCORE,
siteKey: input.dapp,
ipInfo: input.ipInfo,
headers: input.flatHeaders,
},
);
return {
handled: true,
response: res.status(401).json({ error: "Unauthorized" }),
Expand All @@ -145,62 +152,34 @@ export const handleAccessPolicy = async (
headers: input.flatHeaders,
};

if (userAccessPolicy.captchaType === CaptchaType.image) {
// A policy that pins a concrete challenge type serves it directly. The
// three per-type branches this replaces were identical apart from the
// image-only `solvedImagesCount`, which `sendCaptcha` discards for the
// other types — so passing it unconditionally is behaviour-preserving.
if (isChallengeCaptchaType(userAccessPolicy.captchaType)) {
logger.info(() => ({
msg: "Frictionless decision",
data: {
decision: "user_access_policy",
captchaType: CaptchaType.image,
},
}));
attachHoneypot(res, clientRecord);
return {
handled: true,
response: res.json(
await tasks.frictionlessManager.sendImageCaptcha({
...captchaTypeBaseParams,
solvedImagesCount: userAccessPolicy.solvedImagesCount
? Math.min(
userAccessPolicy.solvedImagesCount,
clientRecord.settings.imageMaxRounds,
)
: clientRecord.settings.imageMaxRounds,
}),
),
};
}

if (userAccessPolicy.captchaType === CaptchaType.pow) {
logger.info(() => ({
msg: "Frictionless decision",
data: {
decision: "user_access_policy",
captchaType: CaptchaType.pow,
},
}));
attachHoneypot(res, clientRecord);
return {
handled: true,
response: res.json(
await tasks.frictionlessManager.sendPowCaptcha(captchaTypeBaseParams),
),
};
}

if (userAccessPolicy.captchaType === CaptchaType.puzzle) {
logger.info(() => ({
msg: "Frictionless decision",
data: {
decision: "user_access_policy",
captchaType: CaptchaType.puzzle,
captchaType: userAccessPolicy.captchaType,
},
}));
attachHoneypot(res, clientRecord);
return {
handled: true,
response: res.json(
await tasks.frictionlessManager.sendPuzzleCaptcha(
captchaTypeBaseParams,
await sendChallenge(
tasks.frictionlessManager,
userAccessPolicy.captchaType,
{
...captchaTypeBaseParams,
solvedImagesCount: userAccessPolicy.solvedImagesCount
? Math.min(
userAccessPolicy.solvedImagesCount,
clientRecord.settings.imageMaxRounds,
)
: clientRecord.settings.imageMaxRounds,
},
),
),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ export const runDecisionMachine = async (
},
}));
recordFrictionlessDecision("auto_ban_score");
await tasks.frictionlessManager.registerBlockedSession({
// Auto-ban fires before any challenge is chosen; image is what the
// built-in heuristics below would have served.
await tasks.frictionlessManager.registerBlockedSession(CaptchaType.image, {
solvedImagesCount: clientRecord.settings.imageMaxRounds,
userSitekeyIpHash,
reason: FrictionlessReason.AUTO_BAN_SCORE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ProsopoApiError } from "@prosopo/common";
import {
ApiParams,
CaptchaType,
type ChallengeCaptchaType,
GetFrictionlessCaptchaChallengeRequestBody,
ModeEnum,
type ScoreComponents,
Expand Down Expand Up @@ -280,10 +281,7 @@ export default (
// behave identically. One that mixes per-request signals with
// session-derived ones gets the request-time view of every
// input that's available without re-decrypting the payload.
const cachedCaptchaType = dedup.captchaType as
| CaptchaType.image
| CaptchaType.pow
| CaptchaType.puzzle;
const cachedCaptchaType = dedup.captchaType as ChallengeCaptchaType;
const dedupRouted = normalizedIp
? await tasks.frictionlessManager.applyRoutingMachine(
{
Expand Down Expand Up @@ -387,10 +385,7 @@ export default (
recordFrictionlessDecision("reuse_session");
attachHoneypot(res, clientRecord);
return res.json({
[ApiParams.captchaType]: dedup.captchaType as
| CaptchaType.image
| CaptchaType.pow
| CaptchaType.puzzle,
[ApiParams.captchaType]: dedup.captchaType as ChallengeCaptchaType,
[ApiParams.sessionId]: dedup.sessionId,
[ApiParams.status]: "ok",
dns_url: buildDnsEventUrl(dedup.sessionId),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ import {
type ModeEnum,
type RequestHeaders,
type ScoreComponents,
isChallengeCaptchaType,
} from "@prosopo/types";
import type { ClientRecord } from "@prosopo/types-database";
import type { ProviderEnvironment } from "@prosopo/types-env";
import type { Response } from "express";
import { v4 as uuidv4 } from "uuid";
import type { getCompositeIpAddress } from "../../../compositeIpAddress.js";
import { getDetectorBundlePool } from "../../../tasks/detection/bundlePool.js";
import { sendChallenge } from "../../../tasks/frictionless/challengeDispatch.js";
import type { Tasks } from "../../../tasks/index.js";
import { DEFAULT_FRICTIONLESS_THRESHOLD } from "./constants.js";
import { attachHoneypot } from "./honeypotResponse.js";
Expand Down Expand Up @@ -146,6 +148,11 @@ export const runConfiguredCaptchaTypeShortCircuit = async (
if (!configuredType || configuredType === CaptchaType.frictionless) {
return null;
}
if (!isChallengeCaptchaType(configuredType)) {
throw new Error(
`Unhandled configured captchaType in /frictionless short-circuit: ${configuredType}`,
);
}

const sessionParams = await buildBypassSessionParams(input);

Expand All @@ -158,28 +165,16 @@ export const runConfiguredCaptchaTypeShortCircuit = async (
}));

attachHoneypot(res, input.clientRecord);
switch (configuredType) {
case CaptchaType.image:
return res.json(
await input.tasks.frictionlessManager.sendImageCaptcha({
...sessionParams,
solvedImagesCount: Math.min(
input.env.config.captchas.solved.count,
input.clientRecord.settings.imageMaxRounds,
),
}),
);
case CaptchaType.pow:
return res.json(
await input.tasks.frictionlessManager.sendPowCaptcha(sessionParams),
);
case CaptchaType.puzzle:
return res.json(
await input.tasks.frictionlessManager.sendPuzzleCaptcha(sessionParams),
);
default:
throw new Error(
`Unhandled configured captchaType in /frictionless short-circuit: ${configuredType}`,
);
}
// `solvedImagesCount` is passed unconditionally: `sendCaptcha` discards it
// for any type other than image, so the pow / puzzle results are identical
// to the previous per-type branches.
return res.json(
await sendChallenge(input.tasks.frictionlessManager, configuredType, {
...sessionParams,
solvedImagesCount: Math.min(
input.env.config.captchas.solved.count,
input.clientRecord.settings.imageMaxRounds,
),
}),
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { randomUUID } from "node:crypto";
import {
ApiParams,
type CaptchaResponseBody,
type CaptchaType,
type ChallengeCaptchaType,
type GetFrictionlessCaptchaResponse,
type GetPowCaptchaResponse,
type GetPuzzleCaptchaResponse,
Expand Down Expand Up @@ -68,7 +68,7 @@ export const buildMaintenanceVerificationResponse = (
});

export const buildFrictionlessMaintenanceResponse = (
captchaType: CaptchaType.pow | CaptchaType.image | CaptchaType.puzzle,
captchaType: ChallengeCaptchaType,
host: string | undefined,
): GetFrictionlessCaptchaResponse => ({
[ApiParams.captchaType]: captchaType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import { ProsopoApiError } from "@prosopo/common";
import {
CaptchaType,
type FrictionlessReason,
type InteractiveCaptchaType,
type PowCaptchaSolutionEscalation,
type PowCaptchaSolutionResponse,
SubmitPowCaptchaSolutionBody,
type SubmitPowCaptchaSolutionBodyTypeOutput,
isInteractiveCaptchaType,
} from "@prosopo/types";
import type { ProviderEnvironment } from "@prosopo/types-env";
import { flatten, getIPAddress } from "@prosopo/util";
Expand Down Expand Up @@ -243,7 +245,7 @@ export const buildEscalation = async (
): Promise<PowCaptchaSolutionEscalation | undefined> => {
if (!result.verified || !result.routingOutput) return undefined;
const routedType = result.routingOutput.captchaType;
if (routedType !== CaptchaType.image && routedType !== CaptchaType.puzzle) {
if (!isInteractiveCaptchaType(routedType)) {
return undefined;
}

Expand All @@ -256,7 +258,7 @@ export const buildEscalation = async (
if (!originSession) return undefined;

const routed = result.routingOutput as {
captchaType: CaptchaType.image | CaptchaType.puzzle;
captchaType: InteractiveCaptchaType;
solvedImagesCount?: number;
powDifficulty?: number;
reason?: string;
Expand Down
57 changes: 57 additions & 0 deletions packages/provider/src/tasks/frictionless/challengeDispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright 2021-2026 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import {
CaptchaType,
type ChallengeCaptchaType,
type GetFrictionlessCaptchaResponse,
type Session,
} from "@prosopo/types";
import type { FrictionlessManager } from "./frictionlessTasks.js";

/**
* Params accepted by every `send*Captcha` helper. Type-specific fields
* (`solvedImagesCount`, `powDifficulty`) may always be supplied — the
* manager's `sendCaptcha` discards the ones that don't apply to the type it
* ends up serving, so callers don't need to branch before dispatching.
*/
export type ChallengeSendParams = Partial<Session>;

type ChallengeSender = (
manager: FrictionlessManager,
params: ChallengeSendParams,
) => Promise<GetFrictionlessCaptchaResponse>;

/**
* Single dispatch table from challenge type → the manager call that issues it.
*
* Declared as a total `Record<ChallengeCaptchaType, …>` on purpose: the three
* places that used to branch on captchaType (the configured-type
* short-circuit, the access-policy handler, and the client-side verify
* dispatch in `@prosopo/server`) each had their own if/switch chain, so adding
* a challenge type meant finding all of them. Adding a member to
* `ChallengeCaptchaType` now fails to compile here instead.
*/
const CHALLENGE_SENDERS: Record<ChallengeCaptchaType, ChallengeSender> = {
[CaptchaType.image]: (manager, params) => manager.sendImageCaptcha(params),
[CaptchaType.pow]: (manager, params) => manager.sendPowCaptcha(params),
[CaptchaType.puzzle]: (manager, params) => manager.sendPuzzleCaptcha(params),
};

export const sendChallenge = (
manager: FrictionlessManager,
captchaType: ChallengeCaptchaType,
params: ChallengeSendParams,
): Promise<GetFrictionlessCaptchaResponse> =>
CHALLENGE_SENDERS[captchaType](manager, params);
Loading
Loading