import { environment } from '../environments/environment'; import { akashicFetch } from '../config/clientManifest'; // Recovery by a patient-verified mobile, for someone who cannot sign in. Pre-session, so a plain fetch. // Only a receipt of the agreed shape is `ok`; any other success, or an answer that may have been lost // after the server acted, is `unknown`, so the page repeats the same request instead of moving on. export type PhoneRecoveryOutcome = | { kind: 'ok'; data: T } | { kind: 'refused'; status: number; message: string } | { kind: 'unknown' } | { kind: 'unreachable' }; export type PhoneRequestReceipt = { operationId: string; message: string }; export type PhoneVerifyReceipt = { step: 'email'; maskedNewEmail: string; emailCode: 'admitted' | 'queued' | 'unconfirmed' }; export type PhoneCommitReceipt = | { status: 'changed'; maskedNewEmail: string; notice: 'owed' | 'accepted' | 'unknown' } | { status: 'checking' }; type Body = Record; const isBody = (value: unknown): value is Body => typeof value === 'object' && value !== null && !Array.isArray(value); const isText = (value: unknown): value is string => typeof value === 'string' && value.length > 0; const post = async ( step: string, body: unknown, receipt: (status: number, data: Body) => T | undefined, lostIsUnknown: boolean, ): Promise> => { let response: Response; try { response = await akashicFetch(`${environment.fhirIdentityApi}/credential-recovery/${step}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } catch { return lostIsUnknown ? { kind: 'unknown' } : { kind: 'unreachable' }; } const data: unknown = await response.json().catch(() => undefined); if (response.ok) { const valid = isBody(data) ? receipt(response.status, data) : undefined; return valid === undefined ? { kind: 'unknown' } : { kind: 'ok', data: valid }; } // A gateway failure may come after the server acted; only a proof step can be repeated to find out. if (lostIsUnknown && response.status >= 500) return { kind: 'unknown' }; const message = isBody(data) ? data.message : undefined; return { kind: 'refused', status: response.status, message: isText(message) ? message : 'That did not work. Try again.' }; }; export const requestPhoneRecovery = (args: { email: string; newEmail: string; operationId: string; phoneCode?: string }) => post('phone-request', args, (status, data) => status === 202 && data.operationId === args.operationId && isText(data.message) ? { operationId: data.operationId, message: data.message } : undefined, false); export const verifyPhoneRecovery = (args: { operationId: string; code: string; resend?: true }) => post('phone-verify', args, (status, data) => status === 200 && data.step === 'email' && isText(data.maskedNewEmail) && (data.emailCode === 'admitted' || data.emailCode === 'queued' || data.emailCode === 'unconfirmed') ? { step: 'email', maskedNewEmail: data.maskedNewEmail, emailCode: data.emailCode } : undefined, true); export const commitPhoneRecovery = (args: { operationId: string; code: string }) => post('phone-commit', args, (status, data) => { if (status === 202 && data.status === 'checking') return { status: 'checking' }; if (status === 200 && data.status === 'changed' && isText(data.maskedNewEmail) && (data.notice === 'owed' || data.notice === 'accepted' || data.notice === 'unknown')) { return { status: 'changed', maskedNewEmail: data.maskedNewEmail, notice: data.notice }; } return undefined; }, true);