7e9dafc46041
add v3 paired device registry in chrome.storage.sync with per-network IP cache in storage.local
- time
- 2026-05-18 02:17 UTC · 5 months ago
- machine
- koh2
- parent
- 98614d0cb031
↓ peach-browser-7e9dafc4.tar.gzatlas at this save
files changed2 files
src/background/paired-devices.test.ts+153 −0src/background/paired-devices.ts+134 −0src/background/paired-devices.test.ts+153 −0
@@ -0,0 +1,153 @@1+import { describe, expect, it, beforeEach } from "vitest";2+import type { PairedDevice } from "../shared/sync-types";3+import {4+ clearLastKnownIp,5+ clearPairedDevices,6+ findPairedDeviceById,7+ findPairedDeviceByPubkey,8+ getLastKnownIp,9+ getLastSyncAt,10+ listPairedDevices,11+ markLastSyncedAt,12+ removePairedDeviceById,13+ setLastKnownIp,14+ setLastSyncAt,15+ upsertPairedDevice,16+} from "./paired-devices";17+18+function makeStorageStub() {19+ const sync = new Map<string, unknown>();20+ const local = new Map<string, unknown>();21+ function area(store: Map<string, unknown>) {22+ return {23+ async get(key?: string | string[] | null) {24+ if (key === null || key === undefined) {25+ const all: Record<string, unknown> = {};26+ for (const [k, v] of store) all[k] = v;27+ return all;28+ }29+ const keys = Array.isArray(key) ? key : [key];30+ const out: Record<string, unknown> = {};31+ for (const k of keys) if (store.has(k)) out[k] = store.get(k);32+ return out;33+ },34+ async set(values: Record<string, unknown>) {35+ for (const [k, v] of Object.entries(values)) store.set(k, v);36+ },37+ async remove(key: string | string[]) {38+ const keys = Array.isArray(key) ? key : [key];39+ for (const k of keys) store.delete(k);40+ },41+ };42+ }43+ (globalThis as unknown as { chrome: unknown }).chrome = {44+ storage: { sync: area(sync), local: area(local) },45+ };46+ return { sync, local };47+}48+49+const SAMPLE: PairedDevice = {50+ deviceId: "AAA-111",51+ deviceName: "June's Pixel 9",52+ deviceType: "android",53+ longTermPublicKeyHex: "0".repeat(64),54+ pairedAt: 1700000000000,55+};56+57+describe("paired-devices registry", () => {58+ beforeEach(() => makeStorageStub());59+60+ it("upsert + list", async () => {61+ await upsertPairedDevice(SAMPLE);62+ expect(await listPairedDevices()).toEqual([SAMPLE]);63+ });64+65+ it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {66+ await upsertPairedDevice(SAMPLE);67+ await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });68+ const all = await listPairedDevices();69+ expect(all).toHaveLength(1);70+ expect(all[0].deviceName).toBe("Renamed Pixel");71+ });72+73+ it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {74+ await upsertPairedDevice(SAMPLE);75+ const newPubkey = "f".repeat(64);76+ await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });77+ const found = await findPairedDeviceById(SAMPLE.deviceId);78+ expect(found?.longTermPublicKeyHex).toBe(newPubkey);79+ });80+81+ it("rejects upsert with missing fields", async () => {82+ await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);83+ await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);84+ await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);85+ await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);86+ });87+88+ it("findPairedDeviceByPubkey returns the correct device", async () => {89+ const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };90+ await upsertPairedDevice(SAMPLE);91+ await upsertPairedDevice(other);92+ expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);93+ expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();94+ });95+96+ it("removePairedDeviceById drops the device and its IP cache", async () => {97+ await upsertPairedDevice(SAMPLE);98+ await setLastKnownIp(SAMPLE.longTermPublicKeyHex!, "192.168.1.42");99+ await removePairedDeviceById(SAMPLE.deviceId);100+ expect(await listPairedDevices()).toEqual([]);101+ expect(await getLastKnownIp(SAMPLE.longTermPublicKeyHex!)).toBeNull();102+ });103+104+ it("clearPairedDevices wipes the whole registry", async () => {105+ await upsertPairedDevice(SAMPLE);106+ await clearPairedDevices();107+ expect(await listPairedDevices()).toEqual([]);108+ });109+110+ it("markLastSyncedAt mutates only the target device", async () => {111+ const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };112+ await upsertPairedDevice(SAMPLE);113+ await upsertPairedDevice(other);114+ await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);115+ const found = await findPairedDeviceById(SAMPLE.deviceId);116+ expect(found?.lastSyncedAt).toBe(1700001000000);117+ const untouched = await findPairedDeviceById(other.deviceId);118+ expect(untouched?.lastSyncedAt).toBeUndefined();119+ });120+121+ it("getLastKnownIp / setLastKnownIp round-trip", async () => {122+ await setLastKnownIp("ab".repeat(32), "10.0.0.5");123+ expect(await getLastKnownIp("ab".repeat(32))).toBe("10.0.0.5");124+ await clearLastKnownIp("ab".repeat(32));125+ expect(await getLastKnownIp("ab".repeat(32))).toBeNull();126+ });127+128+ it("last sync timestamp accepts a legacy string value", async () => {129+ (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({130+ peach_last_sync_at: "1700002000000",131+ });132+ expect(await getLastSyncAt()).toBe(1700002000000);133+ });134+135+ it("setLastSyncAt persists a numeric value", async () => {136+ await setLastSyncAt(1700003000000);137+ expect(await getLastSyncAt()).toBe(1700003000000);138+ });139+140+ it("listPairedDevices ignores garbage entries silently", async () => {141+ (globalThis as unknown as { chrome: { storage: { sync: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.sync.set({142+ peach_paired_devices_v3: [143+ { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },144+ { not: "a device" },145+ null,146+ 42,147+ ],148+ });149+ const devices = await listPairedDevices();150+ expect(devices).toHaveLength(1);151+ expect(devices[0].deviceId).toBe("real");152+ });153+});src/background/paired-devices.ts+134 −0
@@ -0,0 +1,134 @@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.13+14+import type { PairedDevice } from "../shared/sync-types";15+16+const REGISTRY_KEY = "peach_paired_devices_v3";17+const LAST_IP_KEY_PREFIX = "peach_last_known_ip_v3:";18+const LAST_SYNC_AT_KEY = "peach_last_sync_at";19+20+// 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.24+25+export 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+}35+36+export async function findPairedDeviceById(deviceId: string): Promise<PairedDevice | null> {37+ const devices = await listPairedDevices();38+ return devices.find((d) => d.deviceId === deviceId) ?? null;39+}40+41+export async function findPairedDeviceByPubkey(pubkeyHex: string): Promise<PairedDevice | null> {42+ const devices = await listPairedDevices();43+ return devices.find((d) => d.longTermPublicKeyHex === pubkeyHex) ?? null;44+}45+46+/**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+ */51+export 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");56+57+ 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+}66+67+export 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+}77+78+export async function clearPairedDevices(): Promise<void> {79+ await chrome.storage.sync.remove(REGISTRY_KEY);80+}81+82+export 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+}89+90+export 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+}100+101+export async function setLastKnownIp(pubkeyHex: string, ip: string): Promise<void> {102+ await chrome.storage.local.set({ [LAST_IP_KEY_PREFIX + pubkeyHex]: ip });103+}104+105+export async function clearLastKnownIp(pubkeyHex: string): Promise<void> {106+ await chrome.storage.local.remove(LAST_IP_KEY_PREFIX + pubkeyHex);107+}108+109+export 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+}123+124+export async function setLastSyncAt(when: number): Promise<void> {125+ await chrome.storage.local.set({ [LAST_SYNC_AT_KEY]: when });126+}127+128+function 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+}