import { Buffer } from 'node:buffer'; import type { CapabilityBillingReceiptV1 } from '../../shared/data-service'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken } from './works-square-session'; export const WEB_SEARCH_ROUTE = '/api/plugins/v1/hosted/web-search/searches'; export const WEB_SEARCH_RECONCILIATION_WINDOW_MS = 155_000; const MAX_JSON_BYTES = 1_048_576; const MAX_REQUEST_BYTES = 98_304; const MAX_QUERY_LENGTH = 2_000; const MAX_ANSWER_LENGTH = 16_000; const MAX_SOURCE_TITLE_LENGTH = 240; const MAX_SOURCE_URL_LENGTH = 2_048; const MAX_SEARCH_QUERY_LENGTH = 500; const MAX_RETRY_AFTER_SECONDS = 86_400; const DECIMAL = /^(?:0|[1-9]\d*)\.\d{2}$/u; const SAFE_ID = /^[\x21-\x7e]{1,128}$/u; const KNOWN_ERROR_CODES = new Set([ 'authentication_required', 'confirmation_required', 'plugin_backend_unavailable', 'plugin_billing_unavailable', 'plugin_execution_unavailable', 'plugin_operation_conflict', 'plugin_provider_unavailable', 'plugin_release_admission_required', 'plugin_reservation_expired', 'plugin_reservation_unavailable', 'plugin_runtime_stale', 'token_point_balance_exhausted', 'web_search_provider_rejected', 'web_search_rate_limited', 'web_search_request_invalid', 'web_search_result_invalid', 'web_search_submission_unknown', ]); const FAILED_ERROR_CODES = new Set([ 'plugin_reservation_expired', 'plugin_reservation_unavailable', 'web_search_provider_rejected', 'web_search_rate_limited', 'web_search_request_invalid', ]); const SUBMISSION_UNKNOWN_ERROR_CODES = new Set([ 'web_search_result_invalid', 'web_search_submission_unknown', ]); type FetchImplementation = typeof fetch; type AccessTokenGetter = typeof getValidWorksSquareAccessToken; type JsonRecord = Record; type ServerBillingStatus = | 'reserved' | 'dispatched' | 'settled' | 'released' | 'expired' | 'pending_review' | 'refunded'; export interface WebSearchRequest { readonly releaseId: string; readonly releaseAdmissionId: string; readonly projectId: string; readonly logicalOperationId: string; readonly query: string; readonly confirmed: true; } export type WebSearchInput = WebSearchRequest; export interface WebSearchSource { readonly title: string; readonly url: string; } export interface WebSearchServerBillingReceipt { readonly mode: 'platform_metered'; readonly status: ServerBillingStatus; readonly reserved_points: string; readonly actual_points?: string; readonly usage_amount?: number; readonly unit: 'search_request'; } export interface WebSearchRead { readonly executionId: string; readonly releaseId: string; readonly logicalOperationId: string; readonly status: 'reserved' | 'dispatched' | 'succeeded' | 'failed' | 'submission_unknown' | 'pending_review'; readonly answer: string | null; readonly sources: readonly WebSearchSource[]; readonly searchQueries: readonly string[]; readonly errorCode: string | null; readonly retryAfterSeconds?: number; readonly billing: WebSearchServerBillingReceipt; } export type WebSearchResult = WebSearchRead; export class WebSearchClientError extends Error { constructor( readonly code: string, readonly status: number, readonly retryable: boolean, message: string, readonly retryAfterSeconds?: number, ) { super(message); this.name = 'WebSearchClientError'; } } export interface WebSearchClientOptions { readonly fetchImpl?: FetchImplementation; readonly getAccessToken?: AccessTokenGetter; readonly apiBaseUrl?: string; readonly now?: () => number; readonly sleep?: (milliseconds: number) => Promise; } class WebSearchTransportError extends Error {} function isRecord(value: unknown): value is JsonRecord { return value !== null && typeof value === 'object' && !Array.isArray(value); } function exactKeys(value: JsonRecord, required: readonly string[], optional: readonly string[] = []): boolean { const allowed = new Set([...required, ...optional]); return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) && Object.keys(value).every((key) => allowed.has(key)); } function boundedText(value: unknown, maximum: number): string | null { return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null; } function boundedIdentifier(value: unknown, maximum: number): string | null { return typeof value === 'string' && value.length > 0 && value.length <= maximum && SAFE_ID.test(value) ? value : null; } function nonNegativeInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): number | null { return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= maximum ? value as number : null; } function errorCode(value: unknown): string | null { return typeof value === 'string' && KNOWN_ERROR_CODES.has(value) ? value : null; } function billing(value: unknown): WebSearchServerBillingReceipt { if (!isRecord(value) || !exactKeys( value, ['mode', 'status', 'reserved_points', 'unit'], ['actual_points', 'usage_amount'], )) { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search billing receipt is invalid'); } const status = value.status; const reservedPoints = value.reserved_points; const actualPoints = value.actual_points === null || value.actual_points === undefined ? undefined : boundedText(value.actual_points, 32) ?? undefined; const usageAmount = value.usage_amount === null || value.usage_amount === undefined ? undefined : nonNegativeInteger(value.usage_amount) ?? undefined; if (value.mode !== 'platform_metered' || typeof status !== 'string' || !['reserved', 'dispatched', 'settled', 'released', 'expired', 'pending_review', 'refunded'].includes(status) || typeof reservedPoints !== 'string' || !DECIMAL.test(reservedPoints) || (value.actual_points !== null && value.actual_points !== undefined && (actualPoints === undefined || !DECIMAL.test(actualPoints))) || (value.usage_amount !== null && value.usage_amount !== undefined && usageAmount === undefined) || (usageAmount !== undefined && usageAmount !== 1) || (['settled', 'refunded'].includes(status) && actualPoints === undefined) || (!['settled', 'refunded'].includes(status) && actualPoints !== undefined) || value.unit !== 'search_request') { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search billing receipt is invalid'); } return { mode: 'platform_metered', status: status as ServerBillingStatus, reserved_points: reservedPoints, ...(actualPoints === undefined ? {} : { actual_points: actualPoints }), ...(usageAmount === undefined ? {} : { usage_amount: usageAmount }), unit: 'search_request', }; } function source(value: unknown): WebSearchSource { if (!isRecord(value) || !exactKeys(value, ['title', 'url'])) { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search source is invalid'); } const title = boundedText(value.title, MAX_SOURCE_TITLE_LENGTH); const url = boundedText(value.url, MAX_SOURCE_URL_LENGTH); if (!title || !url || !safeSourceUrl(url)) { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search source is invalid'); } return { title, url }; } function safeSourceUrl(value: string): boolean { if (!/^https?:\/\//iu.test(value)) return false; let parsed: URL; try { parsed = new URL(value); } catch { return false; } if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || !parsed.hostname || parsed.username.length > 0 || parsed.password.length > 0) { return false; } const schemeSeparator = value.indexOf('//'); const authorityEnd = value.slice(schemeSeparator + 2).search(/[/?#]/u); const authority = value.slice( schemeSeparator + 2, authorityEnd < 0 ? value.length : schemeSeparator + 2 + authorityEnd, ); return !authority.includes('@'); } function read(value: unknown, request: WebSearchRequest): WebSearchRead { if (!isRecord(value) || !exactKeys( value, [ 'schema_version', 'plugin_id', 'execution_id', 'release_id', 'logical_operation_id', 'status', 'billing', ], ['answer', 'sources', 'search_queries', 'error_code', 'retry_after_seconds'], ) || value.schema_version !== 1 || value.plugin_id !== 'makelore.web-search') { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); } const executionId = boundedIdentifier(value.execution_id, 36); const releaseId = boundedIdentifier(value.release_id, 36); const logicalOperationId = boundedIdentifier(value.logical_operation_id, 128); const statuses = ['reserved', 'dispatched', 'succeeded', 'failed', 'submission_unknown', 'pending_review']; const status = typeof value.status === 'string' && statuses.includes(value.status) ? value.status : null; const answer = value.answer === undefined || value.answer === null ? null : boundedText(value.answer, MAX_ANSWER_LENGTH); const rawSources = value.sources === undefined ? [] : value.sources; const rawSearchQueries = value.search_queries === undefined ? [] : value.search_queries; const retryAfter = value.retry_after_seconds === null || value.retry_after_seconds === undefined ? undefined : nonNegativeInteger(value.retry_after_seconds, MAX_RETRY_AFTER_SECONDS) ?? undefined; const parsedErrorCode = value.error_code === undefined || value.error_code === null ? null : errorCode(value.error_code); const parsedBilling = billing(value.billing); if (!executionId || !releaseId || !logicalOperationId || !status || releaseId !== request.releaseId || logicalOperationId !== request.logicalOperationId || (value.answer !== undefined && value.answer !== null && answer === null) || !Array.isArray(rawSources) || rawSources.length > 20 || !Array.isArray(rawSearchQueries) || rawSearchQueries.length > 8 || (value.error_code !== undefined && value.error_code !== null && parsedErrorCode === null) || (value.retry_after_seconds !== null && value.retry_after_seconds !== undefined && retryAfter === undefined) || (retryAfter !== undefined && (status !== 'failed' || parsedErrorCode !== 'web_search_rate_limited')) || ((status === 'reserved' && parsedBilling.status !== 'reserved') || (status === 'dispatched' && parsedBilling.status !== 'dispatched') || (status === 'succeeded' && !['settled', 'refunded'].includes(parsedBilling.status)) || (status === 'failed' && !['released', 'expired'].includes(parsedBilling.status)) || (status === 'submission_unknown' && parsedBilling.status !== 'pending_review') || (status === 'pending_review' && parsedBilling.status !== 'pending_review')) || ((status === 'succeeded' || status === 'pending_review') && parsedBilling.usage_amount !== 1) || (status === 'failed' && (parsedErrorCode === null || !FAILED_ERROR_CODES.has(parsedErrorCode))) || (status === 'submission_unknown' && (parsedErrorCode === null || !SUBMISSION_UNKNOWN_ERROR_CODES.has(parsedErrorCode)))) { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); } const sources = rawSources.map(source); const searchQueries = rawSearchQueries.map((item) => boundedText(item, MAX_SEARCH_QUERY_LENGTH)); if (searchQueries.some((item): item is null => item === null)) { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); } if (status === 'succeeded' || status === 'pending_review') { if (!answer || sources.length === 0 || parsedErrorCode !== null) { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search result is incomplete'); } } else if (answer !== null || sources.length > 0 || searchQueries.length > 0) { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response contains an unexpected result'); } if ((status === 'succeeded' || status === 'reserved' || status === 'dispatched' || status === 'pending_review') && parsedErrorCode !== null) { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response contains an unexpected error'); } return { executionId, releaseId, logicalOperationId, status: status as WebSearchRead['status'], answer, sources, searchQueries: searchQueries as string[], errorCode: parsedErrorCode, ...(retryAfter === undefined ? {} : { retryAfterSeconds: retryAfter }), billing: parsedBilling, }; } function retryAfterHeader(response: Response): number | undefined { const raw = response.headers.get('retry-after'); if (!raw || !/^\d+$/u.test(raw)) return undefined; return nonNegativeInteger(Number(raw), MAX_RETRY_AFTER_SECONDS) ?? undefined; } async function readBoundedJson(response: Response): Promise { const declared = response.headers.get('content-length'); if (declared && /^\d+$/u.test(declared) && Number(declared) > MAX_JSON_BYTES) { await response.body?.cancel().catch(() => undefined); throw new WebSearchClientError('plugin_backend_response_too_large', 502, false, 'Web Search response exceeds its bound'); } if (!response.body) return null; const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let length = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (!(value instanceof Uint8Array)) { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); } length += value.byteLength; if (length > MAX_JSON_BYTES) { await reader.cancel().catch(() => undefined); throw new WebSearchClientError( 'plugin_backend_response_too_large', 502, false, 'Web Search response exceeds its bound', ); } chunks.push(new Uint8Array(value)); } } finally { reader.releaseLock(); } if (length === 0) return null; const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } try { return JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown; } catch { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); } } function requestBody(input: WebSearchRequest): string { const normalized = { release_id: input.releaseId, release_admission_id: input.releaseAdmissionId, project_id: input.projectId, logical_operation_id: input.logicalOperationId, query: input.query.trim(), confirmed: true, }; if (!normalized.query || normalized.query.length > MAX_QUERY_LENGTH || Buffer.byteLength(normalized.query, 'utf8') > MAX_REQUEST_BYTES) { throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search query is invalid'); } const encoded = JSON.stringify(normalized); if (Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) { throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search request is too large'); } return encoded; } function validateInput(input: WebSearchRequest): WebSearchRequest { const value = input as unknown as JsonRecord; if (!isRecord(value) || !exactKeys(value, [ 'releaseId', 'releaseAdmissionId', 'projectId', 'logicalOperationId', 'query', 'confirmed', ])) { throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search input is invalid'); } for (const [key, maximum] of [ ['releaseId', 36], ['releaseAdmissionId', 36], ['projectId', 36], ['logicalOperationId', 128], ] as const) { if (!boundedIdentifier(value[key], maximum)) { throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search input is invalid'); } } if (typeof value.query !== 'string' || !value.query.trim() || value.query.trim().length > MAX_QUERY_LENGTH || value.confirmed !== true) { throw new WebSearchClientError( value.confirmed === false ? 'confirmation_required' : 'plugin_input_invalid', value.confirmed === false ? 400 : 422, false, value.confirmed === false ? 'Explicit Web Search confirmation is required' : 'Web Search input is invalid', ); } return { releaseId: value.releaseId as string, releaseAdmissionId: value.releaseAdmissionId as string, projectId: value.projectId as string, logicalOperationId: value.logicalOperationId as string, query: value.query as string, confirmed: true, }; } function domainMessage(code: string, status: number): string { switch (code) { case 'confirmation_required': return 'Explicit Web Search confirmation is required'; case 'web_search_request_invalid': return 'Web Search request is invalid'; case 'plugin_release_admission_required': return 'Web Search Release admission is required'; case 'plugin_runtime_stale': return 'Web Search worker resources are stale'; case 'plugin_operation_conflict': return 'Web Search operation conflicts with an existing request'; case 'token_point_balance_exhausted': return 'Token Point balance is insufficient'; case 'plugin_provider_unavailable': return 'Web Search Provider is unavailable'; case 'web_search_provider_rejected': return 'Web Search Provider rejected the request'; case 'web_search_rate_limited': return 'Web Search is rate limited; confirm a new search later'; case 'plugin_billing_unavailable': return 'Web Search billing is unavailable'; default: return status === 401 ? 'Works Square sign-in is required' : 'Web Search service is unavailable'; } } async function domainError(response: Response): Promise { const retryAfter = response.status === 429 ? retryAfterHeader(response) : undefined; if (response.status === 429) { await response.body?.cancel().catch(() => undefined); return new WebSearchClientError( 'web_search_rate_limited', 429, false, domainMessage('web_search_rate_limited', 429), retryAfter, ); } let payload: unknown = null; try { payload = await readBoundedJson(response); } catch { // Preserve the HTTP-status fallback when the bounded error body is unusable. } const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null; const code = errorCode(detail?.error_code) ?? 'plugin_backend_unavailable'; const retryable = response.status >= 500 && response.status !== 401; return new WebSearchClientError( code, response.status, retryable, domainMessage(code, response.status), retryAfter, ); } function receiptUnavailable(): WebSearchClientError { return new WebSearchClientError( 'plugin_receipt_unavailable', 503, false, 'Web Search billing status could not be synchronized; do not retry automatically', ); } function delayMilliseconds(response: Response): number { const seconds = retryAfterHeader(response); return (seconds === undefined ? 1 : seconds) * 1_000; } export class WebSearchClient { private readonly fetchImpl: FetchImplementation; private readonly getAccessToken: AccessTokenGetter; private readonly apiBaseUrl: string; private readonly now: () => number; private readonly sleep: (milliseconds: number) => Promise; constructor(options: WebSearchClientOptions = {}) { this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; this.getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken; this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, ''); this.now = options.now ?? (() => Date.now()); this.sleep = options.sleep ?? (async (milliseconds) => { await new Promise((resolve) => setTimeout(resolve, milliseconds)); }); } async search(input: WebSearchRequest): Promise { const valid = validateInput(input); const encoded = requestBody(valid); let token: string | null; try { token = await this.getAccessToken({ fetchImpl: this.fetchImpl }); } catch { token = null; } if (!token) throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); let refreshAttempted = false; const request = async (): Promise => { const send = async (accessToken: string): Promise => await this.fetchImpl( `${this.apiBaseUrl}${WEB_SEARCH_ROUTE}`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: encoded, redirect: 'manual', signal: AbortSignal.timeout(35_000), }, ); let response: Response; try { response = await send(token as string); } catch { throw new WebSearchTransportError('Web Search request transport failed'); } if (response.status !== 401) return response; await response.body?.cancel().catch(() => undefined); if (refreshAttempted) { throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); } refreshAttempted = true; let refreshed: string | null; try { refreshed = await this.getAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true }); } catch { refreshed = null; } if (!refreshed) throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); token = refreshed; try { response = await send(refreshed); } catch { throw new WebSearchTransportError('Web Search request transport failed'); } if (response.status === 401) { await response.body?.cancel().catch(() => undefined); throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); } return response; }; const deadline = this.now() + WEB_SEARCH_RECONCILIATION_WINDOW_MS; while (true) { let response: Response; try { response = await request(); } catch (error) { if (error instanceof WebSearchClientError) throw error; if (this.now() >= deadline) throw receiptUnavailable(); const remaining = deadline - this.now(); await this.sleep(Math.min(1_000, remaining)); continue; } if (response.status === 429) throw await domainError(response); if (response.ok) { let payload: unknown; try { payload = await readBoundedJson(response); } catch (error) { if (error instanceof WebSearchClientError) throw error; if (this.now() >= deadline) throw receiptUnavailable(); const remaining = deadline - this.now(); await this.sleep(Math.min(1_000, remaining)); continue; } const result = read(payload, valid); const shouldBeAccepted = result.status === 'reserved' || result.status === 'dispatched' || result.status === 'submission_unknown' || result.status === 'pending_review'; if ((shouldBeAccepted && response.status !== 202) || (!shouldBeAccepted && response.status !== 200)) { throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response status is invalid'); } if (result.status !== 'reserved' && result.status !== 'dispatched') return result; if (this.now() >= deadline) throw receiptUnavailable(); const remaining = deadline - this.now(); await this.sleep(Math.min(delayMilliseconds(response), remaining)); continue; } throw await domainError(response); } } } export function isWebSearchServerBillingReceipt(value: unknown): value is WebSearchServerBillingReceipt { try { billing(value); return true; } catch { return false; } } export type WebSearchCapabilityBilling = Extract;