import { environment } from '../environments/environment'; import { dpopHeaderFor } from '../utils/dpopRequestHeader'; import { akashicFetch } from '../config/clientManifest'; // / S2 follow-on - tell the server the patient signed out ON PURPOSE. // // Until this existed nothing ever deleted the session row: every session expired by TTL and every row // in My History read "Session ended". The observed-vs-inferred distinction the whole feature rests on // had only one branch that could ever fire. // // WHY THIS IS A MODULE AND NOT THREE LINES INSIDE `logout()`. `logout()` clears localStorage and // calls `window.location.reload()` SYNCHRONOUSLY, and must keep doing so - someone walking away from a // shared or public computer cannot be made to wait on the network to be signed out. Everything here // follows from that: // - never awaited, never rejects; // - fires BEFORE the tokens are cleared, since it needs one; // - `keepalive`, because the reload a few statements later would otherwise abort it in flight. // (`navigator.sendBeacon` is the usual unload-time tool but cannot set an Authorization header, // and this route is authorized like every other.) // Its token is the one the caller captured from the session it ends, never whatever storage holds by then: // a replacement session must never be signed out by an older one. // `organizationId` IS REQUIRED, and leaving it out is not a cosmetic omission - it is a silent 403. // // The authorizer grants a patient the `Patients` role ONLY when an org is present // (`authorizer.ts:188-215`; `cognito:groups` is deliberately ignored, because someone who is Admin at // one org must not be Admin everywhere). With no `X-Organization-ID` the caller resolves to // `userRoles: []` and every `allowedRoles` route denies - the request never reaches the handler. // // Verified the hard way on 2026-07-31: the first version of this sent `Authorization` alone, and a real // portal logout was 403'd at the authorizer with `userRoles: []` while the unit tests stayed green, // because they asserted the shape I had invented rather than what the API actually requires. It is // passed in rather than read from storage because the app keeps the org in a ref, never in // localStorage - `logout()` can read that ref safely despite its empty dependency list. export interface SignOutSnapshot { loggedInAs: string | null; accessToken: string | null; expiresAt: number; } export function sendSignOutBeacon(organizationId: string | undefined, snapshot: SignOutSnapshot): Promise { // Only a patient login has a session row to end. The Cognito post-authentication trigger skips any // account carrying practitioner ids (staff `loggedInAs` unset), and keys the row on the account's // OWN patient ids, so a pure related-person account never got one either. The server agrees - the // route is gated to Patients only, and the handler takes the id from the authorizer context, never // an act-as override. // // Known and accepted: someone who is BOTH a patient and a proxy, signed in in RelatedPerson mode, // does have a session row but will not end it here - their token's resourceType is RelatedPerson, // so the server would refuse anyway. The TTL still closes it and the row reads "Session ended", // which remains true. if (snapshot.loggedInAs !== 'Patient') { return Promise.resolve(); } const { accessToken, expiresAt } = snapshot; // An expired token would just 401. Refreshing it first is NOT an option: the refresh path calls // logout() on failure, so a sign-out that refreshed could re-enter logout. Skipping is correct - // the TTL closes the session and the row reads "Session ended", which is still true. if (!accessToken || Date.now() >= expiresAt) { return Promise.resolve(); } // No org means the call could only ever be denied, so skip it rather than fire a request that // cannot succeed. The TTL still closes the session honestly - same posture as an expired token. if (!organizationId) { return Promise.resolve(); } const url = `${environment.fhirGuardianshipApi}/my-history/sign-out`; try { // - A PROOF, because this goes to OUR gateway and the authorizer now refuses a BOUND // token that arrives without one. Since a real sign-in mints a bound token, so // WITHOUT this every sign-out 403s and the session row keeps reading "Session ended" by TTL - // silently restoring the exact one-branch state this whole beacon was built to fix. // // THE COST, AND WHY THE CALLER NOW WAITS FOR IT. Building a proof means awaiting IndexedDB and a // WebCrypto sign, so the fetch leaves the caller's synchronous turn - and `logout()` reloads the // page a few statements later. `keepalive` only carries a request that has already STARTED, so on // its own this would race the reload and might simply never be sent. // I built exactly that first and backed it out: trading a silent 403 for a silently-unsent // request is not an improvement. This returns the promise, and `logout()` waits for the request // to be ISSUED (never answered) before it reloads - a local crypto sign, not the network, which is // what the module's founding "nobody waits on the network to sign out" rule was protecting. // THE FETCH IS FIRED, NOT AWAITED, AND A TEST CAUGHT ME GETTING THIS WRONG. Chaining `.then` onto // `fetch(...)` makes the returned promise settle on the RESPONSE - so `logout()` would have waited // for the server, which is the one thing this module exists not to do. It settles as soon as the // request has been handed to the browser; `keepalive` carries it from there. return dpopHeaderFor('POST', url).then((proof) => { // Deliberately silent. A failed beacon must never surface to someone on their way out, and // must never reject into logout(). The session still closes on TTL. void akashicFetch(url, { method: 'POST', keepalive: true, headers: { Authorization: `Bearer ${accessToken}`, 'X-Organization-ID': organizationId, ...proof, }, }).catch(() => {}); }, () => { // A proof that cannot be built must not stop sign-out either. ⛔ And it must not fire the // request WITHOUT the proof: the authorizer would refuse it, so that is a 403 with extra steps. }); } catch { // Same reasoning for a synchronous throw (no fetch, a blocked request). return Promise.resolve(); } }