import { backendBaseUrl as BASE, joinBackendPath } from "./runtime-config"; import { clearAuthStateIfCurrent, getAuthGeneration, getAuthState, } from "../auth/authState"; const MAX_ERROR_BODY_BYTES = 8 * 1024; const safeErrorCodes = new Set([ "auth_forbidden", "auth_invalid_credentials", "auth_not_authorized", "auth_unavailable", "auth_not_implemented", "authentication_required", "invalid_credentials", "login_rate_limited", "csrf_failed", "csrf_invalid", "dwh_unreachable", "model_unavailable", "workspace_invalid", "binding_missing", "workspace_not_activatable", "workspace_stale", "git_unavailable", "git_auth_failed", "git_non_fast_forward", "connector_unavailable", "semantic_index_incompatible", "pi_management_forbidden", "pi_management_unavailable", "pi_management_invalid_config", "pi_management_write_failed", "catalog_unavailable", "database_conflict", "database_invalid", "database_not_found", "database_stale", "database_operation_failed", "database_operation_in_progress", "catalog_target_not_found", "description_consolidation_invalid", "description_consolidation_failed", "description_generation_request_invalid", "description_generation_run_active", "description_generation_failed", "description_generation_run_not_found", "description_generation_no_eligible_targets", "description_generation_target_ids_duplicate", "metadata_generation_model_unavailable", "catalog_column_not_found", "catalog_table_not_found", "workspace_configuration_unavailable", "sensitive_data_suggestion_request_invalid", "sensitive_data_suggestion_target_ids_duplicate", "sensitive_data_suggestion_no_columns", "sensitive_data_suggestion_payload_too_large", "sensitive_data_suggestion_invalid_response", "sensitive_data_suggestion_provider_unavailable", "sensitive_data_suggestion_failed", "schema_sync_conflict", "schema_introspection_failed", "schema_request_invalid", "schema_operation_failed", "sync_run_not_found", "table_stale", "column_stale", ]); type SafeErrorPayload = { code: string; }; const localCodeMessages: Record = { auth_forbidden: "Access is not permitted.", auth_invalid_credentials: "Invalid username or password.", auth_not_authorized: "Access is not permitted.", auth_unavailable: "Authentication is temporarily unavailable. Try again.", auth_not_implemented: "This sign-in method is not available.", authentication_required: "Please sign in to continue.", invalid_credentials: "Invalid username or password.", login_rate_limited: "Too many sign-in attempts. Try again later.", csrf_failed: "The security check failed. Please retry.", csrf_invalid: "The security check failed. Please retry.", dwh_unreachable: "The database is unreachable. Please retry.", model_unavailable: "The model provider is unavailable. Please retry.", workspace_invalid: "The workspace configuration is invalid.", binding_missing: "The workspace is missing a required binding.", workspace_not_activatable: "The workspace cannot be activated.", workspace_stale: "The workspace has changed. Refresh and try again.", git_unavailable: "The workspace repository is unavailable.", git_auth_failed: "The workspace repository could not be authenticated.", git_non_fast_forward: "The workspace repository has moved. Refresh and try again.", connector_unavailable: "A workspace connector is unavailable.", semantic_index_incompatible: "The workspace semantic index is incompatible.", pi_management_forbidden: "Pi management is not permitted", pi_management_unavailable: "Pi management is unavailable.", pi_management_invalid_config: "The Pi configuration is invalid.", pi_management_write_failed: "The Pi configuration could not be saved.", catalog_unavailable: "The database catalog is unavailable.", database_conflict: "This workspace already has a database configuration.", database_invalid: "The database configuration is invalid.", database_not_found: "The database configuration was not found.", database_stale: "The database configuration changed. Reload and try again.", database_operation_failed: "The database operation failed.", database_operation_in_progress: "A database operation is already in progress.", catalog_target_not_found: "One or more selected catalog targets were not found.", description_consolidation_invalid: "The description consolidation request is invalid.", description_consolidation_failed: "Description consolidation failed.", description_generation_request_invalid: "The description generation request is invalid.", description_generation_run_active: "A description generation run is already active.", description_generation_failed: "Description generation failed.", description_generation_run_not_found: "The description generation run was not found.", description_generation_no_eligible_targets: "No eligible catalog tables or columns need description generation.", description_generation_target_ids_duplicate: "Description generation target IDs must be unique.", metadata_generation_model_unavailable: "The selected description model is unavailable.", catalog_column_not_found: "The selected catalog column was not found.", catalog_table_not_found: "One or more selected catalog tables were not found.", workspace_configuration_unavailable: "The database workspace configuration is unavailable.", sensitive_data_suggestion_request_invalid: "Select a database, one or more tables, or one or more columns before requesting sensitive-field suggestions.", sensitive_data_suggestion_target_ids_duplicate: "Each selected table or column can be included only once.", sensitive_data_suggestion_no_columns: "The selected scope contains no catalog columns to classify.", sensitive_data_suggestion_payload_too_large: "The selected structural metadata cannot be divided into safe model requests.", sensitive_data_suggestion_invalid_response: "The model returned an incomplete or invalid classification. No suggestions were applied.", sensitive_data_suggestion_provider_unavailable: "The selected model could not complete the request. No suggestions were applied.", sensitive_data_suggestion_failed: "Sensitive-field suggestions failed before review. No changes were applied.", schema_sync_conflict: "A schema synchronization is already active or no longer current.", schema_introspection_failed: "The database schema could not be read safely.", schema_request_invalid: "The schema request is invalid.", schema_operation_failed: "The schema operation failed.", sync_run_not_found: "The synchronization run was not found.", table_stale: "Table metadata changed. Reload and try again.", column_stale: "Column metadata changed. Reload and try again.", }; const localStatusMessages: Record = { 401: "Please sign in to continue.", 403: "Access is not permitted.", 404: "The requested resource was not found.", 409: "The request conflicts with current workspace state.", 429: "Too many requests. Try again later.", 500: "Request failed. Please try again.", 502: "The service is unavailable. Please retry.", 503: "The service is temporarily unavailable. Please retry.", }; const GENERIC_ERROR_MESSAGE = "Request failed. Please try again."; function localErrorMessage(status: number, code?: string): string { return (code && localCodeMessages[code]) || localStatusMessages[status] || GENERIC_ERROR_MESSAGE; } /** * Error thrown for non-2xx responses. The message contains only status and a * whitelisted error code; `.payload` contains a bounded, sanitized JSON shape. */ export class ApiError extends Error { constructor( readonly status: number, readonly bodyText: string, readonly payload: SafeErrorPayload | undefined, ) { super(localErrorMessage(status, payload?.code)); this.name = "ApiError"; } get code(): string | undefined { return this.payload?.code; } } export function apiErrorMessage(error: unknown): string { return error instanceof ApiError ? error.message : GENERIC_ERROR_MESSAGE; } function parseSafeErrorPayload(text: string, truncated: boolean): SafeErrorPayload | undefined { if (truncated || text.length === 0) return undefined; let parsed: unknown; try { parsed = JSON.parse(text); } catch { return undefined; } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; const source = parsed as Record; if (typeof source.code !== "string" || !safeErrorCodes.has(source.code)) return undefined; return { code: source.code }; } async function readBoundedText(response: Response): Promise<{ text: string; truncated: boolean }> { const reader = response.body?.getReader(); if (!reader) return { text: "", truncated: false }; const decoder = new TextDecoder(); let text = ""; let bytes = 0; let truncated = false; try { while (true) { const next = await reader.read(); if (next.done) break; const remaining = MAX_ERROR_BODY_BYTES - bytes; if (remaining <= 0) { truncated = true; await reader.cancel(); break; } const chunk = next.value.byteLength > remaining ? next.value.slice(0, remaining) : next.value; bytes += chunk.byteLength; text += decoder.decode(chunk, { stream: next.value.byteLength <= remaining }); if (next.value.byteLength > remaining) { truncated = true; await reader.cancel(); break; } } } catch (error) { try { await reader.cancel(); } catch { /* preserve the original read failure */ } throw error; } finally { text += decoder.decode(); try { reader.releaseLock(); } catch { /* a completed browser reader may already be released */ } } return { text, truncated }; } function requestHeaders(init: RequestInit | undefined): Headers { const headers = new Headers(init?.headers); // Fetch supplies the multipart boundary for FormData. Declaring JSON here // would prevent Fastify from parsing an imported workspace bundle. if ( init?.body != null && !(typeof FormData !== "undefined" && init.body instanceof FormData) && !headers.has("content-type") ) { headers.set("content-type", "application/json"); } return headers; } async function request(path: string, init?: RequestInit): Promise { const url = joinBackendPath(BASE, path); assertSameOriginRequestUrl(url); const dispatchGeneration = getAuthGeneration(); const headers = requestHeaders(init); const method = (init?.method ?? "GET").toUpperCase(); if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) { headers.delete("X-ThothII-CSRF"); const csrfToken = getAuthState()?.csrfToken; if (csrfToken) headers.set("X-ThothII-CSRF", csrfToken); } const res = await fetch(url, { ...init, credentials: "same-origin", headers, }); if (res.status === 401) clearAuthStateIfCurrent(dispatchGeneration); if (!res.ok) { const { text, truncated } = await readBoundedText(res); const payload = parseSafeErrorPayload(text, truncated); throw new ApiError(res.status, "", payload); } return res; } /** Refuse a credentialed cross-origin base before the browser can send a request. */ export function assertSameOriginRequestUrl(url: string): void { if (typeof window === "undefined") { if (/^https?:\/\//i.test(url)) throw new Error("The browser must use the same-origin /api route"); return; } const parsed = new URL(url, window.location.origin); if (parsed.origin !== window.location.origin) { throw new Error("The browser must use the same-origin /api route"); } } export async function apiFetch(path: string, init?: RequestInit): Promise { // Only declare a JSON content-type when we actually send a body. Body-less // POSTs (resume, close) would otherwise make Fastify reject the empty body // with FST_ERR_CTP_EMPTY_JSON_BODY (400). const res = await request(path, init); if (res.status === 204) return undefined as T; // Accepted fire-and-forget endpoints may legitimately return 202 with no // representation. Keep apiFetch useful for both 202 and 204 contracts. const body = await res.text(); return body ? (JSON.parse(body) as T) : (undefined as T); } /** Download registry bundles without attempting to parse their ZIP body as JSON. */ export async function apiFetchBlob(path: string, init?: RequestInit): Promise { return (await request(path, init)).blob(); } export { BASE };