Files
ThothII/frontend/src/api/client.ts
T
Codex 82e2c91f42
Publish documentation / publish (push) Successful in 1m27s
feat: implement memory and evidence administration with guided repairs
Add PostgreSQL-backed memory, editable evidence with source review and activation, and human-approved archive repairs across the harness, API, and UI. Include migrations, deployment support, regression coverage, and validation documentation.

Refresh permissions from validated session roles so existing administrator logins can access newly deployed archive management features.
2026-09-10 10:31:34 +02:00

289 lines
15 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([
"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<string, string> = {
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<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 };