release: WRNexusJS 0.4.0
This commit is contained in:
@@ -63,4 +63,3 @@ curl -X POST https://captcha.example.com/v1/challenges \
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: WRNexus Managed CAPTCHA API
|
||||
version: 0.3.6
|
||||
version: 0.4.0
|
||||
servers:
|
||||
- url: https://captcha.example.com
|
||||
paths:
|
||||
@@ -18,14 +18,49 @@ paths:
|
||||
properties:
|
||||
siteKey: { type: string }
|
||||
action: { type: string }
|
||||
type: { type: string, enum: [number, alpha, alphanumeric, calculation, image, honeypot, timing, not-robot] }
|
||||
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]
|
||||
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 }
|
||||
@@ -39,13 +74,13 @@ paths:
|
||||
randomizeStyle: { type: boolean, default: false }
|
||||
locale: { type: string }
|
||||
responses:
|
||||
'201':
|
||||
"201":
|
||||
description: Challenge created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Challenge' }
|
||||
'403': { description: Invalid site key or hostname }
|
||||
'429': { description: Project quota exceeded }
|
||||
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
|
||||
@@ -64,12 +99,12 @@ paths:
|
||||
honeypot: { type: string }
|
||||
timingToken: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
"200":
|
||||
description: Challenge solved
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Verification' }
|
||||
'400': { description: Challenge rejected }
|
||||
schema: { $ref: "#/components/schemas/Verification" }
|
||||
"400": { description: Challenge rejected }
|
||||
/v1/verify:
|
||||
post:
|
||||
summary: Verify and consume a response token on the application server
|
||||
@@ -87,19 +122,19 @@ paths:
|
||||
hostname: { type: string }
|
||||
sessionId: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
"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 }
|
||||
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 }
|
||||
"200": { description: Project list without secrets }
|
||||
post:
|
||||
summary: Create a project and return keys once
|
||||
security: [{ adminToken: [] }]
|
||||
@@ -115,7 +150,7 @@ paths:
|
||||
allowedHostnames: { type: array, items: { type: string } }
|
||||
monthlyQuota: { type: integer, minimum: 1 }
|
||||
responses:
|
||||
'201': { description: Project and initial keys }
|
||||
"201": { description: Project and initial keys }
|
||||
/v1/projects/{id}/rotate-secret:
|
||||
post:
|
||||
summary: Rotate a project verification secret
|
||||
@@ -126,8 +161,8 @@ paths:
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200': { description: New secret returned once }
|
||||
'404': { description: Project not found }
|
||||
"200": { description: New secret returned once }
|
||||
"404": { description: Project not found }
|
||||
components:
|
||||
securitySchemes:
|
||||
projectSecret:
|
||||
@@ -139,7 +174,8 @@ components:
|
||||
schemas:
|
||||
Challenge:
|
||||
type: object
|
||||
required: [id, provider, type, presentation, action, prompt, createdAt, expiresAt, responseField]
|
||||
required:
|
||||
[id, provider, type, presentation, action, prompt, createdAt, expiresAt, responseField]
|
||||
properties:
|
||||
id: { type: string }
|
||||
provider: { type: string }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/managed-captcha-service",
|
||||
"version": "0.3.6",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -17,21 +17,31 @@ import type {
|
||||
} 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 } });
|
||||
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 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() : "";
|
||||
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"> {
|
||||
function publicProject(
|
||||
project: ManagedCaptchaProject,
|
||||
): Omit<ManagedCaptchaProject, "secretHash" | "engineSecret"> {
|
||||
const { secretHash: _secretHash, engineSecret: _engineSecret, ...safe } = project;
|
||||
return safe;
|
||||
}
|
||||
@@ -45,14 +55,20 @@ export class ManagedCaptchaService {
|
||||
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");
|
||||
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.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> {
|
||||
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 = {
|
||||
@@ -61,7 +77,13 @@ export class ManagedCaptchaService {
|
||||
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))],
|
||||
allowedHostnames: [
|
||||
...new Set(
|
||||
(input.allowedHostnames ?? [])
|
||||
.map((hostname) => hostname.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
],
|
||||
monthlyQuota: input.monthlyQuota ?? this.defaultMonthlyQuota,
|
||||
usageMonth: month(timestamp),
|
||||
usageCount: 0,
|
||||
@@ -71,7 +93,14 @@ export class ManagedCaptchaService {
|
||||
};
|
||||
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 };
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
siteKey: project.siteKey,
|
||||
secretKey,
|
||||
allowedHostnames: project.allowedHostnames,
|
||||
monthlyQuota: project.monthlyQuota,
|
||||
};
|
||||
}
|
||||
|
||||
async rotateSecret(id: string): Promise<{ secretKey: string }> {
|
||||
@@ -105,40 +134,55 @@ export class ManagedCaptchaService {
|
||||
},
|
||||
});
|
||||
}
|
||||
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);
|
||||
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);
|
||||
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]!));
|
||||
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,
|
||||
});
|
||||
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 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));
|
||||
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 });
|
||||
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`;
|
||||
@@ -187,16 +231,25 @@ export class ManagedCaptchaService {
|
||||
}
|
||||
|
||||
private async solveChallenge(request: Request): Promise<Response> {
|
||||
const payload = await request.json() as Record<string, unknown>;
|
||||
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));
|
||||
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 });
|
||||
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);
|
||||
if (result.success || result.code === "expired" || result.code === "attempts-exhausted")
|
||||
this.challengeOwners.delete(id);
|
||||
return json(result, result.success ? 200 : 400, corsHeaders(request));
|
||||
}
|
||||
|
||||
@@ -204,7 +257,7 @@ export class ManagedCaptchaService {
|
||||
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 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);
|
||||
@@ -212,7 +265,10 @@ export class ManagedCaptchaService {
|
||||
|
||||
private async consumeQuota(project: ManagedCaptchaProject): Promise<boolean> {
|
||||
const currentMonth = month(this.now());
|
||||
if (project.usageMonth !== currentMonth) { project.usageMonth = currentMonth; project.usageCount = 0; }
|
||||
if (project.usageMonth !== currentMonth) {
|
||||
project.usageMonth = currentMonth;
|
||||
project.usageCount = 0;
|
||||
}
|
||||
if (project.usageCount >= project.monthlyQuota) return false;
|
||||
project.usageCount += 1;
|
||||
project.updatedAt = this.now();
|
||||
@@ -226,8 +282,20 @@ export class ManagedCaptchaService {
|
||||
}
|
||||
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); }
|
||||
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);
|
||||
@@ -235,7 +303,10 @@ export class ManagedCaptchaService {
|
||||
}
|
||||
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); }
|
||||
try {
|
||||
return json(await this.rotateSecret(id));
|
||||
} catch {
|
||||
return json({ error: "not-found" }, 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,27 @@ 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");
|
||||
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)); }
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createCaptchaEngine, MemoryCaptchaStore, type CaptchaChallengeGenerator } from "@wrnexus/captcha/server";
|
||||
import {
|
||||
createCaptchaEngine,
|
||||
MemoryCaptchaStore,
|
||||
type CaptchaChallengeGenerator,
|
||||
} from "@wrnexus/captcha/server";
|
||||
import { ManagedCaptchaService } from "../src/service.ts";
|
||||
|
||||
let seed = 11;
|
||||
@@ -30,45 +34,68 @@ describe("managed CAPTCHA service", () => {
|
||||
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,
|
||||
}),
|
||||
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" }),
|
||||
}));
|
||||
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 };
|
||||
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" }),
|
||||
}));
|
||||
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 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" }),
|
||||
}));
|
||||
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" }),
|
||||
}));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user