mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(mobile): let user re-pair supersede recovery
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user