// SPDX-FileCopyrightText: 2026 Xquik Contributors // // SPDX-License-Identifier: MIT import type { FetchFunction, RequestFunction, RequestOptions } from './types.js'; const FETCH_TIMEOUT_MS = 30_000; const CONTENT_TYPE_HEADER = 'content-type'; const API_KEY_HEADER = 'x-api-key'; const AUTHORIZATION_HEADER = 'authorization'; const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'; const BEARER_PREFIX = 'Bearer '; const API_KEY_PREFIX = 'xq_'; const API_V1_PREFIX = '/api/v1/'; const SUPPORT_TICKETS_PREFIX = '/api/v1/support/tickets'; const MAX_SAFE_ERROR_CODE_LENGTH = 80; function buildAuthHeader(credential: string): Record { if (credential.startsWith(API_KEY_PREFIX)) { return { [API_KEY_HEADER]: credential }; } return { [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${credential}` }; } function buildFetchHeaders( credential: string, hasBody: boolean, idempotencyKey?: string, ): Record { const auth = credential === '' ? {} : buildAuthHeader(credential); const contentType = hasBody ? { [CONTENT_TYPE_HEADER]: 'application/json' } : {}; const idempotency = idempotencyKey === undefined ? {} : { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey }; return { ...auth, ...contentType, ...idempotency }; } function createBaseUrl(baseUrl: string): URL { try { return new URL(baseUrl); } catch { throw new Error('Base URL is invalid. Enter an HTTPS URL.'); } } function parseBaseUrl(baseUrl: string): URL { const url = createBaseUrl(baseUrl); if (url.protocol !== 'https:') { throw new Error('Base URL is not HTTPS. Use an HTTPS URL.'); } if (url.username.length > 0 || url.password.length > 0) { throw new Error('Base URL contains credentials. Remove them.'); } return url; } function buildFetchUrl(baseUrl: string, path: string, query?: Readonly>): string { const url = new URL(path, parseBaseUrl(baseUrl)); if (query !== undefined) { for (const [key, value] of Object.entries(query)) { url.searchParams.set(key, value); } } return url.toString(); } const PROHIBITED_PATHS: ReadonlyArray = [ ['PATCH', '/api/v1/account'], ['PUT', '/api/v1/account/x-identity'], ['GET', '/api/v1/api-keys'], ['POST', '/api/v1/api-keys'], ['POST', '/api/v1/credits/topup'], ['GET', '/api/v1/credits/topup/status'], ['POST', '/api/v1/credits/quick-topup'], ['POST', '/api/v1/subscribe'], ['POST', '/api/v1/x/accounts'], ['POST', '/api/v1/x/accounts/'], ['POST', '/api/v1/x/accounts/bulk-retry'], ]; const PROHIBITED_PATH_PATTERNS: ReadonlyArray = [ ['DELETE', /^\/api\/v1\/api-keys\/[^/]+\/?$/u], ['GET', /^\/api\/v1\/x\/account-connection-attempts\/[^/]+\/?$/u], ['DELETE', /^\/api\/v1\/x\/accounts\/[^/]+\/?$/u], ['GET', /^\/api\/v1\/x\/accounts\/[^/]+\/?$/u], ['POST', /^\/api\/v1\/x\/accounts\/[^/]+\/reauth\/?$/u], ]; const SUPPORT_TICKET_METHODS: ReadonlySet = new Set(['GET', 'PATCH', 'POST']); function normalizeProhibitedPath(path: string): string { let end = path.length; while (end > 1 && path.charAt(end - 1) === '/') { end -= 1; } return end === path.length ? path : path.slice(0, end); } function isSupportTicketPath(method: string, path: string): boolean { return SUPPORT_TICKET_METHODS.has(method) && (path === SUPPORT_TICKETS_PREFIX || path.startsWith(`${SUPPORT_TICKETS_PREFIX}/`)); } function isProhibitedRequest(method: string, path: string): boolean { const upperMethod = method.toUpperCase(); const normalizedPath = normalizeProhibitedPath(path); const matchesStaticPath = PROHIBITED_PATHS.some( ([blockedMethod, blockedPath]) => upperMethod === blockedMethod && normalizedPath === blockedPath, ); const matchesPattern = PROHIBITED_PATH_PATTERNS.some( ([blockedMethod, pattern]) => upperMethod === blockedMethod && pattern.test(normalizedPath), ); return matchesStaticPath || matchesPattern || isSupportTicketPath(upperMethod, normalizedPath); } function validateRequestPath(method: string, path: string): void { if (!path.startsWith(API_V1_PREFIX)) { throw new Error(`Path is outside /api/v1/. Use a catalog-listed API path: ${path}`); } if (isProhibitedRequest(method, path)) { throw new Error( 'Endpoint is blocked for agents. Complete account, credential, billing, or support administration at dashboard.xquik.com.', ); } } async function readResponseJson(response: Response): Promise { try { return await response.json(); } catch { return undefined; } } function isAsciiLetter(character: string): boolean { const lower = character.toLowerCase(); return lower >= 'a' && lower <= 'z'; } function isAsciiDigit(character: string): boolean { return character >= '0' && character <= '9'; } function isSafeErrorCodeCharacter(character: string): boolean { return isAsciiLetter(character) || isAsciiDigit(character) || character === '_' || character === '.' || character === ':' || character === '-'; } function isSafeErrorCode(value: string): boolean { if ( value.length === 0 || value.length > MAX_SAFE_ERROR_CODE_LENGTH || !isAsciiLetter(value.slice(0, 1)) ) { return false; } for (const character of value) { if (!isSafeErrorCodeCharacter(character)) { return false; } } return true; } function stringErrorCode(value: unknown): string | undefined { if (typeof value !== 'object' || value === null) { return undefined; } if ('error' in value && typeof value.error === 'string') { return value.error; } if ('code' in value && typeof value.code === 'string') { return value.code; } return undefined; } function safeErrorCode(value: unknown): string | undefined { const code = stringErrorCode(value); return code !== undefined && isSafeErrorCode(code) ? code : undefined; } function formatApiError(response: Response, payload: unknown): string { const status = response.statusText.length > 0 ? `${String(response.status)} ${response.statusText}` : String(response.status); const code = safeErrorCode(payload); if (code === undefined) { return `Xquik API returned ${status}.`; } return `Xquik API returned ${status} (${code}).`; } function createProxiedRequest( baseUrl: string, credential: string, fetchFunction?: FetchFunction, ): RequestFunction { return async (path: string, options?: Readonly): Promise => { const method = options?.method ?? 'GET'; validateRequestPath(method, path); const hasBody = options?.body !== undefined; const activeFetch = fetchFunction ?? globalThis.fetch; const response = await activeFetch(buildFetchUrl(baseUrl, path, options?.query), { ...(hasBody ? { body: JSON.stringify(options.body) } : {}), headers: buildFetchHeaders(credential, hasBody, options?.idempotencyKey), method, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); const json: unknown = await readResponseJson(response); if (!response.ok) { throw new Error(formatApiError(response, json)); } return json; }; } export { buildAuthHeader, buildFetchHeaders, buildFetchUrl, createProxiedRequest, isProhibitedRequest };