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 { 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 { const { secretHash: _secretHash, engineSecret: _engineSecret, ...safe } = project; return safe; } export class ManagedCaptchaService { readonly projects: ManagedProjectStore; private readonly engines = new Map(); private readonly challengeOwners = new Map(); 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 { 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 { try { return await this.route(request); } catch { return json({ error: "invalid-request" }, 400, corsHeaders(request)); } } private async route(request: Request): Promise { 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 { const payload = (await request.json()) as Record; 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 { 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 { const payload = (await request.json()) as Record; 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 { 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 { 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 { 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 { 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 { if (!this.adminAllowed(request)) return json({ error: "unauthorized" }, 401); try { return json(await this.rotateSecret(id)); } catch { return json({ error: "not-found" }, 404); } } }