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
+66
View File
@@ -0,0 +1,66 @@
# WRNexus Managed CAPTCHA service starter
A provider-neutral HTTP service built on `@wrnexus/captcha`. It includes:
- Project creation and listing behind an admin bearer token
- Public site keys and one-time-returned secret keys
- SHA-256 secret-key storage
- Allowed-hostname enforcement
- Monthly challenge quotas
- Secret-key rotation
- Public challenge creation (`POST /v1/challenges`) with visual disturbance from 25 through 75 and 18 generated image renderer styles
- Managed self-hosted `not-robot` checkbox challenges
- Public answer solving (`POST /v1/solve`)
- Secret-authenticated one-use token verification (`POST /v1/verify`)
## Run
```bash
CAPTCHA_ADMIN_TOKEN="replace-with-a-long-random-token" \
CAPTCHA_BASE_URL="http://localhost:8787" \
bun run dev
```
Create a project:
```bash
curl -X POST http://localhost:8787/v1/projects \
-H "Authorization: Bearer $CAPTCHA_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Website","allowedHostnames":["localhost","example.com"],"monthlyQuota":10000}'
```
Create a challenge with controlled visual disturbance:
```bash
curl -X POST http://localhost:8787/v1/challenges \
-H "Content-Type: application/json" \
-d '{"siteKey":"YOUR_SITE_KEY","action":"signup","type":"image","disturbance":50}'
```
Create an “Im not a robot” checkbox challenge:
```bash
curl -X POST http://localhost:8787/v1/challenges \
-H "Content-Type: application/json" \
-d '{"siteKey":"YOUR_SITE_KEY","action":"signup","type":"not-robot"}'
```
The secret key is returned only when a project is created or rotated. Store it in a secret manager.
## Production work still required
The included service is a complete reference/starter, not a turnkey global control plane. Replace the in-memory project and CAPTCHA stores with durable shared adapters, add authenticated organization/workspace ownership, audit logs, billing, dashboards, regional routing, metrics, backups, and operational alerting before public multi-tenant production use.
## Generated image styles
Managed challenge creation accepts the same renderer controls as the self-hosted engine:
```bash
curl -X POST https://captcha.example.com/v1/challenges \
-H "content-type: application/json" \
-d '{"siteKey":"YOUR_SITE_KEY","action":"signup","type":"alphanumeric","imageStyle":"random","allowedStyles":["classic","snow","distortion","wave"],"disturbance":50}'
```
Supported concrete renderers are `classic`, `collision`, `snow`, `corrosion`, `spiderweb`, `cross-shadow`, `split`, `split2`, `cut`, `darts`, `distortion`, `stitch`, `striped`, `wave`, `grid-noise`, `scribble`, `pixel`, and `broken-lines`. Challenge metadata returns the resolved renderer, requested renderer, and active pool.
+176
View File
@@ -0,0 +1,176 @@
openapi: 3.1.0
info:
title: WRNexus Managed CAPTCHA API
version: 0.3.6
servers:
- url: https://captcha.example.com
paths:
/v1/challenges:
post:
summary: Create a public challenge
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [siteKey, action]
properties:
siteKey: { type: string }
action: { type: string }
type: { type: string, enum: [number, alpha, alphanumeric, calculation, image, honeypot, timing, not-robot] }
presentation: { type: string, enum: [visual, audio, invisible] }
difficulty: { type: string, enum: [easy, normal, hard] }
disturbance: { type: integer, minimum: 25, maximum: 75, default: 50 }
imageStyle:
type: string
default: random
enum: [random, classic, collision, snow, corrosion, spiderweb, cross-shadow, split, split2, cut, darts, distortion, stitch, striped, wave, grid-noise, scribble, pixel, broken-lines]
allowedStyles:
oneOf:
- { type: string, description: Comma-separated renderer names }
- type: array
items: { type: string }
excludedStyles:
oneOf:
- { type: string, description: Comma-separated renderer names }
- type: array
items: { type: string }
randomizeStyle: { type: boolean, default: false }
locale: { type: string }
responses:
'201':
description: Challenge created
content:
application/json:
schema: { $ref: '#/components/schemas/Challenge' }
'403': { description: Invalid site key or hostname }
'429': { description: Project quota exceeded }
/v1/solve:
post:
summary: Solve a public challenge and receive a temporary response token
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [challengeId, action]
properties:
challengeId: { type: string }
action: { type: string }
answer: { oneOf: [{ type: string }, { type: number }] }
selections: { type: array, items: { type: string } }
honeypot: { type: string }
timingToken: { type: string }
responses:
'200':
description: Challenge solved
content:
application/json:
schema: { $ref: '#/components/schemas/Verification' }
'400': { description: Challenge rejected }
/v1/verify:
post:
summary: Verify and consume a response token on the application server
security: [{ projectSecret: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [responseToken, action]
properties:
responseToken: { type: string }
action: { type: string }
hostname: { type: string }
sessionId: { type: string }
responses:
'200':
description: Token accepted and consumed
content:
application/json:
schema: { $ref: '#/components/schemas/Verification' }
'400': { description: Token invalid, expired, mismatched, or already used }
'401': { description: Invalid project secret }
/v1/projects:
get:
summary: List projects
security: [{ adminToken: [] }]
responses:
'200': { description: Project list without secrets }
post:
summary: Create a project and return keys once
security: [{ adminToken: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
allowedHostnames: { type: array, items: { type: string } }
monthlyQuota: { type: integer, minimum: 1 }
responses:
'201': { description: Project and initial keys }
/v1/projects/{id}/rotate-secret:
post:
summary: Rotate a project verification secret
security: [{ adminToken: [] }]
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
'200': { description: New secret returned once }
'404': { description: Project not found }
components:
securitySchemes:
projectSecret:
type: http
scheme: bearer
adminToken:
type: http
scheme: bearer
schemas:
Challenge:
type: object
required: [id, provider, type, presentation, action, prompt, createdAt, expiresAt, responseField]
properties:
id: { type: string }
provider: { type: string }
type: { type: string }
presentation: { type: string }
action: { type: string }
prompt: { type: string }
image: { type: string }
audioUrl: { type: string }
items: { type: array, items: { type: object } }
createdAt: { type: integer }
expiresAt: { type: integer }
responseField: { type: string }
metadata:
type: object
properties:
difficulty: { type: string }
disturbance: { type: integer }
imageStyle: { type: string }
requestedImageStyle: { type: string }
imageStylePool:
type: array
items: { type: string }
Verification:
type: object
required: [success, provider, action]
properties:
success: { type: boolean }
provider: { type: string }
action: { type: string }
code: { type: string }
message: { type: string }
responseToken: { type: string }
expiresAt: { type: integer }
@@ -0,0 +1,26 @@
CREATE TABLE captcha_projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
site_key TEXT NOT NULL UNIQUE,
secret_hash TEXT NOT NULL UNIQUE,
engine_secret TEXT NOT NULL,
allowed_hostnames_json TEXT NOT NULL DEFAULT '[]',
monthly_quota INTEGER NOT NULL DEFAULT 10000,
usage_month TEXT NOT NULL,
usage_count INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE captcha_usage_events (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES captcha_projects(id),
event_type TEXT NOT NULL,
action TEXT,
hostname TEXT,
success INTEGER NOT NULL,
provider TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX captcha_usage_project_time ON captcha_usage_events(project_id, created_at);
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@wrnexus/managed-captcha-service",
"version": "0.3.6",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@wrnexus/captcha": "workspace:*"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.2"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { ManagedCaptchaService } from "./service.ts";
export * from "./types.ts";
export * from "./store.ts";
export * from "./service.ts";
if (import.meta.main) {
const port = Number(process.env.PORT ?? 8787);
const service = new ManagedCaptchaService({
adminToken: process.env.CAPTCHA_ADMIN_TOKEN ?? "replace-this-development-admin-token",
baseUrl: process.env.CAPTCHA_BASE_URL ?? `http://localhost:${port}`,
});
Bun.serve({ port, fetch: (request) => service.handle(request) });
console.log(`WRNexus Managed CAPTCHA listening on http://localhost:${port}`);
}
+241
View File
@@ -0,0 +1,241 @@
import {
constantTimeEqual,
createCaptchaEngine,
MemoryCaptchaStore,
randomId,
sha256,
type CaptchaEngine,
type CreateCaptchaOptions,
type VerifyCaptchaInput,
} from "@wrnexus/captcha/server";
import { MemoryManagedProjectStore } from "./store.ts";
import type {
ManagedCaptchaProject,
ManagedCaptchaServiceOptions,
ManagedProjectStore,
PublicProjectKey,
} from "./types.ts";
function json(body: unknown, status = 200, headers: HeadersInit = {}): Response {
return Response.json(body, { status, headers: { "cache-control": "no-store", "x-content-type-options": "nosniff", ...headers } });
}
function corsHeaders(request: Request): HeadersInit {
const origin = request.headers.get("origin");
return origin ? { "access-control-allow-origin": origin, vary: "Origin" } : {};
}
function month(now: number): string { return new Date(now).toISOString().slice(0, 7); }
function bearer(request: Request): string { return request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? ""; }
function hostnameFrom(request: Request, payload: Record<string, unknown>): string {
const origin = request.headers.get("origin");
if (origin) return new URL(origin).hostname.toLowerCase();
const explicit = typeof payload.hostname === "string" ? payload.hostname.trim().toLowerCase() : "";
return explicit || new URL(request.url).hostname.toLowerCase();
}
function publicProject(project: ManagedCaptchaProject): Omit<ManagedCaptchaProject, "secretHash" | "engineSecret"> {
const { secretHash: _secretHash, engineSecret: _engineSecret, ...safe } = project;
return safe;
}
export class ManagedCaptchaService {
readonly projects: ManagedProjectStore;
private readonly engines = new Map<string, CaptchaEngine>();
private readonly challengeOwners = new Map<string, string>();
private readonly now: () => number;
private readonly randomBytes: (length: number) => Uint8Array;
private readonly defaultMonthlyQuota: number;
constructor(private readonly options: ManagedCaptchaServiceOptions) {
if (!options.adminToken || options.adminToken.length < 24) throw new TypeError("A strong admin token is required");
this.projects = options.projectStore ?? new MemoryManagedProjectStore();
this.now = options.now ?? Date.now;
this.randomBytes = options.randomBytes ?? ((length) => crypto.getRandomValues(new Uint8Array(length)));
this.defaultMonthlyQuota = options.defaultMonthlyQuota ?? 10_000;
}
async createProject(input: { name: string; allowedHostnames?: string[]; monthlyQuota?: number }): Promise<PublicProjectKey> {
const timestamp = this.now();
const secretKey = `wrn_secret_${randomId(this.randomBytes, 32)}`;
const project: ManagedCaptchaProject = {
id: `project_${randomId(this.randomBytes, 16)}`,
name: input.name.trim(),
siteKey: `wrn_site_${randomId(this.randomBytes, 20)}`,
secretHash: await sha256(secretKey),
engineSecret: randomId(this.randomBytes, 48),
allowedHostnames: [...new Set((input.allowedHostnames ?? []).map((hostname) => hostname.trim().toLowerCase()).filter(Boolean))],
monthlyQuota: input.monthlyQuota ?? this.defaultMonthlyQuota,
usageMonth: month(timestamp),
usageCount: 0,
enabled: true,
createdAt: timestamp,
updatedAt: timestamp,
};
if (!project.name) throw new TypeError("Project name is required");
await this.projects.create(project);
return { id: project.id, name: project.name, siteKey: project.siteKey, secretKey, allowedHostnames: project.allowedHostnames, monthlyQuota: project.monthlyQuota };
}
async rotateSecret(id: string): Promise<{ secretKey: string }> {
const project = await this.projects.getById(id);
if (!project) throw new Error("Unknown project");
const secretKey = `wrn_secret_${randomId(this.randomBytes, 32)}`;
project.secretHash = await sha256(secretKey);
project.updatedAt = this.now();
await this.projects.update(project);
return { secretKey };
}
async handle(request: Request): Promise<Response> {
try {
return await this.route(request);
} catch {
return json({ error: "invalid-request" }, 400, corsHeaders(request));
}
}
private async route(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
...corsHeaders(request),
"access-control-allow-methods": "GET,POST,OPTIONS",
"access-control-allow-headers": "content-type,authorization",
"access-control-max-age": "600",
},
});
}
if (request.method === "POST" && url.pathname === "/v1/challenges") return this.createChallenge(request);
if (request.method === "POST" && url.pathname === "/v1/solve") return this.solveChallenge(request);
if (request.method === "POST" && url.pathname === "/v1/verify") return this.verifyToken(request);
const audio = url.pathname.match(/^\/v1\/audio\/([^/]+)$/);
if (audio && (request.method === "GET" || request.method === "HEAD")) return this.audioChallenge(request, decodeURIComponent(audio[1]!));
if (url.pathname === "/v1/projects" && request.method === "POST") return this.adminCreateProject(request);
if (url.pathname === "/v1/projects" && request.method === "GET") return this.adminListProjects(request);
const rotate = url.pathname.match(/^\/v1\/projects\/([^/]+)\/rotate-secret$/);
if (rotate && request.method === "POST") return this.adminRotate(request, decodeURIComponent(rotate[1]!));
return json({ error: "not-found" }, 404);
}
private engine(project: ManagedCaptchaProject): CaptchaEngine {
let engine = this.engines.get(project.id);
if (!engine) {
engine = this.options.engineFactory?.(project) ?? createCaptchaEngine({
secret: project.engineSecret,
store: this.options.captchaStore ?? new MemoryCaptchaStore(),
basePath: "/v1",
bindIp: false,
});
this.engines.set(project.id, engine);
}
return engine;
}
private async createChallenge(request: Request): Promise<Response> {
const payload = await request.json() as Record<string, unknown>;
const project = await this.projects.getBySiteKey(String(payload.siteKey ?? ""));
if (!project || !project.enabled) return json({ error: "invalid-site-key" }, 403, corsHeaders(request));
const hostname = hostnameFrom(request, payload);
if (project.allowedHostnames.length && !project.allowedHostnames.includes(hostname)) return json({ error: "hostname-not-allowed" }, 403, corsHeaders(request));
if (!(await this.consumeQuota(project))) return json({ error: "quota-exceeded" }, 429, corsHeaders(request));
const challenge = await this.engine(project).create({ ...(payload as unknown as CreateCaptchaOptions), hostname });
const publicBase = this.options.baseUrl?.replace(/\/+$/, "") ?? "";
challenge.provider = "wrnexus-managed";
challenge.verifyUrl = `${publicBase}/v1/solve`;
if (challenge.audioUrl && publicBase) challenge.audioUrl = `${publicBase}${challenge.audioUrl}`;
this.challengeOwners.set(challenge.id, project.id);
return json(challenge, 201, corsHeaders(request));
}
private async audioChallenge(request: Request, id: string): Promise<Response> {
const owner = this.challengeOwners.get(id);
if (!owner) return new Response("Not Found", { status: 404, headers: corsHeaders(request) });
const project = await this.projects.getById(owner);
if (!project) return new Response("Not Found", { status: 404, headers: corsHeaders(request) });
const key = new URL(request.url).searchParams.get("key") ?? "";
try {
const audio = await this.engine(project).renderAudio(id, key);
if (!audio) return new Response("Not Found", { status: 404, headers: corsHeaders(request) });
const body: BodyInit | null =
request.method === "HEAD" ? null : Uint8Array.from(audio.bytes).buffer;
return new Response(body, {
headers: {
...corsHeaders(request),
"content-type": audio.contentType,
"content-length": String(audio.bytes.byteLength),
"cache-control": "private, no-store, max-age=0",
"x-content-type-options": "nosniff",
},
});
} catch (error) {
console.error(
"[wrnexus/managed-captcha] Unable to render CAPTCHA audio:",
error instanceof Error ? error.message : error,
);
return new Response("CAPTCHA audio is unavailable", {
status: 503,
headers: {
...corsHeaders(request),
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store, max-age=0",
"x-content-type-options": "nosniff",
},
});
}
}
private async solveChallenge(request: Request): Promise<Response> {
const payload = await request.json() as Record<string, unknown>;
const id = String(payload.challengeId ?? "");
const owner = this.challengeOwners.get(id);
if (!owner) return json({ success: false, code: "invalid-input", message: "Unknown challenge" }, 400, corsHeaders(request));
const project = await this.projects.getById(owner);
if (!project) return json({ success: false, code: "invalid-input" }, 400, corsHeaders(request));
const hostname = hostnameFrom(request, payload);
const result = await this.engine(project).verify({ ...(payload as unknown as VerifyCaptchaInput), hostname });
result.provider = "wrnexus-managed";
if (result.success || result.code === "expired" || result.code === "attempts-exhausted") this.challengeOwners.delete(id);
return json(result, result.success ? 200 : 400, corsHeaders(request));
}
private async verifyToken(request: Request): Promise<Response> {
const secret = bearer(request);
const project = secret ? await this.projects.getBySecretHash(await sha256(secret)) : undefined;
if (!project || !project.enabled) return json({ success: false, code: "invalid-secret" }, 401);
const payload = await request.json() as VerifyCaptchaInput;
const result = await this.engine(project).verifyResponseToken(payload);
result.provider = "wrnexus-managed";
return json(result, result.success ? 200 : 400);
}
private async consumeQuota(project: ManagedCaptchaProject): Promise<boolean> {
const currentMonth = month(this.now());
if (project.usageMonth !== currentMonth) { project.usageMonth = currentMonth; project.usageCount = 0; }
if (project.usageCount >= project.monthlyQuota) return false;
project.usageCount += 1;
project.updatedAt = this.now();
await this.projects.update(project);
return true;
}
private adminAllowed(request: Request): boolean {
const supplied = bearer(request);
return supplied.length > 0 && constantTimeEqual(supplied, this.options.adminToken);
}
private async adminCreateProject(request: Request): Promise<Response> {
if (!this.adminAllowed(request)) return json({ error: "unauthorized" }, 401);
try { return json(await this.createProject(await request.json() as { name: string; allowedHostnames?: string[]; monthlyQuota?: number }), 201); }
catch (error) { return json({ error: error instanceof Error ? error.message : "invalid-project" }, 400); }
}
private async adminListProjects(request: Request): Promise<Response> {
if (!this.adminAllowed(request)) return json({ error: "unauthorized" }, 401);
return json({ projects: (await this.projects.list()).map(publicProject) });
}
private async adminRotate(request: Request, id: string): Promise<Response> {
if (!this.adminAllowed(request)) return json({ error: "unauthorized" }, 401);
try { return json(await this.rotateSecret(id)); }
catch { return json({ error: "not-found" }, 404); }
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { ManagedCaptchaProject, ManagedProjectStore } from "./types.ts";
export class MemoryManagedProjectStore implements ManagedProjectStore {
private readonly projects = new Map<string, ManagedCaptchaProject>();
async create(project: ManagedCaptchaProject): Promise<void> {
if ([...this.projects.values()].some((item) => item.siteKey === project.siteKey)) throw new Error("Duplicate site key");
this.projects.set(project.id, structuredClone(project));
}
async update(project: ManagedCaptchaProject): Promise<void> {
if (!this.projects.has(project.id)) throw new Error("Unknown project");
this.projects.set(project.id, structuredClone(project));
}
async getById(id: string) { const value = this.projects.get(id); return value && structuredClone(value); }
async getBySiteKey(siteKey: string) { const value = [...this.projects.values()].find((item) => item.siteKey === siteKey); return value && structuredClone(value); }
async getBySecretHash(secretHash: string) { const value = [...this.projects.values()].find((item) => item.secretHash === secretHash); return value && structuredClone(value); }
async list() { return [...this.projects.values()].map((item) => structuredClone(item)); }
}
+45
View File
@@ -0,0 +1,45 @@
import type { CaptchaEngine, CaptchaStore } from "@wrnexus/captcha/server";
export interface ManagedCaptchaProject {
id: string;
name: string;
siteKey: string;
secretHash: string;
engineSecret: string;
allowedHostnames: string[];
monthlyQuota: number;
usageMonth: string;
usageCount: number;
enabled: boolean;
createdAt: number;
updatedAt: number;
}
export interface PublicProjectKey {
id: string;
name: string;
siteKey: string;
secretKey: string;
allowedHostnames: string[];
monthlyQuota: number;
}
export interface ManagedProjectStore {
create(project: ManagedCaptchaProject): Promise<void>;
update(project: ManagedCaptchaProject): Promise<void>;
getById(id: string): Promise<ManagedCaptchaProject | undefined>;
getBySiteKey(siteKey: string): Promise<ManagedCaptchaProject | undefined>;
getBySecretHash(secretHash: string): Promise<ManagedCaptchaProject | undefined>;
list(): Promise<ManagedCaptchaProject[]>;
}
export interface ManagedCaptchaServiceOptions {
adminToken: string;
projectStore?: ManagedProjectStore;
captchaStore?: CaptchaStore;
baseUrl?: string;
defaultMonthlyQuota?: number;
now?: () => number;
randomBytes?: (length: number) => Uint8Array;
engineFactory?: (project: ManagedCaptchaProject) => CaptchaEngine;
}
@@ -0,0 +1,74 @@
import { describe, expect, test } from "bun:test";
import { createCaptchaEngine, MemoryCaptchaStore, type CaptchaChallengeGenerator } from "@wrnexus/captcha/server";
import { ManagedCaptchaService } from "../src/service.ts";
let seed = 11;
function randomBytes(length: number): Uint8Array {
const output = new Uint8Array(length);
for (let index = 0; index < length; index += 1) {
seed = (seed * 1103515245 + 12345) >>> 0;
output[index] = seed & 255;
}
return output;
}
const generator: CaptchaChallengeGenerator = {
type: "number",
generate: () => ({
type: "number",
presentation: "visual",
prompt: "Enter 7",
answer: "7",
answerKind: "text",
inputMode: "numeric",
}),
};
describe("managed CAPTCHA service", () => {
test("creates a project and completes the public-to-server flow", async () => {
const service = new ManagedCaptchaService({
adminToken: "a-long-admin-token-for-managed-captcha-tests",
baseUrl: "https://captcha.test",
randomBytes,
engineFactory: (project) => createCaptchaEngine({
secret: project.engineSecret,
store: new MemoryCaptchaStore(),
generators: [generator],
defaultType: "number",
minCompletionMs: 0,
randomBytes,
}),
});
const project = await service.createProject({ name: "Test", allowedHostnames: ["app.test"] });
const created = await service.handle(new Request("https://captcha.test/v1/challenges", {
method: "POST",
headers: { origin: "https://app.test", "content-type": "application/json" },
body: JSON.stringify({ siteKey: project.siteKey, action: "signup", type: "number" }),
}));
expect(created.status).toBe(201);
const challenge = await created.json() as { id: string; provider: string };
expect(challenge.provider).toBe("wrnexus-managed");
const solved = await service.handle(new Request("https://captcha.test/v1/solve", {
method: "POST",
headers: { origin: "https://app.test", "content-type": "application/json" },
body: JSON.stringify({ challengeId: challenge.id, action: "signup", answer: "7" }),
}));
expect(solved.status).toBe(200);
const solution = await solved.json() as { responseToken: string };
const verified = await service.handle(new Request("https://captcha.test/v1/verify", {
method: "POST",
headers: { authorization: `Bearer ${project.secretKey}`, "content-type": "application/json" },
body: JSON.stringify({ responseToken: solution.responseToken, action: "signup", hostname: "app.test" }),
}));
expect(verified.status).toBe(200);
const replay = await service.handle(new Request("https://captcha.test/v1/verify", {
method: "POST",
headers: { authorization: `Bearer ${project.secretKey}`, "content-type": "application/json" },
body: JSON.stringify({ responseToken: solution.responseToken, action: "signup", hostname: "app.test" }),
}));
expect(replay.status).toBe(400);
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "noEmit": true },
"include": ["src/**/*.ts", "test/**/*.ts"]
}