Files
WRNexusJS/packages/captcha/src/policy.ts
T
2026-07-27 12:42:18 +05:30

62 lines
2.2 KiB
TypeScript

import type { CaptchaPolicyOptions, CaptchaRiskResult, CaptchaRiskSignals } from "./types.ts";
export function evaluateCaptchaRisk(
signals: CaptchaRiskSignals,
threshold = 50,
): CaptchaRiskResult {
let score = Math.max(0, Math.min(100, signals.customScore ?? 0));
const reasons: string[] = [];
const add = (points: number, reason: string): void => {
score = Math.min(100, score + points);
reasons.push(reason);
};
if ((signals.failedAttempts ?? 0) > 0)
add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
if ((signals.requestsInWindow ?? 0) > 20)
add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
if (signals.completionMs !== undefined && signals.completionMs < 700) add(25, "too-fast");
if (signals.missingBrowserSignals) add(20, "missing-browser-signals");
if (signals.suspiciousHeaders) add(20, "suspicious-headers");
if (signals.tokenReuse) add(70, "token-reuse");
if (signals.knownBadIp) add(60, "known-bad-ip");
return { score, challenge: score >= threshold, reasons };
}
export function shouldRequireCaptcha(
action: string,
options: CaptchaPolicyOptions = {},
signals: CaptchaRiskSignals = {},
): CaptchaRiskResult {
if (options.neverForActions?.includes(action) || options.mode === "never") {
return { score: 0, challenge: false, reasons: ["policy-never"] };
}
if (options.alwaysForActions?.includes(action) || options.mode === "always") {
return { score: 100, challenge: true, reasons: ["policy-always"] };
}
if ((options.mode ?? "adaptive") === "session") {
return { score: 100, challenge: true, reasons: ["session-unverified"] };
}
return evaluateCaptchaRisk(signals, options.threshold ?? 50);
}
export interface CaptchaSessionGrant {
action: string;
routeGroup?: string;
expiresAt: number;
provider: string;
}
export function validCaptchaGrant(
grants: CaptchaSessionGrant[] | undefined,
action: string,
now: number,
routeGroup?: string,
): CaptchaSessionGrant | undefined {
return grants?.find(
(grant) =>
grant.expiresAt > now &&
(grant.action === action || grant.action === "*") &&
(!routeGroup || !grant.routeGroup || grant.routeGroup === routeGroup),
);
}