d8bc7d691d63

extract the source session adapter and cover every callback exhaustively

time
2026-09-10 23:07 UTC · 1 month ago
machine
koh2
agent
deepseek/deepseek-v4-flash
session
adoption adapter coverage
parent
b229cb5bda92
2 files src/background/relay-adoption-bg.test.ts+107 −0src/background/relay-adoption-bg.ts+74 −58
src/background/relay-adoption-bg.test.ts+107 −0
@@ -1190,3 +1190,110 @@11901190     return null;11911191   }11921192 }1193+1194+describe("§10.5 adoption adapter coverage", () => {1195+  const identity = {1196+    deviceId: "source-id",1197+    deviceName: "Source",1198+    deviceType: "extension" as const,1199+    longTermPublicKeyHex: "a".repeat(64),1200+    vaultId: "b".repeat(64),1201+  };1202+  const device = {1203+    deviceId: "receiver-id",1204+    deviceName: "Phone",1205+    deviceType: "android" as const,1206+    longTermPublicKeyHex: "d".repeat(64),1207+    pairedAt: 1,1208+  };1209+1210+  async function build() {1211+    const { buildSourceSessionDeps } = await import("./relay-adoption-bg");1212+    return buildSourceSessionDeps({1213+      identity,1214+      rendezvousSecretHex: "cc".repeat(32),1215+      relayUrl: "wss://relay.test",1216+      sas: "12345678",1217+    });1218+  }1219+1220+  it("carries every member of the union, including the optional ones", async () => {1221+    const deps = await build();1222+1223+    // The compile-time guarantee covers only the non-optional members. An1224+    // inline literal could silently drop `persistPendingHolder`, which is1225+    // ordering guarantee §10.4.1: no root before a listable holder.1226+    expect(Object.keys(deps).sort()).toEqual(1227+      [1228+        "applyLicense",1229+        "clearPendingHolder",1230+        "completePendingIdentity",1231+        "hasDeviceIdentityCollision",1232+        "license",1233+        "onConsent",1234+        "pairState",1235+        "persistPendingHolder",1236+        "persistPendingIdentity",1237+        "rootKeyHex",1238+        "rotateSourceIdentity",1239+        "snapshotCapable",1240+        "sourceIdentity",1241+        "upsertPairedDevice",1242+        "vaultId",1243+      ].sort(),1244+    );1245+  });1246+1247+  it("forwards every callback to its sink", async () => {1248+    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });1249+    const deps = await build();1250+1251+    // Session facts are bound, not forwarded.1252+    expect(deps.sourceIdentity).toEqual(identity);1253+    expect(deps.pairState).toEqual({1254+      rendezvousSecretHex: "cc".repeat(32),1255+      relayUrl: "wss://relay.test",1256+    });1257+    expect(deps.snapshotCapable()).toBe(true);1258+1259+    expect(await deps.rootKeyHex()).toBe("c".repeat(64));1260+    expect(await deps.vaultId()).toBe("b".repeat(64));1261+    expect(await deps.license()).toBe("PPK-source");1262+    await deps.applyLicense("PPK-transferred");1263+    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-transferred");1264+1265+    await deps.upsertPairedDevice(device);1266+    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-id" }));1267+1268+    expect(await deps.hasDeviceIdentityCollision(device)).toBe(false);1269+    expect(mocks.hasIdentityConflict).toHaveBeenCalledWith("receiver-id", "d".repeat(64));1270+1271+    await deps.rotateSourceIdentity();1272+    expect(mocks.rotateDeviceId).toHaveBeenCalled();1273+1274+    // §10.4.1: the pending holder is durable before any root-bearing frame.1275+    // They are optional on the v1 interface, which is exactly why they need1276+    // asserting here: an inline literal could omit them and still typecheck.1277+    expect(deps.persistPendingHolder).toBeDefined();1278+    expect(deps.clearPendingHolder).toBeDefined();1279+    await deps.persistPendingHolder?.({1280+      type: "ADOPT_REQUEST",1281+      device_id: "receiver-id",1282+      device_name: "Phone",1283+      device_type: "android",1284+    } as never);1285+    expect(mocks.storage.has("peach_pending_adoption_holder_v1")).toBe(true);1286+1287+    await deps.persistPendingIdentity(device as never, 1);1288+    expect(mocks.storage.has("peach_pending_adoption_identity_v2")).toBe(true);1289+    await deps.completePendingIdentity(device as never);1290+1291+    await deps.clearPendingHolder?.(1292+      { device_name: "Phone", device_id: "receiver-id" } as never,1293+      device as never,1294+    );1295+    expect(mocks.storage.has("peach_pending_adoption_holder_v1")).toBe(false);1296+1297+    expect(await deps.onConsent({} as never)).toBe(true);1298+  });1299+});

view file

src/background/relay-adoption-bg.ts+74 −58
@@ -77,6 +77,7 @@7777 import { createSyncVaultAdapter } from "./sync-vault-adapter";7878 import { triggerSyncIfReachable } from "./sync-trigger";7979 import { schedulePostAdoptionSync } from "./sync-alarm";80+import type { LocalIdentity } from "../shared/pairing-handshake";8081 8182 const SOURCE_WINDOW_MS = 5 * 60 * 1000;8283 @@ -282,64 +283,12 @@282283       throw new Error("expected_ADOPT_REQUEST");283284     }284285 285-    const sourceDeps: AdoptionSourceDeps & AdoptionV2SourceDeps = {286-      sourceIdentity: local.identity,287-      pairState: {288-        rendezvousSecretHex: bytesToHex(289-          deriveRendezvousSecret(session.handshakeHash),290-        ),291-        relayUrl,292-      },293-      async rootKeyHex() {294-        // grantAdoption calls this only after local password approval and the295-        // receiver's encrypted ADOPT_CONFIRM(true).296-        const value = getRootKeyHex();297-        if (!value) throw new Error("vault_locked");298-        return value;299-      },300-      async vaultId() {301-        const value = getRootKeyHex();302-        if (!value) throw new Error("vault_locked");303-        const bytes = hexToBytes(value);304-        try {305-          return await computeVaultId(bytes);306-        } finally {307-          bytes.fill(0);308-        }309-      },310-      async license() {311-        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;312-      },313-      async applyLicense(license) {314-        await applyTransferredLicense(license);315-      },316-      onConsent: (adoptRequest) => requestConsent(317-        adoptRequest,318-        derivePairingSAS(session.handshakeHash),319-        crypto.randomUUID(),320-      ),321-      persistPendingHolder: persistPendingAdoptionHolder,322-      clearPendingHolder: async (adoptRequest) => {323-        const current = await readPendingAdoptionHolder();324-        if (current?.request.device_id === adoptRequest.device_id) {325-          await removePendingAdoptionHolder();326-        }327-      },328-      async upsertPairedDevice(paired) {329-        await storeAdoptedDevice(paired);330-      },331-      hasDeviceIdentityCollision: async (device) =>332-        hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),333-      rotateSourceIdentity: async () => {334-        await rotateDeviceId();335-        return await buildLocalIdentity();336-      },337-      persistPendingIdentity: persistPendingAdoptionIdentity,338-      async completePendingIdentity(paired: PairedDevice) {339-        await completePendingAdoptionIdentity(paired);340-      },341-      snapshotCapable: () => true,342-    };286+    const sourceDeps = buildSourceSessionDeps({287+      identity: local.identity,288+      rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),289+      relayUrl,290+      sas: derivePairingSAS(session.handshakeHash),291+    });343292     // §11.6: after a secure reset a v1 peer is repair-required, exactly as an344293     // outdated sync peer is. Refused before any grant. A new ADOPT_REJECT345294     // reason is not an option — §10.3 freezes the set to the two v1 peers@@ -728,6 +677,73 @@728677 729678 function encodeJson(value: unknown): Uint8Array {730679   return new TextEncoder().encode(JSON.stringify(value));680+}681+682+/**683+ * Bind a source session's callbacks to the live session facts.684+ *685+ * Extracted from the session function so §10.5 can be proven: the test dirties686+ * every member and asserts each one reaches its sink. TypeScript already687+ * requires the *non-optional* members of the union, but the optional ones --688+ * `persistPendingHolder` above all, which is ordering guarantee §10.4.1 -- would689+ * silently disappear from an inline literal. This is where that is caught.690+ */691+export function buildSourceSessionDeps(input: {692+  identity: LocalIdentity;693+  rendezvousSecretHex: string;694+  relayUrl: string | undefined;695+  sas: string;696+}): AdoptionSourceDeps & AdoptionV2SourceDeps {697+  const { identity, rendezvousSecretHex, relayUrl, sas } = input;698+  return {699+    sourceIdentity: identity,700+    pairState: { rendezvousSecretHex, relayUrl },701+    async rootKeyHex() {702+      // grantAdoption calls this only after local password approval and the703+      // receiver's encrypted ADOPT_CONFIRM(true).704+      const value = getRootKeyHex();705+      if (!value) throw new Error("vault_locked");706+      return value;707+    },708+    async vaultId() {709+      const value = getRootKeyHex();710+      if (!value) throw new Error("vault_locked");711+      const bytes = hexToBytes(value);712+      try {713+        return await computeVaultId(bytes);714+      } finally {715+        bytes.fill(0);716+      }717+    },718+    async license() {719+      return (await isProUser()) ? ((await getLicenseKey()) ?? undefined) : undefined;720+    },721+    async applyLicense(license) {722+      await applyTransferredLicense(license);723+    },724+    onConsent: (adoptRequest) => requestConsent(adoptRequest, sas, crypto.randomUUID()),725+    persistPendingHolder: persistPendingAdoptionHolder,726+    clearPendingHolder: async (adoptRequest) => {727+      const current = await readPendingAdoptionHolder();728+      if (current?.request.device_id === adoptRequest.device_id) {729+        await removePendingAdoptionHolder();730+      }731+    },732+    async upsertPairedDevice(paired) {733+      await storeAdoptedDevice(paired);734+    },735+    hasDeviceIdentityCollision: async (device) =>736+      hasAdoptionIdentityConflict(device.deviceId, device.longTermPublicKeyHex ?? ""),737+    rotateSourceIdentity: async () => {738+      await rotateDeviceId();739+      return await buildLocalIdentity();740+    },741+    persistPendingIdentity: persistPendingAdoptionIdentity,742+    async completePendingIdentity(paired: PairedDevice) {743+      await completePendingAdoptionIdentity(paired);744+    },745+    snapshotCapable: () => true,746+  };731747 }732748 733749 function decodeJson<T>(value: Uint8Array): T {

view file