From 68dcf2e18ab3a96caa82da816622d268f605bf40 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:27:05 -0700 Subject: [PATCH] fix(mobile): let user re-pair supersede recovery --- .../src/transport/host-profile-publication.ts | 23 ++++- ...t-profile-user-pairing-publication.test.ts | 92 +++++++++++++++++++ .../pre-profile-pairing-coordinator.ts | 9 +- 3 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 mobile/src/transport/host-profile-user-pairing-publication.test.ts diff --git a/mobile/src/transport/host-profile-publication.ts b/mobile/src/transport/host-profile-publication.ts index 22a7e3eed23..ab4f4c310e3 100644 --- a/mobile/src/transport/host-profile-publication.ts +++ b/mobile/src/transport/host-profile-publication.ts @@ -2,6 +2,7 @@ import type { HostProfile } from './types' const pendingByHost = new Map>() const revisionByHost = new Map() +const retirementRevisionByHost = new Map() const endpointRevisionByHost = new Map() const endpointGenerationByHost = new Map() const publishedIdentityByHost = new Map>() @@ -40,6 +41,7 @@ export function recordDurableHostIdentity( } export function retireHostProfilePublication(hostId: string): Promise | null { + retirementRevisionByHost.set(hostId, getRetirementRevision(hostId) + 1) recordHostEndpointMutation(hostId) endpointGenerationByHost.set(hostId, (endpointGenerationByHost.get(hostId) ?? 0) + 1) publishedIdentityByHost.delete(hostId) @@ -91,13 +93,21 @@ export function publishHostProfileTransaction( host: HostProfile, beforeHostSave: (() => Promise) | null, saveHost: (host: HostProfile) => Promise, - publicationRevision = getHostProfilePublicationRevision(host.id) + requestedRevision: number | 'adopt-current' = getHostProfilePublicationRevision(host.id) ): Promise { + const retirementRevision = getRetirementRevision(host.id) return serializeHostProfilePublication(host.id, async () => { + requireRetirementRevision(host.id, retirementRevision) + const publicationRevision = + requestedRevision === 'adopt-current' + ? getHostProfilePublicationRevision(host.id) + : requestedRevision requirePublicationRevision(host.id, publicationRevision) await beforeHostSave?.() + requireRetirementRevision(host.id, retirementRevision) requirePublicationRevision(host.id, publicationRevision) await saveHost(host) + requireRetirementRevision(host.id, retirementRevision) requirePublicationRevision(host.id, publicationRevision) revisionByHost.set(host.id, publicationRevision + 1) advanceHostEndpointRevision(host.id) @@ -105,6 +115,16 @@ export function publishHostProfileTransaction( }) } +function getRetirementRevision(hostId: string): number { + return retirementRevisionByHost.get(hostId) ?? 0 +} + +function requireRetirementRevision(hostId: string, expected: number): void { + if (getRetirementRevision(hostId) !== expected) { + throw new Error('host profile publication was retired') + } +} + function getHostEndpointRevision(hostId: string): number { return endpointRevisionByHost.get(hostId) ?? 0 } @@ -123,6 +143,7 @@ function requirePublicationRevision(hostId: string, expected: number): void { export function resetHostProfilePublicationForTests(): void { pendingByHost.clear() revisionByHost.clear() + retirementRevisionByHost.clear() endpointRevisionByHost.clear() endpointGenerationByHost.clear() publishedIdentityByHost.clear() diff --git a/mobile/src/transport/host-profile-user-pairing-publication.test.ts b/mobile/src/transport/host-profile-user-pairing-publication.test.ts new file mode 100644 index 00000000000..24530868e24 --- /dev/null +++ b/mobile/src/transport/host-profile-user-pairing-publication.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getHostProfilePublicationRevision, + publishHostProfileTransaction, + resetHostProfilePublicationForTests, + retireHostProfilePublication +} from './host-profile-publication' + +const HOST = { + id: 'host-1', + name: 'Host 1', + endpoint: 'ws://127.0.0.1:1', + publicKeyB64: 'key', + deviceToken: 'token-1', + lastConnected: 0 +} + +describe('user pairing host publication', () => { + beforeEach(() => resetHostProfilePublicationForTests()) + + it('supersedes a background recovery that commits first', async () => { + let releaseRecovery: (() => void) | null = null + const recoveryGate = new Promise((resolve) => { + releaseRecovery = resolve + }) + const writes: string[] = [] + const recovery = publishHostProfileTransaction( + HOST, + () => recoveryGate, + async () => { + writes.push('recovery') + } + ) + const pairing = publishHostProfileTransaction( + { ...HOST, deviceToken: 'token-2' }, + null, + async () => { + writes.push('pairing') + }, + 'adopt-current' + ) + + releaseRecovery?.() + + await expect(recovery).resolves.toBeUndefined() + await expect(pairing).resolves.toBeUndefined() + expect(writes).toEqual(['recovery', 'pairing']) + expect(getHostProfilePublicationRevision(HOST.id)).toBe(2) + }) + + it('does not let user pairing resurrect a retired host', async () => { + let releaseRecovery: (() => void) | null = null + const recoveryGate = new Promise((resolve) => { + releaseRecovery = resolve + }) + const recovery = publishHostProfileTransaction(HOST, () => recoveryGate, vi.fn()) + const pairing = publishHostProfileTransaction( + { ...HOST, deviceToken: 'token-2' }, + null, + vi.fn(), + 'adopt-current' + ) + + retireHostProfilePublication(HOST.id) + releaseRecovery?.() + + await expect(recovery).rejects.toThrow('host profile publication was retired') + await expect(pairing).rejects.toThrow('host profile publication was retired') + }) + + it('keeps a background recovery fenced behind a newer user pairing', async () => { + let releasePairing: (() => void) | null = null + const pairingGate = new Promise((resolve) => { + releasePairing = resolve + }) + const pairing = publishHostProfileTransaction( + { ...HOST, deviceToken: 'token-2' }, + () => pairingGate, + vi.fn(), + 'adopt-current' + ) + const capturedRevision = getHostProfilePublicationRevision(HOST.id) + const recoveryWrite = vi.fn() + const recovery = publishHostProfileTransaction(HOST, null, recoveryWrite, capturedRevision) + + releasePairing?.() + + await expect(pairing).resolves.toBeUndefined() + await expect(recovery).rejects.toThrow('host profile publication was retired') + expect(recoveryWrite).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts index 33bba30055c..53ed0607394 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -195,7 +195,8 @@ async function runPairing( await publishHostProfileTransaction( baseHost(offer, hostId, hostName, now), null, - dependencies.saveHost + dependencies.saveHost, + 'adopt-current' ) return { hostId } } @@ -220,7 +221,8 @@ async function runPairing( await publishHostProfileTransaction( baseHost(offer, hostId, hostName, now), null, - dependencies.saveHost + dependencies.saveHost, + 'adopt-current' ) await dependencies.clearJournal(journal.metadata.journalId) return { hostId } @@ -246,7 +248,8 @@ async function runPairing( dependencies.writeCredentialBundle( promotePairingJournalCredential({ journal: committedJournal, installed }) ), - dependencies.saveHost + dependencies.saveHost, + 'adopt-current' ) await dependencies.clearJournal(committedJournal.metadata.journalId) return { hostId }