/** * TWO PORTALS ON ONE ORIGIN NEED TWO NAMESPACES, NOT ONE. * * THE DEFECT. Both plugin portals mount from the same code and wrote their session under a single * localStorage prefix, so the second sign-in overwrote the first. Signing into the patient portal ended * the staff session and the reverse - and because there is no password, the way back in is a one-time * code somebody has to fetch from an inbox. That is the real cost: not the eviction, but a person * stopping what they are doing to read an email, several times an hour. * * AND IT IS THE FOUNDING MODEL COLLIDING WITH A STORAGE KEY. One human is one account whose home gear * is a Patient, so a practitioner who is also a patient of their own practice is the ORDINARY case - * and they could not have both open on one device. * * THE STACK SEGMENT IS UNCHANGED AND STILL DOES ITS OWN JOB. Two builds pointed at different stacks * must not share a key; two builds pointed at the same stack must. This adds one more dimension to that * namespace rather than replacing it, so both properties survive. */ /** The two audiences that can hold a session at the same time on one device. */ export type SessionPortal = 'staff' | 'patient'; /** * Which portal is a mount context? * * Decided by the context the PHP emits, not by the URL and not by what is in storage: the same bundle * serves wp-admin, the staff front-end portal and the patient portal, and only the mount knows which one * it is. `admin` and `staff` are ONE audience - a receptionist in wp-admin and the same receptionist on * the staff portal are the same person doing the same job. * * IT TAKES THE CONTEXT RATHER THAN READING IT, and that is not a style choice. `mountContext.ts` imports * `environment`, and `environment` is what needs this - importing it here closes a cycle that happens to * resolve in a browser and throws the moment anything else loads the module first. It also makes the * decision a pure function, which is the only reason it can be tested at all. * * `frontend` is the patient portal; anything else, including an absent or unrecognised value, is staff. * Defaulting the UNKNOWN case to staff matches `getMountContext`, whose own default is `frontend` for * rendering - but a storage namespace is not a render, and putting an unidentifiable session in the * patient bucket is how a staff token ends up somewhere nothing reads it. */ export const sessionPortalForContext = (context: string | undefined): SessionPortal => context === 'frontend' ? 'patient' : 'staff'; /** * The full key prefix for one portal's session. * * `akashic___` - the stack namespace with the audience appended, * so the two can never overwrite each other and a key in devtools still says which stack it belongs to. */ export const portalStorageKeyPrefix = (stackPrefix: string, portal: SessionPortal): string => `${stackPrefix}_${portal}`; /** * Every key the session is spread across. * * LISTED, NOT DISCOVERED BY SCANNING THE PREFIX. A scan would also sweep up anything else that ever * shared it, and the migration below MOVES what it finds - so a wrong list is a wrong move rather than * a missed one. A key added to the session and not added here simply is not migrated, which strands * exactly the thing this file exists to stop; the spec pins the count so that omission fails a test. */ export const SESSION_KEYS = [ 'accessToken', 'idToken', 'refreshToken', 'tokenExpiration', 'userInfo', 'loggedInAs', 'actingAs', 'organizationId', 'practitionerRole', 'workspace', ] as const; /** * Which portal does a session found under the LEGACY prefix belong to? * * `loggedInAs` is written only by a patient-side sign-in - it holds 'Patient' or 'RelatedPerson' and is * removed on the staff side. So its presence identifies the audience without needing to decode a token, * which matters because this runs at module load and a malformed token must not throw. * * ABSENT MEANS STAFF, deliberately. A session with no marker is far more likely to be staff than to be a * patient whose marker failed to write, and guessing 'patient' would move a staff session into the * patient namespace where nothing would ever read it. */ export const legacySessionPortal = (loggedInAs: string | null): SessionPortal => loggedInAs === 'Patient' || loggedInAs === 'RelatedPerson' ? 'patient' : 'staff'; /** * Move a session written under the old single prefix into the namespace it belongs to. * * IT MIGRATES TO WHERE THE SESSION BELONGS, NOT TO WHERE WE ARE STANDING, and that is the whole design. * A staff session found while the PATIENT portal is booting is moved into the STAFF namespace and left * there - it is not adopted, and it is not deleted. Doing either would be the original bug with extra * steps: one silently hands a staff token to the patient app, the other signs the staff portal out from * a page that has nothing to do with it. * * IT NEVER OVERWRITES A SESSION THAT IS ALREADY THERE. If the destination already holds one, the legacy * copy is the older of the two and is simply cleared. * * IT RUNS ONCE, because it REMOVES the legacy keys either way. Leaving them would mean re-running this * on every load, and a stale entry nothing reads sitting in storage forever is one of the two outcomes * this was written to avoid - the other being a user who appears signed out with no explanation. */ export const migrateLegacySession = ( stackPrefix: string, storage: Pick, ): SessionPortal | null => { let moved: SessionPortal | null = null; try { const legacyKey = (name: string) => `${stackPrefix}_${name}`; // NOTHING TO DO IS THE COMMON CASE, and it must cost nothing. A browser that has already migrated, // or has never signed in, hits this and stops. if (!storage.getItem(legacyKey('accessToken')) && !storage.getItem(legacyKey('refreshToken'))) { return null; } const portal = legacySessionPortal(storage.getItem(legacyKey('loggedInAs'))); const destination = portalStorageKeyPrefix(stackPrefix, portal); const occupied = Boolean(storage.getItem(`${destination}_refreshToken`)); for (const name of SESSION_KEYS) { const value = storage.getItem(legacyKey(name)); if (value !== null && !occupied) { storage.setItem(`${destination}_${name}`, value); } storage.removeItem(legacyKey(name)); } moved = occupied ? null : portal; } catch { // Storage can throw - a browser in private mode, or a quota. A migration that fails must not take // the app down with it: the worst case is the old behaviour, which is what shipped for months. } return moved; }; /** * One-time move of a session from the stack-derived portal prefix to the identity-derived one. A * session already at the destination wins: it is newer by construction, and with refresh-token * rotation the older copy is the one that would sign the person out. */ export const migrateStackSession = ( fromPrefix: string, toPrefix: string, storage: Pick, ): boolean => { let moved = false; try { if (fromPrefix === toPrefix) return false; const source = (name: string) => `${fromPrefix}_${name}`; if (!storage.getItem(source('accessToken')) && !storage.getItem(source('refreshToken'))) { return false; } const occupied = Boolean(storage.getItem(`${toPrefix}_refreshToken`)); for (const name of SESSION_KEYS) { const value = storage.getItem(source(name)); if (value !== null && !occupied) { storage.setItem(`${toPrefix}_${name}`, value); } storage.removeItem(source(name)); } moved = !occupied; } catch { // Storage can throw - a browser in private mode, or a quota. Same tolerance as migrateLegacySession: // the worst case is leaving the session under its old prefix, not taking the app down. } return moved; }; /** * Keys under a portal's prefix that must SURVIVE the sweep below, each named with its reason. The legacy token * keys: the session lives in the credential record, and a token an older build left proves nothing about who owns * it, so a sign-out neither reads, revokes nor deletes it. */ export const SESSION_SURVIVORS: readonly string[] = ['accessToken', 'idToken', 'refreshToken', 'tokenExpiration', 'sessionId']; /** * Clear one portal's session by PREFIX, not by listing what to remove. * * WHY A SWEEP RATHER THAN A LIST. The list was complete, and it was complete every time somebody checked * it - but it is hand-maintained, and three leaks have already happened exactly that way: the in-memory * `loggedInAs` ref, the campaign CTA attribution, and the message-thread cache that was leaving patient * and practitioner NAMES on the browser for whoever used it next. Each was added to the session and not * added to the removals, and nothing failed. A sweep makes the fourth one impossible instead of unlikely. * * SCOPED TO ONE PORTAL, deliberately. Storage is namespaced by role, so a staff sign-out must not evict a * patient session sitting beside it on the same browser - the two portals hold sessions simultaneously by * design. This removes the prefix the caller names and nothing else. * * Collects the keys before removing any of them: `removeItem` during a live index walk over localStorage * re-indexes the remaining entries, which silently skips every other match - the same trap the direct * thread cache's sweep documents. */ export const clearPortalSession = ( prefix: string, storage: Pick, ): number => { try { const doomed: string[] = []; const survivors = new Set(SESSION_SURVIVORS.map((name) => `${prefix}_${name}`)); for (let index = 0; index < storage.length; index += 1) { const key = storage.key(index); if (key?.startsWith(`${prefix}_`) && !survivors.has(key)) { doomed.push(key); } } doomed.forEach((key) => storage.removeItem(key)); return doomed.length; } catch { // Storage unavailable - private mode, or a quota. Nothing was written in that case either, so // there is nothing to leak; taking the sign-out down with it would be the worse outcome. return 0; } };