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
↓ peach-browser-d8bc7d69.tar.gzatlas at this save
files changed2 files
src/background/relay-adoption-bg.test.ts+107 −0src/background/relay-adoption-bg.ts+74 −58src/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+});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 {