// D4, Task 12 - the two emailed credential-recovery links, and the unlock token's storage. // // Pure and React-free (mirrors utils/proofingFlow.ts) so both pages and their tests can use it // without dragging the services barrel in, and so the URL parsing is testable on its own. // // WHY THE TOKEN IS READ FROM THE RAW `href` AND NOT FROM `useLocation().search`. // The plugin is a **HashRouter** SPA (App.tsx: `HashRouter as Router`) mounted on a WordPress page by // shortcode (includes/frontend/class-akashere-shortcode.php) - there are no rewrite rules, so the only // URL that can reach an in-app route is `https://practice.example/portal/#/credential-unlock?u=...`. // Under HashRouter, `useLocation().search` is the query INSIDE the fragment; the document-level // `window.location.search` is a different string entirely. The two links can plausibly arrive in // either shape (see task-12-report.md - the server's builder currently emits the path-and-query shape // with the fragment explicitly cleared), so this reads BOTH and never depends on a Router being // mounted above it. Reading only one of the two is a silent dead link, which is the failure mode this // whole journey has hit three times already. /** `/credential-revert?t=` - the "this wasn't me" link. */ export const REVERT_TOKEN_PARAM = 't'; /** `/credential-unlock?u=` - the account-locked notice's "Verify your identity" link. */ export const UNLOCK_TOKEN_PARAM = 'u'; /** * localStorage, NOT sessionStorage - unlock.ts's client contract #3, and the single most likely * way this journey breaks a fourth time. * * Stripe's return URL deliberately carries no `u=` (handing a live bearer credential to a third party * and its logs would be worse), so on return the page can only re-read the token from its own * storage. sessionStorage survives Stripe's same-tab redirect but NOT a closed tab, a new tab, or * Stripe Identity's phone hand-off for document capture - and the design explicitly assumes the * patient may re-open the emailed link hours later. Anything tab-scoped strands them. */ export const UNLOCK_TOKEN_STORAGE_KEY = 'akashic.credential-recovery.unlockToken'; /** * Read a link token out of a full `href`, checking the fragment's query first and the document's * query second. * * The in-hash query wins when both carry the parameter: that one is the SPA's own location, i.e. the * value the router itself would have seen. * * The token is deliberately NOT trimmed or reshaped - it is a credential compared against a stored * value server-side, and silently rewriting it here could only turn a real mismatch into a confusing * one. (Same reasoning as acceptInviteParams.ts.) */ export const readLinkToken = (name: string, href: string): string | undefined => { const source = href ?? ''; const hashIndex = source.indexOf('#'); const beforeHash = hashIndex === -1 ? source : source.slice(0, hashIndex); const fragment = hashIndex === -1 ? '' : source.slice(hashIndex + 1); const queryOf = (part: string): string => { const at = part.indexOf('?'); return at === -1 ? '' : part.slice(at); }; for (const query of [queryOf(fragment), queryOf(beforeHash)]) { if (!query) continue; const value = new URLSearchParams(query).get(name); if (value) return value; } return undefined; }; // Storage access is wrapped because localStorage throws outright in a few real browser // configurations (Safari private mode historically, and any embedding that blocks storage). A throw // here would blank the only page that can unlock the account, so every accessor degrades to "no // stored token" and lets the URL be authoritative. const readStored = (): string | undefined => { try { return window.localStorage.getItem(UNLOCK_TOKEN_STORAGE_KEY) || undefined; } catch { return undefined; } }; const writeStored = (token: string): void => { try { window.localStorage.setItem(UNLOCK_TOKEN_STORAGE_KEY, token); } catch { /* non-fatal — the in-URL token still works for this visit. */ } }; /** Drop the stored token. Called once the unlock has actually succeeded (the token is revoked). */ export const forgetUnlockToken = (): void => { try { window.localStorage.removeItem(UNLOCK_TOKEN_STORAGE_KEY); } catch { /* non-fatal. */ } }; /** * The unlock token for this visit. * * `?u=` IS ALWAYS AUTHORITATIVE and is re-persisted every time the emailed link is opened - a * patient re-opening a newer notice must not be pinned to whatever an older one left behind. Only * when the URL carries none (which is exactly the Stripe return leg, by design) does the stored copy * answer. */ export const resolveUnlockToken = (href: string): string | undefined => { const fromLink = readLinkToken(UNLOCK_TOKEN_PARAM, href); if (fromLink) { writeStored(fromLink); return fromLink; } return readStored(); };