// Unconfirmed sends kept for this tab so a full reload can find them: an opaque scope digest, the random // operation id and when it was held. Never the words, a person's id or a token; the words stay in memory only. /** The server's answer for one closed send: never a message, only whether it was written. */ export type SendCloseAnswer = { state: "sent"; id: string } | { state: "not-sent" } | { state: "unknown" }; const SEND_RECORD_ID = /^send-[0-9a-f]{40}$/; /** A close answer as it arrived. Anything but a complete sent answer naming its own record, or not-sent, is unknown. */ export const closeAnswerOf = (value: unknown): SendCloseAnswer => { if (!value || typeof value !== "object") return { state: "unknown" }; const { state, id } = value as { state?: unknown; id?: unknown }; if (state === "not-sent") return { state: "not-sent" }; if (state === "sent" && typeof id === "string" && SEND_RECORD_ID.test(id)) return { state: "sent", id }; return { state: "unknown" }; }; export const RECOVERY_LIMIT = 20; export const RECOVERY_AGE_MS = 24 * 60 * 60 * 1000; const VERSION = 1; export interface HeldRecord { scope: string; operationId: string; heldAt: number; } export interface UnresolvedSend extends HeldRecord { /** Older than the age bound: still unsettled, since local age proves nothing about the server. */ expired: boolean; } /** The record could not be read or kept, so a send that depends on it must not go. */ export class RecoveryUnavailable extends Error {} /** Every slot is held by an unsettled send; none is dropped to make room. */ export class RecoveryFull extends Error {} type Store = Pick; const isRecord = (value: unknown): value is HeldRecord => { const each = value as Partial | null; return typeof each?.scope === "string" && /^[0-9a-f]{64}$/.test(each.scope) && typeof each.operationId === "string" && /^[A-Za-z0-9-]{8,64}$/.test(each.operationId) && typeof each.heldAt === "number" && Number.isFinite(each.heldAt); }; /** The digest naming one conversation as seen by one signer; the same parts always give the same digest. */ export const scopeDigest = async (parts: readonly unknown[]): Promise => { const bytes = new TextEncoder().encode(JSON.stringify(["akashic.sendRecovery", VERSION, ...parts])); const digest = await crypto.subtle.digest("SHA-256", bytes); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); }; // Bumped by a sign-out, so a reservation that began before it can never write after it. let generation = 0; export const createSendRecovery = (storage: Store | undefined, prefix: string) => { const key = `${prefix}_sendRecovery_v${VERSION}`; const read = (): HeldRecord[] => { if (!storage) throw new RecoveryUnavailable("no session storage"); let raw: string | null; try { raw = storage.getItem(key); } catch (error) { throw new RecoveryUnavailable(String(error)); } if (raw === null) return []; try { const parsed = JSON.parse(raw) as { v?: unknown; held?: unknown }; if (parsed?.v === VERSION && Array.isArray(parsed.held) && parsed.held.every(isRecord)) return parsed.held; } catch { // Falls through: unreadable is not empty. } throw new RecoveryUnavailable("unreadable send record"); }; const write = (held: HeldRecord[]) => { const raw = JSON.stringify({ v: VERSION, held }); try { if (held.length) storage?.setItem(key, raw); else storage?.removeItem(key); } catch (error) { throw new RecoveryUnavailable(String(error)); } const kept = read(); if (JSON.stringify(kept) !== JSON.stringify(held)) throw new RecoveryUnavailable("send record not kept"); }; return { /** * Holds a send before it may leave. Throws when it cannot be held, and the caller then does not send. * Holding the same operation again is a no-op, so a retry of the same send needs no second slot. */ reserve: (scope: string, operationId: string, owner: number, now = Date.now()) => { if (owner !== generation) throw new RecoveryUnavailable("signed out since the send began"); const held = read(); if (held.some((each) => each.operationId === operationId)) return; if (held.length >= RECOVERY_LIMIT) throw new RecoveryFull("too many unconfirmed sends"); write([...held, { scope, operationId, heldAt: now }]); }, /** Settles exactly this operation: a confirmed send or a definite refusal. Others are untouched. */ settle: (operationId: string) => { const held = read(); if (held.some((each) => each.operationId === operationId)) { write(held.filter((each) => each.operationId !== operationId)); } }, /** Nothing is held in any scope. Throws when the record cannot be read. */ isEmpty: (): boolean => read().length === 0, /** The unsettled sends in this scope, oldest first. Throws when the record cannot be read. */ unresolved: (scope: string, now = Date.now()): UnresolvedSend[] => read().filter((each) => each.scope === scope) .sort((a, b) => a.heldAt - b.heldAt) .map((each) => ({ ...each, expired: now - each.heldAt > RECOVERY_AGE_MS })), /** Explicit sign-out: this portal's record goes, and nothing begun before can write it back. */ clear: () => { generation += 1; try { storage?.removeItem(key); } catch { // The generation bump still stops any late write. } }, }; }; /** The owner a reservation must still match when it writes: taken when the send begins. */ export const recoveryOwner = () => generation; export type SendRecovery = ReturnType;