web: one API call layer (fetchJson/fetchOk/fetchBlob), notifyError, ratchets with the full baseline (audit package 5, stage 1)

lib/scopedFetch.ts becomes lib/apiClient.ts: ApiError carries retryAfterSeconds (body first,
Retry-After header second), fetchJson<T> refuses empty and malformed 2xx bodies instead of
handing back undefined, sets Content-Type for string bodies, fetchOk covers action calls and
fetchBlob downloads. describeApiError renders 429/503 with the server's retry advice;
apiErrorText is the one-string form for inline error slots; apiErrorNotification folds the
"Session Expired" dialect into one place and NotificationContext.notifyError uses it.
useAuthenticatedFetch throws ApiError (correlation id preserved), drops the dead 204 branch
and onTokenExpired. dedupedFetchJson<T> for the three feature-flag readers.

lib/__tests__/apiClient.guard.test.ts ratchets raw authenticatedFetch(/dedupedAuthFetch(/
.json()/instanceof TokenExpiredError per file against a baseline that may only shrink
(a count above fails as a new site, below as a stale entry); fetchJson without a type
argument is a violation. Baseline today: 228 raw fetches, 221 parses, 140 token checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-06 01:01:41 +02:00
parent fef09690d2
commit 1554540431
18 changed files with 830 additions and 252 deletions
@@ -42,12 +42,10 @@ export default function AnalyzeRulesPage() {
const { data: rules, loading, execute: fetchRulesExec, setData: setRules } = useAuthenticatedFetch<AnalyzeRule[]>({
onError: (err) => showError(err.message),
onTokenExpired: (err) => showError(err.message),
});
const { execute: mutate } = useAuthenticatedFetch({
onError: (err) => showError(err.message),
onTokenExpired: (err) => showError(err.message),
});
// Filter state
@@ -7,8 +7,7 @@ import { useTenant } from "../../../contexts/TenantContext";
import { useAuth } from "../../../contexts/AuthContext";
import { useNotifications } from "../../../contexts/NotificationContext";
import { scopedApi } from "@/lib/scopedApi";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { apiErrorFromResponse, describeApiError } from "@/lib/scopedFetch";
import { fetchJson } from "@/lib/apiClient";
import { formatBytes, formatDuration } from "@/lib/formatting";
import DoBreakdownBar from "@/components/DoBreakdownBar";
import { CalculatingInline } from "@/components/CalculatingCard";
@@ -82,7 +81,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
const router = useRouter();
const { tenantId } = useTenant();
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const { notifyError } = useNotifications();
const { isGlobalAdmin, scopeInitialized, scopeKey } = scope;
const [data, setData] = useState<AppsListResponse | null>(null);
@@ -110,32 +109,26 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
const listUrl = scopedApi.appsList(scope, days);
const metricsUrl = scopedApi.appMetrics(scope, days);
const [listRes, metricsRes] = await Promise.all([
authenticatedFetch(listUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }),
authenticatedFetch(metricsUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }),
// The list is the page; the DO rollup is decoration — its failure stays silent.
const [listRes, metricsRes] = await Promise.allSettled([
fetchJson<AppsListResponse>(listUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }),
fetchJson<AppMetricsResponse>(metricsUrl, getAccessToken, { signal: AbortSignal.timeout(APPS_FETCH_TIMEOUT_MS) }),
]);
if (cancelled) return;
if (listRes.ok) {
setData((await listRes.json()) as AppsListResponse);
if (listRes.status === "fulfilled") {
setData(listRes.value);
succeeded = true;
} else {
// Error envelope: the backend's message plus the correlation id as a quotable reference.
const { message, reference } = describeApiError(await apiErrorFromResponse(listRes));
addNotification("error", "Backend Error", `Failed to load apps: ${message}`, "apps-list-error", undefined, reference ?? undefined);
notifyError("Backend Error", listRes.reason, "apps-list-error", "Failed to load apps.");
}
if (metricsRes.ok) {
const m = (await metricsRes.json()) as AppMetricsResponse;
setDoRollup(m.deliveryOptimization ?? null);
if (metricsRes.status === "fulfilled") {
setDoRollup(metricsRes.value.deliveryOptimization ?? null);
}
} catch (err) {
if (cancelled) return;
if (err instanceof TokenExpiredError) {
addNotification("error", "Session Expired", err.message, "session-expired-error");
} else {
console.error("Failed to fetch app installs", err);
addNotification("error", "Backend Not Reachable", "Unable to load app dashboard data.", "apps-list-error");
}
console.error("Failed to fetch app installs", err);
notifyError("Backend Not Reachable", err, "apps-list-error", "Unable to load app dashboard data.");
} finally {
progressFinish(succeeded);
if (!cancelled) setLoading(false);
@@ -3,17 +3,11 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { TokenExpiredError } from "@/lib/authenticatedFetch";
import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch";
import type { NotificationType } from "@/contexts/NotificationContext";
import { dedupedFetchJson } from "@/lib/dedupedAuthFetch";
import { missingContactProfileParts } from "@/lib/edition";
type AddNotification = (
type: NotificationType,
title: string,
message: string,
key?: string,
href?: string,
) => void;
/** NotificationContext.notifyError, injected so the hook stays free of the provider. */
type NotifyError = (title: string, err: unknown, key?: string, fallback?: string) => void;
/** The subset of the feature-flags response the dashboard banners read. */
export interface TenantConfigurationSummary {
@@ -79,7 +73,7 @@ export function useTenantSecurityConfig(
tenantId: string | null | undefined,
user: User | null | undefined,
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
addNotification: AddNotification,
notifyError: NotifyError,
): TenantSecuritySummary {
const [summary, setSummary] = useState<TenantSecuritySummary>(EMPTY_SUMMARY);
@@ -95,19 +89,12 @@ export function useTenantSecurityConfig(
if (!user.isTenantAdmin && !user.isGlobalAdmin && user.role == null) return;
try {
const response = await dedupedAuthFetch(api.config.featureFlags(tenantId), getAccessToken);
if (!response.ok) {
setSummary(EMPTY_SUMMARY);
return;
}
const data: TenantConfigurationSummary = await response.json();
const data = await dedupedFetchJson<TenantConfigurationSummary>(api.config.featureFlags(tenantId), getAccessToken);
setSummary(summarizeTenantSecurityConfig(data));
} catch (error) {
if (error instanceof TokenExpiredError) {
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
}
// Fail-soft on a backend refusal (the banners just stay quiet); a token expiry is the
// one failure the user must hear about.
if (error instanceof TokenExpiredError) notifyError('Session Expired', error);
setSummary(EMPTY_SUMMARY);
}
};
@@ -99,7 +99,7 @@ function HomeContent() {
? "w-full px-4 sm:px-6 lg:px-8 py-4"
: "max-w-7xl mx-auto py-4 sm:px-6 lg:px-8";
const { user, getAccessToken, isActivationPending, hasGlobalScope } = useAuth();
const { addNotification } = useNotifications();
const { addNotification, notifyError } = useNotifications();
// `?tenant=<id>` deep-links a cross-tenant view onto one tenant — used by the /fleet card grid to drill
// a managed tenant into this dashboard. Ignored for non-cross-tenant users (the filter is unused there).
const initialTenantFilter = searchParams?.get("tenant") ?? "";
@@ -262,7 +262,7 @@ function HomeContent() {
}
}, [user, router]);
const { serialValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification);
const { serialValidationEnabled, proContactMissing, proContactMissingParts, appHomingFunnelActive } = useTenantSecurityConfig(tenantId, user, getAccessToken, notifyError);
// Disable global-scope mode for users without platform scope. A read-only Global Reader keeps it
// (their cross-tenant view is read-only-safe; writes are gated separately + backend-enforced).
@@ -10,7 +10,7 @@ import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
import { useNotifications } from "../../contexts/NotificationContext";
import { scopedApi } from "@/lib/scopedApi";
import { fetchJson } from "@/lib/scopedFetch";
import { fetchJson } from "@/lib/apiClient";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import FleetStatCard from "./components/FleetStatCard";
import TimeAttributionSection, { TimeAttributionResponseDto } from "./components/TimeAttributionSection";
@@ -28,12 +28,10 @@ export default function GatherRulesPage() {
const { data: rules, loading, execute: fetchRulesExec, setData: setRules } = useAuthenticatedFetch<GatherRule[]>({
onError: (err) => showError(err.message),
onTokenExpired: (err) => showError(err.message),
});
const { execute: mutate } = useAuthenticatedFetch({
onError: (err) => showError(err.message),
onTokenExpired: (err) => showError(err.message),
});
// Filter state
@@ -28,12 +28,10 @@ export default function ImeLogPatternsPage() {
const { data: patterns, loading, execute: fetchPatternsExec, setData: setPatterns } = useAuthenticatedFetch<ImeLogPattern[]>({
onError: (err) => showError(err.message),
onTokenExpired: (err) => showError(err.message),
});
const { execute: mutate } = useAuthenticatedFetch({
onError: (err) => showError(err.message),
onTokenExpired: (err) => showError(err.message),
});
// Filters
@@ -2,7 +2,8 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch";
import { dedupedFetchJson } from "@/lib/dedupedAuthFetch";
import type { TenantFeatureFlagsResponse } from "@/utils/wire-types.generated";
interface UseSessionTenantConfigReturn {
showScriptOutput: boolean;
@@ -36,9 +37,7 @@ export function useSessionTenantConfig(
// These flags are exposed via the member-readable feature-flags endpoint so that
// Operators and Viewers can load session details without 403'ing on the admin-only
// full /api/config/{tenantId} response.
const res = await dedupedAuthFetch(api.config.featureFlags(sessionTenantId), getAccessToken);
if (!res.ok || cancelled) return;
const cfg = await res.json();
const cfg = await dedupedFetchJson<TenantFeatureFlagsResponse>(api.config.featureFlags(sessionTenantId), getAccessToken);
if (cancelled) return;
setShowScriptOutput(cfg.showScriptOutput ?? true);
setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false);
@@ -1,6 +1,7 @@
"use client";
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { apiErrorNotification } from '@/lib/apiClient';
export type NotificationType = 'error' | 'warning' | 'info' | 'success';
@@ -20,6 +21,11 @@ interface NotificationContextType {
notifications: Notification[];
unreadCount: number;
addNotification: (type: NotificationType, title: string, message: string, key?: string, href?: string, reference?: string) => void;
/**
* The error toast for a failed API call: message and reference from the error envelope
* (describeApiError); a token expiry renders as "Session Expired" whatever the title.
*/
notifyError: (title: string, err: unknown, key?: string, fallback?: string) => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
removeNotification: (id: string) => void;
@@ -101,6 +107,11 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
}
}, []);
const notifyError = useCallback((title: string, err: unknown, key?: string, fallback?: string) => {
const n = apiErrorNotification(title, err, key, fallback);
addNotification(n.type, n.title, n.message, n.key, undefined, n.reference);
}, [addNotification]);
const markAsRead = useCallback((id: string) => {
setNotifications(prev =>
prev.map(n => n.id === id ? { ...n, read: true } : n)
@@ -130,11 +141,12 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
notifications,
unreadCount,
addNotification,
notifyError,
markAsRead,
markAllAsRead,
removeNotification,
clearAll,
}), [notifications, unreadCount, addNotification, markAsRead, markAllAsRead, removeNotification, clearAll]);
}), [notifications, unreadCount, addNotification, notifyError, markAsRead, markAllAsRead, removeNotification, clearAll]);
return (
<NotificationContext.Provider value={value}>
@@ -4,16 +4,15 @@ import { useState, useCallback, useRef } from "react";
import { useLatest } from "@/hooks/useLatest";
import { useAuth } from "@/contexts/AuthContext";
import { useNotifications } from "@/contexts/NotificationContext";
import {
authenticatedFetch,
TokenExpiredError,
} from "@/lib/authenticatedFetch";
import { TokenExpiredError } from "@/lib/authenticatedFetch";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
export interface UseAuthenticatedFetchOptions {
/** Called on non-TokenExpiredError failures. If not set, only the error state is updated. */
/**
* Called on every failure (ApiError, TokenExpiredError, network). If not set, only the error
* state is updated — except a token expiry, which the notification bell reports.
*/
onError?: (error: Error) => void;
/** Called on TokenExpiredError. Default: addNotification via NotificationContext. */
onTokenExpired?: (error: TokenExpiredError) => void;
}
export interface ExecuteOptions<T> {
@@ -41,7 +40,7 @@ export function useAuthenticatedFetch<T = unknown>(
options?: UseAuthenticatedFetchOptions,
): UseAuthenticatedFetchReturn<T> {
const { getAccessToken } = useAuth();
const { addNotification } = useNotifications();
const { notifyError } = useNotifications();
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
@@ -78,25 +77,7 @@ export function useAuthenticatedFetch<T = unknown>(
}
setError(null);
const response = await authenticatedFetch(url, getAccessToken, init);
if (!response.ok) {
// Try to extract error message from response body (API returns { message: "..." })
let message = `Failed: ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.message) message = errorData.message;
} catch {
/* response body wasn't JSON */
}
throw new Error(message);
}
if (response.status === 204) {
return null;
}
const json = await response.json();
const json = await fetchJson<unknown>(url, getAccessToken, init);
const result = executeOptions?.transform
? executeOptions.transform(json)
: (json as T);
@@ -111,26 +92,13 @@ export function useAuthenticatedFetch<T = unknown>(
return null;
}
const opts = optionsRef.current;
if (err instanceof TokenExpiredError) {
if (opts?.onTokenExpired) {
opts.onTokenExpired(err);
} else {
addNotification(
"error",
"Session Expired",
err.message,
"session-expired",
);
}
} else if (opts?.onError) {
opts.onError(
err instanceof Error ? err : new Error(String(err)),
);
if (opts?.onError) {
opts.onError(err instanceof Error ? err : new Error(String(err)));
} else if (err instanceof TokenExpiredError) {
notifyError("Session Expired", err);
}
const message =
err instanceof Error ? err.message : "An unknown error occurred";
setError(message);
setError(apiErrorText(err, "An unknown error occurred"));
return null;
} finally {
// Only the spinner's owner may clear it — a superseded request finishing early
@@ -140,7 +108,7 @@ export function useAuthenticatedFetch<T = unknown>(
}
}
},
[getAccessToken, addNotification, optionsRef],
[getAccessToken, notifyError, optionsRef],
);
return { data, loading, error, execute, clearError, setData };
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import { dedupedAuthFetch } from "@/lib/dedupedAuthFetch";
import { dedupedFetchJson } from "@/lib/dedupedAuthFetch";
import { parseEditionInfo, type EditionInfo } from "@/lib/edition";
/**
@@ -30,9 +30,7 @@ export function useEditionInfo(): EditionInfo | null {
let cancelled = false;
const run = async () => {
try {
const res = await dedupedAuthFetch(api.config.featureFlags(tenantId), getAccessToken);
if (!res.ok) return;
const parsed = parseEditionInfo(await res.json());
const parsed = parseEditionInfo(await dedupedFetchJson<unknown>(api.config.featureFlags(tenantId), getAccessToken));
if (!cancelled) setInfo(parsed);
} catch {
// Leave null — chrome renders nothing rather than a guessed edition.
@@ -0,0 +1,273 @@
// Ratchet baseline for lib/__tests__/apiClient.guard.test.ts — per-file counts of the legacy
// call-site patterns on 2026-09-06. Only ever shrinks: a migrated file lowers or drops its entry,
// a new raw site anywhere fails the guard. Regenerate a shrunken map by hand, never by script over
// the whole tree (the guard's stale check tells you which entries to lower).
/** Files that keep a raw site for a stated reason; they never leave the baseline. */
export const PERMANENT: Record<string, string> = {
"contexts/AuthContext.tsx": "auth/me bootstrap runs before the auth context exists — raw fetch by design",
"components/landing/StatsBand.tsx": "unauthenticated public blob manifest, not the API",
"app/health-check/page.tsx": "/version.json of the SWA itself, not the API",
"app/settings/TenantConfigContext.tsx": "consent return path routes on TokenExpiredError (router.replace) — the one non-message handling",
};
export const AUTHENTICATEDFETCH_BASELINE: Record<string, number> = {
"app/admin/AdminConfigContext.tsx": 8,
"app/admin/backups/components/RestoreRowDiffModal.tsx": 1,
"app/admin/backups/detail/page.tsx": 2,
"app/admin/backups/page.tsx": 3,
"app/admin/components/ConfigReseedSection.tsx": 4,
"app/admin/components/DeviceBlockSection.tsx": 6,
"app/admin/components/DistressReportsSection.tsx": 1,
"app/admin/components/FeedbackSection.tsx": 1,
"app/admin/components/IdentityBindingsSection.tsx": 3,
"app/admin/components/IgnoredSoftwareTab.tsx": 4,
"app/admin/components/MaintenanceTriggerSection.tsx": 2,
"app/admin/components/MappedSoftwareTab.tsx": 3,
"app/admin/components/OpsEventsSection.tsx": 1,
"app/admin/components/SessionExportSection.tsx": 1,
"app/admin/components/SessionReportsSection.tsx": 3,
"app/admin/components/TenantAdminSection.tsx": 5,
"app/admin/components/TenantManagementSection.tsx": 10,
"app/admin/components/UnmappedSoftwareTab.tsx": 5,
"app/admin/components/VersionBlockSection.tsx": 3,
"app/admin/components/VulnerabilityDataSection.tsx": 11,
"app/admin/customs-archive/detail/page.tsx": 3,
"app/admin/customs-archive/page.tsx": 2,
"app/admin/metrics/sections/SectionAgentMetrics.tsx": 1,
"app/admin/metrics/sections/SectionImePatternHealth.tsx": 1,
"app/admin/metrics/sections/SectionMcpUsage.tsx": 2,
"app/admin/metrics/sections/SectionPlatformUsage.tsx": 1,
"app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1,
"app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 2,
"app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 2,
"app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2,
"app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1,
"app/admin/ops/session-cleanup/page.tsx": 3,
"app/admin/presence/page.tsx": 1,
"app/admin/settings/sections/SectionDelegatedAdmins.tsx": 4,
"app/admin/settings/sections/SectionEmailTemplates.tsx": 4,
"app/admin/settings/sections/SectionTenantGroups.tsx": 2,
"app/admin/settings/sections/SectionUsagePlans.tsx": 2,
"app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2,
"app/analyze-rules/page.tsx": 2,
"app/annotations/page.tsx": 1,
"app/apps/components/InventoryTab.tsx": 1,
"app/apps/components/VulnerabilitiesTab.tsx": 1,
"app/apps/detail/page.tsx": 2,
"app/audit/page.tsx": 1,
"app/dashboard/hooks/useBlockDevice.ts": 1,
"app/dashboard/hooks/useDashboardSessions.ts": 3,
"app/dashboard/hooks/useDashboardStats.ts": 1,
"app/dashboard/hooks/useDeleteSession.ts": 3,
"app/dashboard/hooks/useTenantList.ts": 1,
"app/dashboard/page.tsx": 1,
"app/delegations/accept/page.tsx": 2,
"app/diagnosis/page.tsx": 3,
"app/fleet-health/hooks/useFleetHealth.ts": 1,
"app/fleet-health/page.tsx": 1,
"app/fleet/hooks/useFleetSummaries.ts": 1,
"app/gather-rules/page.tsx": 1,
"app/geographic-performance/page.tsx": 1,
"app/geographic-performance/sessions/page.tsx": 1,
"app/health-check/page.tsx": 2,
"app/progress/hooks/useProgressEvents.ts": 3,
"app/progress/hooks/useProgressSearch.ts": 1,
"app/sessions/components/AnalysisResultsSection.tsx": 1,
"app/sessions/components/CollectLogsButton.tsx": 3,
"app/sessions/components/DeviceHistoryBanner.tsx": 1,
"app/sessions/components/SessionAnnotationsCard.tsx": 2,
"app/sessions/hooks/useSessionAnalysis.ts": 2,
"app/sessions/hooks/useSessionDetail.ts": 1,
"app/sessions/hooks/useSessionEvents.ts": 2,
"app/sessions/network-timeline/page.tsx": 2,
"app/sessions/page.tsx": 5,
"app/settings/TenantConfigContext.tsx": 20,
"app/settings/components/HardwareRejectionInsights.tsx": 1,
"app/settings/components/McpUsersSection.tsx": 8,
"app/settings/components/NotRegisteredDevicesInsights.tsx": 1,
"app/settings/components/OffboardingSection.tsx": 1,
"app/settings/components/TpmIncompatibleDevicesInsights.tsx": 1,
"app/settings/reporting/sections/SectionMcpUsage.tsx": 2,
"app/settings/tenant/sections/SectionDelegatedAccess.tsx": 6,
"app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx": 2,
"app/settings/tenant/sections/SectionSubmitLogs.tsx": 1,
"app/sla/page.tsx": 1,
"app/usage-metrics/page.tsx": 1,
"components/DelegatedSlotPrompt.tsx": 1,
"components/FeedbackBubble.tsx": 3,
"components/GlobalSearch.tsx": 1,
"contexts/GlobalNotificationContext.tsx": 3,
"contexts/SignalRContext.tsx": 3,
"contexts/TenantNotificationContext.tsx": 3,
"hooks/useDiagnosticsPathsCatalog.ts": 1,
"hooks/useTenantList.ts": 1,
};
export const DEDUPEDAUTHFETCH_BASELINE: Record<string, number> = {
};
export const JSONPARSE_BASELINE: Record<string, number> = {
"app/activation/page.tsx": 1,
"app/admin/AdminConfigContext.tsx": 8,
"app/admin/backups/components/RestoreRowDiffModal.tsx": 1,
"app/admin/backups/detail/page.tsx": 2,
"app/admin/backups/page.tsx": 3,
"app/admin/components/ConfigReseedSection.tsx": 8,
"app/admin/components/DeviceBlockSection.tsx": 6,
"app/admin/components/DistressReportsSection.tsx": 1,
"app/admin/components/FeedbackSection.tsx": 1,
"app/admin/components/IdentityBindingsSection.tsx": 3,
"app/admin/components/IgnoredSoftwareTab.tsx": 2,
"app/admin/components/MaintenanceTriggerSection.tsx": 1,
"app/admin/components/MappedSoftwareTab.tsx": 3,
"app/admin/components/OpsEventsSection.tsx": 1,
"app/admin/components/SessionExportSection.tsx": 1,
"app/admin/components/SessionReportsSection.tsx": 2,
"app/admin/components/TenantAdminSection.tsx": 5,
"app/admin/components/TenantManagementSection.tsx": 12,
"app/admin/components/UnmappedSoftwareTab.tsx": 3,
"app/admin/components/VersionBlockSection.tsx": 3,
"app/admin/components/VulnerabilityDataSection.tsx": 11,
"app/admin/customs-archive/detail/page.tsx": 2,
"app/admin/customs-archive/page.tsx": 2,
"app/admin/metrics/sections/SectionAgentMetrics.tsx": 1,
"app/admin/metrics/sections/SectionImePatternHealth.tsx": 1,
"app/admin/metrics/sections/SectionMcpUsage.tsx": 2,
"app/admin/metrics/sections/SectionPlatformUsage.tsx": 1,
"app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1,
"app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 1,
"app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 1,
"app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2,
"app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1,
"app/admin/ops/session-cleanup/page.tsx": 3,
"app/admin/presence/page.tsx": 1,
"app/admin/settings/sections/SectionDelegatedAdmins.tsx": 4,
"app/admin/settings/sections/SectionEmailTemplates.tsx": 3,
"app/admin/settings/sections/SectionTenantGroups.tsx": 2,
"app/admin/settings/sections/SectionUsagePlans.tsx": 3,
"app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2,
"app/analyze-rules/page.tsx": 2,
"app/annotations/page.tsx": 1,
"app/apps/components/InventoryTab.tsx": 1,
"app/apps/components/VulnerabilitiesTab.tsx": 1,
"app/apps/detail/page.tsx": 2,
"app/audit/page.tsx": 1,
"app/dashboard/hooks/deleteSessionResponse.ts": 1,
"app/dashboard/hooks/useBlockDevice.ts": 1,
"app/dashboard/hooks/useDashboardSessions.ts": 4,
"app/dashboard/hooks/useDashboardStats.ts": 2,
"app/dashboard/hooks/useTenantList.ts": 1,
"app/dashboard/page.tsx": 1,
"app/delegations/accept/page.tsx": 3,
"app/diagnosis/page.tsx": 3,
"app/fleet-health/hooks/useFleetHealth.ts": 2,
"app/fleet-health/page.tsx": 1,
"app/fleet/hooks/useFleetSummaries.ts": 1,
"app/gather-rules/page.tsx": 1,
"app/geographic-performance/page.tsx": 1,
"app/geographic-performance/sessions/page.tsx": 1,
"app/health-check/page.tsx": 3,
"app/progress/hooks/useProgressEvents.ts": 3,
"app/progress/hooks/useProgressSearch.ts": 1,
"app/sessions/components/AnalysisResultsSection.tsx": 1,
"app/sessions/components/CollectLogsButton.tsx": 1,
"app/sessions/components/DeviceHistoryBanner.tsx": 1,
"app/sessions/components/SessionAnnotationsCard.tsx": 2,
"app/sessions/hooks/useSessionAnalysis.ts": 2,
"app/sessions/hooks/useSessionDetail.ts": 1,
"app/sessions/hooks/useSessionEvents.ts": 2,
"app/sessions/network-timeline/page.tsx": 2,
"app/sessions/page.tsx": 2,
"app/settings/TenantConfigContext.tsx": 23,
"app/settings/components/HardwareRejectionInsights.tsx": 1,
"app/settings/components/McpUsersSection.tsx": 8,
"app/settings/components/NotRegisteredDevicesInsights.tsx": 1,
"app/settings/components/TpmIncompatibleDevicesInsights.tsx": 1,
"app/settings/reporting/sections/SectionMcpUsage.tsx": 2,
"app/settings/tenant/sections/SectionDelegatedAccess.tsx": 6,
"app/settings/tenant/sections/SectionOptionalGraphCapabilities.tsx": 1,
"app/settings/tenant/sections/SectionSubmitLogs.tsx": 1,
"app/sla/page.tsx": 1,
"app/usage-metrics/page.tsx": 1,
"components/DelegatedSlotPrompt.tsx": 1,
"components/FeedbackBubble.tsx": 1,
"components/GlobalSearch.tsx": 1,
"components/landing/StatsBand.tsx": 2,
"contexts/AuthContext.tsx": 3,
"contexts/GlobalNotificationContext.tsx": 1,
"contexts/TenantNotificationContext.tsx": 1,
"hooks/useDiagnosticsPathsCatalog.ts": 1,
"hooks/useTenantList.ts": 1,
};
export const TOKENEXPIRED_BASELINE: Record<string, number> = {
"app/admin/AdminConfigContext.tsx": 6,
"app/admin/backups/components/RestoreRowDiffModal.tsx": 1,
"app/admin/backups/detail/page.tsx": 2,
"app/admin/backups/page.tsx": 3,
"app/admin/components/ConfigReseedSection.tsx": 4,
"app/admin/components/DeviceBlockSection.tsx": 6,
"app/admin/components/DistressReportsSection.tsx": 1,
"app/admin/components/FeedbackSection.tsx": 1,
"app/admin/components/IdentityBindingsSection.tsx": 1,
"app/admin/components/MaintenanceTriggerSection.tsx": 2,
"app/admin/components/MappedSoftwareTab.tsx": 3,
"app/admin/components/OpsEventsSection.tsx": 1,
"app/admin/components/SessionExportSection.tsx": 1,
"app/admin/components/SessionReportsSection.tsx": 3,
"app/admin/components/TenantAdminSection.tsx": 5,
"app/admin/components/TenantManagementSection.tsx": 6,
"app/admin/components/UnmappedSoftwareTab.tsx": 2,
"app/admin/components/VersionBlockSection.tsx": 3,
"app/admin/components/VulnerabilityDataSection.tsx": 5,
"app/admin/customs-archive/detail/page.tsx": 1,
"app/admin/customs-archive/page.tsx": 2,
"app/admin/metrics/sections/SectionAgentMetrics.tsx": 1,
"app/admin/metrics/sections/SectionImePatternHealth.tsx": 1,
"app/admin/metrics/sections/SectionMcpUsage.tsx": 1,
"app/admin/metrics/sections/SectionPlatformUsage.tsx": 1,
"app/admin/metrics/sections/SectionVerdictCalibration.tsx": 1,
"app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx": 2,
"app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 2,
"app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx": 2,
"app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx": 1,
"app/admin/ops/session-cleanup/page.tsx": 3,
"app/admin/presence/page.tsx": 1,
"app/admin/settings/sections/SectionDelegatedAdmins.tsx": 1,
"app/admin/settings/sections/SectionEmailTemplates.tsx": 1,
"app/admin/settings/sections/SectionTenantGroups.tsx": 1,
"app/admin/settings/sections/SectionUsagePlans.tsx": 2,
"app/admin/tenants/sections/SectionTenantConfigReport.tsx": 2,
"app/apps/components/InventoryTab.tsx": 1,
"app/apps/components/VulnerabilitiesTab.tsx": 1,
"app/apps/detail/page.tsx": 2,
"app/audit/page.tsx": 1,
"app/dashboard/hooks/useBlockDevice.ts": 2,
"app/dashboard/hooks/useDashboardSessions.ts": 3,
"app/dashboard/hooks/useDashboardStats.ts": 1,
"app/dashboard/hooks/useDeleteSession.ts": 2,
"app/dashboard/hooks/useTenantSecurityConfig.ts": 1,
"app/delegations/accept/page.tsx": 1,
"app/diagnosis/page.tsx": 3,
"app/fleet-health/hooks/useFleetHealth.ts": 1,
"app/fleet-health/page.tsx": 1,
"app/geographic-performance/page.tsx": 1,
"app/geographic-performance/sessions/page.tsx": 1,
"app/health-check/page.tsx": 1,
"app/progress/hooks/useProgressEvents.ts": 1,
"app/progress/hooks/useProgressSearch.ts": 1,
"app/sessions/components/CollectLogsButton.tsx": 1,
"app/sessions/components/SessionAnnotationsCard.tsx": 1,
"app/sessions/hooks/useSessionDetail.ts": 1,
"app/sessions/hooks/useSessionEvents.ts": 1,
"app/sessions/page.tsx": 1,
"app/settings/TenantConfigContext.tsx": 16,
"app/settings/components/McpUsersSection.tsx": 6,
"app/settings/components/OffboardingSection.tsx": 1,
"app/settings/reporting/sections/SectionMcpUsage.tsx": 1,
"app/settings/tenant/sections/SectionDelegatedAccess.tsx": 1,
"app/settings/tenant/sections/SectionSubmitLogs.tsx": 1,
"app/sla/page.tsx": 1,
"app/usage-metrics/page.tsx": 1,
};
@@ -0,0 +1,160 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import {
AUTHENTICATEDFETCH_BASELINE,
DEDUPEDAUTHFETCH_BASELINE,
JSONPARSE_BASELINE,
PERMANENT,
TOKENEXPIRED_BASELINE,
} from "./apiClient.guard.baseline";
/**
* Ratchet for the one API call layer (lib/apiClient.ts, D-203): pages, components, hooks and
* contexts call the backend through fetchJson<T>/fetchOk/fetchBlob and render failures through
* describeApiError/apiErrorText/notifyError. Raw `authenticatedFetch(`, `.json()` parses and
* `instanceof TokenExpiredError` branches are legacy; their per-file counts live in the baseline
* module and may only go down. A count above its baseline (or a file not in the baseline) is a new
* raw site; a count below it is a stale baseline entry that must be lowered — so the baseline
* always states the true remaining debt.
*
* Comment lines are ignored; test files are excluded (they stub fetch deliberately).
*/
const WEB_ROOT = join(__dirname, "..", "..");
const SCAN_DIRS = ["app", "components", "hooks", "contexts"];
/** The lib-level hook is itself a fetchJson consumer and the one place allowed to spell TokenExpiredError. */
const EXEMPT_FILES = new Set(["hooks/useAuthenticatedFetch.ts"]);
interface Pattern {
name: string;
regex: RegExp;
baseline: Record<string, number>;
remedy: string;
}
const PATTERNS: Pattern[] = [
{
name: "authenticatedFetch(",
regex: /\bauthenticatedFetch\(/g,
baseline: AUTHENTICATEDFETCH_BASELINE,
remedy: "call fetchJson<T>/fetchOk/fetchBlob from @/lib/apiClient instead",
},
{
name: "dedupedAuthFetch(",
regex: /\bdedupedAuthFetch\(/g,
baseline: DEDUPEDAUTHFETCH_BASELINE,
remedy: "call dedupedFetchJson<T> from @/lib/dedupedAuthFetch instead",
},
{
name: ".json()",
regex: /\.json\(\)/g,
baseline: JSONPARSE_BASELINE,
remedy: "fetchJson<T> parses the body; a hand parse hides the error envelope",
},
{
name: "instanceof TokenExpiredError",
regex: /instanceof TokenExpiredError/g,
baseline: TOKENEXPIRED_BASELINE,
remedy: "notifyError/apiErrorText render a token expiry centrally; keep a branch only where the UI must act (route, reset)",
},
];
function collectFiles(dir: string, acc: string[]): void {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry === "__tests__" || entry.startsWith(".")) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
collectFiles(full, acc);
} else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.(ts|tsx)$/.test(entry)) {
acc.push(full);
}
}
}
function isCommentLine(line: string): boolean {
const trimmed = line.trimStart();
return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
}
/** file (repo-relative, forward slashes) → code lines. */
function scannedSources(): Map<string, string[]> {
const files: string[] = [];
for (const dir of SCAN_DIRS) collectFiles(join(WEB_ROOT, dir), files);
const out = new Map<string, string[]>();
for (const file of files) {
const rel = relative(WEB_ROOT, file).replace(/\\/g, "/");
if (EXEMPT_FILES.has(rel)) continue;
out.set(
rel,
readFileSync(file, "utf-8")
.split("\n")
.filter((l) => !isCommentLine(l)),
);
}
return out;
}
function countPerFile(sources: Map<string, string[]>, regex: RegExp): Map<string, number> {
const counts = new Map<string, number>();
for (const [rel, lines] of sources) {
let n = 0;
for (const line of lines) n += line.match(regex)?.length ?? 0;
if (n > 0) counts.set(rel, n);
}
return counts;
}
const sources = scannedSources();
describe("apiClient ratchet", () => {
it("scans the app (plausibility floor)", () => {
expect(sources.size).toBeGreaterThan(50);
});
for (const pattern of PATTERNS) {
describe(pattern.name, () => {
const counts = countPerFile(sources, pattern.regex);
it("has no raw site above its baseline", () => {
const violations: string[] = [];
for (const [rel, n] of counts) {
const allowed = pattern.baseline[rel] ?? 0;
if (n > allowed) violations.push(`${rel}: ${n} (baseline ${allowed})`);
}
expect(
violations,
`New raw \`${pattern.name}\` site(s) — ${pattern.remedy}:\n ${violations.join("\n ")}`,
).toEqual([]);
});
it("has no stale baseline entry (the baseline states the true remaining debt)", () => {
const stale: string[] = [];
for (const [rel, allowed] of Object.entries(pattern.baseline)) {
const n = counts.get(rel) ?? 0;
if (n < allowed) stale.push(`${rel}: now ${n} (baseline ${allowed})`);
}
expect(
stale,
`Lower these entries in lib/__tests__/apiClient.guard.baseline.ts (${pattern.name}):\n ${stale.join("\n ")}`,
).toEqual([]);
});
});
}
it("never calls fetchJson without an explicit type argument", () => {
const violations: string[] = [];
for (const [rel, lines] of sources) {
lines.forEach((line, i) => {
if (/\b(?:fetchJson|dedupedFetchJson)\(/.test(line)) violations.push(`${rel}:${i + 1}`);
});
}
expect(violations, "fetchJson<T>(…) — name the wire type (utils/wire-types.generated):\n " + violations.join("\n ")).toEqual([]);
});
it("documents every permanent exception against a file that is still in a baseline", () => {
const inSomeBaseline = new Set(PATTERNS.flatMap((p) => Object.keys(p.baseline)));
const rotten = Object.keys(PERMANENT).filter((rel) => !inSomeBaseline.has(rel));
expect(rotten, "PERMANENT names a file with no baseline entry left — drop the reason").toEqual([]);
});
});
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ApiError,
apiErrorFromResponse,
apiErrorNotification,
apiErrorText,
describeApiError,
fetchBlob,
fetchJson,
fetchOk,
} from "../apiClient";
import { TokenExpiredError } from "../authenticatedFetch";
vi.mock("../appInsights", () => ({ trackEvent: vi.fn() }));
function jsonResponse(status: number, body: unknown, statusText = "Error", headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), { status, statusText, headers: { "Content-Type": "application/json", ...headers } });
}
describe("apiErrorFromResponse", () => {
it("reads the error envelope: error, code, correlationId, hint, retryAfterSeconds", async () => {
const err = await apiErrorFromResponse(
jsonResponse(429, {
error: "Rate limit exceeded.",
code: "RateLimitExceeded",
correlationId: "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f",
hint: "Slow down.",
retryAfterSeconds: 12,
}),
);
expect(err).toBeInstanceOf(ApiError);
expect(err.status).toBe(429);
expect(err.message).toBe("Rate limit exceeded.");
expect(err.code).toBe("RateLimitExceeded");
expect(err.correlationId).toBe("3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
expect(err.hint).toBe("Slow down.");
expect(err.retryAfterSeconds).toBe(12);
});
it("takes Retry-After from the header when the body has no retryAfterSeconds", async () => {
const err = await apiErrorFromResponse(jsonResponse(503, { error: "Busy.", code: "ServiceUnavailable", correlationId: "c" }, "x", { "Retry-After": "5" }));
expect(err.retryAfterSeconds).toBe(5);
const none = await apiErrorFromResponse(jsonResponse(503, { error: "Busy.", code: "ServiceUnavailable", correlationId: "c" }));
expect(none.retryAfterSeconds).toBeNull();
});
it("falls back to the pre-envelope message, then to statusText; an empty body is not an exception", async () => {
const legacy = await apiErrorFromResponse(jsonResponse(400, { success: false, message: "tenantId is required" }, "Bad Request"));
expect(legacy.message).toBe("tenantId is required");
expect(legacy.code).toBe("");
expect(legacy.correlationId).toBe("");
const bare = await apiErrorFromResponse(new Response("not json", { status: 502, statusText: "Bad Gateway" }));
expect(bare.message).toBe("Bad Gateway");
expect(bare.hint).toBeNull();
const empty = await apiErrorFromResponse(new Response("", { status: 500, statusText: "Internal Server Error" }));
expect(empty.message).toBe("Internal Server Error");
});
});
describe("fetchJson / fetchOk / fetchBlob", () => {
beforeEach(() => vi.stubGlobal("fetch", vi.fn()));
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const fetchMock = () => globalThis.fetch as ReturnType<typeof vi.fn>;
const getToken = () => vi.fn().mockResolvedValue("tok");
it("returns the parsed body on success and throws the envelope ApiError otherwise", async () => {
fetchMock().mockResolvedValueOnce(jsonResponse(200, { items: [1] }));
await expect(fetchJson<{ items: number[] }>("https://api/x", getToken())).resolves.toEqual({ items: [1] });
fetchMock().mockResolvedValueOnce(jsonResponse(403, { error: "Access denied.", code: "Forbidden", correlationId: "cid-1" }));
const err = await fetchJson<unknown>("https://api/x", getToken()).catch((e) => e);
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).code).toBe("Forbidden");
expect((err as ApiError).correlationId).toBe("cid-1");
});
it("refuses an empty or malformed 2xx body instead of handing back undefined", async () => {
fetchMock().mockResolvedValueOnce(new Response("", { status: 200 }));
const empty = await fetchJson<unknown>("https://api/x", getToken()).catch((e) => e);
expect(empty).toBeInstanceOf(ApiError);
expect((empty as ApiError).message).toBe("Empty response body");
fetchMock().mockResolvedValueOnce(new Response("<html>", { status: 200 }));
const html = await fetchJson<unknown>("https://api/x", getToken()).catch((e) => e);
expect((html as ApiError).message).toBe("Malformed response body");
});
it("sets Content-Type: application/json for a string body unless the caller set one", async () => {
fetchMock().mockResolvedValue(jsonResponse(200, {}));
await fetchJson<unknown>("https://api/x", getToken(), { method: "POST", body: JSON.stringify({ a: 1 }) });
const headers1 = fetchMock().mock.calls[0][1].headers as Headers;
expect(headers1.get("Content-Type")).toBe("application/json");
await fetchOk("https://api/x", getToken(), { method: "PUT", body: "raw", headers: { "Content-Type": "text/plain" } });
const headers2 = fetchMock().mock.calls[1][1].headers as Headers;
expect(headers2.get("Content-Type")).toBe("text/plain");
await fetchOk("https://api/x", getToken(), { method: "DELETE" });
const headers3 = fetchMock().mock.calls[2][1].headers as Headers;
expect(headers3.has("Content-Type")).toBe(false);
});
it("fetchOk returns the response for status checks and throws on non-ok; fetchBlob returns the blob", async () => {
fetchMock().mockResolvedValueOnce(new Response("", { status: 202 }));
const res = await fetchOk("https://api/x", getToken(), { method: "POST" });
expect(res.status).toBe(202);
fetchMock().mockResolvedValueOnce(jsonResponse(409, { error: "Locked.", code: "Conflict", correlationId: "c" }));
await expect(fetchOk("https://api/x", getToken())).rejects.toBeInstanceOf(ApiError);
fetchMock().mockResolvedValueOnce(new Response(new Blob(["zip"]), { status: 200 }));
const blob = await fetchBlob("https://api/x", getToken());
expect(await blob.text()).toBe("zip");
});
it("lets TokenExpiredError pass through untouched", async () => {
const noToken = vi.fn().mockResolvedValue(null);
await expect(fetchJson<unknown>("https://api/x", noToken)).rejects.toBeInstanceOf(TokenExpiredError);
});
});
describe("describeApiError / apiErrorText / apiErrorNotification", () => {
it("renders an ApiError with the short correlation id as reference", () => {
const err = new ApiError(500, "GetSessions failed.", "InternalError", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
expect(describeApiError(err)).toEqual({ message: "GetSessions failed.", reference: "3f2a9c1e" });
expect(apiErrorText(err)).toBe("GetSessions failed. (Ref 3f2a9c1e)");
});
it("adds the retry advice on 429 and 503", () => {
const limited = new ApiError(429, "Rate limit exceeded.", "RateLimitExceeded", "cid", null, 12);
expect(describeApiError(limited).message).toBe("Rate limit exceeded. Try again in 12 s.");
const busy = new ApiError(503, "", "ServiceUnavailable", "cid");
expect(describeApiError(busy).message).toBe("Service temporarily unavailable. Try again shortly.");
});
it("has no reference for a token expiry or a plain Error, and uses the fallback for unknown values", () => {
expect(describeApiError(new TokenExpiredError()).reference).toBeNull();
expect(describeApiError(new Error("boom"))).toEqual({ message: "boom", reference: null });
expect(describeApiError(undefined, "Could not load.")).toEqual({ message: "Could not load.", reference: null });
expect(describeApiError(new ApiError(502, "", "", ""), "Upstream failed.").message).toBe("Upstream failed.");
expect(apiErrorText(new Error("boom"))).toBe("boom");
});
it("builds the notification: caller title and key, reference from the envelope; token expiry overrides both", () => {
const err = new ApiError(404, "Session not found.", "NotFound", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
expect(apiErrorNotification("Backend Error", err, "apps-list-error")).toEqual({
type: "error",
title: "Backend Error",
message: "Session not found.",
key: "apps-list-error",
reference: "3f2a9c1e",
});
expect(apiErrorNotification("Backend Error", new TokenExpiredError(), "apps-list-error")).toEqual({
type: "error",
title: "Session Expired",
message: new TokenExpiredError().message,
key: "session-expired",
reference: undefined,
});
expect(apiErrorNotification("Backend Error", "?", undefined, "Unable to load.").message).toBe("Unable to load.");
});
});
@@ -1,71 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, apiErrorFromResponse, describeApiError, fetchJson } from "../scopedFetch";
import { TokenExpiredError } from "../authenticatedFetch";
vi.mock("../appInsights", () => ({ trackEvent: vi.fn() }));
function jsonResponse(status: number, body: unknown, statusText = "Error"): Response {
return new Response(JSON.stringify(body), { status, statusText, headers: { "Content-Type": "application/json" } });
}
describe("apiErrorFromResponse", () => {
it("reads the error envelope: error, code, correlationId, hint", async () => {
const err = await apiErrorFromResponse(
jsonResponse(404, { error: "Session not found", code: "NotFound", correlationId: "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f", hint: "Check the id." }),
);
expect(err).toBeInstanceOf(ApiError);
expect(err.status).toBe(404);
expect(err.message).toBe("Session not found");
expect(err.code).toBe("NotFound");
expect(err.correlationId).toBe("3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
expect(err.hint).toBe("Check the id.");
});
it("falls back to the pre-envelope message and then to statusText", async () => {
const legacy = await apiErrorFromResponse(jsonResponse(400, { success: false, message: "tenantId is required" }, "Bad Request"));
expect(legacy.message).toBe("tenantId is required");
expect(legacy.code).toBe("");
expect(legacy.correlationId).toBe("");
const bare = await apiErrorFromResponse(new Response("not json", { status: 502, statusText: "Bad Gateway" }));
expect(bare.message).toBe("Bad Gateway");
expect(bare.hint).toBeNull();
});
});
describe("fetchJson", () => {
beforeEach(() => vi.stubGlobal("fetch", vi.fn()));
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("returns the parsed body on success and throws the envelope ApiError otherwise", async () => {
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
fetchMock.mockResolvedValueOnce(jsonResponse(200, { items: [1] }));
const getToken = vi.fn().mockResolvedValue("tok");
await expect(fetchJson<{ items: number[] }>("https://api/x", getToken)).resolves.toEqual({ items: [1] });
fetchMock.mockResolvedValueOnce(jsonResponse(403, { error: "Access denied.", code: "Forbidden", correlationId: "cid-1" }));
const err = await fetchJson("https://api/x", getToken).catch((e) => e);
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).code).toBe("Forbidden");
expect((err as ApiError).correlationId).toBe("cid-1");
});
});
describe("describeApiError", () => {
it("renders an ApiError with the short correlation id as reference", () => {
const err = new ApiError(500, "GetSessions failed.", "InternalError", "3f2a9c1e-7b4d-4e0a-9d2c-1a2b3c4d5e6f");
expect(describeApiError(err)).toEqual({ message: "GetSessions failed.", reference: "3f2a9c1e" });
});
it("has no reference for a token expiry or a plain Error, and uses the fallback for unknown values", () => {
expect(describeApiError(new TokenExpiredError()).reference).toBeNull();
expect(describeApiError(new Error("boom"))).toEqual({ message: "boom", reference: null });
expect(describeApiError(undefined, "Could not load.")).toEqual({ message: "Could not load.", reference: null });
expect(describeApiError(new ApiError(502, "", "", ""), "Upstream failed.").message).toBe("Upstream failed.");
});
});
@@ -0,0 +1,164 @@
import { authenticatedFetch, TokenExpiredError } from "./authenticatedFetch";
import { shortCorrelationId } from "./correlationId";
import type { ApiErrorResponse } from "@/utils/wire-types.generated";
/**
* The one API call layer above authenticatedFetch: ok-check, body parse and the backend's
* error envelope in one place, so no call site hand-rolls `if (!res.ok)`, `.json()` or an
* `instanceof TokenExpiredError` branch (guarded by lib/__tests__/apiClient.guard.test.ts).
*
* fetchJson<T> — JSON body expected; throws ApiError on non-2xx, empty or malformed body
* fetchOk — action calls whose body nobody reads; returns the ok Response (status only)
* fetchBlob — downloads
*
* Every non-2xx body is the typed envelope `{ error, code, correlationId, hint?,
* retryAfterSeconds? }` (D-192); TokenExpiredError from authenticatedFetch passes through
* untouched and is rendered by describeApiError like any other failure.
*/
export type GetAccessToken = (forceRefresh?: boolean) => Promise<string | null>;
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
/** Machine-readable code (Constants.ApiErrorCodes or a domain code class); "" when the body had none. */
public readonly code: string = "",
/** The request's correlation id — the handle for the backend log; "" when the body had none. */
public readonly correlationId: string = "",
public readonly hint: string | null = null,
/** Seconds the server asked us to wait (429/503); null when it sent none. */
public readonly retryAfterSeconds: number | null = null,
) {
super(message);
this.name = "ApiError";
}
}
/**
* What a non-2xx body may carry: the generated envelope plus `message`, which pre-envelope
* backends wrote. The `message` fallback is a deploy-window remnant (backend 1.5.1171 shipped the
* envelope on 2026-09-05); drop it once no older backend can answer this web build.
*/
type ErrorEnvelope = Partial<ApiErrorResponse> & { message?: unknown };
const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null);
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
function retryAfterFromHeader(response: Response): number | null {
// Platform CORS may not expose the header to browser script; the body's retryAfterSeconds
// is the primary source, this is the best-effort second one.
const raw = response.headers?.get?.("Retry-After");
if (!raw) return null;
const seconds = Number.parseInt(raw, 10);
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
}
/** Build an ApiError from a non-ok response: envelope fields when present, statusText otherwise. */
export async function apiErrorFromResponse(response: Response): Promise<ApiError> {
let body: ErrorEnvelope | null = null;
try {
const text = await response.text();
if (text.length > 0) body = JSON.parse(text) as ErrorEnvelope;
} catch {
/* not JSON */
}
const message = str(body?.error) ?? str(body?.message) ?? response.statusText;
return new ApiError(
response.status,
message,
str(body?.code) ?? "",
str(body?.correlationId) ?? "",
str(body?.hint),
num(body?.retryAfterSeconds) ?? retryAfterFromHeader(response),
);
}
/** A string body without an explicit Content-Type is JSON — every backend route deserialises JSON. */
function withJsonContentType(init: RequestInit | undefined): RequestInit | undefined {
if (typeof init?.body !== "string") return init;
const headers = new Headers(init.headers);
if (headers.has("Content-Type")) return init;
headers.set("Content-Type", "application/json");
return { ...init, headers };
}
/** Parse the JSON body of an ok response; an empty or malformed body is an ApiError, never `undefined`. */
export async function parseJsonBody<T>(response: Response): Promise<T> {
const text = await response.text();
if (text.length === 0) throw new ApiError(response.status, "Empty response body");
try {
return JSON.parse(text) as T;
} catch {
throw new ApiError(response.status, "Malformed response body");
}
}
export async function fetchJson<T>(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise<T> {
const response = await authenticatedFetch(url, getAccessToken, withJsonContentType(init));
if (!response.ok) throw await apiErrorFromResponse(response);
return parseJsonBody<T>(response);
}
/** For calls whose body nobody reads: resolves with the ok Response (for status checks), throws ApiError otherwise. */
export async function fetchOk(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise<Response> {
const response = await authenticatedFetch(url, getAccessToken, withJsonContentType(init));
if (!response.ok) throw await apiErrorFromResponse(response);
return response;
}
export async function fetchBlob(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise<Blob> {
const response = await authenticatedFetch(url, getAccessToken, init);
if (!response.ok) throw await apiErrorFromResponse(response);
return response.blob();
}
function retryAdvice(err: ApiError): string {
return err.retryAfterSeconds !== null && err.retryAfterSeconds > 0
? `Try again in ${err.retryAfterSeconds} s.`
: "Try again shortly.";
}
/**
* One rendering for any thrown error: the user-facing message plus, for backend failures,
* the short correlation id as a reference the user can quote to support. 429 and 503 add the
* server's retry advice. Pass `reference` to addNotification so the bell shows it.
*/
export function describeApiError(err: unknown, fallback = "Request failed."): { message: string; reference: string | null } {
if (err instanceof TokenExpiredError) return { message: err.message, reference: null };
if (err instanceof ApiError) {
let message = err.message || fallback;
if (err.status === 429) message = `${err.message || "Too many requests."} ${retryAdvice(err)}`;
else if (err.status === 503) message = `${err.message || "Service temporarily unavailable."} ${retryAdvice(err)}`;
return { message, reference: err.correlationId ? shortCorrelationId(err.correlationId) : null };
}
if (err instanceof Error && err.message) return { message: err.message, reference: null };
return { message: fallback, reference: null };
}
/** describeApiError as one string for inline error slots (`setError`): "message (Ref abcd1234)". */
export function apiErrorText(err: unknown, fallback = "Request failed."): string {
const { message, reference } = describeApiError(err, fallback);
return reference ? `${message} (Ref ${reference})` : message;
}
export interface ApiErrorNotification {
type: "error";
title: string;
message: string;
key: string | undefined;
reference: string | undefined;
}
/**
* The notification for a failed call. A token expiry always renders as "Session Expired" under
* one dedup key, whatever the caller's title — that is the one dialect every catch block used to
* hand-write.
*/
export function apiErrorNotification(title: string, err: unknown, key?: string, fallback?: string): ApiErrorNotification {
const { message, reference } = describeApiError(err, fallback);
if (err instanceof TokenExpiredError) {
return { type: "error", title: "Session Expired", message, key: "session-expired", reference: undefined };
}
return { type: "error", title, message, key, reference: reference ?? undefined };
}
@@ -16,8 +16,7 @@
* body stream is independently readable.
*/
import { authenticatedFetch } from './authenticatedFetch';
type GetAccessToken = (forceRefresh?: boolean) => Promise<string | null>;
import { apiErrorFromResponse, parseJsonBody, type GetAccessToken } from './apiClient';
const inFlight = new Map<string, Promise<Response>>();
@@ -47,6 +46,13 @@ export async function dedupedAuthFetch(
return (await pending).clone();
}
/** dedupedAuthFetch with the apiClient contract: ApiError on non-ok, parsed JSON body otherwise. */
export async function dedupedFetchJson<T>(url: string, getAccessToken: GetAccessToken, init?: RequestInit): Promise<T> {
const response = await dedupedAuthFetch(url, getAccessToken, init);
if (!response.ok) throw await apiErrorFromResponse(response);
return parseJsonBody<T>(response);
}
/**
* Test-only helper to clear the in-flight Map between tests so they don't
* leak state into one another.
@@ -1,74 +0,0 @@
import { authenticatedFetch, TokenExpiredError } from "./authenticatedFetch";
import { shortCorrelationId } from "./correlationId";
/**
* JSON fetch layer for the scope-migrated pages: authenticatedFetch + ok-check + body
* parse in one call, so call sites stop hand-rolling the response boilerplate. Throws
* ApiError on a non-ok response carrying the backend's error envelope
* (`{ error, code, correlationId, hint? }` — every non-2xx body since the 2026-09 envelope
* pass; TokenExpiredError from authenticatedFetch passes through untouched).
*/
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
/** Machine-readable code (Constants.ApiErrorCodes or a domain code class); "" when the body had none. */
public readonly code: string = "",
/** The request's correlation id — the handle for the backend log; "" when the body had none. */
public readonly correlationId: string = "",
public readonly hint: string | null = null,
) {
super(message);
this.name = "ApiError";
}
}
/** Shape of the backend error envelope (ApiErrorResponse and its specialised siblings). */
interface ErrorEnvelope {
error?: unknown;
code?: unknown;
correlationId?: unknown;
hint?: unknown;
/** Pre-envelope bodies used `message`; still read for the deploy window between backend and web. */
message?: unknown;
}
const str = (v: unknown): string | null => (typeof v === "string" && v.length > 0 ? v : null);
/** Build an ApiError from a non-ok response: envelope fields when present, statusText otherwise. */
export async function apiErrorFromResponse(response: Response): Promise<ApiError> {
let body: ErrorEnvelope | null = null;
try {
body = (await response.json()) as ErrorEnvelope;
} catch {
/* not JSON */
}
const message = str(body?.error) ?? str(body?.message) ?? response.statusText;
return new ApiError(response.status, message, str(body?.code) ?? "", str(body?.correlationId) ?? "", str(body?.hint));
}
export async function fetchJson<T>(
url: string,
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>,
init?: RequestInit,
): Promise<T> {
const response = await authenticatedFetch(url, getAccessToken, init);
if (!response.ok) {
throw await apiErrorFromResponse(response);
}
return (await response.json()) as T;
}
/**
* One rendering for any thrown error: the user-facing message plus, for backend failures,
* the short correlation id as a reference the user can quote to support. Pass `reference`
* to addNotification so the bell shows it.
*/
export function describeApiError(err: unknown, fallback = "Request failed."): { message: string; reference: string | null } {
if (err instanceof TokenExpiredError) return { message: err.message, reference: null };
if (err instanceof ApiError) {
return { message: err.message || fallback, reference: err.correlationId ? shortCorrelationId(err.correlationId) : null };
}
if (err instanceof Error && err.message) return { message: err.message, reference: null };
return { message: fallback, reference: null };
}