import type { SaveTimedOutError } from './saveOperation'; import type { RecordChangedError } from './recordChanged'; import type { ArchiveNotice } from '../hooks/useArchive'; /** * (design-pass findings B44 / B46 / B30) - WHAT TO TELL SOMEONE WHEN A LOAD FAILED. * * THE DEFECT THIS EXISTS TO END. 41 hook files caught every failure and set * `${errorMessage}. Please try again.` - none of them distinguished a permanent authorization denial. * So an HR user landed on *"Communication system error... Please try again."* backed by a **403 explicit * deny**, and on Billing the same message came with a **Retry button**: an affordance guaranteed to fail * on every press. Measured across the design pass: 16 denied requests on ordinary page loads, across 5 * roles - including **Admin**, the least restricted role in the product. * * Instructing someone to repeat an action that cannot ever succeed is worse than saying nothing. It moves * the blame to them, it invites them to hammer a denied endpoint, and it hides the real cause - which is * that the client mounted a surface this role was never entitled to. * * WHY THIS IS NOT A COPY TWEAK. The decision is *"is this failure retryable?"*, which is a property of * the RESPONSE, not of the sentence. That is why it lives next to the transport error rather than in each * hook's string. * * IT DOES NOT SUPPRESS ANYTHING. Every caller still gets a message and still shows it - a denial that * renders as nothing at all is the reference-authz-denial-never-renders-as-empty-data class, and is * strictly worse than this bug. The user is told the truth: they do not have access, and retrying is not * the answer. * * The requests themselves are the real fix (a capability gate at mount time, so an unentitled surface * never asks). This makes the failure honest while that lands. */ /** * A failure the user can do something about by trying again. * * Reads `status` structurally rather than `instanceof HttpError`, for two reasons: this stays a pure * util (importing HttpService would drag `environment.ts` and its `window` access into every consumer, * including tests), and an `instanceof` check silently returns false when the same class is reached * through two module instances - failing in the direction that reinstates the bug. */ export const isRetryableFailure = (error: unknown): boolean => { const status = (error as { status?: unknown } | null | undefined)?.status; // Anything that is not a recognised authorization outcome is treated as retryable - a network blip, // a 5xx, a timeout. Failing OPEN here is deliberate: mislabelling a transient fault as permanent // strands a user who could simply have retried, which is the more harmful direction. if (typeof status === 'number') { return !isPermanentDenial(status); } return true; }; /** * 401 and 403 only. Deliberately NOT 404: a missing resource is frequently a race or a stale link, and * telling someone not to retry that is wrong. Deliberately NOT 400 either - a bad request is our bug, and * the user retrying costs nothing. */ export const isPermanentDenial = (status: number): boolean => status === 401 || status === 403; /** The server answered a read with a 403: it was refused, which a network or 5xx failure never is. */ export const isRefusedRead = (error: unknown): boolean => (error as { status?: unknown } | null | undefined)?.status === 403; /** * Could a write have landed even though the caller was told it failed? * * Only when nothing of ours answered: the browser got no response, or API Gateway wrote the * response itself (an integration timeout or crash). Our handlers refuse in their own sentences and * never set `x-amzn-errortype`, so a failure carrying one of those is a definite answer. */ export const mayHaveBeenWritten = (error: unknown): boolean => { const failure = error as { name?: unknown; status?: unknown; body?: unknown; awsErrorType?: unknown } | null | undefined; // Accepted, then its answer could not be read: written (HttpService's UnreadableWriteAnswerError). if (failure?.name === 'UnreadableWriteAnswerError') return true; if (typeof failure?.status !== 'number') return failure?.name === 'TypeError'; if (failure.status < 500) return false; return Boolean(failure.awsErrorType) || failure.body === undefined; }; /** * DID ANYTHING OF OURS ANSWER AT ALL? * * A fetch that could not be made throws a TypeError carrying no status, and its message - 'Failed to * fetch' - belongs to the browser. MEASURED on the referrals send workbench: it replaced the sentence * the call site had written for exactly that case, in a dialog holding a patient's referral. * * THE DISCRIMINATOR IS THE TYPEERROR AND NOT THE ABSENT STATUS. Our own code throws plain Errors * carrying sentences written for a reader, and those have no status either; swallowing them would be * this defect pointing the other way. */ export const isTransportFailure = (error: unknown): boolean => { const failure = error as { name?: unknown; status?: unknown } | null | undefined; return typeof failure?.status !== 'number' && failure?.name === 'TypeError'; }; /** * Build the user-facing sentence for a failed load. * * @param error whatever was caught * @param context what failed, in the product's voice and already sentence-shaped - * e.g. `'Failed to load conditions'` */ export const describeFetchFailure = (error: unknown, context: string): string => { if (!isRetryableFailure(error)) { // No "Please try again" - this cannot succeed on a retry. Name the reason instead, so the person // knows it is a permission boundary and not a broken page. return `${context}. You do not have access to this information.`; } return `${context}. Please try again.`; }; /** * The same decision for a WRITE, and the reason it is separate from the read sentence above. * * A DENIED WRITE PRINTED THE RAW AWS STRING TO A CLINICIAN: *"User is not authorized to access this * resource with an explicit deny in an identity-based policy."* That sentence means nothing to the person * reading it and it names an implementation detail of our infrastructure inside a clinical screen. * Showing it at all was the right instinct - a refused save that renders as silence is worse. It is * only the words that fail. * * THE SERVER'S MESSAGE IS KEPT FOR EVERYTHING ELSE, and that is the whole care in this function. On a * 400 the server is telling the user which field is wrong, and swallowing it to say "please try again" * would turn a fixable mistake into a mystery. Only a PERMANENT DENIAL gets replaced, because that is the * one case where the server is talking to us and not to them. * * Says *permission*, not *access to this information* - the read sentence is about seeing something and * this is about changing it. A person refused a save needs to know the save did not happen. */ /** * IS THE SERVER'S MESSAGE A STRUCTURAL VALIDATION DUMP THAT NAMES NOTHING? * * `describeWriteFailure` relays a 400 verbatim on the reasoning that *"on a 400 the server is telling * the user which field is wrong"*. That is true of OUR routes, which refuse in sentences. It is not * true of HealthLake's FHIR structural validator, which answers: * * ERROR: @value cannot be empty * ERROR: ele-1: 'All FHIR elements must have a @value or children' Rule '...' Failed * * There is nothing in that to act on - no field, no section, no hint. Relaying it satisfies the * letter of "keep the server's message" and defeats its purpose, and a practice met it on the one * onboarding step marked Required. * * DELIBERATELY NARROW, because control 2 on the card that raised this is that a genuine server error * must still surface. It matches only the two phrases the FHIR validator emits and nothing else - a * message we cannot recognise is still relayed, exactly as before. Do not widen this to "looks * technical": swallowing a real fault is worse than an ugly one. */ export const isUnreadableValidationDump = (message: string): boolean => /All FHIR elements must have a @value or children/i.test(message) || /@value cannot be empty/i.test(message) || /Array cannot be empty/i.test(message); /** * What to say when the server did not tell us WHICH field. * * CORRECTED 2026-09-02: it usually does. This comment previously read *"the server did not tell * us one and inventing it would send somebody to the wrong place"*, and that was wrong - measured * against a real rejection rather than assumed. HealthLake answers with an `OperationOutcome` whose * every issue carries a `location`: * * "diagnostics": "@value cannot be empty", * "location": ["Location.name", "Line 1, Col 56"] * * The message threw it away because `HttpService` joins only `diagnostics` - but it keeps the whole * body on the error, so the field was one property away the entire time. `validationFieldLabel` below * reads it, and this text is now the FALLBACK for the case where there genuinely is no location. */ /** * WHAT A REFUSAL SAYS WHEN THE SERVER DID NOT WRITE A SENTENCE ITSELF * * TWO STRINGS, AND THE SECOND ONE IS A CORRECTION I HAD TO MAKE MID-FIX, so the reason is written * down rather than rediscovered. The first version of this shared ONE constant between both helpers. * The full suite refused it, and it was right to: `relayServerFailure` is not a write helper. Fifteen * of its call sites are LOADS - "Could not load your visit invitations.", "Failed to load portal * pages.", "We could not load this order" - and telling somebody they may not "make this change" * about a list they were only trying to READ is a new wrong sentence in place of the old one. * * THE SENTENCE HAS TO BE TRUE OF EVERY CALL SITE THE FUNCTION HAS, and the two functions do not * have the same ones. `describeWriteFailure` is a write helper by name and by contract, so it can say * the stronger thing; `relayServerFailure` serves both and says the thing that is true of both. * * THIS IS NOT THE DRIFT THE CARD WARNED ABOUT. That warning is against a second IMPLEMENTATION of * a curated refusal - two functions deciding separately whether a response is a denial. They still * share that decision (`isUncuratedDenial`). What differs is one sentence, because they describe two * different situations. Merging them would mean one of the two lying about what the caller did. * * Both say PERMISSION rather than access, and neither invites a retry: that is the whole point. */ export const UNCURATED_WRITE_DENIAL_MESSAGE = 'You do not have permission to make this change.'; export const UNCURATED_DENIAL_MESSAGE = 'You do not have permission to do this.'; export const UNREADABLE_VALIDATION_MESSAGE = 'Something on this form was left without a value. Check every field you have filled in - ' + 'a field containing only a space counts as empty.'; /** * "Location.address.line[0]" -> "Address line 1". * * The resource type is dropped because the person is looking at a form, not a FHIR document, and every * path on this screen starts with the same word. An index becomes a human 1-based ordinal for the same * reason. */ const humaniseFhirPath = (path: string): string | undefined => { const parts = path.split('.').slice(1); if (parts.length === 0) return undefined; const words = parts.flatMap((part) => { const match = /^(.+?)\[(\d+)\]$/.exec(part); const name = match ? match[1] : part; const spaced = name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase(); return match ? [spaced, String(Number(match[2]) + 1)] : [spaced]; }); const sentence = words.join(' '); return sentence.charAt(0).toUpperCase() + sentence.slice(1); }; /** * WHICH FIELD THE VALIDATOR REJECTED, when it said. * * `HttpError` carries the whole `OperationOutcome` alongside the flattened message, and its issues carry * a `location` - the first entry is the FHIR path, the second is a line and column that means nothing to * anybody reading a form. * * IT RETURNS UNDEFINED RATHER THAN GUESSING. A wrong field name is worse than none: it sends somebody * to a box that is already correct, and they will believe it. */ export const validationFieldLabel = (error: unknown): string | undefined => { const issues = (error as { body?: { issue?: unknown } } | null | undefined)?.body?.issue; if (!Array.isArray(issues)) return undefined; for (const issue of issues) { const location = (issue as { location?: unknown })?.location; const path = Array.isArray(location) ? location[0] : undefined; // A path, not the "Line 1, Col 56" that sits beside it. if (typeof path === 'string' && path.includes('.')) { const label = humaniseFhirPath(path); if (label) return label; } } return undefined; }; /** Names the field when the server named one, and falls back to the shape of the problem when it did not. */ export const validationMessageFor = (error: unknown): string => { const field = validationFieldLabel(error); return field ? `${field} was left without a value. A field containing only a space counts as empty.` : UNREADABLE_VALIDATION_MESSAGE; }; /** * Take the transport wrapper off a message that is about to be read by a person. * * `HttpService` throws `Request failed: ` for every refusal that is not an * OperationOutcome. Those three words are for a console and a log, and they are genuinely useful there - * several call sites key on their PRESENCE to tell a curated refusal from a bare `TypeError: Failed to * fetch`, which carries no wrapper. So the wrapper is right where it is thrown and wrong on a screen. * * WHY THIS LIVES HERE RATHER THAN AT EITHER END. Five screens had each written this same replace by * hand, and their comments each explain it as if for the first time - *"so the server's own sentence * reads plainly"*, *"showing that would put debugging language in front of a patient"*. Five authors * reaching the same conclusion separately, and none able to see the other four, is the product saying * the default is wrong rather than the screens being fussy. Every surface written since got the defect * again, because a per-screen fix cannot generalise. * * IT IS A PURE STRIP AND MAY RETURN AN EMPTY STRING, deliberately. The guard that matters - never show * a refusal that says nothing - belongs to whoever owns the fallback copy, and every caller already has * one written for the case where the server said nothing usable. Putting the guard in here instead * would return the wrapper itself as though it were a sentence, and would silently suppress fallbacks * that call sites already rely on. */ const TRANSPORT_WRAPPER = /^\s*request failed:\s*/i; export const withoutTransportWrapper = (message: string): string => message.replace(TRANSPORT_WRAPPER, '').trim(); /** * WHAT TO SAY WHEN AN ACTION THE READER TOOK DID NOT HAPPEN. * * THE DIFFERENCE FROM `describeWriteFailure` IS WHAT A TRANSIENT FAILURE DESERVES HERE. That one * relays the server's own sentence on a retryable failure, and it is right to: it is for a SAVE, where * a 400 names the field that is wrong and swallowing it turns a fixable mistake into a mystery. * * Opening a conversation has no field. There is nothing for the reader to correct, so the server's * transport-level words - `Failed to fetch` - are the least useful thing that could be put on screen, * and the useful thing is the one instruction that actually works: try again. * * MEASURED WHILE WRITING THE TEST FOR THIS, against the helper rather than against my assumption - * I had written a control asserting a network fault still said "try again" and it went red. * * SO THE TWO OUTCOMES ARE SPLIT ON THE RESPONSE'S STATUS, WHICH IS THE ONLY THING THAT KNOWS: * * a 401 or 403 the server refused, and no number of presses changes that. Name the refusal. * anything else it may well work next time. Say so, and say nothing else. * * A fix that made every message a denial would be the same defect pointing the other way: somebody * who only had to press again is told they are not allowed. */ export const describeBlockedAction = (error: unknown, context: string): string => ( isRetryableFailure(error) ? `${context}. Please try again.` : describeWriteFailure(error, context) ); export const describeWriteFailure = (error: unknown, context: string): string => { const raw = (error as { message?: unknown } | null | undefined)?.message; const detail = typeof raw === 'string' ? withoutTransportWrapper(raw) : raw; // A transport failure carries the browser's words, not ours - same rule as the relay below. const relayableRaw = !isTransportFailure(error) && typeof detail === 'string' && detail.trim() ? detail : undefined; const relayable = relayableRaw && isUnreadableValidationDump(relayableRaw) ? validationMessageFor(error) : relayableRaw; if (!isRetryableFailure(error)) { // NOT EVERY DENIAL IS THE GATEWAY'S. Our own routes refuse with sentences written to be read - // *"this practice has not said who owns it yet"*, *"the account was not locked in the first // place"* - and those are the most useful thing on the screen. Replacing them with generic copy // would be the same mistake in the other direction. return isUncuratedDenial(error) || !relayable ? `${context}. ${UNCURATED_WRITE_DENIAL_MESSAGE}` : `${context}:\n${relayable}`; } return relayable ? `${context}:\n${relayable}` : `${context}. Please try again.`; }; const GENERIC_SERVER_ERROR = /^internal server error\.?$/i; /** * Relay the server's own sentence, and replace ONLY the gateway's. * * THE DIFFERENCE FROM `describeWriteFailure` IS WHO ALREADY OWNS THE CONTEXT. That one is for a call * site whose copy was written as *"Failed to save X: "* - the context is already in the sentence, * so it belongs in the argument. This one is for a call site that shows the server's sentence **verbatim** * because the surrounding UI already says what failed: a Notice headed "Upload failed", a panel that is * plainly the invitations panel. * * AND THAT DISTINCTION IS PINNED BY TESTS THAT WERE ALREADY THERE - *"renders the server's own message * in a warning Notice, verbatim"*, *"leaves an ordinary failure alone"*. Folding a context into those sites * changed copy nobody asked to change, and the suite said so. **Fixing a leak must not restyle the * product on the way past.** * * So the behaviour here is byte-identical to the ternary it replaces, in every case but one: a refusal * from the gateway falls through to the caller's own fallback instead of printing an IAM policy string. */ export const relayServerFailure = (error: unknown, fallback: string): string => { // Nothing of ours answered, so there are no server words to prefer over the caller's own. if (isTransportFailure(error)) return fallback; if (isUncuratedDenial(error)) { // THIS BRANCH USED TO RETURN THE CALLER'S FALLBACK, AND THE FALLBACKS WERE WRITTEN // FOR THE ORDINARY FAILURE - so a 403 arrived on screen as "Please try again." at 92 call // sites, including the ones a patient meets: recording an agreement, answering a practice // invitation, starting identity verification. Measured as HR One, refused POST /R4/ // HealthcareService 403, dialog reading "Failed to save service. Please try again." // // AN INSTRUCTION TO RETRY A PERMISSION IS WORSE THAN SILENCE. The reader presses again, is // refused identically, and concludes the product is broken rather than that they were // refused. The settled rule is that a refusal renders as a denial naming what is denied. // // IT REPLACES RATHER THAN EXTENDS, deliberately. Several fallbacks already end in "Please try // again.", so appending a permission sentence would produce a paragraph arguing with itself. // // AND AN EMPTY FALLBACK STAYS EMPTY. `DuplicateMatchPrompt` passes one on purpose - it has // somewhere better to put the refusal - and a call site that asked for no sentence must not // be given one by a fix to a different problem. // // The half this KEEPS is the half that was always right: an uncurated denial never prints // the gateway's IAM policy string on a clinical screen. return fallback ? UNCURATED_DENIAL_MESSAGE : fallback; } const raw = (error as { message?: unknown } | null | undefined)?.message; if (!(typeof raw === 'string' && raw.trim())) return fallback; // STRIPPED, THEN CHECKED AGAIN. A message that was nothing but the transport wrapper leaves nothing // behind, and returning that empty string would be the one regression this change must not make: a // refusal that says nothing looks tidier than one carrying a prefix and tells the reader strictly // less. The caller's own fallback is the right answer there. const detail = withoutTransportWrapper(raw); if (!detail) return fallback; // A 500's stock body names nothing that happened; the caller's own sentence says more. if (GENERIC_SERVER_ERROR.test(detail)) return fallback; // A body with no message arrives as its JSON dump, which is not a sentence anybody can act on. if (/^[[{]/.test(detail)) return fallback; // Same treatment as the write sentence, and for the same reason: this function's whole purpose is // to show the server's own words because they are the most useful thing on the screen. A FHIR // structural dump is the one message where that premise is false, so relaying it here would leave // the defect alive on every call site that happens to use this helper instead of the other one. return isUnreadableValidationDump(detail) ? UNREADABLE_VALIDATION_MESSAGE : detail; }; /** * Did the GATEWAY refuse this, rather than one of our own routes? * * THE DISCRIMINATOR IS STRUCTURAL, NOT PROSE. `HttpService` reads `body.message` first and AWS's * `body.Message` second - *"try lowercase message, uppercase Message (AWS style)"* - and our handlers all * refuse through `json(status, { message })`. **A denial carrying a lowercase `message` (or an * OperationOutcome) is ours and was written for the reader; one carrying neither is the gateway's, and its * "message" is an IAM policy string.** * * **Never match on the sentence itself.** Several call sites already carry comments saying so, and they * are right: the copy is allowed to change, and a client keyed on prose breaks silently the first time it * does - which is the whole problem with this class of bug. * * A denial with NO parsed body counts as uncurated: nothing was written for the reader, so there is * nothing to prefer over our own words. * * THE BODY SHAPE ALONE WAS NOT ENOUGH, and the case that proves it was measured on a diagnostic * report save. The authorizer's own refusal comes back as * `{"message": "User is not authorized to access this resource with an explicit deny in an * identity-based policy"}` - a LOWERCASE `message`, which is exactly the key this function reads as * evidence that one of OUR routes wrote the sentence. So the rule above admitted an IAM policy string * as curated copy and printed it at a clinician. * * `x-amzn-errortype` settles it, and settles it STRUCTURALLY, which is the standard this function is * held to: API Gateway sets that header on the responses it generates itself and our handlers never * set it, so its presence on a permanent denial means nothing in the payload was written for a reader. * Matching the sentence would have been the other way, and it is the one thing this file forbids - * copy is allowed to change, and a client keyed on prose breaks silently the first time it does. */ export const isUncuratedDenial = (error: unknown): boolean => { const status = (error as { status?: unknown } | null | undefined)?.status; if (typeof status !== 'number' || !isPermanentDenial(status)) { return false; } const awsErrorType = (error as { awsErrorType?: unknown } | null | undefined)?.awsErrorType; if (typeof awsErrorType === 'string' && awsErrorType.trim()) { return true; } const body = (error as { body?: unknown } | null | undefined)?.body; if (!body || typeof body !== 'object') { return true; } const shaped = body as { message?: unknown; resourceType?: unknown }; if (shaped.resourceType === 'OperationOutcome') { // HealthLake's own scope refusal also arrives as an OperationOutcome, but nobody wrote it for a person. const issues = (body as { issue?: Array<{ diagnostics?: unknown }> }).issue ?? []; return issues.some((issue) => typeof issue.diagnostics === 'string' && /insufficient smart scope/i.test(issue.diagnostics)); } return typeof shaped.message !== 'string' || !shaped.message.trim(); }; /** * The sentence one of our own client errors carries: a save that ran out of time, a record that changed under the * edit, an archive that said why it stopped. Each is built from copy written to be read, never a server's words. The * classes are nominal, so the compiler refuses any other error here; it checks the call, not where a message came from. */ export const ownFailureSentence = (error: SaveTimedOutError | RecordChangedError | ArchiveNotice): string => error.message;