Fix retrying interrupted Relay pairing (#8809)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
ef87461bfe
commit
58f222b43f
|
|
@ -130,7 +130,7 @@ describe('mobile relay pairing journal store', () => {
|
|||
expect(metadataRaw).toBeNull()
|
||||
})
|
||||
|
||||
it('cleans mismatched secret records and refuses to replace a recoverable journal', async () => {
|
||||
it('cleans mismatched secret records', async () => {
|
||||
const journal = createMobileRelayPairingJournal({
|
||||
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
|
||||
hostId: 'host-1',
|
||||
|
|
@ -142,6 +142,137 @@ describe('mobile relay pairing journal store', () => {
|
|||
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
|
||||
expect(metadataRaw).toBeNull()
|
||||
expect(secretRaw).toBeNull()
|
||||
})
|
||||
|
||||
it('replaces a journal that never selected an authorization path', async () => {
|
||||
const journal = createMobileRelayPairingJournal({
|
||||
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
|
||||
hostId: 'host-1',
|
||||
hostName: 'Blue Whale',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(10)
|
||||
})
|
||||
await saveMobileRelayPairingJournal(journal)
|
||||
const replacement = createMobileRelayPairingJournal({
|
||||
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
|
||||
hostId: 'host-2',
|
||||
hostName: 'Red Panda',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(12)
|
||||
})
|
||||
|
||||
const replacementSave = saveMobileRelayPairingJournal(replacement)
|
||||
const staleUpdate = updateMobileRelayPairingJournal(
|
||||
journal.metadata.journalId,
|
||||
(metadata) => metadata
|
||||
)
|
||||
|
||||
await expect(replacementSave).resolves.toBeUndefined()
|
||||
await expect(staleUpdate).rejects.toThrow(/stale/)
|
||||
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement)
|
||||
})
|
||||
|
||||
it('serializes a replacement behind an in-flight journal snapshot', async () => {
|
||||
const journal = createMobileRelayPairingJournal({
|
||||
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
|
||||
hostId: 'host-1',
|
||||
hostName: 'Blue Whale',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(10)
|
||||
})
|
||||
await saveMobileRelayPairingJournal(journal)
|
||||
let releaseSecretRead!: () => void
|
||||
const secretReadGate = new Promise<void>((resolve) => {
|
||||
releaseSecretRead = resolve
|
||||
})
|
||||
secureStore.getItemAsync.mockImplementationOnce(async () => {
|
||||
await secretReadGate
|
||||
return secretRaw
|
||||
})
|
||||
const loading = loadMobileRelayPairingJournal()
|
||||
await vi.waitFor(() => expect(secureStore.getItemAsync).toHaveBeenCalled())
|
||||
|
||||
const replacement = createMobileRelayPairingJournal({
|
||||
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
|
||||
hostId: 'host-2',
|
||||
hostName: 'Red Panda',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(12)
|
||||
})
|
||||
let replacementSaved = false
|
||||
const saving = saveMobileRelayPairingJournal(replacement).then(() => {
|
||||
replacementSaved = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(replacementSaved).toBe(false)
|
||||
|
||||
releaseSecretRead()
|
||||
await expect(loading).resolves.toEqual(journal)
|
||||
await expect(saving).resolves.toBeUndefined()
|
||||
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement)
|
||||
expect(asyncStorage.removeItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans a replacement whose secret write fails', async () => {
|
||||
const journal = createMobileRelayPairingJournal({
|
||||
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
|
||||
hostId: 'host-1',
|
||||
hostName: 'Blue Whale',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(10)
|
||||
})
|
||||
await saveMobileRelayPairingJournal(journal)
|
||||
const replacement = createMobileRelayPairingJournal({
|
||||
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
|
||||
hostId: 'host-2',
|
||||
hostName: 'Red Panda',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(12)
|
||||
})
|
||||
secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable'))
|
||||
|
||||
await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/)
|
||||
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
|
||||
expect(metadataRaw).toBeNull()
|
||||
expect(secretRaw).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks replacement when the old authorization update wins the mutation race', async () => {
|
||||
const journal = createMobileRelayPairingJournal({
|
||||
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
|
||||
hostId: 'host-1',
|
||||
hostName: 'Blue Whale',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(10)
|
||||
})
|
||||
await saveMobileRelayPairingJournal(journal)
|
||||
const replacement = createMobileRelayPairingJournal({
|
||||
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
|
||||
hostId: 'host-2',
|
||||
hostName: 'Red Panda',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(12)
|
||||
})
|
||||
const authorizationUpdate = updateMobileRelayPairingJournal(
|
||||
journal.metadata.journalId,
|
||||
(metadata) => ({
|
||||
...metadata,
|
||||
winner: 'direct',
|
||||
authorizationMode: 'authenticated-direct'
|
||||
})
|
||||
)
|
||||
const replacementSave = saveMobileRelayPairingJournal(replacement)
|
||||
|
||||
await expect(authorizationUpdate).resolves.toBeUndefined()
|
||||
await expect(replacementSave).rejects.toThrow(/recovery pending/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['winner', { winner: 'direct' as const }],
|
||||
['authorization mode', { authorizationMode: 'authenticated-direct' as const }]
|
||||
])('refuses replacement once the durable %s exists', async (_name, authorization) => {
|
||||
const created = createMobileRelayPairingJournal({
|
||||
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
|
||||
hostId: 'host-1',
|
||||
hostName: 'Blue Whale',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(10)
|
||||
})
|
||||
const journal = {
|
||||
...created,
|
||||
metadata: { ...created.metadata, ...authorization }
|
||||
}
|
||||
|
||||
await saveMobileRelayPairingJournal(journal)
|
||||
const replacement = createMobileRelayPairingJournal({
|
||||
|
|
|
|||
|
|
@ -27,9 +27,15 @@ export async function saveMobileRelayPairingJournal(
|
|||
const mutation = journalMutation.then(async () => {
|
||||
const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
|
||||
const existing = existingRaw ? parseMetadata(existingRaw) : null
|
||||
if (existing && existing.journalId !== metadata.journalId) {
|
||||
if (
|
||||
existing &&
|
||||
existing.journalId !== metadata.journalId &&
|
||||
(existing.winner !== undefined || existing.authorizationMode !== undefined)
|
||||
) {
|
||||
throw new Error('mobile relay pairing recovery pending')
|
||||
}
|
||||
// Why: no install RPC can run before winner+authorization are durable, so
|
||||
// a new user-initiated scan may safely supersede a pre-authorization attempt.
|
||||
// Why: metadata-first makes a crash before the keychain write recover as
|
||||
// an incomplete journal, never as an untracked bearer secret.
|
||||
await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata))
|
||||
|
|
@ -41,28 +47,36 @@ export async function saveMobileRelayPairingJournal(
|
|||
|
||||
export async function loadMobileRelayPairingJournal(): Promise<MobileRelayPairingJournal | null> {
|
||||
requireNativeSecretStore()
|
||||
await journalMutation
|
||||
const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
|
||||
if (rawMetadata === null) {
|
||||
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
|
||||
return null
|
||||
}
|
||||
const metadata = parseMetadata(rawMetadata)
|
||||
if (!metadata) {
|
||||
await removeIncompleteJournal()
|
||||
return null
|
||||
}
|
||||
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
|
||||
if (rawSecrets === null) {
|
||||
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
const secrets = parseSecrets(rawSecrets)
|
||||
if (!secrets || secrets.journalId !== metadata.journalId) {
|
||||
await removeIncompleteJournal()
|
||||
return null
|
||||
}
|
||||
return { metadata, secrets }
|
||||
const load = journalMutation.then(async () => {
|
||||
const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
|
||||
if (rawMetadata === null) {
|
||||
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
|
||||
return null
|
||||
}
|
||||
const metadata = parseMetadata(rawMetadata)
|
||||
if (!metadata) {
|
||||
await removeIncompleteJournal()
|
||||
return null
|
||||
}
|
||||
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
|
||||
if (rawSecrets === null) {
|
||||
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
const secrets = parseSecrets(rawSecrets)
|
||||
if (!secrets || secrets.journalId !== metadata.journalId) {
|
||||
await removeIncompleteJournal()
|
||||
return null
|
||||
}
|
||||
return { metadata, secrets }
|
||||
})
|
||||
// Why: recovery may load while a new scan saves; serialize the complete
|
||||
// metadata/secret snapshot and any cleanup so it cannot delete the new journal.
|
||||
journalMutation = load.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return load
|
||||
}
|
||||
|
||||
async function removeIncompleteJournal(): Promise<void> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue