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([ "evidence_unavailable", "evidence_not_found", "memory_invalid", "memory_forbidden", "memory_not_found", "memory_conflict", "memory_unavailable", "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", "sensitivity_source_unavailable", "sensitivity_analysis_timeout", "sensitivity_analysis_interrupted", "sensitive_data_suggestion_failed", "sensitive_data_suggestion_history_request_invalid", "sensitive_data_suggestion_history_failed", "sensitive_data_suggestion_run_not_found", "schema_sync_conflict", "schema_introspection_failed", "schema_request_invalid", "schema_operation_failed", "sync_run_not_found", "table_stale", "column_stale", "relationship_not_found", "relationship_duplicate", "relationship_target_not_unique", "relationship_type_incompatible", "relationship_read_only", "relationship_request_invalid", "relationship_operation_failed", "relationship_schema_stale", "column_not_found", "preprocessing_request_invalid", "preprocessing_blocked", "preprocessing_run_failed", ]); type SafeErrorPayload = { code: string; }; const localCodeMessages: Record = { evidence_unavailable: "Evidence archive is unavailable. Check the local files and installation configuration.", evidence_not_found: "Evidence was not found. Refresh the file list.", memory_invalid: "Check the card fields, family requirements and schema dependencies.", memory_forbidden: "Memory administration is not permitted.", memory_not_found: "This Memory card or pending update no longer exists in the workspace.", memory_conflict: "A linked card is missing or invalid. Review the links and retry.", memory_unavailable: "The Memory archive is unavailable. Check the installation connection and migrations, then retry.", 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 running sensitivity analysis.", 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 assess.", sensitivity_source_unavailable: "The database content could not be read for sensitivity analysis. No assessments were applied.", sensitivity_analysis_timeout: "Sensitivity analysis reached its time limit. No assessments were applied.", sensitivity_analysis_interrupted: "Sensitivity analysis was interrupted before completion. No assessments were applied.", sensitive_data_suggestion_failed: "Sensitivity analysis failed before review. No changes were applied.", sensitive_data_suggestion_history_request_invalid: "Sensitivity analysis history parameters are invalid.", sensitive_data_suggestion_history_failed: "Sensitivity analysis history could not be loaded.", sensitive_data_suggestion_run_not_found: "The sensitivity analysis run was not found.", schema_sync_conflict: "A schema synchronization is already active or no longer current.", schema_introspection_failed: "The database schema could not be read. Check the connection and credentials, then try again.", 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.", relationship_not_found: "The relationship no longer exists. Refresh and try again.", relationship_duplicate: "This relationship already exists.", relationship_target_not_unique: "The target column must be the only primary-key column of its table.", relationship_type_incompatible: "Source and target column types are not compatible.", relationship_read_only: "Physical relationships are read-only.", relationship_request_invalid: "The relationship request is invalid.", relationship_operation_failed: "The relationship operation failed.", relationship_schema_stale: "Synchronize the current database schema before managing logical relationships.", column_not_found: "The selected catalog column was not found. Refresh and try again.", preprocessing_request_invalid: "The preprocessing request is invalid.", preprocessing_blocked: "Preprocessing is blocked by a current workspace prerequisite.", preprocessing_run_failed: "Preprocessing failed. Review the latest diagnostic and retry.", }; 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 };