/** * One custom-agreement upload attempt, as this browser remembers it. * * The reference is written BEFORE the first send and holds only what proves which attempt it was: no file, * no upload address, no token. Recovery and retirement send it unchanged, so a status read since, or a * reload, can never retarget it. A reference that is incomplete, old or for someone else is unproved. */ export interface PendingDraftRef { documentKey: string; signedAt: string; } export interface BaaUploadReference { schema: 1; practiceId: string; viewer: string; actorUserId: string; attemptId: string; inputsDigest: string; admissionGeneration: number; expectedDraft: PendingDraftRef | null; } export interface BaaUploadInputs { signerName: string; signerTitle: string; orgName: string; orgAddress: string; orgPhone: string; fileName: string; fileSha256: string; } const storageKey = (practiceId: string) => `akashic.baaUploadAttempt.${practiceId}`; const isDraft = (value: unknown): value is PendingDraftRef => Boolean(value) && typeof (value as PendingDraftRef).documentKey === 'string' && typeof (value as PendingDraftRef).signedAt === 'string'; export const parseBaaUploadReference = (value: unknown): BaaUploadReference | undefined => { const reference = value as Partial | null; if (!reference || reference.schema !== 1) return undefined; const strings = [reference.practiceId, reference.viewer, reference.actorUserId, reference.attemptId, reference.inputsDigest]; if (strings.some((part) => typeof part !== 'string' || part.length === 0)) return undefined; if (typeof reference.admissionGeneration !== 'number' || !Number.isSafeInteger(reference.admissionGeneration) || reference.admissionGeneration < 0) return undefined; if (!('expectedDraft' in reference) || (reference.expectedDraft !== null && !isDraft(reference.expectedDraft))) return undefined; return reference as BaaUploadReference; }; export type StoredReference = | { reference: BaaUploadReference } | { unproved: true } | null; /** The open attempt for this practice and viewer: none, a complete one, or one that proves nothing. */ export const loadBaaUploadReference = (practiceId: string, viewer: string | null, actorUserId: string | undefined): StoredReference => { let raw: string | null = null; try { raw = window.localStorage.getItem(storageKey(practiceId)); } catch { return null; } if (!raw) return null; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { unproved: true }; } const reference = parseBaaUploadReference(parsed); if (!reference || reference.practiceId !== practiceId) return { unproved: true }; // Another person's attempt in this browser is not this viewer's to recover or retire. if (reference.viewer !== viewer || reference.actorUserId !== actorUserId) return null; return { reference }; }; /** Throws when it cannot be kept: an attempt that could not be remembered is not started. */ export const saveBaaUploadReference = (reference: BaaUploadReference): void => { window.localStorage.setItem(storageKey(reference.practiceId), JSON.stringify(reference)); }; export const clearBaaUploadReference = (practiceId: string, attemptId: string): void => { try { const current = parseBaaUploadReference(JSON.parse(window.localStorage.getItem(storageKey(practiceId)) ?? 'null')); if (!current || current.attemptId === attemptId) window.localStorage.removeItem(storageKey(practiceId)); } catch { // Nothing kept, or nothing readable: nothing to clear. } }; const hex = (buffer: ArrayBuffer) => Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, '0')).join(''); const base64 = (buffer: ArrayBuffer) => btoa(String.fromCharCode(...new Uint8Array(buffer))); /** Base64 SHA-256 of the file; the server signs it into the upload address. */ export const fileSha256 = async (file: Blob): Promise => base64(await crypto.subtle.digest('SHA-256', await file.arrayBuffer())); /** The same digest the server computes over the attempt's inputs. */ export const baaUploadInputsDigest = async (inputs: BaaUploadInputs): Promise => hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([ inputs.signerName, inputs.signerTitle, inputs.orgName, inputs.orgAddress, inputs.orgPhone, inputs.fileName, inputs.fileSha256, ])))); export const newAttemptId = (): string => crypto.randomUUID(); /** * Only these answers from storage prove the file was not stored: refusals of the request itself, given * with S3's own error body. Anything else - a server error, a bare status, no answer at all - may have * stored it. */ const DEFINITE_REFUSALS = new Set(['AccessDenied', 'SignatureDoesNotMatch', 'BadDigest', 'InvalidDigest', 'RequestTimeTooSkewed', 'EntityTooLarge']); export type TransferResult = 'stored' | 'refused' | 'unknown'; export const classifyTransferResponse = async (response: Response): Promise => { if (response.ok) return 'stored'; if (response.status < 400 || response.status >= 500) return 'unknown'; try { const code = /([^<]+)<\/Code>/.exec(await response.text())?.[1]; return code && DEFINITE_REFUSALS.has(code) ? 'refused' : 'unknown'; } catch { return 'unknown'; } };