import { noteGatewayRefusal } from '../utils/gatewayRefusal'; import { useCallback } from 'react'; import { environment } from '../environments/environment'; import { withClientVersion } from '../config/clientManifest'; import { useAuthServiceContext } from './AuthService.tsx'; import { emitBillingLimitError } from './BillingContext.tsx'; import { dpopHeaderFor } from '../utils/dpopRequestHeader'; import { authSessionId, credentials, demoTabAtLoad } from './authStorage'; import { noteDemoRefusal } from './demoTab'; import { holdResponse, startWrite } from '../utils/writeThroughHold'; import type { SaveTransport } from '../utils/saveOperation'; import type { ServedAnswer } from '../hooks/anchorView/readOptions'; // Thrown by the HTTP helpers on a non-ok response, carrying the HTTP status so callers can tell a // genuine "cannot resolve" (404 / 403) from a transient failure (5xx / network / throttle) instead of // treating every failure the same. Extends Error, so existing catch/.message handling is unchanged. // // `body` is the PARSED error payload, and it exists because a status code alone is not a diagnosis. // utils/error-handler.ts relays an upstream HealthLake status and body VERBATIM, so several of our own // deliberate status codes are indistinguishable from an unrelated upstream failure with the same // number - a 409 on the comm-preferences route means "your carrier blocked this handset" when WE raise // it and "write conflict" when HealthLake does. Handlers that need to tell those apart key on a field // in the body (the carrier one sends `disabledReason`), never on the status alone. Optional and // additive: every existing `new HttpError(status, message)` call and every `.status` / `.message` // reader is unchanged. export class HttpError extends Error { /** * `x-amzn-errortype`, when the response carried one. * * API GATEWAY SETS THIS AND OUR HANDLERS NEVER DO, which makes it the only structural way to tell * a refusal the gateway wrote from one of ours. The body shape is not enough: the authorizer's * AccessDeniedException arrives as `{"message": "...explicit deny in an identity-based policy"}` * - a LOWERCASE `message`, the same key our own curated refusals use - so a reader keyed on the * body alone hands an IAM policy string to a clinician. Measured on a diagnostic report save. */ constructor( public status: number, message: string, public body?: unknown, public awsErrorType?: string, ) { super(message); this.name = 'HttpError'; } /** A refused write's own response headers, on the writes that read headers: some say what the server did first. */ header?: (name: string) => string | null; } /** Answer admission still checks ownership; a send-only capability check can distinguish a completed write. */ export type RequestAdmission = (phase?: 'answer') => void; interface FetchOptions extends RequestInit { bypassAuth?: boolean; bypassOrganization?: boolean; bypassActingAs?: boolean; organizationId?: string; /** * The operation this request belongs to (a save's `transport.admit`). Called before the request starts, again * after the token and proof waits - immediately before anything is sent - and before the answer is published to * a shared cache (phase `answer`); it throws once that operation is retired or out of time, so a request prepared for one person * or record is never sent, or held, under another. Pair it with `signal` so a request already sent is cancelled. */ admit?: RequestAdmission; /** Called once, immediately before the request is handed to `fetch` (see `SaveTransport.onSend`). */ onSend?: () => void; dispatch?: SaveTransport['dispatch']; /** A read's own answer, headers and exact body text, for an original view's receipt; see hooks/anchorView. */ onResponse?: (answer: ServedAnswer) => void | Promise; } /** * A write that also hands back what the response SAID ABOUT ITSELF. * * The shared helpers end in `return response.json()`, which drops the `Response` and with it every * header. That is fine for a body-shaped answer and useless for an outcome the server reports alongside * one - whether a courtesy notification actually went out, for instance, which must not fail a write that * already succeeded and so cannot be a status code. * * `header` WRAPS `Headers.get` RATHER THAN COPYING THE HEADERS INTO AN OBJECT, and that is the whole * reason this type exists in this shape: * * absent -> null * present and empty -> '' * * Those are different outcomes. "We do not know" is not "nothing happened", and a plain * `Record` built by iterating collapses them into `undefined` and `''`, which is exactly * the distinction a caller needs when the header IS the result. */ export interface ResponseWithHeaders { data: T; /** `null` when the header is absent, `''` when it is present and empty. Case-insensitive, per `Headers`. */ header: (name: string) => string | null; } /** * DELETE's body is not always JSON, and this is lifted out of `del` unchanged so the header-aware variant * cannot drift from it. A 204, an empty body with no content-length, and a body that is not JSON at all * are each answered with `{}` rather than throwing - callers of `delete` have relied on that. */ /** A write the server accepted (2xx) whose answer could not be read: it was written, so it is never a plain failure. */ export class UnreadableWriteAnswerError extends Error { constructor() { super('The save was answered, but its answer could not be read.'); this.name = 'UnreadableWriteAnswerError'; } } const readWriteAnswer = async (response: Response): Promise => { try { return await response.json(); } catch (error) { // Cancelled while reading: the save's own deadline or retirement decides what that means. if ((error as { name?: unknown } | null)?.name === 'AbortError') throw error; throw new UnreadableWriteAnswerError(); } }; const readDeleteBody = async (response: Response): Promise => { const contentLength = response.headers.get('content-length'); const hasContent = contentLength && parseInt(contentLength) > 0; if (!hasContent && response.status !== 204) { const text = await response.text(); if (!text) { return {} as T; } try { return JSON.parse(text) as T; } catch (e) { return {} as T; } } if (response.status === 204) { return {} as T; } return response.json(); }; export const useHttpService = () => { const { getValidToken, wpOrganization, actingAsRef } = useAuthServiceContext(); const fetchWithAuth = useCallback(async (url: string, options: FetchOptions = {}): Promise => { const { admit, onSend, dispatch, ...requestOptions } = options; admit?.(); const fetchOptions: RequestInit = { ...requestOptions }; fetchOptions.headers = fetchOptions.headers || {}; // The committed session the token below belongs to; a request is never sent under a session that replaced it. let tokenEpoch: string | null = null; if (!options.bypassAuth) { try { const token = await getValidToken(); tokenEpoch = credentials().epochOf(token); fetchOptions.headers = { ...fetchOptions.headers, 'Authorization': `Bearer ${token}` }; } catch (error) { console.error('Failed to get auth token:', error); throw new Error('Authentication failed'); } // A token held past the caller's operation never goes on to sign a proof for it. admit?.(); // - the proof that this request came from the device the token was issued to. // Never on a bypassAuth request: there is no token for a proof to be bound to, and an // unauthenticated public endpoint has nothing to compare it against. // READ THE METHOD OFF `fetchOptions`, which is what `fetch` is actually handed - not off // `options`. `htm` is compared byte for byte against the request the server received, so a // proof signed for the wrong verb fails as a BINDING REFUSAL, which reads like an attack // rather than like a bug. The `?? 'GET'` matches fetch's own default for a method-less call, // which is how ~24 direct `fetchWithAuth` callers reach this line. fetchOptions.headers = { ...fetchOptions.headers, ...(await dpopHeaderFor(fetchOptions.method ?? 'GET', url)), }; } // No practice, no header: an absent id must never travel as the string "undefined". const organizationId = options.organizationId ?? wpOrganization?.current?.id; if (!options.bypassOrganization && organizationId) { fetchOptions.headers = { ...fetchOptions.headers, 'X-Organization-ID': organizationId, 'X-Organization-Context': 'wordpress' }; } // Act-as: while a logged-in user has switched into a RelatedPerson they proxy, carry // X-Acting-As on every request so the server attributes the call to that RelatedPerson. // Never on a bypassAuth (unauthenticated/public) request - there's no session to attribute // it to. bypassActingAs lets specific calls (the exit call, "my proxies" list) stay as-self // while acting-as is still active. if (!options.bypassAuth && !options.bypassActingAs && actingAsRef.current) { fetchOptions.headers = { ...fetchOptions.headers, 'X-Acting-As': `RelatedPerson/${actingAsRef.current.relatedPersonId}` }; } if (!environment.production) { // console.log(`${options.method || 'GET'} ${url}`); } if (dispatch) { const response = await dispatch(async () => { // Recheck the committed session inside the caller's lock, after its admission wait. if (!options.bypassAuth && !(tokenEpoch === null ? authSessionId() === null : await credentials().stillCommitted(tokenEpoch))) { throw new Error('The sign-in changed before this request was sent, so it was not sent.'); } return () => { if (fetchOptions.signal?.aborted) throw new DOMException('The request was cancelled before it was sent.', 'AbortError'); admit?.(); onSend?.(); return fetch(url, withClientVersion(url, fetchOptions)); }; }); if (demoTabAtLoad) void noteDemoRefusal(response.clone()); return response; } // The session the token was issued under is admitted again from the record itself, after every wait above. if (!options.bypassAuth && !(tokenEpoch === null ? authSessionId() === null : await credentials().stillCommitted(tokenEpoch))) { throw new Error('The sign-in changed before this request was sent, so it was not sent.'); } // Preparing the token and proof can outlast the caller; one cancelled meanwhile is never sent. if (fetchOptions.signal?.aborted) throw new DOMException('The request was cancelled before it was sent.', 'AbortError'); // Whatever this request belongs to is checked again here, with the practice and acted-for headers it is // about to carry, before anything leaves. admit?.(); onSend?.(); const response = await fetch(url, withClientVersion(url, fetchOptions)); if (demoTabAtLoad) void noteDemoRefusal(response.clone()); return response; }, [getValidToken]); const handleErrorResponse = useCallback(async (response: Response): Promise => { if (!response.ok) { // Handle billing limit (402) before generic error parsing if (response.status === 402) { let body: any = null; try { body = await response.json(); } catch { throw new HttpError(response.status, 'Billing limit reached'); } if (body?.billing) { emitBillingLimitError({ limitType: body.billing.limitType ?? 'unknown', currentUsage: body.billing.currentUsage ?? 0, limit: body.billing.limit ?? 0, upgradeTarget: body.billing.upgradeTarget ?? '', diagnostics: body?.issue?.[0]?.diagnostics ?? 'Billing limit reached', }); } throw new HttpError(response.status, 'Billing limit reached'); } // existing OperationOutcome handling (unchanged): let responseBody = null; try { responseBody = await response.json(); } catch (e) { throw new HttpError(response.status, `Request failed: ${response.status} ${response.statusText}`); } // (B30/B24) - an AUTHORIZATION OUTCOME IS A RESULT, NOT A TRANSPORT FAULT. // The design pass measured 16 requests across 5 roles that the caller's own token denies on // an ordinary page load, and each one printed here at error level. Logging a 401/403 as an // error asserts a fault before any caller has decided whether it was expected, and it is the // bulk of the noise that makes this console unreadable - the console method reads. // Everything else still logs at error level: a 404, a 409 or a 5xx genuinely is a fault worth // seeing. The status is included either way, because the level alone loses it. // // This lowers the LEVEL, it does not swallow anything - the HttpError below is thrown // exactly as before, so every caller's own handling is untouched. The fix for the requests // themselves is the capability gate; this stops them shouting while that lands. // Read once, before any branch: every throw below carries it, so no call site has to know // which shape of failure it is looking at to find out who refused. const awsErrorType = response.headers.get('x-amzn-errortype') ?? undefined; noteGatewayRefusal(response.status, awsErrorType); const isAuthorizationOutcome = response.status === 401 || response.status === 403; const logApiError = isAuthorizationOutcome ? console.debug : console.error; logApiError(`API error response body (${response.status}):`, responseBody); if (responseBody?.resourceType === 'OperationOutcome') { const issues = responseBody.issue || []; const errorIssues = issues.filter((issue: any) => issue.severity === 'error' || issue.severity === 'fatal'); const errorMessages = errorIssues.map((issue: any) => { const severityText = issue.severity?.toUpperCase() || 'ERROR'; return `${severityText}: ${issue.diagnostics || issue.details?.text || 'Unknown issue'}`; }); if (errorMessages.length > 0) { // One line per problem: the store repeats a structural sentence once per field it finds it in. throw new HttpError(response.status, [...new Set(errorMessages)].join('\n'), responseBody, awsErrorType); } else { // OperationOutcome without error/fatal issues - show all issues const allMessages = issues.map((issue: any) => `${(issue.severity || 'unknown').toUpperCase()}: ${issue.diagnostics || issue.details?.text || 'Unknown issue'}` ); const fallback = allMessages.length > 0 ? allMessages.join('\n') : JSON.stringify(responseBody); throw new HttpError(response.status, `Request failed: ${fallback}`, responseBody, awsErrorType); } } else { // Not an OperationOutcome - try lowercase message, uppercase Message (AWS style), then full body // The parsed body rides along: the message is lossy by construction (one string), and a // caller that must distinguish two failures sharing a status code needs the fields. const msg = responseBody?.message ?? responseBody?.Message ?? responseBody?.errorMessage ?? responseBody?.error; throw new HttpError(response.status, `Request failed: ${msg ?? JSON.stringify(responseBody)}`, responseBody, awsErrorType); } } }, []); const get = useCallback(async (url: string, options: FetchOptions = {}): Promise => { const { onResponse, ...requestOptions } = options; const response = await fetchWithAuth(url, { method: 'GET', ...requestOptions, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', ...options.headers } }); await handleErrorResponse(response); if (!onResponse) return response.json(); // The exact text is what a receipt digests; the read answers the same parsed body either way. const text = await response.text(); const body = JSON.parse(text) as T; try { await onResponse({ header: (name: string) => response.headers.get(name), text }); } catch { /* A receipt never fails the read. */ } return body; }, [fetchWithAuth, handleErrorResponse]); const post = useCallback(async (url: string, data: any, options: FetchOptions = {}): Promise => { // A response admitted under a later viewer would be held for the wrong person. const startedIn = startWrite(); const response = await fetchWithAuth(url, { method: 'POST', body: JSON.stringify(data), ...options, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', ...options.headers } }); await handleErrorResponse(response); const body = await readWriteAnswer(response); // An answer for an operation that has moved on is not published, even to the write-through hold. options.admit?.('answer'); holdResponse('POST', url, body, startedIn); return body; }, [fetchWithAuth, handleErrorResponse]); const put = useCallback(async (url: string, data: any, options: FetchOptions = {}): Promise => { // A response admitted under a later viewer would be held for the wrong person. const startedIn = startWrite(); const response = await fetchWithAuth(url, { method: 'PUT', body: JSON.stringify(data), ...options, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', ...options.headers } }); await handleErrorResponse(response); const body = await readWriteAnswer(response); // An answer for an operation that has moved on is not published, even to the write-through hold. options.admit?.('answer'); holdResponse('PUT', url, body, startedIn); return body; }, [fetchWithAuth, handleErrorResponse]); const patch = useCallback(async (url: string, data: any, options: FetchOptions = {}): Promise => { // A response admitted under a later viewer would be held for the wrong person. const startedIn = startWrite(); const response = await fetchWithAuth(url, { method: 'PATCH', body: JSON.stringify(data), ...options, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', ...options.headers } }); await handleErrorResponse(response); const body = await readWriteAnswer(response); // An answer for an operation that has moved on is not published, even to the write-through hold. options.admit?.('answer'); holdResponse('PATCH', url, body, startedIn); return body; }, [fetchWithAuth, handleErrorResponse]); const del = useCallback(async (url: string, options: FetchOptions = {}): Promise => { // A response admitted under a later viewer would be held for the wrong person. const startedIn = startWrite(); const response = await fetchWithAuth(url, { method: 'DELETE', ...options, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', ...options.headers } }); await handleErrorResponse(response); const body = await readDeleteBody(response); // An answer for an operation that has moved on is not published, even to the write-through hold. options.admit?.('answer'); holdResponse('DELETE', url, body, startedIn); return body; }, [fetchWithAuth, handleErrorResponse]); /** * The header-aware write. Same request as `post` / `put` / `delete`, and it additionally returns a * reader for the response headers. * * A SEPARATE ENTRY POINT RATHER THAN A CHANGED RETURN TYPE, deliberately. The three helpers above have * callers that destructure the parsed body directly; widening what they return would make every one of * them a migration, to serve the one screen that needs a header today. * * THE HEADER MUST BE ON THE SERVER'S `Access-Control-Expose-Headers` LIST OR THIS READS `null`. * A browser hands script only the CORS-safelisted response headers. An unexposed header still arrives, * still shows in the network panel, and still reads as `null` here - nothing throws and nothing is * logged. That is indistinguishable from the server never having sent it, and it is why a caller must * be walked in a browser rather than trusted because a unit test passes. */ const writeWithHeaders = useCallback(async ( method: 'POST' | 'PUT' | 'DELETE', url: string, data?: unknown, options: FetchOptions = {}, ): Promise> => { // A response admitted under a later viewer would be held for the wrong person. const startedIn = startWrite(); const response = await fetchWithAuth(url, { method, ...(data === undefined ? {} : { body: JSON.stringify(data) }), ...options, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', ...options.headers } }); try { await handleErrorResponse(response); } catch (error) { if (error instanceof HttpError) error.header = (name: string) => response.headers.get(name); throw error; } // Bound to the Response's own Headers rather than a snapshot, so `null` vs `''` survives. const header = (name: string) => response.headers.get(name); const answer = method === 'DELETE' ? await readDeleteBody(response) : await readWriteAnswer(response); // An answer for an operation that has moved on is not published, even to the write-through hold. options.admit?.('answer'); holdResponse(method, url, answer, startedIn); return { data: answer, header }; }, [fetchWithAuth, handleErrorResponse]); return { fetchWithAuth, handleErrorResponse, get, post, put, patch, delete: del, writeWithHeaders }; };