Files
WRNexusJS/services/managed-captcha/src/service.ts
T
2026-07-27 12:42:18 +05:30

313 lines
12 KiB
TypeScript

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);
}
}
}