// After a confirmed Accept the new link can lag in my-proxies, a HealthLake search. One loop per accepted link re-reads // the list until that exact link is present, one read at a time, and every mounted proxy list hears // each read. Nothing is inserted before the server lists it, and Accept is never sent again. import { isPermanentDenial } from '../utils/fetchFailureMessage'; import { withinDeadline } from '../utils/withinDeadline'; import { onViewerChange } from '../utils/writeThroughHold'; import type { MyProxy } from './useMyProxies.tsx'; // `ended`/`closed`: the server says the accepted invitation can never show (a guardianship that has ended, or a link // no longer open), so the wait stops rather than promising a catch-up that cannot come. export type AcceptedLinkStatus = 'updating' | 'present' | 'unconfirmed' | 'failed' | 'refused' | 'ended' | 'closed'; /** An invitation this account accepted, or a child it just added: both wait for the same exact link. */ export type AcceptedLinkKind = 'accepted' | 'added'; export interface AcceptedLinkSnapshot { accountId: string; relatedPersonId: string; subjectName: string; kind?: AcceptedLinkKind; status: AcceptedLinkStatus; /** The list this loop last read, and the read sequence it was started under. */ proxies?: MyProxy[]; seq?: number; } export interface AcceptedLinkRefresh { accountId: string; relatedPersonId: string; subjectName: string; kind?: AcceptedLinkKind; read: (signal?: AbortSignal, admit?: () => void) => Promise; wait?: (ms: number) => Promise; /** The accepted invitation's own state, asked once when the list never showed it; never sends the Accept again. */ confirm?: (signal: AbortSignal) => Promise<'pending' | 'accepted' | 'closed' | 'ended' | 'unavailable'>; /** Whether whoever started this wait still owns it; once not, it reads, confirms and publishes nothing more. */ owned?: () => boolean; } /** How long one read of the list may take, token and device proof included; past it the read counts as failed. */ export const ACCEPTED_LINK_READ_DEADLINE_MS = 10_000; /** Waits before each read: about thirty seconds in all. */ export const ACCEPTED_LINK_WAITS_MS = [0, 1000, 1500, 2500, 4000, 6000, 7000, 8000]; // Every my-proxies read takes a number when it starts, so a list only ever replaces an older one. let readSeq = 0; export const nextProxyReadSeq = (): number => ++readSeq; type Listener = (snapshot: AcceptedLinkSnapshot | null) => void; const listeners = new Set(); let current: AcceptedLinkSnapshot | null = null; let run = 0; let lastRefresh: AcceptedLinkRefresh | null = null; const publish = (snapshot: AcceptedLinkSnapshot | null) => { current = snapshot; listeners.forEach((listener) => listener(snapshot)); }; export const onAcceptedLink = (listener: Listener): (() => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }; export const currentAcceptedLink = (): AcceptedLinkSnapshot | null => current; // EVERY READ THIS WAIT FINISHES IS A DECISION, answered or not. A read that failed, timed out or was refused is // still newer than any read started before it, so an older (broader) answer arriving after it must not be shown: // each list hears that the sequence finished, and installs nothing for a read that did not answer. type ReadFinished = (accountId: string, seq: number) => void; const readFinishedListeners = new Set(); export const onAcceptedLinkReadFinished = (listener: ReadFinished): (() => void) => { readFinishedListeners.add(listener); return () => { readFinishedListeners.delete(listener); }; }; const finished = (accountId: string, seq: number) => readFinishedListeners.forEach((listener) => listener(accountId, seq)); // The read in the air, aborted the moment its wait is cancelled or replaced. let inFlight: AbortController | null = null; export const refreshUntilAccepted = async (refresh: AcceptedLinkRefresh): Promise => { const mine = ++run; // A wait replaced by this one ends now, its read in the air included. inFlight?.abort(); inFlight = null; lastRefresh = refresh; rememberAwaitedLink(refresh.accountId, refresh.relatedPersonId); const wait = refresh.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const base = { accountId: refresh.accountId, relatedPersonId: refresh.relatedPersonId, subjectName: refresh.subjectName, kind: refresh.kind ?? 'accepted', }; publish({ ...base, status: 'updating', proxies: current?.accountId === base.accountId ? current.proxies : undefined }); // A wait whose owner has moved on ends here and says nothing more; a read already sent is dropped when it answers. const ours = () => mine === run && refresh.owned?.() !== false; const retired = () => { if (mine !== run) return true; if (ours()) return false; cancelAcceptedLink(); return true; }; const admit = refresh.owned ? () => { if (!ours()) throw new DOMException('This wait was put aside.', 'AbortError'); } : undefined; let readFailed = false; for (const ms of ACCEPTED_LINK_WAITS_MS) { if (ms) await wait(ms); if (retired()) return; const seq = nextProxyReadSeq(); // One read at a time, each bounded and abortable: a read that never answers is given up, its late answer is // dropped, and the next wait still comes. const controller = new AbortController(); inFlight = controller; try { const proxies = await withinDeadline(refresh.read(controller.signal, admit), ACCEPTED_LINK_READ_DEADLINE_MS, 'accepted-link read') .finally(() => { controller.abort(); if (inFlight === controller) inFlight = null; }); // A read that outlives its loop (cancelled, superseded, another account, viewer or owner) is dropped. if (retired()) return; finished(base.accountId, seq); readFailed = false; // The exact link: another link to the same patient must not settle this one. if (proxies.some((proxy) => proxy.relatedPersonId === base.relatedPersonId)) { if (listedAt?.relatedPersonId !== base.relatedPersonId) listedAt = { relatedPersonId: base.relatedPersonId, at: Date.now() }; publish({ ...base, status: 'present', proxies, seq }); return; } publish({ ...base, status: 'updating', proxies, seq }); } catch (err) { if (retired()) return; finished(base.accountId, seq); const status = (err as { status?: unknown } | null)?.status; // A refusal is an answer, not a delay: stop rather than poll through it. if (typeof status === 'number' && isPermanentDenial(status)) { publish({ ...base, status: 'refused' }); return; } readFailed = true; } } if (retired()) return; let settled: 'ended' | 'closed' | undefined; if (refresh.confirm) { const controller = new AbortController(); inFlight = controller; try { const state = await withinDeadline(refresh.confirm(controller.signal), ACCEPTED_LINK_READ_DEADLINE_MS, 'accepted-link state'); if (state === 'ended' || state === 'closed') settled = state; } catch { // Not read: the wait ends as it would have, unconfirmed or failed. } finally { controller.abort(); if (inFlight === controller) inFlight = null; } if (retired()) return; } publish({ ...base, status: settled ?? (readFailed ? 'failed' : 'unconfirmed'), proxies: current?.proxies, seq: current?.seq }); }; // The signed-in account's own list reader, lent by whichever proxy lists are mounted (the shell // always mounts one for a patient), so the notice that answered the invitation needs neither. export interface ProxyReader { accountId: string; read: (signal?: AbortSignal, admit?: () => void) => Promise } const readers: ProxyReader[] = []; export const provideProxyReader = (reader: ProxyReader): (() => void) => { readers.push(reader); return () => { const at = readers.indexOf(reader); if (at >= 0) readers.splice(at, 1); }; }; // Links already waited for again after a later read lost them; cleared with the wait itself. const recheckedAfterPresent = new Set(); /** * How long after a link was first listed its absence still reads as the search catching up. The index was * measured lagging about 45 s on a new link; twice that, and more. After it, a link that was listed and is no * longer is a link that was removed (the person ended this account's access), and the accepted note goes. */ export const ACCEPTED_LINK_TRUST_MS = 120_000; // Every my-proxies read of the account names its links announced within the trust window, and the server reads // those by id, so a search that has not listed a new link yet, or lists it and loses it again, cannot drop it. const MAX_AWAITED_LINKS = 5; const awaitedLinks: Array<{ accountId: string; relatedPersonId: string; at: number }> = []; export const rememberAwaitedLink = (accountId: string, relatedPersonId: string): void => { const at = awaitedLinks.findIndex((link) => link.accountId === accountId && link.relatedPersonId === relatedPersonId); if (at >= 0) awaitedLinks.splice(at, 1); awaitedLinks.push({ accountId, relatedPersonId, at: Date.now() }); }; export const awaitedLinkIds = (accountId: string): string[] => awaitedLinks .filter((link) => link.accountId === accountId && Date.now() - link.at < ACCEPTED_LINK_TRUST_MS) .map((link) => link.relatedPersonId) .slice(-MAX_AWAITED_LINKS); export const forgetAwaitedLinks = (): void => { awaitedLinks.length = 0; }; // When the wait first found its link listed. let listedAt: { relatedPersonId: string; at: number } | null = null; // THE ANSWER IS NEVER LOST FOR WANT OF A LIST ON SCREEN. A shell that mounts no proxy list (the global portal // does not) left nothing to lend a reader, and the accepted link was then waited for by nobody: the next list // to open read once, and a search that had not caught up showed "no access" with nothing still waiting. The // caller that has the answer can lend its own reader for exactly that case. const announce = ( link: { relatedPersonId: string; subjectName: string }, kind: AcceptedLinkKind, owner?: ProxyReader, confirm?: AcceptedLinkRefresh['confirm'], owned?: AcceptedLinkRefresh['owned'], ): Promise => { // The account that answered is the one waited for: never another account's list that happens to be mounted. const mounted = owner ? [...readers].reverse().find((reader) => reader.accountId === owner.accountId) : readers[readers.length - 1]; const reader = mounted ?? owner; if (!reader) return Promise.resolve(); return refreshUntilAccepted({ ...link, kind, accountId: reader.accountId, read: reader.read, ...(confirm ? { confirm } : {}), ...(owned ? { owned } : {}) }); }; /** A confirmed Accept: wait for that link, by the id the server answered with, for the account that accepted it. */ export const announceAcceptedLink = ( accepted: { relatedPersonId: string; subjectName: string }, owner?: ProxyReader, confirm?: AcceptedLinkRefresh['confirm'], owned?: AcceptedLinkRefresh['owned'], ): Promise => announce(accepted, 'accepted', owner, confirm, owned); /** A created child: wait for the guardian link the create answered with; the child is never created twice. */ export const announceAddedChild = (added: { relatedPersonId: string; subjectName: string }): Promise => announce(added, 'added'); /** Starts the same wait again; it never re-sends the Accept or the create. */ export const retryAcceptedLink = (): Promise => lastRefresh ? refreshUntilAccepted(lastRefresh) : Promise.resolve(); /** * A list read after the wait found the link, and the link is not in it: the search behind the list is not * consistent from one read to the next yet. That is not a revocation to ignore nor a grant to keep showing; the * read stands, and the same bounded wait runs once more for that link. Never re-sends the Accept. */ export const recheckPresentLink = (accountId: string | undefined, proxies: readonly MyProxy[]): void => { const link = current; if (!link || !lastRefresh || link.accountId !== accountId) return; if (proxies.some((proxy) => proxy.relatedPersonId === link.relatedPersonId)) return; // Listed long enough ago that the search has caught up: it was removed, and nothing is waited for any more. if (listedAt?.relatedPersonId === link.relatedPersonId && Date.now() - listedAt.at > ACCEPTED_LINK_TRUST_MS) { cancelAcceptedLink(); return; } if (link.status !== 'present') return; if (recheckedAfterPresent.has(link.relatedPersonId)) return; recheckedAfterPresent.add(link.relatedPersonId); void refreshUntilAccepted(lastRefresh); }; /** Account change, acting-as, sign-out: stop, and let no late read land. */ export const cancelAcceptedLink = (): void => { run += 1; inFlight?.abort(); inFlight = null; lastRefresh = null; listedAt = null; recheckedAfterPresent.clear(); if (current) publish(null); }; // THE WAIT BELONGS TO THE VIEWER THAT ACCEPTED. Another account, practice or acted-as link ends it at once, its read // in the air included, whether or not any proxy list is mounted to notice: a screen that mounts none (the reason the // banner lends its own reader) would otherwise let the old wait go on reading under the new session. onViewerChange(() => cancelAcceptedLink());