import { useCallback, createContext, useContext, ReactNode } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; // D4, Task 10 - the client half of the credential-recovery family. All four server routes // live in templates/fhir/02-fhir-base.yaml, the SAME base createPatient / matchCandidates use (see // PatientService.tsx's comment) - fhirBaseApi already ends in /R4; a wrong base here is a // SILENT 404, not an error, and the caller would simply see nothing happen. NOT apiUrl. const API_URL = environment.fhirIdentityApi; // THREE OF THE FOUR ROUTES THIS SERVICE CALLS ARE UNAUTHENTICATED BY DESIGN (`Authorizer: NONE` in // 02-fhir-base.yaml - revert, proof-session, unlock). Each is reached by a patient who, by // construction, CANNOT sign in: revert.ts is what disables their Cognito user, proof-session.ts is // how they prove identity to get back in, and unlock.ts is the only door back in. bypassAuth // stops useHttpService's fetchWithAuth from attaching an Authorization header at all (it only does so // when `!options.bypassAuth`) - there is no session here to mint a bearer token from, and forcing one // would be inert at best. bypassOrganization rides along, mirroring every other unauthenticated call in // this codebase (OrganizationService.fetchOrganization/fetchWordPressOrganization, // ValueSetService's SNOMED/LOINC/task-code lookups): there is no logged-in org context to assert either. const UNAUTHENTICATED = { bypassAuth: true, bypassOrganization: true }; /** *. Deliberately NOT `Omit` - declaring it in full keeps the * two doors independent, so tightening the attest body later cannot silently change what an unlock * sends. The identity-evidence fields are the same four on purpose (one D2 standard, both doors). */ export interface StaffUnlockInput { patientId: string; verifiedIdType: string; verifiedIdState: string; verifiedIdLast4: string; verifiedIdExpiry: string; } export interface AttestRecoveryInput { patientId: string; newEmail: string; verifiedIdType: string; verifiedIdState: string; verifiedIdLast4: string; verifiedIdExpiry: string; } export const useCredentialRecoveryService = () => { const { post } = useHttpService(); // POST {fhirIdentityApi}/credential-recovery/attest - THE ONE AUTHENTICATED ROUTE IN THIS FAMILY: a // signed-in receptionist attesting an in-person identity check for a patient locked out at the // desk. No bypass options - useHttpService's DEFAULT headers (Authorization + X-Organization-ID) // are exactly what handlers/CredentialRecovery/attest.ts requires (`requireSystemAccess: true`, // staff-only via routes.ts). `patientId` still travels in the body even though the caller is // authenticated - staff act FOR someone else, so there is no "caller's own identity" to derive it // from; the server's own org-affiliation + patient-access gates are what makes trusting it safe // (attest.ts's own header comment covers this - do not read the body-supplied id as a shortcut). // // `oldEmailNotified` (added post-Task-11 review) - attest.ts:440-447 returns it and its own // comment calls it "operationally important, not cosmetic": per D3/A1 the OLD address is the MORE // important of the two notice targets (a victim of a false attestation still holds it; a genuine // recoverer has, by definition, lost the other one). It was silently dropped by this type before - // a caller reading only `noticeReachedSecondChannel` had no way to know the more important leg // failed. Do not drop it again. const attestRecovery = useCallback((input: AttestRecoveryInput): Promise<{ status: 'changed'; tagSynced: boolean; noticeReachedSecondChannel: boolean; oldEmailNotified: boolean; }> => post(`${API_URL}/credential-recovery/attest`, input), [post]); // POST {fhirIdentityApi}/credential-recovery/revert {token} - the "wasn't me" link. UNAUTHENTICATED: // called by someone who, by construction, cannot sign in. The single-use, 7-day, server-minted // revert token IS the authorization; there is no patientId to send even if we wanted to - // revert.ts resolves the patient exclusively from the token via redeemRevertToken, never from the // body. const revertCredential = useCallback((token: string): Promise<{ status: 'reverted-and-locked' }> => post(`${API_URL}/credential-recovery/revert`, { token }, UNAUTHENTICATED), [post]); // POST {fhirIdentityApi}/credential-recovery/unlock {token} - the ONLY door back into an account // "wasn't me" locked. UNAUTHENTICATED, and the body carries a TOKEN, NOT a patientId. // // This deliberately differs from the task-10 brief's original sketch, which had this method take // a patientId and post `{ patientId }`. handlers/CredentialRecovery/unlock.ts (infra repo, // lines 22-29) is explicit: "THE PATIENT COMES FROM THE TOKEN, NEVER THE BODY... Any extra body // field (`patientId`, `email`,...) is inert" - the handler destructures only `token` from // event.body and resolves the patient via resolveUnlockToken(token). A client posting `{ patientId // }` instead would make this route permanently unreachable: `token` would always be undefined, // which unlock.ts's own `typeof token !== 'string'` check refuses with the same uniform 403 as // every other dead-token case. It is also the only shape reachable in practice - the unlock page // (Task 12) is opened from `${portalUrl}/credential-unlock?u=` and is never told a // patientId at all. See task-10-report.md for the full writeup. const unlockAccount = useCallback((token: string): Promise<{ status: 'unlocked' }> => post(`${API_URL}/credential-recovery/unlock`, { token }, UNAUTHENTICATED), [post]); // POST {fhirIdentityApi}/credential-recovery/proof-session {token} -> {url, sessionId}. UNAUTHENTICATED // - the second step of the "wasn't me" journey: locked patient clicks the emailed link -> this call // -> redirect to `url` (Stripe hosted verification) -> Stripe returns the browser -> unlockAccount. // Added retroactively (coordinator, 2026-08-04): the route didn't exist when task-10-brief.md was // written (it was inserted mid-plan once the lock turned out to have no exit), so the brief's // interface list never mentioned it - but the unlock page cannot start verification without it. // // BODY IS `{ token }` ONLY - same discipline as unlockAccount, and for the identical reason. // handlers/CredentialRecovery/proof-session.ts's own header: "personKey COMES FROM THE TOKEN, NEVER // THE BODY... a caller holding ANY valid unlock token could otherwise point proofing at SOMEONE // ELSE's locked account and unlock it. Any extra body field (`patientId`, `personKey`,...) is // inert." There is deliberately no parameter here for one - the signature itself is the guard, not // just the request body. const startProofSession = useCallback((token: string): Promise<{ url: string; sessionId: string }> => post(`${API_URL}/credential-recovery/proof-session`, { token }, UNAUTHENTICATED), [post]); // POST {fhirIdentityApi}/credential-recovery/staff-unlock -, THE SECOND door out of a locked // account, and the only one with a human in it. AUTHENTICATED and receptionist-only, exactly like // `attestRecovery` above and unlike `unlockAccount` next to it - so NO bypass options. // // THE TWO UNLOCK METHODS ARE NOT INTERCHANGEABLE, AND THE SIGNATURES SAY SO. `unlockAccount` // takes a TOKEN because its caller cannot sign in; this one takes a patientId because its caller is // a signed-in receptionist acting for someone else. Do not "unify" them: the token route resolves // the patient from the token precisely so an unauthenticated caller can never name a subject, while // this route's body-supplied patientId is safe only because the server re-derives authority from the // session (org affiliation + patient access, both audited). // // NO `newEmail` PARAMETER, deliberately - an unlock changes no address. The server re-enables the // account at the address on its own lock record, which is what stops a single-attester override from // handing anyone a mailbox. There is nothing to send, and the absence of the parameter is the point. // // AND NO IMAGE FIELD, ever - D2. The server rejects one outright rather than ignoring it. const staffUnlockAccount = useCallback((input: StaffUnlockInput): Promise<{ status: 'unlocked' }> => post(`${API_URL}/credential-recovery/staff-unlock`, input), [post]); return { attestRecovery, revertCredential, unlockAccount, startProofSession, staffUnlockAccount }; }; const CredentialRecoveryServiceContext = createContext | undefined>(undefined); export const CredentialRecoveryServiceProvider = ({ children }: { children: ReactNode }) => { const credentialRecoveryService = useCredentialRecoveryService(); return ( {children} ); }; export const useCredentialRecoveryServiceContext = () => { const context = useContext(CredentialRecoveryServiceContext); if (context === undefined) { throw new Error('useCredentialRecoveryServiceContext must be used within a CredentialRecoveryServiceProvider'); } return context; };