The client bundle aliases `@fusion/core` to the leaf `core/src/types.ts` to keep Node-only dependencies out of the browser, so a package-root import of `FUSION_CLIENT_HEADER`/`FUSION_DASHBOARD_UI_CLIENT` typechecked but failed `vite build`: "FUSION_CLIENT_HEADER" is not exported by "../core/src/types.ts" Follow the documented pattern instead of widening the root alias: declare a `./task-delete-attribution` subpath export, add the matching Vite alias ahead of the broader `@fusion/core` key (Vite matches in order), register the module in the browser-safe-core allowlist, and import the subpath from the client. `task-delete-attribution.ts` has no imports at all, so it is a safe leaf. `app/utils/detectContentLanguage.ts` already warned about exactly this trap; the miss was mine for verifying with typecheck, lint and test:gate but not `pnpm build`, which is one of the four checks CI blocks on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
6.1 KiB
TypeScript
162 lines
6.1 KiB
TypeScript
/**
|
|
* FNXC:CodeOrganization 2026-07-15-16:00:
|
|
* Dashboard API client core (fetch wrapper + ApiRequestError).
|
|
*/
|
|
// FNXC:TaskDeleteAttribution 2026-07-26-17:05: import the browser-safe leaf, not the package root — the root alias resolves to `core/src/types.ts` in the client bundle and does not carry these constants.
|
|
import { FUSION_CLIENT_HEADER, FUSION_DASHBOARD_UI_CLIENT } from "@fusion/core/task-delete-attribution";
|
|
import { getAuthToken, withTokenHeader } from "../auth";
|
|
import type { DedupeOptions } from "./dedupe";
|
|
|
|
/**
|
|
* FNXC:DashboardApi 2026-07-15-13:25:
|
|
* Options accepted by deduped fetchers. Pass `{ forceFresh: true }` after a
|
|
* mutation to bypass any in-flight pre-mutation request and force a new one.
|
|
*/
|
|
export type FetchOptions = DedupeOptions;
|
|
|
|
export class ApiRequestError extends Error {
|
|
readonly status: number;
|
|
readonly details?: Record<string, unknown>;
|
|
|
|
constructor(message: string, status: number, details?: Record<string, unknown>) {
|
|
super(message);
|
|
this.name = "ApiRequestError";
|
|
this.status = status;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
export function looksLikeHtml(body: string): boolean {
|
|
const trimmed = body.trim();
|
|
return trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
|
|
}
|
|
|
|
export function buildApiUrl(path: string): string {
|
|
return `/api${path}`;
|
|
}
|
|
|
|
/**
|
|
* FNXC:TaskDeleteAttribution 2026-07-26-14:30:
|
|
* Stamp every dashboard-originated request with `x-fusion-client: dashboard-ui` so server-side
|
|
* run-audit can tell an operator's click apart from an unlabeled script or agent hitting the same
|
|
* endpoint (the four-delete incident where `DELETE /api/tasks/:id` rows were byte-identical
|
|
* regardless of who called). Applied once here rather than per-call so no future mutation route
|
|
* has to remember it; the desktop shell mounts this same App and therefore inherits it.
|
|
*
|
|
* Self-reported and explicitly NOT a security boundary — anything can send this header. It
|
|
* separates "the client said it was the dashboard UI" from "nothing identified itself"; no
|
|
* authorization decision may depend on it. An existing explicit value is left alone.
|
|
*/
|
|
function applyClientIdentityHeader(headers: Headers): void {
|
|
if (!headers.has(FUSION_CLIENT_HEADER)) {
|
|
headers.set(FUSION_CLIENT_HEADER, FUSION_DASHBOARD_UI_CLIENT);
|
|
}
|
|
}
|
|
|
|
export async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
|
const url = buildApiUrl(path);
|
|
const token = getAuthToken();
|
|
const headers = (() => {
|
|
if (token) {
|
|
const authenticatedHeaders = new Headers(opts.headers ?? {});
|
|
if (!authenticatedHeaders.has("Content-Type")) {
|
|
authenticatedHeaders.set("Content-Type", "application/json");
|
|
}
|
|
applyClientIdentityHeader(authenticatedHeaders);
|
|
return withTokenHeader(authenticatedHeaders);
|
|
}
|
|
|
|
const defaultHeaders = new Headers(opts.headers ?? {});
|
|
if (!defaultHeaders.has("Content-Type")) {
|
|
defaultHeaders.set("Content-Type", "application/json");
|
|
}
|
|
applyClientIdentityHeader(defaultHeaders);
|
|
return Object.fromEntries(defaultHeaders.entries());
|
|
})();
|
|
|
|
const res = await fetch(url, {
|
|
...opts,
|
|
headers,
|
|
});
|
|
|
|
/*
|
|
* FNXC:DashboardApi 2026-07-15-13:25:
|
|
* Successful 204 responses (for example DELETE and reorder) have no body or
|
|
* JSON content type, so return undefined for void endpoints before parsing.
|
|
*/
|
|
if (res.status === 204) {
|
|
if (!res.ok) {
|
|
// 204 is always ok by definition, but guard anyway
|
|
throw new Error(`Request failed for ${url}: ${res.status} ${res.statusText}`);
|
|
}
|
|
return undefined as T;
|
|
}
|
|
|
|
const contentType = res.headers.get("content-type") ?? "";
|
|
const bodyText = await res.text();
|
|
const isJson = contentType.includes("application/json");
|
|
const isHtml = contentType.includes("text/html") || looksLikeHtml(bodyText);
|
|
|
|
if (isHtml) {
|
|
throw new Error(
|
|
`API returned HTML instead of JSON for ${url}. ` +
|
|
`The endpoint may not be properly configured. (${res.status} ${res.statusText})`
|
|
);
|
|
}
|
|
|
|
if (!isJson) {
|
|
const preview = bodyText.length > 160 ? `${bodyText.slice(0, 160)}...` : bodyText;
|
|
throw new Error(
|
|
`API returned ${contentType || "an unknown content type"} instead of JSON for ${url}. ` +
|
|
`(${res.status} ${res.statusText})${preview ? ` Response: ${preview}` : ""}`
|
|
);
|
|
}
|
|
|
|
let data: unknown;
|
|
try {
|
|
data = bodyText ? JSON.parse(bodyText) : null;
|
|
} catch {
|
|
throw new Error(
|
|
`API returned invalid JSON for ${url}. (${res.status} ${res.statusText})`
|
|
);
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const payload = data as { error?: string; details?: Record<string, unknown> } | null;
|
|
throw new ApiRequestError(
|
|
payload?.error || `Request failed for ${url}: ${res.status} ${res.statusText}`,
|
|
res.status,
|
|
payload?.details,
|
|
);
|
|
}
|
|
|
|
return data as T;
|
|
}
|
|
|
|
/**
|
|
* Rewrite a path to route through the node proxy when viewing a remote node.
|
|
* When nodeId is provided and differs from localNodeId (i.e., it's a remote node),
|
|
* rewrites the path from `/tasks` to `/proxy/${encodeURIComponent(nodeId)}/tasks`.
|
|
* When nodeId is undefined or matches localNodeId, returns the path unchanged.
|
|
*/
|
|
export function withNodeId(path: string, nodeId?: string, localNodeId?: string): string {
|
|
if (!nodeId || nodeId === localNodeId) return path;
|
|
// Rewrite path to proxy endpoint: /tasks -> /proxy/:nodeId/tasks
|
|
// Strip leading /api prefix if present since proxyApi adds it
|
|
const apiPrefix = "/api";
|
|
const pathWithoutPrefix = path.startsWith(apiPrefix) ? path.slice(apiPrefix.length) : path;
|
|
return `/proxy/${encodeURIComponent(nodeId)}${pathWithoutPrefix}`;
|
|
}
|
|
|
|
/**
|
|
* Make an API request, optionally routing through the node proxy for remote nodes.
|
|
* When nodeId is provided and differs from localNodeId, the request is routed
|
|
* through /api/proxy/:nodeId/... instead of directly.
|
|
*/
|
|
export function proxyApi<T>(path: string, opts?: RequestInit & { nodeId?: string; localNodeId?: string }): Promise<T> {
|
|
// Extract nodeId/localNodeId from opts before passing to api()
|
|
const { nodeId, localNodeId, ...fetchOpts } = opts ?? {};
|
|
const resolvedPath = withNodeId(path, nodeId, localNodeId);
|
|
return api<T>(resolvedPath, fetchOpts);
|
|
}
|