import type { IChallengeAssessRequest, IChallengeDecision, IChallengeProvider, IChallengeProviderManifest, IChallengeRenderRequest, IChallengeRenderResponse, IChallengeVerifyRequest, IChallengeVerifyResult, } from './interfaces.js'; export interface ISmartChallengeClientOptions { baseUrl: string; providerId?: string; fetchImplementation?: typeof fetch; timeoutMs?: number; maxResponseBytes?: number; } export class SmartChallengeClient implements IChallengeProvider { public readonly providerId: string; private readonly baseUrl: string; private readonly fetchImplementation: typeof fetch; private readonly timeoutMs: number; private readonly maxResponseBytes: number; constructor(optionsArg: ISmartChallengeClientOptions) { this.providerId = optionsArg.providerId || 'smartchallenge'; this.baseUrl = optionsArg.baseUrl.replace(/\/$/, ''); this.fetchImplementation = optionsArg.fetchImplementation || fetch; this.timeoutMs = optionsArg.timeoutMs ?? 10_000; this.maxResponseBytes = optionsArg.maxResponseBytes ?? 1024 * 1024; } public async getManifest(): Promise { return this.request('GET', '/manifest'); } public async assess(requestArg: IChallengeAssessRequest): Promise { return this.request('POST', '/assess', requestArg); } public async render(requestArg: IChallengeRenderRequest): Promise { return this.request('POST', '/render', requestArg); } public async verify(requestArg: IChallengeVerifyRequest): Promise { return this.request('POST', '/verify', requestArg); } private async request( methodArg: 'GET' | 'POST', pathArg: string, payloadArg?: TRequest, ): Promise { const abortController = new AbortController(); const timeout = setTimeout(() => abortController.abort(), this.timeoutMs); timeout.unref?.(); let response: Response; let responseText = ''; try { response = await this.fetchImplementation(`${this.baseUrl}${pathArg}`, { method: methodArg, headers: methodArg === 'POST' ? { 'Content-Type': 'application/json' } : undefined, body: methodArg === 'POST' ? JSON.stringify(payloadArg) : undefined, signal: abortController.signal, }); responseText = await this.readResponseText(response); } finally { clearTimeout(timeout); } const parsed = responseText ? JSON.parse(responseText) as unknown : undefined; if (!response.ok) { const message = typeof parsed === 'object' && parsed && 'error' in parsed ? String((parsed as { error: unknown }).error) : `SmartChallenge request failed with status ${response.status}`; throw new Error(message); } return parsed as TResponse; } private async readResponseText(responseArg: Response): Promise { const contentLengthHeader = responseArg.headers.get('content-length'); const contentLength = contentLengthHeader ? Number(contentLengthHeader) : undefined; if (contentLength && contentLength > this.maxResponseBytes) { await responseArg.body?.cancel('SmartChallenge response exceeds maximum size'); throw new Error('SmartChallenge response exceeds maximum size'); } const reader = responseArg.body?.getReader(); if (!reader) { const responseText = await responseArg.text(); if (Buffer.byteLength(responseText) > this.maxResponseBytes) { throw new Error('SmartChallenge response exceeds maximum size'); } return responseText; } const chunks: Buffer[] = []; let totalBytes = 0; while (true) { const result = await reader.read(); if (result.done) { break; } const chunk = Buffer.from(result.value); totalBytes += chunk.length; if (totalBytes > this.maxResponseBytes) { await reader.cancel('SmartChallenge response exceeds maximum size'); throw new Error('SmartChallenge response exceeds maximum size'); } chunks.push(chunk); } return Buffer.concat(chunks).toString('utf8'); } }