258 lines
12 KiB
TypeScript
258 lines
12 KiB
TypeScript
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<string, string> = {
|
|
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<number, string> = {
|
|
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<string, unknown>;
|
|
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<Response> {
|
|
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<T>(path: string, init?: RequestInit): Promise<T> {
|
|
// 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<Blob> {
|
|
return (await request(path, init)).blob();
|
|
}
|
|
|
|
export { BASE };
|