paired-devices.ts

5.2 KB · raw

1// Paired-device registry — v3 chirp-sync.2//3// Per the brief: long-term records live in `chrome.storage.sync` so the4// registry roams across browser profiles signed into Chrome/Firefox sync.5// `lastKnownIp` is network-specific and lives in `chrome.storage.local`6// instead — bringing the IP across networks would do nothing but cause7// the subnet probe to chase a stale address.8//9// `deviceId` is the canonical primary key (UUID v4, stable for the10// lifetime of the installation). `longTermPublicKeyHex` is also unique11// per device but rotates if the user reinstalls before re-pairing, so we12// key on deviceId and update the key in place during re-pair.1314import type { PairedDevice } from "../shared/sync-types";1516const REGISTRY_KEY = "peach_paired_devices_v3";17const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";18const LAST_SYNC_AT_KEY = "peach_last_sync_at";1920// chrome.storage.sync has hard quotas: 100KB total, 8KB per item. A paired21// device record is ~200 bytes (one X25519 hex pubkey + UUIDs + a name);22// 100KB easily holds the realistic ~5-10 devices a user pairs. We store23// the array under a single key to avoid eating the per-key quota.2425export async function listPairedDevices(): Promise<PairedDevice[]> {26  try {27    const result = await chrome.storage.sync.get(REGISTRY_KEY);28    const devices = (result as Record<string, unknown>)[REGISTRY_KEY];29    if (!Array.isArray(devices)) return [];30    return devices.filter(isPairedDevice);31  } catch {32    return [];33  }34}3536export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {37  const devices = await listPairedDevices();38  return devices.find((d) => d.deviceId === deviceId) ?? null;39}4041export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {42  const devices = await listPairedDevices();43  return devices.find((d) => d.longTermPublicKeyHex === pubkeyHex) ?? null;44}4546/**47 * Add or update a device by `deviceId`. On re-pair the new pubkey48 * silently replaces the old one; the deviceId is stable across the49 * other device's reinstall.50 */51export async function upsertPairedDevice(device: PairedDevice): Promise<void> {52  if (!device.deviceId) throw new Error("device.deviceId is required");53  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");54  if (!device.deviceName) throw new Error("device.deviceName is required");55  if (!device.deviceType) throw new Error("device.deviceType is required");5657  const devices = await listPairedDevices();58  const idx = devices.findIndex((d) => d.deviceId === device.deviceId);59  if (idx >= 0) {60    devices[idx] = { ...devices[idx], ...device };61  } else {62    devices.push(device);63  }64  await chrome.storage.sync.set({ [REGISTRY_KEY]: devices });65}6667export async function removePairedDeviceById(deviceId: string): Promise<void> {68  const devices = await listPairedDevices();69  const filtered = devices.filter((d) => d.deviceId !== deviceId);70  await chrome.storage.sync.set({ [REGISTRY_KEY]: filtered });71  // Also clear the per-network IP cache for this device.72  const device = devices.find((d) => d.deviceId === deviceId);73  if (device?.longTermPublicKeyHex) {74    await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + device.longTermPublicKeyHex);75  }76}7778export async function clearPairedDevices(): Promise<void> {79  await chrome.storage.sync.remove(REGISTRY_KEY);80}8182export async function markLastSyncedAt(deviceId: string, when: number): Promise<void> {83  const devices = await listPairedDevices();84  const idx = devices.findIndex((d) => d.deviceId === deviceId);85  if (idx < 0) return;86  devices[idx] = { ...devices[idx], lastSyncedAt: when };87  await chrome.storage.sync.set({ [REGISTRY_KEY]: devices });88}8990export async function getLastKnownIp(pubkeyHex: string): Promise<string | null> {91  try {92    const key = LAST_IP_KEY_PREFIX + pubkeyHex;93    const result = await chrome.storage.local.get(key);94    const value = (result as Record<string, unknown>)[key];95    return typeof value === "string" ? value : null;96  } catch {97    return null;98  }99}100101export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {102  await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });103}104105export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {106  await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);107}108109export async function getLastSyncAt(): Promise<number | null> {110  try {111    const result = await chrome.storage.local.get(LAST_SYNC_AT_KEY);112    const value = (result as Record<string, unknown>)[LAST_SYNC_AT_KEY];113    if (typeof value === "number") return value;114    if (typeof value === "string" && value !== "") {115      const n = Number.parseInt(value, 10);116      return Number.isFinite(n) ? n : null;117    }118    return null;119  } catch {120    return null;121  }122}123124export async function setLastSyncAt(when: number): Promise<void> {125  await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });126}127128function isPairedDevice(x: unknown): x is PairedDevice {129  if (typeof x !== "object" || x === null) return false;130  const r = x as Record<string, unknown>;131  if (typeof r.deviceId !== "string" || !r.deviceId) return false;132  if (typeof r.pairedAt !== "number") return false;133  return true;134}