/** * GraphQL client for the Sophos Fusion APIs (Cases v2, Detections v2, Threat * Timeline v2, Events v1, Live Endpoint Search v1, Search v1). Sits beside SophosClient, * which keeps serving the Sophos Central REST APIs. * * One endpoint for every tenant, no regional host lookup. Auth is unchanged: * the same bearer token from TokenManager and the same X-Tenant-ID header the * REST client sends. * * The rule that shapes this file: a GraphQL-layer failure comes back as HTTP * 200 with an `errors` array beside `data`. A 200 is not success until the * body has been read. Transport failures (expired token, 429, 5xx) use the * usual non-2xx status and the standard Sophos error object, and those are * retried the same way SophosClient retries them: 429 always, 5xx and network * failures for queries only, never for a mutation. The one 5xx never retried * is the 503 Fusion returns for a tenant it does not hold * (FusionTenantNotOnFusionError). GraphQL-layer errors are not retried, * with one measured exception: the intermittent * partnerPreferences fault on the case write path, which is keyed on the * error path and bounded (see FusionGraphQLError.isTransientPartnerPreferences). */ import type { TokenManager } from "../auth/token-manager.js"; /** One entry of a GraphQL response `errors` array. */ export interface GraphQLErrorEntry { message: string; path?: Array; locations?: Array<{ line: number; column: number; }>; extensions?: Record; } interface GraphQLResponseBody { data?: T | null; errors?: GraphQLErrorEntry[]; } export interface FusionQueryResult { /** The response `data` object. Never null: a response with no usable data throws. */ data: T; /** * GraphQL errors that arrived beside usable data (a partial response, for * example a federated field that could not be resolved). Empty on a clean * response. Tools must surface these; they are not silently dropped. */ warnings: string[]; } export interface FusionClientOptions { /** GraphQL endpoint. Defaults to SOPHOS_FUSION_GRAPHQL_URL. */ url?: string; /** Per-attempt timeout. Default 30 s, matching SophosClient. */ timeoutMs?: number; /** Retries after the first attempt for 429, 5xx and network errors. Default 2. */ retries?: number; /** Base for the full-jitter exponential backoff. Default 1000 ms. */ backoffBaseMs?: number; /** * Total attempts for a call that hits the transient partnerPreferences * fault, which affects createCase and addEvidenceToCase. Measured on a live * tenant 22/09/2026: 111 faults in 150 calls, so 74%, and it drifts, with * four samples over six minutes running 60%, 85%, 72.5% and 80%. Default 20: * about 1 call in 410 fails at the measured 74%, and about 1 in 26 at the * worst rate observed. updateCase does not carry the fault at all, 0 in 30 * in the same minute addEvidenceToCase was 24 in 30, so the defect is in the * two resolvers that read partner preferences. */ transientAttempts?: number; /** Base for the short full-jitter backoff between those attempts. Default 300 ms, capped at 2 s. */ transientBackoffMs?: number; } /** Thrown when the GraphQL layer returns errors and no usable data. */ export declare class FusionGraphQLError extends Error { readonly errors: GraphQLErrorEntry[]; constructor(message: string, errors: GraphQLErrorEntry[]); /** * True when every error says the record does not exist. Fusion reports an * unknown case ID this way, as HTTP 200 with "record not found" beside a * null root field, rather than as a bare null (live tenant, 21/09/2026). */ get notFound(): boolean; /** * True for the intermittent investigations-v2 fault on the case write * path: `errors[0].path[0]` is "partnerPreferences" ("not allowed", * DOWNSTREAM_SERVICE_ERROR). It is not an authorisation failure: the * identical call with identical input succeeds on retry. Keyed on the path * and nothing else, because `extensions.code` is DOWNSTREAM_SERVICE_ERROR * for transient faults, not found and malformed queries alike, while a * genuine input or permission error carries the operation name in the path * (createCase, addEvidenceToCase, tdrusers) with a specific message. */ get isTransientPartnerPreferences(): boolean; } /** * Thrown for the HTTP 503 Fusion returns for a tenant it does not hold, or * one this credential cannot reach there: "tenant not found or not * accessible" from tenantsv4 GetTenant, with no error code (live, 29/10/2026). * The answer is the same on every attempt, so it is never retried. */ export declare class FusionTenantNotOnFusionError extends Error { readonly tenantId: string; constructor(message: string, tenantId: string); } export declare class FusionClient { private tokenManager; private readonly url; private readonly timeoutMs; private readonly retries; private readonly backoffBaseMs; private readonly transientAttempts; private readonly transientBackoffMs; constructor(tokenManager: TokenManager, options?: FusionClientOptions); /** The endpoint this client posts to. */ get endpoint(): string; /** * Run a GraphQL query or mutation for a tenant. * * Returns the `data` object plus any partial-response warnings. Throws * FusionGraphQLError when the response carries errors and no usable data, * and a plain Error for transport failures. The transient partnerPreferences * fault is retried up to transientAttempts times; every other GraphQL-layer * error surfaces at once. */ query(tenantId: string, document: string, variables?: Record): Promise>; /** * PUT raw bytes to a presigned upload URL (case files). The signature lives * in the URL, so no bearer token is sent, and the Content-Type must match * what startCaseFileUpload was told. One attempt, no retry. A redirect is * refused rather than followed, so the file's contents never go to a host * the API did not name. */ putPresigned(url: string, body: Uint8Array, contentType: string): Promise; /** * Transport retries: 429 always; 5xx, timeouts and network errors only for * a query, apart from the not-on-Fusion 503, which is never retried. A * mutation that failed that way may already have taken effect (a case * created, evidence attached), so it is not repeated. */ private executeWithRetry; private sleep; } /** * Decide what a 200 response actually means. * * - errors present, no usable data: throw with every message joined. * - errors present beside usable data: return the data with the errors as warnings. * - no errors, data present: clean result. A root field that is null with no * error (for example `case` for an unknown ID) is legitimate and passes * through for the tool to report. * - no errors, no data: malformed, throw. */ export declare function interpretGraphQLBody(body: GraphQLResponseBody | null | undefined): FusionQueryResult; export declare function formatGraphQLError(error: GraphQLErrorEntry): string; /** True when the document's operation is a mutation (leading comments skipped). */ export declare function isMutation(document: string): boolean; export {}; //# sourceMappingURL=fusion-client.d.ts.map