fix(mobile): let user re-pair supersede recovery

This commit is contained in:
Brennan Benson
2026-08-04 16:27:05 -07:00
parent 45b2bd94d4
commit 68dcf2e18a
3 changed files with 120 additions and 4 deletions
@@ -2,6 +2,7 @@ import type { HostProfile } from './types'
const pendingByHost = new Map<string, Promise<void>>()
const revisionByHost = new Map<string, number>()
const retirementRevisionByHost = new Map<string, number>()
const endpointRevisionByHost = new Map<string, number>()
const endpointGenerationByHost = new Map<string, number>()
const publishedIdentityByHost = new Map<string, Pick<HostProfile, 'deviceToken' | 'publicKeyB64'>>()
@@ -40,6 +41,7 @@ export function recordDurableHostIdentity(
}
export function retireHostProfilePublication(hostId: string): Promise<void> | 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<void>) | null,
saveHost: (host: HostProfile) => Promise<void>,
publicationRevision = getHostProfilePublicationRevision(host.id)
requestedRevision: number | 'adopt-current' = getHostProfilePublicationRevision(host.id)
): Promise<void> {
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()
@@ -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<void>((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<void>((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<void>((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()
})
})
@@ -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 }