import { v5 as uuidV5 } from 'uuid'; import { environment } from '../environments/environment'; import { authStorageArea, credentials } from '../services/authStorage'; import { onViewerChange, writeThroughViewer } from './writeThroughHold'; interface Kept { scope: string; attemptId: string; savedAt: number } export const RECEIPT_STORAGE_KEY = `${environment.localStoragePrefix}_receiptRecovery_v2`; export const UNCONFIRMED_RECEIPT_KEPT_MS = 24 * 60 * 60 * 1000; export const RECEIPT_RECOVERY_LIMIT = 20; const LEGACY_KEY = 'akashic_unconfirmed_receipts'; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; let memory: Kept[] | undefined; const unsaved = new Map(); const settled = new Set(); let unreadableAtLoad = false; let lastReadableSession: string | null = null; let viewerGeneration = 0; let clearGeneration = 0; export class ReceiptRecoveryUnavailable extends Error { constructor(message = 'Receipt recovery could not be kept on this browser. Nothing was sent. Restore browser storage, reload, and try again.') { super(message); } } const scopeFor = (orderId: string, viewer = writeThroughViewer() ?? ''): string => { // A record not read yet, or unreadable, cannot replace the last admitted identity. const state = credentials().current().state; if (state === 'signed-in' || state === 'signed-out') lastReadableSession = credentials().epoch(); return uuidV5(JSON.stringify([environment.localStoragePrefix, lastReadableSession, viewer, orderId]), uuidV5.URL); }; const valid = (value: unknown): value is Kept => { const kept = value as Partial | null; return Boolean(kept && typeof kept.scope === 'string' && UUID.test(kept.scope) && typeof kept.attemptId === 'string' && UUID.test(kept.attemptId) && typeof kept.savedAt === 'number' && Number.isFinite(kept.savedAt)); }; const durable = (): Kept[] => { const area = authStorageArea(); if (!area) throw new Error('No browser storage'); const raw = area.getItem(RECEIPT_STORAGE_KEY); if (raw) { const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed) || parsed.length > RECEIPT_RECOVERY_LIMIT || !parsed.every(valid)) throw new ReceiptRecoveryUnavailable(); return parsed; } const legacy = area.getItem(LEGACY_KEY); const parsed: unknown = legacy ? JSON.parse(legacy) : []; if (!Array.isArray(parsed) || parsed.length > RECEIPT_RECOVERY_LIMIT) throw new ReceiptRecoveryUnavailable(); if (!parsed.every((kept) => kept && typeof kept.viewer === 'string' && typeof kept.orderId === 'string')) throw new ReceiptRecoveryUnavailable(); const migrated = parsed.map((kept) => ({ scope: scopeFor(kept.orderId, kept.viewer), attemptId: kept.attemptId, savedAt: kept.savedAt })); if (!migrated.every(valid)) throw new ReceiptRecoveryUnavailable(); return migrated; }; const overlay = (stored: Kept[]): Kept[] => { const effective = new Map(stored.filter((kept) => !settled.has(kept.attemptId)).map((kept) => [kept.scope, kept])); unsaved.forEach((kept, scope) => effective.set(scope, kept)); return [...effective.values()]; }; const readAll = (): Kept[] => { try { // Only unsaved local changes outrank disk; a settled operation in another tab must stay settled here. return memory = overlay(durable()); } catch (failure) { if (failure instanceof ReceiptRecoveryUnavailable || failure instanceof SyntaxError) throw new ReceiptRecoveryUnavailable(); if (!memory) unreadableAtLoad = true; return memory = overlay(memory ?? []); } }; const persist = (): boolean => { if (unreadableAtLoad) return false; try { const area = authStorageArea(); if (!area) return false; const stored = durable(); // An unwritten local reservation cannot replace a different operation another tab may already have sent. if (stored.some((kept) => !settled.has(kept.attemptId) && unsaved.has(kept.scope) && unsaved.get(kept.scope)!.attemptId !== kept.attemptId)) return false; const all = overlay(stored); if (all.length > RECEIPT_RECOVERY_LIMIT) return false; const raw = JSON.stringify(all); area.setItem(RECEIPT_STORAGE_KEY, raw); if (area.getItem(RECEIPT_STORAGE_KEY) !== raw) return false; area.removeItem(LEGACY_KEY); memory = all; unsaved.clear(); settled.clear(); return true; } catch { return false; } }; /** The unconfirmed identity stays until a receipt or definite refusal settles it; age alone proves neither. */ export const receiptAttemptFor = (orderId: string, now = Date.now()): string => { const scope = scopeFor(orderId); const all = readAll(); const kept = all.find((each) => each.scope === scope); if (kept) return kept.attemptId; if (all.length >= RECEIPT_RECOVERY_LIMIT) throw new ReceiptRecoveryUnavailable('Receipt recovery is full. Confirm earlier receipts before receiving another specimen. Nothing was sent.'); const attemptId = crypto.randomUUID(); const pending = { scope, attemptId, savedAt: now }; unsaved.set(scope, pending); memory = [...all, pending]; persist(); return attemptId; }; /** A POST cannot leave without a verified reload-safe identity, even when memory can retain one for this visit. */ export const reserveReceiptAttempt = (orderId: string, now = Date.now()): string => { const attemptId = receiptAttemptFor(orderId, now); if (!persist()) throw new ReceiptRecoveryUnavailable(); return attemptId; }; /** Settles only that admitted identity, including an answer that returns after its practice was left. */ export const settleReceiptAttempt = (_orderId: string, attemptId: string, _now = Date.now()): void => { const all = readAll(); if (!all.some((kept) => kept.attemptId === attemptId)) return; all.filter((kept) => kept.attemptId === attemptId).forEach((kept) => unsaved.delete(kept.scope)); settled.add(attemptId); memory = all.filter((kept) => kept.attemptId !== attemptId); persist(); }; const receiptLock = (): LockManager => { const locks = globalThis.navigator?.locks; if (typeof locks?.request !== 'function') throw new ReceiptRecoveryUnavailable('This browser cannot safely coordinate specimen receipt across tabs. Nothing was sent. Use a supported browser and try again.'); return locks; }; /** Reserves under the same origin-wide lock every receipt sender and confirmer uses. */ export const reserveReceiptForSend = async (orderId: string, now = Date.now()): Promise => { const owner = viewerGeneration; const signedOut = clearGeneration; const viewer = writeThroughViewer(); const scope = scopeFor(orderId); try { return await receiptLock().request(RECEIPT_STORAGE_KEY, () => { if (owner !== viewerGeneration || signedOut !== clearGeneration || viewer !== writeThroughViewer() || scope !== scopeFor(orderId)) { throw new ReceiptRecoveryUnavailable('The account or practice changed while receipt was waiting. Nothing was sent. Reopen the current order before trying again.'); } return reserveReceiptAttempt(orderId, now); }); } catch (failure) { if (failure instanceof ReceiptRecoveryUnavailable) throw failure; throw new ReceiptRecoveryUnavailable(); } }; /** A confirmed answer retires its exact identity under the reservation lock, even after changing practice. */ export const confirmReceiptAttempt = async (orderId: string, attemptId: string): Promise => { const signedOut = clearGeneration; try { await receiptLock().request(RECEIPT_STORAGE_KEY, () => { if (signedOut !== clearGeneration) return; settleReceiptAttempt(orderId, attemptId); }); } catch { throw new ReceiptRecoveryUnavailable('The server answered, but this browser could not retire its receipt recovery record. Reopen the bench and confirm its status before receiving again.'); } }; /** Sign-out retires this portal's memory as well as its prefixed durable records. */ export const clearReceiptAttempts = (): void => { clearGeneration += 1; let all = memory ?? []; try { all = readAll(); } catch { /* Sign-out can still retire known local operations. */ } unsaved.clear(); all.forEach((kept) => settled.add(kept.attemptId)); memory = []; unreadableAtLoad = false; lastReadableSession = null; try { const area = authStorageArea(); area?.removeItem(RECEIPT_STORAGE_KEY); area?.removeItem(LEGACY_KEY); } catch { /* A fresh sign-in uses a different session scope. */ } }; onViewerChange(() => { viewerGeneration += 1; if (writeThroughViewer() === null) clearReceiptAttempts(); });