New Captcha Package added

This commit is contained in:
2026-07-25 13:38:18 +05:30
parent d0aded0392
commit 8b728a3e5d
157 changed files with 12092 additions and 1913 deletions
+8
View File
@@ -0,0 +1,8 @@
import type { CaptchaProvider } from "../types.ts";
export function defineCaptchaProvider<T extends CaptchaProvider>(provider: T): T {
if (!provider.name) throw new TypeError("Custom CAPTCHA provider requires a stable name");
if (!provider.client?.responseField) throw new TypeError("Custom CAPTCHA provider requires client.responseField");
if (typeof provider.verify !== "function") throw new TypeError("Custom CAPTCHA provider requires verify()");
return provider;
}
@@ -0,0 +1,24 @@
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
export class HcaptchaProvider extends SiteverifyCaptchaProvider {
constructor(options: SiteverifyProviderOptions) {
super(
{
name: "hcaptcha",
endpoint: "https://api.hcaptcha.com/siteverify",
client: {
responseField: "h-captcha-response",
scriptUrl: "https://js.hcaptcha.com/1/api.js?render=explicit",
widgetClass: "h-captcha",
},
sendSiteKey: true,
scoreDirection: "higher-is-risk",
},
options,
);
}
}
export function hcaptchaProvider(options: SiteverifyProviderOptions): HcaptchaProvider {
return new HcaptchaProvider(options);
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./self-hosted.ts";
export * from "./siteverify.ts";
export * from "./turnstile.ts";
export * from "./recaptcha.ts";
export * from "./hcaptcha.ts";
export * from "./managed.ts";
export * from "./custom.ts";
+69
View File
@@ -0,0 +1,69 @@
import type {
CaptchaChallenge,
CaptchaProvider,
CaptchaVerificationResult,
CreateCaptchaOptions,
VerifyCaptchaInput,
} from "../types.ts";
export interface ManagedCaptchaProviderOptions {
baseUrl: string;
siteKey: string;
secretKey: string;
timeoutMs?: number;
fetch?: typeof fetch;
}
export class ManagedCaptchaProvider implements CaptchaProvider {
readonly name = "wrnexus-managed" as const;
readonly client;
private readonly baseUrl: string;
constructor(private readonly options: ManagedCaptchaProviderOptions) {
if (!options.baseUrl) throw new TypeError("Managed CAPTCHA baseUrl is required");
if (!options.siteKey) throw new TypeError("Managed CAPTCHA siteKey is required");
if (!options.secretKey) throw new TypeError("Managed CAPTCHA secretKey is required");
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
this.client = {
responseField: "wrn-captcha-response",
siteKey: options.siteKey,
managedCreateUrl: `${this.baseUrl}/v1/challenges`,
};
}
async createChallenge(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
return this.request<CaptchaChallenge>("/v1/challenges", {
siteKey: this.options.siteKey,
...options,
}, false);
}
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
return this.request<CaptchaVerificationResult>("/v1/verify", input, true);
}
private async request<T>(path: string, payload: unknown, secret: boolean): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
try {
const response = await (this.options.fetch ?? fetch)(`${this.baseUrl}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
...(secret ? { authorization: `Bearer ${this.options.secretKey}` } : {}),
},
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!response.ok) throw new Error(`Managed CAPTCHA returned HTTP ${response.status}`);
return (await response.json()) as T;
} finally {
clearTimeout(timeout);
}
}
}
export function managedCaptchaProvider(options: ManagedCaptchaProviderOptions): ManagedCaptchaProvider {
return new ManagedCaptchaProvider(options);
}
@@ -0,0 +1,23 @@
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
export class RecaptchaProvider extends SiteverifyCaptchaProvider {
constructor(options: SiteverifyProviderOptions) {
super(
{
name: "recaptcha",
endpoint: "https://www.google.com/recaptcha/api/siteverify",
client: {
responseField: "g-recaptcha-response",
scriptUrl: "https://www.google.com/recaptcha/api.js?render=explicit",
widgetClass: "g-recaptcha",
},
scoreDirection: "higher-is-human",
},
options,
);
}
}
export function recaptchaProvider(options: SiteverifyProviderOptions): RecaptchaProvider {
return new RecaptchaProvider(options);
}
@@ -0,0 +1,32 @@
import type {
CaptchaEngine,
CaptchaProvider,
CreateCaptchaOptions,
VerifyCaptchaInput,
} from "../types.ts";
export class SelfHostedCaptchaProvider implements CaptchaProvider {
readonly name = "self-hosted" as const;
readonly client;
constructor(readonly engine: CaptchaEngine) {
this.client = {
responseField: "wrn-captcha-response",
managedCreateUrl: `${engine.basePath}/challenge`,
};
}
createChallenge(options: CreateCaptchaOptions) {
return this.engine.create(options);
}
verify(input: VerifyCaptchaInput) {
return input.responseToken || input.providerToken
? this.engine.verifyResponseToken({ ...input, responseToken: input.responseToken ?? input.providerToken })
: this.engine.verify(input);
}
}
export function selfHostedProvider(engine: CaptchaEngine): SelfHostedCaptchaProvider {
return new SelfHostedCaptchaProvider(engine);
}
@@ -0,0 +1,147 @@
import type {
CaptchaProvider,
CaptchaProviderClientConfig,
CaptchaProviderName,
CaptchaVerificationResult,
VerifyCaptchaInput,
} from "../types.ts";
export interface SiteverifyProviderOptions {
secretKey: string;
siteKey?: string;
expectedHostnames?: string[];
expectedAction?: string;
minScore?: number;
timeoutMs?: number;
fetch?: typeof fetch;
}
export interface SiteverifyPreset {
name: CaptchaProviderName;
endpoint: string;
client: CaptchaProviderClientConfig;
sendSiteKey?: boolean;
scoreDirection?: "higher-is-human" | "higher-is-risk";
}
interface SiteverifyPayload {
success?: boolean;
hostname?: string;
action?: string;
score?: number;
challenge_ts?: string;
"error-codes"?: string[];
error_codes?: string[];
[key: string]: unknown;
}
function providerFailure(
name: CaptchaProviderName,
action: string,
code: string,
message: string,
metadata?: Record<string, unknown>,
): CaptchaVerificationResult {
return { success: false, provider: name, action, code, message, metadata };
}
export class SiteverifyCaptchaProvider implements CaptchaProvider {
readonly name: CaptchaProviderName;
readonly client: CaptchaProviderClientConfig;
constructor(
private readonly preset: SiteverifyPreset,
private readonly options: SiteverifyProviderOptions,
) {
if (!options.secretKey) throw new TypeError(`${preset.name} secretKey is required`);
this.name = preset.name;
this.client = { ...preset.client, siteKey: options.siteKey };
}
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
const token = input.providerToken ?? input.responseToken;
const action = input.action;
if (!token) return providerFailure(this.name, action, "missing-input", "Missing provider response token");
if (token.length > 4096) return providerFailure(this.name, action, "invalid-input", "Provider token is too long");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
const body = new URLSearchParams({ secret: this.options.secretKey, response: token });
if (input.ip) body.set("remoteip", input.ip);
if (this.preset.sendSiteKey && this.options.siteKey) body.set("sitekey", this.options.siteKey);
try {
const response = await (this.options.fetch ?? fetch)(this.preset.endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body,
signal: controller.signal,
});
if (!response.ok) {
return providerFailure(this.name, action, "provider-error", `${this.name} verification returned HTTP ${response.status}`);
}
const data = (await response.json()) as SiteverifyPayload;
if (!data.success) {
const codes = data["error-codes"] ?? data.error_codes ?? [];
return providerFailure(
this.name,
action,
codes.includes("timeout-or-duplicate") || codes.includes("already-seen-response")
? "already-used"
: codes.includes("expired-input-response")
? "expired"
: "invalid-input",
"Provider verification failed",
{ errorCodes: codes },
);
}
const expectedAction = this.options.expectedAction ?? action;
if (data.action && expectedAction && data.action !== expectedAction) {
return providerFailure(this.name, action, "action-mismatch", "Provider action does not match", {
receivedAction: data.action,
});
}
if (
this.options.expectedHostnames?.length &&
(!data.hostname || !this.options.expectedHostnames.includes(data.hostname))
) {
return providerFailure(this.name, action, "hostname-mismatch", "Provider hostname does not match", {
hostname: data.hostname,
});
}
if (this.options.minScore !== undefined && typeof data.score === "number") {
const rejected = this.preset.scoreDirection === "higher-is-risk"
? data.score >= this.options.minScore
: data.score < this.options.minScore;
if (rejected) {
return providerFailure(this.name, action, "risk-rejected", "Provider risk score did not pass", {
score: data.score,
});
}
}
return {
success: true,
provider: this.name,
action,
score: data.score,
hostname: data.hostname,
metadata: {
challengeTimestamp: data.challenge_ts,
raw: data,
},
};
} catch (error) {
return providerFailure(
this.name,
action,
error instanceof DOMException && error.name === "AbortError" ? "network-error" : "provider-error",
error instanceof DOMException && error.name === "AbortError"
? `${this.name} verification timed out`
: `${this.name} verification failed`,
);
} finally {
clearTimeout(timeout);
}
}
}
@@ -0,0 +1,23 @@
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
export class TurnstileCaptchaProvider extends SiteverifyCaptchaProvider {
constructor(options: SiteverifyProviderOptions) {
super(
{
name: "turnstile",
endpoint: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
client: {
responseField: "cf-turnstile-response",
scriptUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
widgetClass: "cf-turnstile",
},
scoreDirection: "higher-is-human",
},
options,
);
}
}
export function turnstileProvider(options: SiteverifyProviderOptions): TurnstileCaptchaProvider {
return new TurnstileCaptchaProvider(options);
}