web: request bodies as jsonBody<T> against the generated wire types (D-207)

Every request body in app/, components/, contexts/, hooks/ and lib/ is encoded
as jsonBody<SomeRequest>(…) from lib/apiClient.ts; the apiClient ratchet gains
the `body[:=] JSON.stringify(` pattern (74 → 10 sites) and refuses a bare
jsonBody(. The hand-written RestoreRowRequestBody/RestoreRowMode are the
generated RestoreRowRequest/RestoreRowMode now; scriptDisplayNames posts through
fetchJson; the tenant-group and delegation mutate helpers take a pre-encoded body.

What the typing surfaced and fixes here: canManageBootstrapTokens and `all` are
sent explicitly, AdminConfiguration goes back on the wire through
toWireAdminConfiguration (the outbound twin of fromWire…), TenantManagementSection
uses the wire TenantConfiguration instead of a local subset, nameless CPE entries
are filtered before they are sent, the session action posts
QueueSessionActionRequest, create-from-template sends { variables }.

Remaining baseline (with reason): the three rule/pattern pages, whose local rule
types diverge from the wire — B-cme.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-06 11:42:24 +02:00
parent 00ad230ffe
commit 26cef3ab06
40 changed files with 305 additions and 238 deletions
@@ -4,9 +4,10 @@ import { useAuth } from "../../contexts/AuthContext";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchOk } from "@/lib/apiClient";
import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient";
import { DOCS_URL } from "@/utils/config";
import { BrandMark } from "../../components/BrandMark";
import type { SaveNotificationEmailRequest } from "@/utils/wire-types.generated";
// The URL query string is fixed for the lifetime of a page load — nothing to subscribe to.
const subscribeNever = () => () => {};
@@ -120,7 +121,7 @@ export default function ActivationPage() {
await fetchOk(api.preview.notificationEmail(), getAccessToken, {
method: "PUT",
body: JSON.stringify({ email }),
body: jsonBody<SaveNotificationEmailRequest>({ email }),
});
setEmailStatus("saved");
@@ -3,9 +3,16 @@
import { createContext, useCallback, useContext, useRef, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import { fromWireAdminConfiguration, type AdminConfiguration, type OpsAlertRule } from "@/types/adminConfig";
import type { AdminConfiguration as WireAdminConfiguration, GetAllPreviewNotificationEmailsResponse, GetPreviewWhitelistResponse, TestWebhookNotificationResponse, UpdateAdminConfigurationResponse } from "@/utils/wire-types.generated";
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
import { fromWireAdminConfiguration, toWireAdminConfiguration, type AdminConfiguration, type OpsAlertRule } from "@/types/adminConfig";
import type {
AdminConfiguration as WireAdminConfiguration,
GetAllPreviewNotificationEmailsResponse,
GetPreviewWhitelistResponse,
TestNotificationChannelRequest,
TestWebhookNotificationResponse,
UpdateAdminConfigurationResponse,
} from "@/utils/wire-types.generated";
import type { NotificationChannel } from "@/app/settings/types";
// Re-export so existing `import { AdminConfiguration } from "../AdminConfigContext"` consumers keep working
@@ -360,7 +367,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const result = await fetchJson<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
method: "PUT",
body: JSON.stringify(updatedConfig),
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
});
setAdminConfig(fromWireAdminConfiguration(result.config));
setSuccessMessage("Admin configuration saved successfully!");
@@ -440,7 +447,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const result = await fetchJson<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
method: "PUT",
body: JSON.stringify(updatedConfig),
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
});
setAdminConfig(fromWireAdminConfiguration(result.config));
setGlobalDiagPaths(paths);
@@ -484,7 +491,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const result = await fetchJson<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
method: "PUT",
body: JSON.stringify(updatedConfig),
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
});
setAdminConfig(fromWireAdminConfiguration(result.config));
setOpsAlertRules(rules);
@@ -515,7 +522,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const result = await fetchJson<TestWebhookNotificationResponse>(api.globalConfig.testOpsChannel(), getAccessToken, {
method: "POST",
body: JSON.stringify({ channelId }),
body: jsonBody<TestNotificationChannelRequest>({ channelId }),
});
setTestOpsChannelResult({
channelId,
@@ -1,14 +1,15 @@
"use client";
import { useState } from "react";
import { api, type RestoreRowRequestBody } from "@/lib/api";
import { api } from "@/lib/api";
import type {
RestoreRowCommitResponse,
RestoreRowRequest,
RestoreRowPreviewResponse,
RestoreRowPropertyDiff,
RestoreRowPropertySnapshot,
} from "@/utils/wire-types.generated";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
interface RestoreRowDiffModalProps {
backupId: string;
@@ -39,17 +40,17 @@ export function RestoreRowDiffModal({
setError(null);
setCommitting(true);
try {
const body: RestoreRowRequestBody = {
const body: RestoreRowRequest = {
tableName: preview.tableName,
partitionKey: preview.partitionKey,
rowKey: preview.rowKey,
mode: "Commit",
ifSha256: preview.rowSha256,
ifCurrentETag: preview.currentETag ?? null,
ifCurrentETag: preview.currentETag ?? undefined,
};
const response = await fetchJson<RestoreRowCommitResponse>(api.backups.restoreRow(backupId), getAccessToken, {
method: "POST",
body: JSON.stringify(body),
body: jsonBody<RestoreRowRequest>(body),
});
onCommitted(response);
} catch (err) {
@@ -7,10 +7,9 @@ import {
api,
type BackupManifest,
type BackupTableEntry,
type RestoreRowRequestBody,
} from "@/lib/api";
import type { RestoreRowCommitResponse, RestoreRowPreviewResponse } from "@/utils/wire-types.generated";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import type { RestoreRowCommitResponse, RestoreRowPreviewResponse, RestoreRowRequest } from "@/utils/wire-types.generated";
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
import { useAdminConfig } from "../../AdminConfigContext";
import { AdminNotifications } from "../../AdminNotifications";
import { RestoreRowDiffModal } from "../components/RestoreRowDiffModal";
@@ -66,7 +65,7 @@ function BackupDetailContent() {
setError(null);
setPreviewLoading(true);
try {
const body: RestoreRowRequestBody = {
const body: RestoreRowRequest = {
tableName,
partitionKey: pk,
rowKey: rk,
@@ -74,7 +73,7 @@ function BackupDetailContent() {
};
const preview = await fetchJson<RestoreRowPreviewResponse>(api.backups.restoreRow(backupId), getAccessToken, {
method: "POST",
body: JSON.stringify(body),
body: jsonBody<RestoreRowRequest>(body),
});
setActivePreview(preview);
} catch (err) {
@@ -5,14 +5,14 @@ import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { trackEvent } from "@/lib/appInsights";
import { TenantConfiguration } from "./TenantManagementSection";
import { TenantSearchSelect } from "./TenantSearchSelect";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { firstBlockedSessionId, blockedSessionCount } from "./blockedDeviceHelpers";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type { BlockedDeviceEntry, BlockedDeviceListResponse, GetSessionResponse } from "@/utils/wire-types.generated";
import type { BlockDeviceRequest, BlockedDeviceEntry, BlockedDeviceListResponse, GetSessionResponse } from "@/utils/wire-types.generated";
interface ResolvedDevice {
sessionId: string;
@@ -176,7 +176,7 @@ function DeviceBlockSectionInner({
setError(null);
await fetchOk(api.devices.block(), getAccessToken, {
method: "POST",
body: JSON.stringify({
body: jsonBody<BlockDeviceRequest>({
tenantId: blockTenantId,
serialNumber: blockSerialNumber.trim(),
durationHours: blockDurationHours,
@@ -239,7 +239,7 @@ function DeviceBlockSectionInner({
const remainingHours = Math.max(1, Math.ceil((unblockAtMs - Date.now()) / 3600000));
await fetchOk(api.devices.block(), getAccessToken, {
method: "POST",
body: JSON.stringify({
body: jsonBody<BlockDeviceRequest>({
tenantId: device.tenantId,
serialNumber: device.serialNumber,
durationHours: remainingHours,
@@ -2,10 +2,10 @@
import { useCallback, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { isGuid, type IdentityBinding } from "@/lib/identityBinding";
import type { IdentityBindingListResponse } from "@/utils/wire-types.generated";
import type { IdentityBindingListResponse, IdentityBindingRequest } from "@/utils/wire-types.generated";
interface IdentityBindingsSectionProps {
tenantId: string;
@@ -80,7 +80,7 @@ export function IdentityBindingsSection({
setError(null);
await fetchOk(api.identityBindings.put(upn), getAccessToken, {
method: "PUT",
body: JSON.stringify({ homeTenantId: tid, objectId: oid || undefined }),
body: jsonBody<IdentityBindingRequest>({ homeTenantId: tid, objectId: oid || undefined }),
});
setSuccessMessage(`Identity binding for ${upn} updated.`);
setEditing(null);
@@ -2,12 +2,12 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import TruncatedLabel from "@/components/TruncatedLabel";
import { trackEvent } from "@/lib/appInsights";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import type { AutoResolveResult, IgnoredSoftwareEntry } from "./SoftwareMappingTypes";
import type { GetIgnoredSoftwareResponse } from "@/utils/wire-types.generated";
import type { AutoResolveCpeMappingRequest, GetIgnoredSoftwareResponse, IgnoreSoftwareDeleteRequest } from "@/utils/wire-types.generated";
interface IgnoredSoftwareTabProps {
getAccessToken: () => Promise<string | null>;
@@ -73,7 +73,7 @@ export function IgnoredSoftwareTab({
setError(null);
await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, {
method: "DELETE",
body: JSON.stringify({ softwareName: entry.softwareName, publisher: entry.publisher || "" }),
body: jsonBody<IgnoreSoftwareDeleteRequest>({ softwareName: entry.softwareName, publisher: entry.publisher || "" }),
});
setIgnoredEntries((prev) => {
const next = prev.filter((e) => `${e.softwareName}::${e.publisher}` !== key);
@@ -97,7 +97,7 @@ export function IgnoredSoftwareTab({
// First, call auto-resolve for this single item
const data = await fetchJson<AutoResolveResult>(api.vulnerability.cpeAutoResolve(), getAccessToken, {
method: "POST",
body: JSON.stringify({
body: jsonBody<AutoResolveCpeMappingRequest>({
items: [{ softwareName: entry.softwareName, publisher: entry.publisher || "", normalizedVendor: entry.publisher || "" }],
}),
});
@@ -106,7 +106,7 @@ export function IgnoredSoftwareTab({
// Resolved! Remove from ignore list and refresh
await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, {
method: "DELETE",
body: JSON.stringify({ softwareName: entry.softwareName, publisher: entry.publisher || "" }),
body: jsonBody<IgnoreSoftwareDeleteRequest>({ softwareName: entry.softwareName, publisher: entry.publisher || "" }),
});
setIgnoredEntries((prev) => {
const next = prev.filter((e) => `${e.softwareName}::${e.publisher}` !== key);
@@ -2,11 +2,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { trackEvent } from "@/lib/appInsights";
import { CpeMappingEntry } from "./SoftwareMappingTypes";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import type { GetCpeMappingsResponse } from "@/utils/wire-types.generated";
import type { DeleteCustomCpeMappingRequest, GetCpeMappingsResponse, SaveCustomCpeMappingRequest } from "@/utils/wire-types.generated";
interface MappedSoftwareTabProps {
getAccessToken: () => Promise<string | null>;
@@ -176,7 +176,7 @@ export function MappedSoftwareTab({
getAccessToken,
{
method: "POST",
body: JSON.stringify({
body: jsonBody<SaveCustomCpeMappingRequest>({
normalizedProduct: entry.normalizedProduct,
normalizedVendor: entry.normalizedVendor,
cpeUri: cpeUri,
@@ -214,7 +214,7 @@ export function MappedSoftwareTab({
getAccessToken,
{
method: "DELETE",
body: JSON.stringify({
body: jsonBody<DeleteCustomCpeMappingRequest>({
normalizedProduct: entry.normalizedProduct,
normalizedVendor: entry.normalizedVendor,
}),
@@ -3,7 +3,7 @@
import { Suspense, useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk, nullOn404 } from "@/lib/apiClient";
import { apiErrorText, fetchJson, fetchOk, jsonBody, nullOn404 } from "@/lib/apiClient";
import TruncatedLabel from "@/components/TruncatedLabel";
import { extractContinuation } from "@/lib/paginationLink";
import { isGuid } from "@/utils/inputValidation";
@@ -11,7 +11,7 @@ import { trackEvent } from "@/lib/appInsights";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type { SessionReportDownloadUrlResponse, SessionReportListResponse, SessionReportMetadata } from "@/utils/wire-types.generated";
import type { SessionReportDownloadUrlResponse, SessionReportListResponse, SessionReportMetadata, UpdateSessionReportNoteRequest } from "@/utils/wire-types.generated";
const PAGE_SIZE = 20;
@@ -100,7 +100,7 @@ function AdminNoteEditor({
getAccessToken,
{
method: "PATCH",
body: JSON.stringify({ adminNote: adminNoteValue }),
body: jsonBody<UpdateSessionReportNoteRequest>({ adminNote: adminNoteValue }),
}
);
@@ -2,9 +2,9 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import type { TenantAdminRow } from "@/utils/wire-types.generated";
import type { AddTenantAdminRequest, TenantAdminRow, UpdateMemberPermissionsRequest } from "@/utils/wire-types.generated";
import { isApplicationKey, looksLikeGuid, principalLabel, type MemberKind } from "@/utils/principalKeys";
// Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows).
@@ -72,11 +72,11 @@ export function TenantAdminSection({
setError(null);
const body = addingApplication
? { applicationId: newAdminEmail.trim(), role: "Viewer" }
: { upn: newAdminEmail.trim(), role: newMemberRole };
? { applicationId: newAdminEmail.trim(), role: "Viewer", canManageBootstrapTokens: false }
: { upn: newAdminEmail.trim(), role: newMemberRole, canManageBootstrapTokens: false };
await fetchOk(api.tenants.admins(tenantId), getAccessToken, {
method: "POST",
body: JSON.stringify(body),
body: jsonBody<AddTenantAdminRequest>(body),
});
setSuccessMessage(addingApplication
@@ -158,7 +158,7 @@ export function TenantAdminSection({
await fetchOk(api.tenants.adminPermissions(tenantId, adminUpn), getAccessToken, {
method: "PATCH",
body: JSON.stringify({ role, canManageBootstrapTokens }),
body: jsonBody<UpdateMemberPermissionsRequest>({ role, canManageBootstrapTokens }),
});
setSuccessMessage(`Permissions for ${adminUpn} updated successfully!`);
@@ -1,10 +1,25 @@
"use client";
import { Suspense, useState, useEffect } from "react";
import type { AppHomingDeniedResponse, DelegatedSlotUsageResponse, GetPreviewNotificationEmailResponse, OffboardResponse, PlanTierDefinitionsResponse, PreviewWhitelistActionResponse, SetTenantPlanTierResponse, UpdateTenantAppHomingResponse, UpdateTenantConfigurationResponse } from "@/utils/wire-types.generated";
import type {
AppHomingDeniedResponse,
AppHomingRequest,
DelegatedSlotUsageResponse,
GetPreviewNotificationEmailResponse,
OffboardResponse,
PatchTenantPlanRequest,
PlanTierDefinitionsResponse,
PreviewWhitelistActionResponse,
ReleaseDelegatedSlotHoldRequest,
SaveNotificationEmailRequest,
SetTenantPlanTierResponse,
TenantConfiguration as WireTenantConfiguration,
UpdateTenantAppHomingResponse,
UpdateTenantConfigurationResponse,
} from "@/utils/wire-types.generated";
import { useSearchParams } from "next/navigation";
import { api } from "@/lib/api";
import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { classifyClientId, legacyConfigured } from "@/lib/authApp";
import { appHomingErrorMessage } from "@/lib/appHoming";
import { trackEvent } from "@/lib/appInsights";
@@ -23,62 +38,12 @@ function effectiveEditionLabel(edition: unknown, source: unknown): string {
return "Pro";
}
export interface TenantConfiguration {
tenantId: string;
domainName: string;
lastUpdated: string;
updatedBy: string;
/** Tenant-maintained contact address for service matters. Read-only here. */
contactEmail?: string | null;
/** Tenant-maintained company name (contact profile). Read-only here. */
companyName?: string | null;
disabled: boolean;
disabledReason?: string;
disabledUntil?: string;
/** Per-tenant device API rate-limit override (null/undefined = inherit global). GA-only. */
customRateLimitRequestsPerMinute?: number | null;
/** Per-tenant user API rate-limit override for standard users (null/undefined = inherit global). GA-only. */
customUserRateLimitRequestsPerMinute?: number | null;
manufacturerWhitelist: string;
modelWhitelist: string;
validateAutopilotDevice: boolean;
allowInsecureAgentRequests?: boolean;
bootstrapTokenEnabled?: boolean;
unrestrictedModeEnabled?: boolean;
entraAppRolesEnabled?: boolean;
/** Operator-set (GA-only): Device-phase ESP failures on Continue-Anyway profiles get a 60-min observation instead of an immediate hard fail. */
enableEspContinueAnywayObservation?: boolean;
dataRetentionDays: number;
sessionTimeoutHours: number;
planTier?: string;
/** Pro-trial end (ISO, UTC). Null/undefined = no trial. Managed via PATCH plan. */
trialExpiresUtc?: string | null;
/** Whether the tenant has used its one self-service trial. */
trialConsumed?: boolean;
/** Delegated (MSP) tenant slot override; null/undefined = plan entitlement (Community 0, Pro 2). Managed via PATCH plan. */
maxDelegatedTenantsOverride?: number | null;
/**
* MCP usage-plan override — a SectionUsagePlans plan name applied to the WHOLE tenant (every member's
* default plan + the organization windows); null/undefined = edition default. Managed via PATCH plan.
*/
mcpUsagePlanOverride?: string | null;
/** Sales bookkeeping: the tenant pays for its plan. No entitlement effect. Managed via PATCH plan. */
payingCustomer?: boolean;
/**
* Read-time projection (never stored): the permanent-Pro tenant whose delegation confers Pro on this
* one, or null. While set the effective edition is "Pro (MSP)".
*/
managedByProTenantId?: string | null;
/**
* Dual app-reg homing: null/undefined = legacy app. Typed explicitly so a payload refactor
* cannot silently drop the field on the generic PUT round-trip (absent ⇒ backend resets to
* legacy). Mutated ONLY via POST app-homing — the PUT preserves it server-side.
*/
homedAppClientId?: string | null;
/** System-written login provenance (AuthFunction) — read-only observability. */
lastAuthClientId?: string | null;
lastAuthClientIdSince?: string | null;
}
/**
* The tenant row as the backend serves and accepts it (PUT config/{tenantId} sends the whole
* object back, PATCH plan the presence-sensitive subset). The former local subset hid fields
* the PUT carries anyway; D-207 types the body against the wire.
*/
export type TenantConfiguration = WireTenantConfiguration;
/** Small "New app" / "Legacy app" indicator for the dual app-reg parallel window. */
function HomingBadge({ clientId }: { clientId?: string | null }) {
@@ -170,7 +135,7 @@ function TenantManagementSectionInner({
setError(null);
await fetchOk(api.delegatedSlots.releaseHold(tenantId), getAccessToken, {
method: "POST",
body: JSON.stringify({ invitationId }),
body: jsonBody<ReleaseDelegatedSlotHoldRequest>({ invitationId, all: false }),
});
setSlotUsage((prev) => prev
? { ...prev, holds: prev.holds.filter((h) => h.invitationId !== invitationId), used: Math.max(0, prev.used - 1) }
@@ -270,7 +235,7 @@ function TenantManagementSectionInner({
const result = await fetchJson<UpdateTenantConfigurationResponse>(api.config.tenant(tenant.tenantId), getAccessToken, {
method: "PUT",
body: JSON.stringify(tenant),
body: jsonBody<TenantConfiguration>(tenant),
});
// Update tenant in list
@@ -299,7 +264,7 @@ function TenantManagementSectionInner({
const result = await fetchJson<SetTenantPlanTierResponse>(api.config.plan(tenant.tenantId), getAccessToken, {
method: "PATCH",
body: JSON.stringify({
body: jsonBody<PatchTenantPlanRequest>({
// The select only offers the two canonical write values (community/pro); legacy stored
// "enterprise" normalizes to "pro" on save, legacy "free" to "community".
planTier: tenant.planTier === "pro" || tenant.planTier === "enterprise" ? "pro" : "community",
@@ -343,7 +308,7 @@ function TenantManagementSectionInner({
try {
data = await fetchJson<UpdateTenantAppHomingResponse>(api.config.appHoming(tenant.tenantId), getAccessToken, {
method: "POST",
body: JSON.stringify({ target, force }),
body: jsonBody<AppHomingRequest>({ target, force }),
});
} catch (err) {
if (err instanceof ApiError) {
@@ -435,7 +400,7 @@ function TenantManagementSectionInner({
getAccessToken,
{
method: "POST",
body: JSON.stringify({ email: email || "" }),
body: jsonBody<SaveNotificationEmailRequest>({ email: email || "" }),
}
);
// The send persisted the address too — keep the search map in step so the tenant is
@@ -3,12 +3,12 @@
import { sessionUrl } from "@/lib/routes";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import TruncatedLabel from "@/components/TruncatedLabel";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import { trackEvent } from "@/lib/appInsights";
import type { UnmatchedSoftwareEntry, AutoResolveResult } from "./SoftwareMappingTypes";
import type { GetUnmatchedSoftwareResponse } from "@/utils/wire-types.generated";
import type { AutoResolveCpeMappingRequest, GetUnmatchedSoftwareResponse, IgnoreSoftwareRequest, SaveCustomCpeMappingRequest } from "@/utils/wire-types.generated";
interface UnmappedSoftwareTabProps {
getAccessToken: () => Promise<string | null>;
@@ -166,7 +166,12 @@ export function UnmappedSoftwareTab({
}
};
/** The backend keys mappings and ignores by software name; a nameless entry has nothing to send. */
const hasSoftwareName = (e: UnmatchedSoftwareEntry): e is UnmatchedSoftwareEntry & { softwareName: string } =>
typeof e.softwareName === "string" && e.softwareName.length > 0;
const handleSaveMapping = async (entry: UnmatchedSoftwareEntry) => {
if (!hasSoftwareName(entry)) return;
const key = getRowKey(entry);
const cpeUri = (cpeInputs[key] || "").trim();
if (!cpeUri) return;
@@ -180,7 +185,7 @@ export function UnmappedSoftwareTab({
getAccessToken,
{
method: "POST",
body: JSON.stringify({
body: jsonBody<SaveCustomCpeMappingRequest>({
normalizedProduct: entry.softwareName,
normalizedVendor: entry.publisher || "",
cpeUri: cpeUri,
@@ -265,8 +270,8 @@ export function UnmappedSoftwareTab({
getAccessToken,
{
method: "POST",
body: JSON.stringify({
items: batches[i].map((e) => ({
body: jsonBody<AutoResolveCpeMappingRequest>({
items: batches[i].filter(hasSoftwareName).map((e) => ({
softwareName: e.softwareName,
publisher: e.publisher,
normalizedVendor: e.normalizedVendor || e.publisher || "",
@@ -310,13 +315,14 @@ export function UnmappedSoftwareTab({
// --- Ignore ---
const handleIgnoreSoftware = async (entry: UnmatchedSoftwareEntry) => {
if (!hasSoftwareName(entry)) return;
const key = getRowKey(entry);
try {
setIgnoringRow(key);
setError(null);
await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, {
method: "POST",
body: JSON.stringify({
body: jsonBody<IgnoreSoftwareRequest>({
items: [{ softwareName: entry.softwareName, publisher: entry.publisher || "", reason: "manual" }],
}),
});
@@ -340,8 +346,8 @@ export function UnmappedSoftwareTab({
setError(null);
await fetchOk(api.vulnerability.ignoredSoftware(), getAccessToken, {
method: "POST",
body: JSON.stringify({
items: selectedList.map((e) => ({ softwareName: e.softwareName, publisher: e.publisher || "", reason: "manual" })),
body: jsonBody<IgnoreSoftwareRequest>({
items: selectedList.filter(hasSoftwareName).map((e) => ({ softwareName: e.softwareName, publisher: e.publisher || "", reason: "manual" })),
}),
});
setSelectedEntries(new Map());
@@ -2,10 +2,10 @@
import { useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type { BlockedVersionListResponse } from "@/utils/wire-types.generated";
import type { BlockedVersionListResponse, BlockVersionRequest } from "@/utils/wire-types.generated";
interface BlockedVersion {
versionPattern: string;
@@ -61,7 +61,7 @@ export function VersionBlockSection({
setError(null);
await fetchOk(api.versions.block(), getAccessToken, {
method: "POST",
body: JSON.stringify({
body: jsonBody<BlockVersionRequest>({
versionPattern: versionPattern.trim(),
action: versionAction,
reason: versionReason.trim() || undefined,
@@ -2,13 +2,21 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
import SaveResetBar from "../../settings/components/SaveResetBar";
import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel";
import { fromWireAdminConfiguration, type AdminConfiguration } from "@/types/adminConfig";
import { fromWireAdminConfiguration, toWireAdminConfiguration, type AdminConfiguration } from "@/types/adminConfig";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type { AdminConfiguration as WireAdminConfiguration, GetVulnerabilitySyncStatusResponse, TriggerEpssSyncResponse, TriggerMsrcSyncResponse, TriggerNvdCacheRefreshResponse, TriggerVulnerabilityDataSyncResponse, UpdateAdminConfigurationResponse } from "@/utils/wire-types.generated";
import type {
AdminConfiguration as WireAdminConfiguration,
GetVulnerabilitySyncStatusResponse,
TriggerEpssSyncResponse,
TriggerMsrcSyncResponse,
TriggerNvdCacheRefreshResponse,
TriggerVulnerabilityDataSyncResponse,
UpdateAdminConfigurationResponse,
} from "@/utils/wire-types.generated";
interface VulnerabilityTopCve {
cveId: string;
@@ -181,7 +189,7 @@ export function VulnerabilityDataSection({
const result = await fetchJson<UpdateAdminConfigurationResponse>(api.globalConfig.get(), getAccessToken, {
method: "PUT",
body: JSON.stringify(updatedConfig),
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
});
onAdminConfigUpdated(fromWireAdminConfiguration(result.config));
setSuccessMessage("Vulnerability configuration saved successfully!");
@@ -2,8 +2,8 @@
import { useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import type { SessionRestoreResponse } from "@/utils/wire-types.generated";
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
import type { RestoreSessionRequest, SessionRestoreResponse } from "@/utils/wire-types.generated";
interface RestoreConfirmDialogProps {
tenantId: string;
@@ -52,7 +52,7 @@ export function RestoreConfirmDialog({
getAccessToken,
{
method: "POST",
body: JSON.stringify({
body: jsonBody<RestoreSessionRequest>({
tenantId,
manifestId,
reason: reason.trim() || null,
@@ -3,13 +3,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useTenantList } from "@/hooks/useTenantList";
import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { api } from "@/lib/api";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
import { parseSlotLimitError, type SlotLimitError } from "@/lib/delegatedSlots";
import { DelegatedSlotPrompt, raiseDelegatedSlotLimit } from "@/components/DelegatedSlotPrompt";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type { DelegatedAdminListResponse } from "@/utils/wire-types.generated";
import type { DelegatedAdminListResponse, GrantDelegatedAdminRequest } from "@/utils/wire-types.generated";
/** One delegated-admin assignment as returned by /api/global/delegated-admins (camelCase JSON). */
interface DelegatedAssignment {
@@ -97,7 +97,7 @@ export function SectionDelegatedAdmins() {
try {
await fetchOk(api.delegatedAdmins.grant(), getAccessToken, {
method: "POST",
body: JSON.stringify({
body: jsonBody<GrantDelegatedAdminRequest>({
upn,
tenantId: newTenantId,
role: newRole,
@@ -2,10 +2,10 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { fetchJson, fetchOk } from "@/lib/apiClient";
import { fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { useAdminConfig } from "../../AdminConfigContext";
import { AdminNotifications } from "../../AdminNotifications";
import type { EmailTemplateTestSendResponse } from "@/utils/wire-types.generated";
import type { EmailTemplateRequest, EmailTemplateTestSendResponse } from "@/utils/wire-types.generated";
type TemplateKind = "welcome" | "farewell";
@@ -122,7 +122,7 @@ function TemplateCard({ kind, title, description, getAccessToken, setError, setS
setError(null);
await fetchOk(api.emailTemplates.save(kind), getAccessToken, {
method: "PUT",
body: JSON.stringify({ html: draft }),
body: jsonBody<EmailTemplateRequest>({ html: draft }),
});
flash(`${title} saved — real sends use the customized HTML from now on.`);
setEditing(false);
@@ -156,7 +156,7 @@ function TemplateCard({ kind, title, description, getAccessToken, setError, setS
setError(null);
const body = await fetchJson<EmailTemplateTestSendResponse>(api.emailTemplates.sendTest(kind), getAccessToken, {
method: "POST",
body: JSON.stringify(useDraft ? { html: draft } : {}),
body: jsonBody<EmailTemplateRequest>(useDraft ? { html: draft } : {}),
});
flash(`Test ${title.toLowerCase()} sent to ${body?.sentTo ?? "your tenant contact address"}${useDraft ? " (unsaved draft)" : ""}.`);
} catch (err) {
@@ -3,17 +3,24 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useTenantList } from "@/hooks/useTenantList";
import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { api } from "@/lib/api";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type { TenantGroup, TenantGroupListResponse } from "@/utils/wire-types.generated";
import type {
AddGroupTenantRequest,
AssignGroupRequest,
CreateTenantGroupRequest,
TenantGroup,
TenantGroupListResponse,
UpdateTenantGroupRequest,
} from "@/utils/wire-types.generated";
import { parseSlotLimitError, type SlotLimitError } from "@/lib/delegatedSlots";
import { DelegatedSlotPrompt, raiseDelegatedSlotLimit } from "@/components/DelegatedSlotPrompt";
/** A mutation held back by the slot limit, with everything needed to replay it after the limit is raised. */
interface SlotPromptState extends SlotLimitError {
retry: { key: string; url: string; method: string; body: unknown; ok: string };
retry: { key: string; url: string; method: string; body: string | undefined; ok: string };
}
const ROLE_LABELS: Record<string, string> = {
@@ -83,14 +90,14 @@ export function SectionTenantGroups() {
void run();
}, [fetchGroups]);
/** Runs a mutation with shared busy/error/refetch handling. `body` undefined ⇒ no JSON body. */
/** Runs a mutation with shared busy/error/refetch handling. `body` is a jsonBody<T>() string; undefined ⇒ no JSON body. */
const mutate = useCallback(
async (key: string, url: string, method: string, body: unknown, ok: string) => {
async (key: string, url: string, method: string, body: string | undefined, ok: string) => {
try {
setBusyKey(key);
setError(null);
const init: RequestInit = { method };
if (body !== undefined) init.body = JSON.stringify(body);
if (body !== undefined) init.body = body;
try {
await fetchOk(url, getAccessToken, init);
} catch (err) {
@@ -139,7 +146,7 @@ export function SectionTenantGroups() {
const name = newName.trim();
if (!name) return;
setCreating(true);
const ok = await mutate("create", api.tenantGroups.create(), "POST", { name }, `Created group "${name}".`);
const ok = await mutate("create", api.tenantGroups.create(), "POST", jsonBody<CreateTenantGroupRequest>({ name }), `Created group "${name}".`);
setCreating(false);
if (ok) setNewName("");
}, [newName, mutate]);
@@ -148,7 +155,7 @@ export function SectionTenantGroups() {
async (t: TenantGroup) => {
const name = prompt("Rename group:", t.name)?.trim();
if (!name || name === t.name) return;
await mutate(`rename:${t.groupId}`, api.tenantGroups.update(t.groupId), "PATCH", { name }, `Renamed to "${name}".`);
await mutate(`rename:${t.groupId}`, api.tenantGroups.update(t.groupId), "PATCH", jsonBody<UpdateTenantGroupRequest>({ name }), `Renamed to "${name}".`);
},
[mutate],
);
@@ -165,7 +172,7 @@ export function SectionTenantGroups() {
`charge:${t.groupId}`,
api.tenantGroups.update(t.groupId),
"PATCH",
{ chargeHomeTenantQuota: next },
jsonBody<UpdateTenantGroupRequest>({ chargeHomeTenantQuota: next }),
next
? `"${t.name}": MCP reads are now charged to the assignee's home tenant.`
: `"${t.name}": MCP reads are now charged to the managed tenant.`,
@@ -191,7 +198,7 @@ export function SectionTenantGroups() {
`addtenant:${t.groupId}`,
api.tenantGroups.addTenant(t.groupId),
"POST",
{ tenantId },
jsonBody<AddGroupTenantRequest>({ tenantId }),
`Added ${domainOf(tenantId) || tenantId} to "${t.name}".`,
);
if (ok) setTenantToAdd((prev) => ({ ...prev, [t.groupId]: "" }));
@@ -230,11 +237,11 @@ export function SectionTenantGroups() {
`assign:${t.groupId}`,
api.tenantGroups.assign(t.groupId),
"POST",
{
jsonBody<AssignGroupRequest>({
upn,
role,
homeTenantId: needHomeTenant[t.groupId] ? homeTenantPick[t.groupId] : undefined,
},
}),
`Assigned ${upn} to "${t.name}".`,
);
if (ok) {
@@ -3,10 +3,10 @@
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../../../../contexts/AuthContext";
import { useAdminConfig } from "../../AdminConfigContext";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
import { api } from "@/lib/api";
import { TableSkeleton } from "@/components/skeletons/TableSkeleton";
import type { PlanTierDefinitionsResponse } from "@/utils/wire-types.generated";
import type { PlanTierDefinitionsResponse, SetPlanTierDefinitionsRequest } from "@/utils/wire-types.generated";
interface PlanTierDefinition {
name: string;
@@ -88,7 +88,7 @@ export function SectionUsagePlans() {
const data = await fetchJson<PlanTierDefinitionsResponse>(api.mcpUsage.planTiers(), getAccessToken, {
method: "PUT",
body: JSON.stringify({ tiers }),
body: jsonBody<SetPlanTierDefinitionsRequest>({ tiers }),
});
setTiers(data.tiers || tiers);
setHasChanges(false);
@@ -33,8 +33,8 @@ import TemplateConfigModal from "./components/TemplateConfigModal";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { DocsLink } from "@/components/DocsLink";
import type { RuleStatsResponse, TenantConfiguration } from "@/utils/wire-types.generated";
import { fetchJson } from "@/lib/apiClient";
import type { CreateAnalyzeRuleFromTemplateRequest, RuleStatsResponse, TenantConfiguration } from "@/utils/wire-types.generated";
import { fetchJson, jsonBody } from "@/lib/apiClient";
export default function AnalyzeRulesPage() {
const { user, getAccessToken } = useAuth();
@@ -437,7 +437,7 @@ export default function AnalyzeRulesPage() {
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(variables),
body: jsonBody<CreateAnalyzeRuleFromTemplateRequest>({ variables }),
}
);
if (result !== null) {
@@ -4,7 +4,8 @@ import { api } from "@/lib/api";
import { TokenExpiredError } from "@/lib/authenticatedFetch";
import { NotificationType } from "@/contexts/NotificationContext";
import { BULK_CONCURRENCY, runWithConcurrency, summarizeBlockOutcomes, type BlockOutcome } from "./bulkActions";
import { ApiError, fetchOk } from "@/lib/apiClient";
import { ApiError, fetchOk, jsonBody } from "@/lib/apiClient";
import type { BlockDeviceRequest } from "@/utils/wire-types.generated";
export interface BlockTarget {
serialNumber: string;
@@ -50,7 +51,7 @@ export function useBlockDevice(
try {
await fetchOk(api.devices.block(), getAccessToken, {
method: 'POST',
body: JSON.stringify({
body: jsonBody<BlockDeviceRequest>({
tenantId: target.tenantId,
serialNumber: target.serialNumber,
durationHours: 24,
@@ -6,8 +6,8 @@ import { useSearchParams } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import { describeDelegationError, invitationStatusLabel } from "@/lib/delegations";
import type { AcceptDelegationInvitationResponse, DelegationAcceptPreviewResponse } from "@/utils/wire-types.generated";
import { ApiError, apiErrorText, fetchJson } from "@/lib/apiClient";
import type { AcceptDelegationInvitationRequest, AcceptDelegationInvitationResponse, DelegationAcceptPreviewResponse } from "@/utils/wire-types.generated";
import { ApiError, apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
export default function AcceptDelegationPage() {
// useSearchParams needs a Suspense boundary for the static prerender (query-string route).
@@ -70,7 +70,7 @@ function AcceptDelegationInner() {
try {
setDone(await fetchJson<AcceptDelegationInvitationResponse>(api.delegations.accept(), getAccessToken, {
method: "POST",
body: JSON.stringify({ token }),
body: jsonBody<AcceptDelegationInvitationRequest>({ token }),
}));
} catch (err) {
setError(explain(err, "Could not accept the invitation."));
@@ -17,6 +17,7 @@ import ImePatternCard from "./components/ImePatternCard";
import { trackEvent } from "@/lib/appInsights";
import { DocsLink } from "@/components/DocsLink";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { } from "@/lib/apiClient";
export default function ImeLogPatternsPage() {
const { tenantId } = useTenant();
@@ -3,9 +3,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { EnrollmentEvent } from "@/types";
import { api } from "@/lib/api";
import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { NotificationType } from "@/contexts/NotificationContext";
import type { TenantConfiguration } from "@/utils/wire-types.generated";
import type { QueueSessionActionRequest, TenantConfiguration } from "@/utils/wire-types.generated";
import {
CollectPhase,
COLLECT_TIMEOUT_MS,
@@ -78,7 +78,7 @@ export default function CollectLogsButton({
const queueAction = useCallback(async (type: string, reason: string): Promise<boolean> => {
return fetchOk(api.sessions.queueAction(sessionId, effectiveTenantId), getAccessToken, {
method: "POST",
body: JSON.stringify({ type, reason }),
body: jsonBody<QueueSessionActionRequest>({ type, reason }),
}).then(
() => true,
(err: unknown) => {
@@ -142,7 +142,7 @@ export default function CollectLogsButton({
await fetchOk(api.config.tenantCollectLogsQuickConfig(effectiveTenantId), getAccessToken, {
method: "PUT",
body: JSON.stringify(updated),
body: jsonBody<TenantConfiguration>(updated),
});
onDiagnosticsConfigured();
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
import { useAdminMode } from "@/hooks/useAdminMode";
import {
ANNOTATION_MAX_NOTE_LENGTH,
@@ -21,6 +21,7 @@ import {
type AnnotationVerdict,
type SessionAnnotationDto,
} from "./sessionAnnotationLogic";
import type { UpsertSessionAnnotationRequest } from "@/utils/wire-types.generated";
/**
* Session annotations: per-lane human verdict + note about this enrollment's analysis
@@ -131,7 +132,7 @@ export default function SessionAnnotationsCard({
getAccessToken,
{
method: "PUT",
body: JSON.stringify(body),
body: jsonBody<UpsertSessionAnnotationRequest>(body),
}
);
setAnnotations((prev) => {
@@ -17,7 +17,7 @@ import { useLatestVersions } from '@/lib/useLatestVersions';
import { useScriptDisplayNames } from '@/lib/scriptDisplayNames';
import { api } from "@/lib/api";
import { isGuid } from "@/utils/inputValidation";
import { ApiError, fetchBlob, fetchJson, fetchOk } from "@/lib/apiClient";
import { ApiError, fetchBlob, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { useSessionAnalysis } from "./hooks/useSessionAnalysis";
import { useAutoScroll } from "./hooks/useAutoScroll";
@@ -48,7 +48,7 @@ import { trackEvent } from "@/lib/appInsights";
import { useAdminMode } from "@/hooks/useAdminMode";
import { DocsLink } from "@/components/DocsLink";
import { DOCS_PATHS } from "@/lib/docsPaths";
import type { GetSessionTimeAttributionResponse } from "@/utils/wire-types.generated";
import type { GetSessionTimeAttributionResponse, SubmitSessionReportRequest } from "@/utils/wire-types.generated";
export default function SessionDetailPage() {
// useSearchParams() in SessionDetailContent requires a Suspense boundary for static prerender.
@@ -279,7 +279,7 @@ function SessionDetailContent() {
getAccessToken,
{
method: 'POST',
body: JSON.stringify({
body: jsonBody<SubmitSessionReportRequest>({
tenantId: effectiveTenantId,
sessionId,
comment,
@@ -7,7 +7,7 @@ import { useAuth } from "../../contexts/AuthContext";
import { useNotifications } from "../../contexts/NotificationContext";
import { api } from "@/lib/api";
import { TokenExpiredError } from "@/lib/authenticatedFetch";
import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { trackEvent } from "@/lib/appInsights";
import { classifyAccessCheck, type AccessCheckOutcome, type AccessCheckPayload } from "@/lib/accessCheck";
import { primaryClientId } from "@/lib/authApp";
@@ -32,7 +32,20 @@ import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath, NotificationChann
import { SECTION_FIELD_MAP, type SectionFieldSpec, type SettingsSectionName } from "./sectionFieldMap";
import { looksLikeGuid, type MemberKind } from "@/utils/principalKeys";
import { type BootstrapSessionItem } from "./components/BootstrapSessionsSection";
import type { AutopilotConsentStatusResponse, AutopilotConsentUrlResponse, OffboardResponse, TenantFeatureFlagsResponse, TestWebhookNotificationResponse } from "@/utils/wire-types.generated";
import type {
AddTenantAdminRequest,
AutopilotConsentFailureRequest,
AutopilotConsentStatusResponse,
AutopilotConsentSuccessRequest,
AutopilotConsentUrlResponse,
CreateBootstrapSessionRequest,
OffboardResponse,
PatchTenantConfigurationFieldsRequest,
TenantFeatureFlagsResponse,
TestNotificationChannelRequest,
TestWebhookNotificationResponse,
UpdateMemberPermissionsRequest,
} from "@/utils/wire-types.generated";
/**
* Channels for display/editing from a loaded config: prefers notificationChannelsJson; while
@@ -828,7 +841,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// backend verified exactly these fields changed, so merge them locally.
await fetchOk(api.config.fields(tenantId), getAccessToken, {
method: "PATCH",
body: JSON.stringify({ fields: patchFields, reason: `settings:${sectionName}` }),
body: jsonBody<PatchTenantConfigurationFieldsRequest>({ fields: patchFields, reason: `settings:${sectionName}` }),
});
setConfig({ ...config, ...patchFields } as TenantConfiguration);
}
@@ -1005,7 +1018,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
try {
await fetchOk(api.config.autopilotConsentFailure(tenantId), getAccessToken, {
method: "POST",
body: JSON.stringify({
body: jsonBody<AutopilotConsentFailureRequest>({
error: consentError,
errorDescription: consentErrorDescription ? decodeURIComponent(consentErrorDescription) : undefined,
}),
@@ -1065,7 +1078,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
try {
await fetchOk(api.config.autopilotConsentSuccess(tenantId), getAccessToken, {
method: "POST",
body: JSON.stringify({ trigger }),
body: jsonBody<AutopilotConsentSuccessRequest>({ trigger }),
});
} catch {
// swallow — observability only
@@ -1170,7 +1183,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
try {
const data = await fetchJson<TestWebhookNotificationResponse>(api.config.testNotification(tenantId), getAccessToken, {
method: "POST",
body: JSON.stringify({ channelId }),
body: jsonBody<TestNotificationChannelRequest>({ channelId }),
});
setTestChannelResult({ channelId, success: data.success, message: data.message });
} catch (err) {
@@ -1340,11 +1353,11 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// A service principal is stored under app:<client-id> and is always read-only (Viewer).
const body = addingApplication
? { applicationId: newAdminEmail.trim(), role: "Viewer" }
: { upn: newAdminEmail.trim(), role: newMemberRole };
? { applicationId: newAdminEmail.trim(), role: "Viewer", canManageBootstrapTokens: false }
: { upn: newAdminEmail.trim(), role: newMemberRole, canManageBootstrapTokens: false };
await fetchOk(api.tenants.admins(tenantId), getAccessToken, {
method: "POST",
body: JSON.stringify(body),
body: jsonBody<AddTenantAdminRequest>(body),
});
trackEvent("admin_member_added", { role: body.role, kind: newMemberKind });
@@ -1429,7 +1442,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
getAccessToken,
{
method: "PATCH",
body: JSON.stringify({ role, canManageBootstrapTokens }),
body: jsonBody<UpdateMemberPermissionsRequest>({ role, canManageBootstrapTokens }),
},
);
@@ -1455,7 +1468,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
try {
const data = await fetchJson<{ bootstrapUrl?: string }>(api.bootstrap.sessions(), getAccessToken, {
method: "POST",
body: JSON.stringify({ tenantId, validityHours, label }),
body: jsonBody<CreateBootstrapSessionRequest>({ tenantId, validityHours, label }),
});
trackEvent("bootstrap_session_created", { validityHours });
await fetchBootstrapSessions();
@@ -2,14 +2,22 @@
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../../../contexts/AuthContext";
import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { api } from "@/lib/api";
import { useTenantList } from "@/hooks/useTenantList";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
import { isApplicationKey, looksLikeGuid, principalLabel } from "@/utils/principalKeys";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import type { AdminConfiguration, GetMcpUsersResponse, McpUserEntry, PlanTierDefinition as PlanTierDefinitionWire, PlanTierDefinitionsResponse } from "@/utils/wire-types.generated";
import type {
AddMcpUserRequest,
AdminConfiguration,
GetMcpUsersResponse,
McpUserEntry,
PlanTierDefinition as PlanTierDefinitionWire,
PlanTierDefinitionsResponse,
SetUsagePlanRequest,
} from "@/utils/wire-types.generated";
/** One MCP user row and one plan tier — the wire shapes. */
type McpUser = McpUserEntry;
@@ -108,7 +116,7 @@ export default function McpUsersSection() {
await fetchOk(api.globalConfig.get(), getAccessToken, {
method: "PUT",
body: JSON.stringify({ ...config, mcpAccessPolicy: newPolicy }),
body: jsonBody<AdminConfiguration>({ ...config, mcpAccessPolicy: newPolicy }),
});
setPolicy(newPolicy);
@@ -132,7 +140,7 @@ export default function McpUsersSection() {
? { applicationId: newEmail.trim(), homeTenantId: homeTenantPick }
: { upn: newEmail.trim(), homeTenantId: needHomeTenant ? homeTenantPick : undefined };
try {
await fetchOk(api.mcpUsers.add(), getAccessToken, { method: "POST", body: JSON.stringify(body) });
await fetchOk(api.mcpUsers.add(), getAccessToken, { method: "POST", body: jsonBody<AddMcpUserRequest>(body) });
} catch (err) {
if (err instanceof ApiError && err.status === 422 && err.code === HOME_TENANT_UNRESOLVED) setNeedHomeTenant(true);
throw err;
@@ -197,7 +205,7 @@ export default function McpUsersSection() {
await fetchOk(api.mcpUsers.setUsagePlan(upn), getAccessToken, {
method: "PATCH",
body: JSON.stringify({ usagePlan: usagePlan || null }),
body: jsonBody<SetUsagePlanRequest>({ usagePlan: usagePlan || null }),
});
setSuccessMessage(`Usage plan for ${upn} updated.`);
@@ -3,9 +3,10 @@
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { TokenExpiredError } from "@/lib/authenticatedFetch";
import { apiErrorText, fetchOk } from "@/lib/apiClient";
import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import type { SubmitOffboardingFeedbackRequest } from "@/utils/wire-types.generated";
interface OffboardingInProgressInfo {
status: string; // "Queued" | "Initiated" | "InProgress" | "Completed" | "Failed"
@@ -272,7 +273,7 @@ function OffboardingInProgressBanner({
try {
await fetchOk(api.tenants.offboardFeedback(tenantId), getAccessToken, {
method: "POST",
body: JSON.stringify({ comment: trimmed.slice(0, FEEDBACK_MAX_CHARS) }),
body: jsonBody<SubmitOffboardingFeedbackRequest>({ comment: trimmed.slice(0, FEEDBACK_MAX_CHARS) }),
});
try {
window.localStorage.setItem(localStorageKey, "1");
@@ -4,14 +4,23 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/contexts/AuthContext";
import { useTenantConfig } from "../../TenantConfigContext";
import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
import { api } from "@/lib/api";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import { isProViaMsp } from "@/lib/edition";
import { buildInviteLink, describeDelegationError, holdRemainingLabel, invitationStatusLabel } from "@/lib/delegations";
import { principalLabel } from "@/utils/principalKeys";
import type { CreateDelegationInvitationResponse, DelegationAssigneeListResponse, DelegationInvitationListResponse, ManagedTenantListResponse, TenantManagerListResponse } from "@/utils/wire-types.generated";
import type {
CreateDelegationInvitationResponse,
DelegationAssigneeListResponse,
DelegationAssignRequest,
DelegationInvitationListResponse,
ManagedTenantListResponse,
RemoveManagedTenantRequest,
RevokeTenantManagerRequest,
TenantManagerListResponse,
} from "@/utils/wire-types.generated";
type Confirm =
| { kind: "revoke"; homeTenantId: string; label: string }
@@ -91,17 +100,14 @@ export function SectionDelegatedAccess() {
void run();
}, [load, canEditConfig]);
/** Runs a mutation; a structured error body ({ error, code }) is explained via describeDelegationError. */
/** Runs a mutation (`body` is a jsonBody<T>() string; undefined ⇒ none); a structured error body ({ error, code }) is explained via describeDelegationError. */
const mutate = useCallback(
async (key: string, url: string, method: string, body: unknown, ok: string): Promise<boolean | null> => {
async (key: string, url: string, method: string, body: string | undefined, ok: string): Promise<boolean | null> => {
setBusy(key);
setError(null);
try {
const init: RequestInit = { method };
if (body !== undefined) {
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(body);
}
if (body !== undefined) init.body = body;
try {
await fetchOk(url, getAccessToken, init);
} catch (err) {
@@ -225,7 +231,7 @@ export function SectionDelegatedAccess() {
<button
type="button"
disabled={busy !== null}
onClick={() => mutate("revoke", api.delegations.revokeManager(), "POST", { homeTenantId: m.ownerTenantId }, `Access of ${confirm.label} revoked.`)}
onClick={() => mutate("revoke", api.delegations.revokeManager(), "POST", jsonBody<RevokeTenantManagerRequest>({ homeTenantId: m.ownerTenantId ?? "" }), `Access of ${confirm.label} revoked.`)}
className="text-xs font-medium text-white bg-red-600 rounded px-2 py-1 hover:bg-red-700 disabled:opacity-50"
>
{busy === "revoke" ? "Revoking…" : "Confirm"}
@@ -313,7 +319,7 @@ export function SectionDelegatedAccess() {
<button
type="button"
disabled={busy !== null}
onClick={() => mutate("remove", api.delegations.removeManaged(), "POST", { tenantId: t.tenantId }, `${confirm.label} removed.`)}
onClick={() => mutate("remove", api.delegations.removeManaged(), "POST", jsonBody<RemoveManagedTenantRequest>({ tenantId: t.tenantId }), `${confirm.label} removed.`)}
className="text-xs font-medium text-white bg-red-600 rounded px-2 py-1 hover:bg-red-700 disabled:opacity-50"
>
{busy === "remove" ? "Removing…" : "Confirm"}
@@ -452,7 +458,7 @@ export function SectionDelegatedAccess() {
type="button"
disabled={!assignUpn || busy !== null}
onClick={async () => {
const ok = await mutate("assign", api.delegations.assignees(), "POST", { upn: assignUpn }, `${principalLabel(assignUpn)} can now read your managed tenants.`);
const ok = await mutate("assign", api.delegations.assignees(), "POST", jsonBody<DelegationAssignRequest>({ upn: assignUpn }), `${principalLabel(assignUpn)} can now read your managed tenants.`);
if (ok) setAssignUpn("");
}}
className="shrink-0 px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
@@ -2,9 +2,10 @@
import { useRef, useState } from "react";
import { useAuth } from "../../../../contexts/AuthContext";
import { apiErrorText, fetchOk } from "@/lib/apiClient";
import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient";
import { api } from "@/lib/api";
import { trackEvent } from "@/lib/appInsights";
import type { SubmitDiagFilesReportRequest } from "@/utils/wire-types.generated";
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
const LOG_ACCEPT = ".log,.txt,.zip,.json,.jsonl,.ndjson";
@@ -108,7 +109,7 @@ export function SectionSubmitLogs() {
getAccessToken,
{
method: "POST",
body: JSON.stringify({
body: jsonBody<SubmitDiagFilesReportRequest>({
tenantId: user.tenantId,
comment,
email,
@@ -2,7 +2,8 @@
import { api } from "@/lib/api";
import { nextSlotLimit, slotTenantLabel, type SlotLimitError } from "@/lib/delegatedSlots";
import { ApiError, fetchOk } from "@/lib/apiClient";
import { ApiError, fetchOk, jsonBody } from "@/lib/apiClient";
import type { PatchTenantPlanRequest } from "@/utils/wire-types.generated";
/**
* Raises a managing tenant's delegated slot override via PATCH config/{tenantId}/plan (GlobalAdminOnly).
@@ -17,7 +18,7 @@ export async function raiseDelegatedSlotLimit(
try {
await fetchOk(api.config.plan(homeTenantId), getAccessToken, {
method: "PATCH",
body: JSON.stringify({ maxDelegatedTenants: newLimit }),
body: jsonBody<PatchTenantPlanRequest>({ maxDelegatedTenants: newLimit }),
});
return null;
} catch (err) {
@@ -3,8 +3,8 @@
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type { FeedbackEligibilityResponse } from "@/utils/wire-types.generated";
import { fetchJson, fetchOk, nullOnApiError } from "@/lib/apiClient";
import type { FeedbackEligibilityResponse, FeedbackRequest } from "@/utils/wire-types.generated";
import { fetchJson, fetchOk, jsonBody, nullOnApiError } from "@/lib/apiClient";
type Phase = "loading" | "bubble" | "form" | "thankyou" | "hidden";
@@ -57,7 +57,7 @@ export default function FeedbackBubble() {
try {
await fetchOk(api.feedback.submit(), getAccessToken, {
method: "POST",
body: JSON.stringify({ dismissed: true }),
body: jsonBody<FeedbackRequest>({ dismissed: true }),
});
} catch {
// Best effort
@@ -71,7 +71,7 @@ export default function FeedbackBubble() {
try {
await fetchOk(api.feedback.submit(), getAccessToken, {
method: "POST",
body: JSON.stringify({ rating, comment: comment.trim() || null, dismissed: false }),
body: jsonBody<FeedbackRequest>({ rating, comment: comment.trim() || null, dismissed: false }),
});
setPhase("thankyou");
@@ -6,7 +6,8 @@ import { api } from '@/lib/api';
import { trackEvent } from '@/lib/appInsights';
import type { SignalRMessageName } from '@/lib/signalrMessages';
import { useAuth } from './AuthContext';
import { ApiError, fetchOk } from "@/lib/apiClient";
import { ApiError, fetchOk, jsonBody } from "@/lib/apiClient";
import type { SignalRJoinGroupRequest, SignalRLeaveGroupRequest } from "@/utils/wire-types.generated";
// Hub payloads are untyped JSON; mirror @microsoft/signalr's own callback signature so
// consumer handlers keep their narrower parameter types without laundering here.
@@ -144,7 +145,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
method: 'POST',
// Re-present the stored serial proof for session groups: a roleless
// Progress-Portal user's rejoin is refused without it.
body: JSON.stringify({
body: jsonBody<SignalRJoinGroupRequest>({
connectionId,
groupName,
serialNumber: joinSerialsRef.current.get(groupName),
@@ -283,7 +284,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
try {
await fetchOk(api.realtime.joinGroup(), getAccessToken, {
method: 'POST',
body: JSON.stringify({
body: jsonBody<SignalRJoinGroupRequest>({
connectionId,
groupName,
serialNumber: options?.serialNumber,
@@ -331,7 +332,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
await fetchOk(api.realtime.leaveGroup(), getAccessToken, {
method: 'POST',
body: JSON.stringify({ connectionId, groupName }),
body: jsonBody<SignalRLeaveGroupRequest>({ connectionId, groupName }),
});
// Confirmed leave: drop the stored serial proof for this group (a refusal lands in the catch, which re-adds the group).
joinSerialsRef.current.delete(groupName);
@@ -16,6 +16,13 @@ export const PERMANENT: Record<string, string> = {
"app/dashboard/hooks/useTenantSecurityConfig.ts": "fail-soft banner data; the expiry is the one failure the user must hear about",
"app/sessions/hooks/useSessionDetail.ts": "a network failure keeps the retry flag, an expiry must not",
"app/settings/components/OffboardingSection.tsx": "expiry message tells the user the offboarding continues in the background",
// Rule documents: the pages edit a LOCAL rule shape (app/*/types.ts) whose nested types diverge from the
// wire (RuleCondition.itemField optional vs required, suppressByEvent inline vs SuppressByEventConfig,
// GatherRule.triggerPhase nullable vs string) and the PUT accepts toggle partials the service merges.
// Typing the body against the generated document needs the forms moved onto the wire types first (backlog).
"app/analyze-rules/page.tsx": "rule document bodies until the local rule types are the generated ones",
"app/gather-rules/page.tsx": "rule document bodies until the local rule types are the generated ones",
"app/ime-log-patterns/page.tsx": "pattern document bodies until the local pattern type is the generated one",
};
export const AUTHENTICATEDFETCH_BASELINE: Record<string, number> = {
@@ -31,6 +38,17 @@ export const JSONPARSE_BASELINE: Record<string, number> = {
"contexts/AuthContext.tsx": 3,
};
/**
* `body: JSON.stringify(` sites (D-207): request bodies are `jsonBody<SomeRequest>(…)` so tsc
* checks them against the generated request DTO. Scanned over the app dirs AND lib/ (the layer
* itself is exempt). Frozen 2026-09-06; only shrinks.
*/
export const BODY_STRINGIFY_BASELINE: Record<string, number> = {
"app/analyze-rules/page.tsx": 5,
"app/gather-rules/page.tsx": 3,
"app/ime-log-patterns/page.tsx": 2,
};
export const TOKENEXPIRED_BASELINE: Record<string, number> = {
"app/admin/ops/session-cleanup/components/MaintenanceStatusBanner.tsx": 1,
"app/dashboard/hooks/useBlockDevice.ts": 1,
@@ -3,6 +3,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import {
AUTHENTICATEDFETCH_BASELINE,
BODY_STRINGIFY_BASELINE,
DEDUPEDAUTHFETCH_BASELINE,
JSONPARSE_BASELINE,
PERMANENT,
@@ -23,6 +24,9 @@ import {
const WEB_ROOT = join(__dirname, "..", "..");
const SCAN_DIRS = ["app", "components", "hooks", "contexts"];
/** Request bodies are also written from lib/ helpers; the call layer itself is exempt. */
const BODY_SCAN_DIRS = [...SCAN_DIRS, "lib"];
const BODY_EXEMPT_FILES = new Set(["lib/apiClient.ts"]);
/** 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"]);
@@ -31,6 +35,8 @@ interface Pattern {
regex: RegExp;
baseline: Record<string, number>;
remedy: string;
/** Which source map to scan; the default is the app dirs. */
scope?: "body";
}
const PATTERNS: Pattern[] = [
@@ -52,6 +58,13 @@ const PATTERNS: Pattern[] = [
baseline: JSONPARSE_BASELINE,
remedy: "fetchJson<T> parses the body; a hand parse hides the error envelope",
},
{
name: "body: JSON.stringify(",
regex: /\bbody\s*[:=]\s*JSON\.stringify\(/g,
baseline: BODY_STRINGIFY_BASELINE,
remedy: "encode request bodies as jsonBody<SomeRequest>(…) from @/lib/apiClient (D-207)",
scope: "body",
},
{
name: "instanceof TokenExpiredError",
regex: /instanceof TokenExpiredError/g,
@@ -78,13 +91,13 @@ function isCommentLine(line: string): boolean {
}
/** file (repo-relative, forward slashes) → code lines. */
function scannedSources(): Map<string, string[]> {
function scannedSources(dirs: string[] = SCAN_DIRS, exempt: Set<string> = EXEMPT_FILES): Map<string, string[]> {
const files: string[] = [];
for (const dir of SCAN_DIRS) collectFiles(join(WEB_ROOT, dir), files);
for (const dir of 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;
if (exempt.has(rel)) continue;
out.set(
rel,
readFileSync(file, "utf-8")
@@ -106,6 +119,7 @@ function countPerFile(sources: Map<string, string[]>, regex: RegExp): Map<string
}
const sources = scannedSources();
const bodySources = scannedSources(BODY_SCAN_DIRS, new Set([...EXEMPT_FILES, ...BODY_EXEMPT_FILES]));
describe("apiClient ratchet", () => {
it("scans the app (plausibility floor)", () => {
@@ -114,7 +128,7 @@ describe("apiClient ratchet", () => {
for (const pattern of PATTERNS) {
describe(pattern.name, () => {
const counts = countPerFile(sources, pattern.regex);
const counts = countPerFile(pattern.scope === "body" ? bodySources : sources, pattern.regex);
it("has no raw site above its baseline", () => {
const violations: string[] = [];
@@ -142,6 +156,16 @@ describe("apiClient ratchet", () => {
});
}
it("never calls jsonBody without an explicit request type argument", () => {
const violations: string[] = [];
for (const [rel, lines] of bodySources) {
lines.forEach((line, i) => {
if (/\bjsonBody\(/.test(line)) violations.push(`${rel}:${i + 1}`);
});
}
expect(violations, "jsonBody<SomeRequest>(…) — name the generated request type (utils/wire-types.generated):\n " + violations.join("\n ")).toEqual([]);
});
it("never calls fetchJson without an explicit type argument", () => {
const violations: string[] = [];
for (const [rel, lines] of sources) {
+1 -12
View File
@@ -814,15 +814,4 @@ export interface BackupTriggerResponse {
statusUrl: string;
}
// ── PR2: single-row restore ───────────────────────────────────────────────
export type RestoreRowMode = "Preview" | "Commit";
export interface RestoreRowRequestBody {
tableName: string;
partitionKey: string;
rowKey: string;
mode: RestoreRowMode;
ifSha256?: string;
ifCurrentETag?: string | null;
}
// ── PR2: single-row restore — request/response types are generated (RestoreRowRequest, RestoreRowMode) ──
@@ -10,6 +10,7 @@ import type { ApiErrorResponse } from "@/utils/wire-types.generated";
* 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
* jsonBody<T> — request bodies, typed against the generated request DTO
*
* Every non-2xx body is the typed envelope `{ error, code, correlationId, hint?,
* retryAfterSeconds? }` (D-192); TokenExpiredError from authenticatedFetch passes through
@@ -18,6 +19,16 @@ import type { ApiErrorResponse } from "@/utils/wire-types.generated";
export type GetAccessToken = (forceRefresh?: boolean) => Promise<string | null>;
/**
* The one way a request body is encoded (D-207): the generic names the generated request wire
* type (utils/wire-types.generated), so a renamed or retyped backend field fails tsc here instead
* of surfacing as a 400 at runtime. The guard refuses `body: JSON.stringify(` and a bare
* `jsonBody(` without a type argument.
*/
export function jsonBody<TReq>(value: TReq): string {
return JSON.stringify(value);
}
export class ApiError extends Error {
constructor(
public readonly status: number,
@@ -15,7 +15,9 @@
*/
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { TokenExpiredError } from "@/lib/authenticatedFetch";
import { fetchJson, jsonBody } from "@/lib/apiClient";
import type { GetScriptDisplayNamesResponse, ScriptDisplayNamesRequest } from "@/utils/wire-types.generated";
/** Matches the shape produced by `useAuth().getAccessToken` (may return null when expired). */
export type GetAccessToken = (forceRefresh?: boolean) => Promise<string | null>;
@@ -80,11 +82,6 @@ function pickString(d: Record<string, unknown>, keys: string[]): string | undefi
return undefined;
}
interface DisplayNamesResponse {
refs: Record<string, string | null>;
malformed?: string[];
}
/** Backend's per-request cap on ref count (mirrors `MaxRefsPerRequest` in the C# function). */
export const SCRIPT_DISPLAY_NAMES_CHUNK_SIZE = 200;
@@ -113,16 +110,10 @@ export async function fetchScriptDisplayNames(
const chunk = refs.slice(i, i + SCRIPT_DISPLAY_NAMES_CHUNK_SIZE);
try {
const response = await authenticatedFetch(url, getAccessToken, {
const body = await fetchJson<GetScriptDisplayNamesResponse>(url, getAccessToken, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refs: chunk.map(formatRefKey) }),
body: jsonBody<ScriptDisplayNamesRequest>({ refs: chunk.map(formatRefKey) }),
});
if (!response.ok) {
// Non-2xx: preserve earlier chunks, stop hammering. UI degrades to "show IDs".
break;
}
const body = (await response.json()) as DisplayNamesResponse;
if (body.refs) {
Object.assign(merged, body.refs);
}
@@ -131,9 +122,10 @@ export async function fetchScriptDisplayNames(
// can react (token refresh / sign-in redirect). Any partial map collected so far is
// sacrificed -- the user is being signed out anyway.
if (err instanceof TokenExpiredError) throw err;
// Transport-level failure (network timeout, abort, JSON parse, ...): keep whatever
// earlier chunks produced and stop. Without this break the exception would bubble to
// the hook's outer catch and discard partial results -- defeating the chunked design.
// A backend refusal (ApiError) or a transport-level failure (network timeout, abort, ...):
// keep whatever earlier chunks produced and stop hammering. UI degrades to "show IDs".
// Without this break the exception would bubble to the hook's outer catch and discard
// partial results -- defeating the chunked design.
break;
}
}
@@ -26,6 +26,11 @@ export function fromWireAdminConfiguration(wire: WireAdminConfiguration): AdminC
return { ...wire, excessiveEventAutoActionMode: mode };
}
/** The UI configuration back on the wire: an unknown/unset mode is sent as the server default ("Off"). */
export function toWireAdminConfiguration(config: AdminConfiguration): WireAdminConfiguration {
return { ...config, excessiveEventAutoActionMode: config.excessiveEventAutoActionMode ?? "Off" };
}
/** One parsed entry of AdminConfiguration.opsAlertRulesJson (client-side view). */
export interface OpsAlertRule {
eventType: string;