1import type { SyncVaultAdapter } from "./sync-coordinator";2import type { SyncPeerIdentity } from "../shared/sync-transport";3import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";4import type { DeviceType } from "../shared/sync-types";56const STORAGE_KEY = "peach_locked_sync_spool_v1";78interface CachedEntry {9 modifiedAt: number;10 ciphertext: string;11}1213interface StagedEntry extends CachedEntry {}1415interface LockedSyncSpool {16 version: 1;17 vaultId: string;18 deviceId: string;19 deviceName: string;20 deviceType: DeviceType;21 localVersion: number;22 longTermPublicKeyHex: string;23 longTermPrivateKeyHex: string;24 entries: Record<string, CachedEntry>;25 tombstones: Record<string, number>;26 inboxEntries: Record<string, StagedEntry>;27 inboxTombstones: Record<string, number>;28}2930let spoolMutation: Promise<void> = Promise.resolve();3132export async function replaceLockedSyncSpool(33 next: Omit<LockedSyncSpool, "version" | "inboxEntries" | "inboxTombstones">,34): Promise<void> {35 await mutateSpool(async (current) => {36 await saveSpool({37 ...next,38 version: 1,39 inboxEntries: current?.inboxEntries ?? {},40 inboxTombstones: current?.inboxTombstones ?? {},41 });42 });43}4445export async function clearLockedSyncSpool(): Promise<void> {46 await mutateSpool(async () => chrome.storage.local.remove(STORAGE_KEY));47}4849export async function getLockedSyncIdentity(): Promise<SyncPeerIdentity | null> {50 const spool = await loadSpool();51 if (!spool) return null;52 return {53 deviceId: spool.deviceId,54 deviceName: spool.deviceName,55 deviceType: spool.deviceType,56 longTermPublicKeyHex: spool.longTermPublicKeyHex,57 longTermPrivateKey: hexToBytes(spool.longTermPrivateKeyHex),58 };59}6061export async function drainLockedSyncInbox(62 live: SyncVaultAdapter,63): Promise<void> {64 const snapshot = await loadSpool();65 if (!snapshot) return;66 const entries = Object.fromEntries(67 Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.ciphertext]),68 );69 const manifest = Object.fromEntries(70 Object.entries(snapshot.inboxEntries).map(([id, entry]) => [id, entry.modifiedAt]),71 );72 if (Object.keys(entries).length > 0) await live.applyEntries(entries, manifest);73 if (Object.keys(snapshot.inboxTombstones).length > 0) {74 await live.applyTombstones(snapshot.inboxTombstones);75 }76 await mutateSpool(async (current) => {77 if (!current) return;78 const inboxEntries = { ...current.inboxEntries };79 const inboxTombstones = { ...current.inboxTombstones };80 for (const [id, applied] of Object.entries(snapshot.inboxEntries)) {81 if (inboxEntries[id]?.ciphertext === applied.ciphertext) delete inboxEntries[id];82 }83 for (const [id, appliedAt] of Object.entries(snapshot.inboxTombstones)) {84 if (inboxTombstones[id] === appliedAt) delete inboxTombstones[id];85 }86 await saveSpool({ ...current, inboxEntries, inboxTombstones });87 });88}8990export async function createLockedSyncVaultAdapter(): Promise<SyncVaultAdapter | null> {91 const snapshot = await loadSpool();92 if (!snapshot) return null;93 return {94 async computeVaultId() { return snapshot.vaultId; },95 async getOurDeviceId() { return snapshot.deviceId; },96 async getLocalVersion() { return snapshot.localVersion; },97 async buildEntryManifest() {98 return Object.fromEntries(99 Object.entries(snapshot.entries).map(([id, entry]) => [id, entry.modifiedAt]),100 );101 },102 async buildTombstoneManifest() { return { ...snapshot.tombstones }; },103 async getEncryptedEntries(entryIds) {104 const out: Record<string, string> = {};105 for (const id of entryIds) {106 const entry = snapshot.entries[id];107 if (entry) out[id] = entry.ciphertext;108 }109 return out;110 },111 async applyEntries(entries, remoteManifest = {}) {112 const list = Object.entries(entries);113 if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded");114 await mutateSpool(async (current) => {115 if (!current || current.vaultId !== snapshot.vaultId) return;116 const inbox = { ...current.inboxEntries };117 const now = Date.now();118 for (const [id, ciphertext] of list) {119 const claimed = remoteManifest[id];120 if (121 typeof claimed !== "number" || !Number.isFinite(claimed) || claimed <= 0 ||122 ciphertext.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64123 ) continue;124 const modifiedAt = clampSyncTimestamp(claimed, now);125 if ((inbox[id]?.modifiedAt ?? 0) < modifiedAt) {126 inbox[id] = { modifiedAt, ciphertext };127 }128 }129 if (Object.keys(inbox).length > SYNC_LIMITS.MAX_ENTRY_COUNT) {130 throw new Error("sync_entry_count_exceeded");131 }132 await saveSpool({ ...current, inboxEntries: inbox });133 });134 },135 async applyTombstones(tombstones) {136 const list = Object.entries(tombstones);137 if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded");138 await mutateSpool(async (current) => {139 if (!current || current.vaultId !== snapshot.vaultId) return;140 const inbox = { ...current.inboxTombstones };141 const now = Date.now();142 for (const [id, value] of list) {143 if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) continue;144 const deletedAt = clampSyncTimestamp(value, now);145 if ((inbox[id] ?? 0) < deletedAt) inbox[id] = deletedAt;146 }147 if (Object.keys(inbox).length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {148 throw new Error("sync_tombstone_count_exceeded");149 }150 await saveSpool({ ...current, inboxTombstones: inbox });151 });152 },153 };154}155156async function mutateSpool(work: (spool: LockedSyncSpool | null) => Promise<void>): Promise<void> {157 const next = spoolMutation.then(async () => work(await loadSpool()));158 spoolMutation = next.catch(() => undefined);159 return next;160}161162async function loadSpool(): Promise<LockedSyncSpool | null> {163 const result = await chrome.storage.local.get(STORAGE_KEY);164 const value = (result as Record<string, unknown>)[STORAGE_KEY];165 return isLockedSyncSpool(value) ? value : null;166}167168async function saveSpool(spool: LockedSyncSpool): Promise<void> {169 await chrome.storage.local.set({ [STORAGE_KEY]: spool });170}171172function isLockedSyncSpool(value: unknown): value is LockedSyncSpool {173 if (!value || typeof value !== "object") return false;174 const candidate = value as Partial<LockedSyncSpool>;175 return candidate.version === 1 &&176 typeof candidate.vaultId === "string" && /^[0-9a-f]{64}$/.test(candidate.vaultId) &&177 typeof candidate.deviceId === "string" && candidate.deviceId.length > 0 &&178 typeof candidate.deviceName === "string" &&179 typeof candidate.longTermPublicKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPublicKeyHex) &&180 typeof candidate.longTermPrivateKeyHex === "string" && /^[0-9a-f]{64}$/.test(candidate.longTermPrivateKeyHex) &&181 typeof candidate.localVersion === "number" &&182 isRecord(candidate.entries) && isRecord(candidate.tombstones) &&183 isRecord(candidate.inboxEntries) && isRecord(candidate.inboxTombstones);184}185186function isRecord(value: unknown): value is Record<string, unknown> {187 return !!value && typeof value === "object" && !Array.isArray(value);188}189190function hexToBytes(hex: string): Uint8Array {191 const out = new Uint8Array(hex.length / 2);192 for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);193 return out;194}