// A member who has just joined a practice, carried from the invitation page to the session it opens. Held in // memory for this page only, like the sign-in address handoff beside it. // // Why it exists: with one code the new member reaches the staff portal seconds after accepting, and the first // read that opens the session searches for their role in the practice. HealthLake's search trails the write // that made that role (measured at about 30 s), so the read is refused although the membership is real. For // the practitioner who has JUST joined THIS practice, and for their first read of their own record only, that // refusal is waited out, bounded; the read spends the mark. Anybody else is refused at once, as before. export const JUST_JOINED_WINDOW_MS = 2 * 60 * 1000; // Spans the measured lag with room, and gives up well inside a minute: 2+3+4+5+6+7+8 = 35 s of waiting. const RETRY_DELAYS_MS = [2000, 3000, 4000, 5000, 6000, 7000, 8000]; let joined: { organizationId: string; practitionerId: string; at: number } | undefined; export const rememberJustJoined = (organizationId: string, practitionerId: string) => { joined = { organizationId, practitionerId, at: Date.now() }; }; export const forgetJustJoined = () => { joined = undefined; }; /** * Whether the admission check should wait on collaboration-team, judged from the token's roles at this * practice. That route admits practitioners (and patients and helpers), never a receptionist or other staff, * so waiting on it for them spent the whole budget on a refusal that is correct. A token that names no role * here says nothing either way (a best-effort re-mint can leave the sign-in claims), so it is still asked. */ export const probesCollaborationTeam = (roles: string[] | undefined): boolean => !roles?.length || roles.includes("Practitioners"); /** The gateways the workspace opens on, by the route key the server's capability answer uses. */ export const JOIN_PROBES = { presence: "GET /R4/presence", location: "GET /R4/Location", colleagues: "GET /R4/colleagues", "collaboration-team": "GET /R4/collaboration-team", } as const; export type JoinProbe = keyof typeof JOIN_PROBES; /** * Which gateways the admission check waits on. A route the member's own role and permissions do not reach is * refused for good, and waiting on it read an ordinary missing permission as access "still being set up". The * server's capability answer is the evidence, and only when it is for this practice and names a role; without * it, every gateway is asked, except collaboration-team for a token that names a non-practitioner role here. */ export const joinProbesFor = ( organizationId: string | undefined, capabilities: { organizationId?: unknown; roles?: unknown; permitted?: unknown } | undefined, tokenRoles: string[] | undefined, ): JoinProbe[] => { const probes = Object.keys(JOIN_PROBES) as JoinProbe[]; const permitted = capabilities?.permitted; const answered = Boolean(organizationId) && capabilities?.organizationId === organizationId && Array.isArray(capabilities?.roles) && capabilities.roles.length > 0 && Array.isArray(permitted); if (answered) return probes.filter((probe) => (permitted as unknown[]).includes(JOIN_PROBES[probe])); return probes.filter((probe) => probe !== "collaboration-team" || probesCollaborationTeam(tokenRoles)); }; /** * The join wait ran out before the member's own record could be read: it stalled, or it was still refused. * Carries the refusal's status, if there was one, so an agency member owed Terms is still told so. */ export class JoinNotReady extends Error { readonly status?: number; constructor(cause?: unknown) { super("Access at the practice was not ready within the join wait."); this.name = "JoinNotReady"; this.status = (cause as { status?: number } | null | undefined)?.status; } } /** Whether this practitioner is in the join wait at this practice; asking does not spend the mark. */ export const isJustJoined = (organizationId: string | undefined, practitionerId: string | undefined): boolean => justJoined(organizationId, practitionerId); const justJoined = (organizationId: string | undefined, practitionerId: string | undefined): boolean => Boolean(organizationId && practitionerId && joined && joined.organizationId === organizationId && joined.practitionerId === practitionerId && Date.now() - joined.at <= JUST_JOINED_WINDOW_MS); const refused = (error: unknown) => (error as { status?: number } | null)?.status === 403; const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms)); /** * Runs `read` of `practitionerId`'s own record. Only when that practitioner has just joined `organizationId`, a * refusal is tried again on a bounded back-off while the role index catches up, and the mark is spent however the * read ends, so it stretches exactly one read. Any other failure, and anybody else's refusal, is thrown at once. * For that member each read is also cut off at the budget, its signal aborted, and a read that stalls or is still * refused when the budget runs out ends the wait with `JoinNotReady`; nothing it answers later is used. * * The session's token was minted at sign-in, while the same index still missed the new role, so it carries only * the base scopes for its hour. Once the read succeeds the index has caught up, and `reMint` replaces the token. * * The gateways look the role up on their own and can trail this read by several seconds, refusing the workspace's * first reads. So the session opens once `admitted` has answered yes twice in a row, a moment apart, since a * lagging search can answer either way while it catches up. Everything after the first read - its back-off, the * re-mint, each probe and every pause - comes out of one budget; at its end the session opens as it would have. */ export async function readWhileJoining( read: (signal?: AbortSignal) => Promise, organizationId: string | undefined, practitionerId: string | undefined, { wait = sleep, reMint, admitted, onSettled }: { wait?: (ms: number) => Promise; reMint?: () => Promise; admitted?: (signal: AbortSignal) => Promise; /** For a member in the join wait only: whether every gateway admitted them before the session opened. */ onSettled?: (admitted: boolean) => void; } = {}, ): Promise { const waitingOut = justJoined(organizationId, practitionerId); if (waitingOut) forgetJustJoined(); const deadline = Date.now() + JOIN_BUDGET_MS; const left = () => deadline - Date.now(); let value: T; let lastRefusal: unknown; for (let attempt = 0; ; attempt += 1) { if (waitingOut && left() <= 0) throw new JoinNotReady(lastRefusal); try { value = waitingOut ? await beforeDeadline(read, left()) : await read(); break; } catch (error) { if (error === EXPIRED) throw new JoinNotReady(lastRefusal); if (!refused(error) || !waitingOut) throw error; lastRefusal = error; if (attempt >= RETRY_DELAYS_MS.length || left() <= 0) throw new JoinNotReady(error); await wait(Math.min(RETRY_DELAYS_MS[attempt], left())); } } if (!waitingOut) return value; // Best effort: the record was read, and a failed or stalled re-mint leaves the session as it was. if (reMint) await within(() => reMint(), left(), undefined); const admittedAll = !admitted || await waitForAdmission(admitted, left, wait); // Opened before every gateway admitted the member: the caller hears it here, and a refused read can be tried again. onSettled?.(admittedAll); return value; } const JOIN_BUDGET_MS = RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0); const PROBE_MS = 5000; const CONFIRM_MS = 1000; /** `work`'s answer, or `fallback` once `ms` has passed or it fails; on timeout its signal is aborted. */ async function within(work: (signal: AbortSignal) => Promise, ms: number, fallback: F): Promise { const controller = new AbortController(); let timer: ReturnType | undefined; const timedOut = new Promise((resolve) => { timer = setTimeout(() => { controller.abort(); resolve(fallback); }, Math.max(0, ms)); }); try { return await Promise.race([work(controller.signal).catch(() => fallback), timedOut]); } finally { clearTimeout(timer); } } /** Whether `admitted` answers yes within `ms`; a stall anywhere in it, a failure or the timeout is a no. */ export const admittedWithin = (admitted: (signal: AbortSignal) => Promise, ms: number): Promise => within(admitted, ms, false); const EXPIRED = Symbol("join budget spent"); /** `work`'s answer, or a rejection with EXPIRED once `ms` has passed, aborting its signal. */ async function beforeDeadline(work: (signal: AbortSignal) => Promise, ms: number): Promise { const controller = new AbortController(); let timer: ReturnType | undefined; const expired = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(EXPIRED); }, Math.max(0, ms)); }); try { return await Promise.race([work(controller.signal), expired]); } finally { clearTimeout(timer); } } async function waitForAdmission( admitted: (signal: AbortSignal) => Promise, left: () => number, wait: (ms: number) => Promise, ): Promise { let confirmed = false; for (let backoff = 0; left() > 0;) { const yes = await within(admitted, Math.min(PROBE_MS, left()), false); if (yes && confirmed) return true; confirmed = yes; const pause = yes ? CONFIRM_MS : RETRY_DELAYS_MS[Math.min(backoff++, RETRY_DELAYS_MS.length - 1)]; if (left() <= 0) break; await wait(Math.min(pause, left())); } return false; }