feat(mobile): recover pairing through relay director

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-13 01:41:02 -04:00
co-authored by Orca
parent 9a7dfec237
commit 74039db6f0
7 changed files with 368 additions and 4 deletions
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
class FakeSocket {
sent: string[] = []
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number }) => void) | null = null
send(value: string): void {
this.sent.push(value)
}
close(): void {}
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: Date.now() + 300_000,
e2eeFraming: 2 as const
}
describe('pairing invite director resolution', () => {
it('authenticates only to the configured director and accepts a strictly newer move', async () => {
const socket = new FakeSocket()
let url = ''
const resolving = resolvePairingInviteThroughDirector({
relay,
createSocket: (value) => {
url = value
return socket as unknown as WebSocket
}
})
socket.onopen?.()
expect(url).toBe('wss://relay.onorca.dev/v1/connect/AbCdEf0123_-xyZ9')
expect(url).not.toContain('?')
expect(JSON.parse(socket.sent[0]!)).toEqual({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: relay.inviteToken
})
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
})
})
await expect(resolving).resolves.toMatchObject({
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
})
})
it('rejects same/older epochs and untrusted extra fields', async () => {
const socket = new FakeSocket()
const resolving = resolvePairingInviteThroughDirector({
relay,
createSocket: () => socket as unknown as WebSocket
})
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 7,
targetFromCell: true
})
})
await expect(resolving).rejects.toThrow(/not strictly newer/)
})
})
@@ -0,0 +1,78 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol'
export function resolvePairingInviteThroughDirector(args: {
relay: PairingRelay
timeoutMs?: number
createSocket?: (url: string) => WebSocket
}): Promise<PairingRelay> {
const socket = (args.createSocket ?? ((url) => new WebSocket(url)))(
directorWebSocketUrl(args.relay)
)
return new Promise((resolve, reject) => {
let settled = false
const timeout = setTimeout(
() => finish(new Error('relay director resolution timed out')),
args.timeoutMs ?? 5_000
)
socket.onopen = () => {
socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: args.relay.inviteToken
})
)
}
socket.onmessage = (event) => {
if (typeof event.data !== 'string') {
finish(new Error('invalid relay director move'))
return
}
let value: unknown
try {
value = JSON.parse(event.data)
} catch {
finish(new Error('invalid relay director move'))
return
}
const moved = RelayMovedSchema.safeParse(value)
if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
finish(new Error('relay director move was not strictly newer'))
return
}
settled = true
clearTimeout(timeout)
socket.close()
resolve({
...args.relay,
cellUrl: moved.data.cellUrl,
assignmentEpoch: moved.data.assignmentEpoch
})
}
socket.onerror = () => finish(new Error('relay director transport error'))
socket.onclose = (event) => {
if (!settled) {
finish(new Error(`relay director closed before move: ${event.code || 1006}`))
}
}
function finish(error: Error): void {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
socket.close()
reject(error)
}
})
}
export function directorWebSocketUrl(relay: PairingRelay): string {
const url = new URL(relay.directorUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import type { PairingCandidateClient } from './mobile-relay-physical-client'
import { RelayOuterError } from './mobile-relay-physical-client'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(length)
}))
const journal = {
metadata: {
v: 1,
journalId: 'pair-1',
offerFingerprint: 'A'.repeat(43),
host: {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
},
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteExpiresAt: 10_000,
e2eeFraming: 2
},
installReqId: 'install-1',
pendingResumeTokenHash: 'B'.repeat(43)
},
secrets: {
v: 1,
journalId: 'pair-1',
deviceToken: 'device-token',
inviteToken: 'C'.repeat(43),
pendingResumeToken: 'D'.repeat(43)
}
} satisfies MobileRelayPairingJournal
function client(
result: Promise<never> | Promise<ReturnType<typeof success>>
): PairingCandidateClient {
return { sendRequest: vi.fn(() => result), close: vi.fn() }
}
function success() {
return {
id: 'rpc-1',
ok: true as const,
result: { path: 'relay' },
_meta: { runtimeId: 'runtime-1' }
}
}
describe('recovering pairing relay candidate', () => {
it('persists a strictly-newer director move before retrying the target', async () => {
const events: string[] = []
const stale = client(Promise.reject(new RelayOuterError(4409)))
const target = client(Promise.resolve(success()))
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) => {
events.push(`connect:${relay.assignmentEpoch}`)
return connects++ === 0 ? stale : target
},
resolveDirector: async (relay) => ({
...relay,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
}),
persistMove: async (relay) => {
events.push(`persist:${relay.assignmentEpoch}`)
},
now: () => 1
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(events).toEqual(['connect:7', 'persist:8', 'connect:8'])
expect(stale.close).toHaveBeenCalledOnce()
})
it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => {
const offline = client(Promise.reject(new RelayOuterError(4404)))
const resolveDirector = vi.fn()
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => offline,
resolveDirector,
persistMove: vi.fn(),
now: () => 1
})
await expect(candidate.sendRequest('status.get')).rejects.toEqual(new RelayOuterError(4404))
expect(resolveDirector).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,61 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client'
export function createRecoveringPairingRelayCandidate(args: {
journal: MobileRelayPairingJournal
connect: (relay: PairingRelay) => PairingCandidateClient
resolveDirector: (relay: PairingRelay) => Promise<PairingRelay>
persistMove: (relay: PairingRelay) => Promise<void>
now: () => number
}): PairingCandidateClient {
let relay = pairingRelayFromJournal(args.journal)
let client = args.connect(relay)
let recoveryAttempted = false
let closed = false
return {
async sendRequest(method, params) {
try {
return await client.sendRequest(method, params)
} catch (error) {
if (
method !== 'status.get' ||
recoveryAttempted ||
closed ||
relay.inviteExpiresAt <= args.now() ||
!isDirectorRecoverable(error)
) {
throw error
}
recoveryAttempted = true
const moved = await args.resolveDirector(relay)
// Why: the authenticated newer assignment must be durable before a
// target dial so a crash cannot revert to the known-stale cell.
await args.persistMove(moved)
client.close()
relay = moved
client = args.connect(relay)
return client.sendRequest(method, params)
}
},
close() {
closed = true
client.close()
}
}
}
function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {
return {
...journal.metadata.relay,
inviteToken: journal.secrets.inviteToken
}
}
function isDirectorRecoverable(error: unknown): boolean {
if (!(error instanceof RelayOuterError)) {
return true
}
return error.code === 4409 || error.code === 4503 || error.code === 1006
}
@@ -61,6 +61,9 @@ function dependencies(client: RpcClient, events: string[]) {
return {
connectDirect: vi.fn(() => (events.push('connect'), client)),
connectRelay: vi.fn(() => unavailableRelay),
resolveInviteDirector: vi.fn(async () => {
throw new Error('director unavailable')
}),
getNextHostName: vi.fn(async () => 'Blue Whale'),
saveHost: vi.fn(async (_host: HostProfile) => {
events.push('save-host')
@@ -26,6 +26,8 @@ import {
type PairingCandidateClient
} from './mobile-relay-physical-client'
import { racePairingCandidates, type PairingCandidate } from './pairing-candidate-race'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
export type PreProfilePairingAttempt = {
readonly result: Promise<{ hostId: string }>
@@ -36,6 +38,7 @@ export type PreProfilePairingAttempt = {
type Dependencies = {
connectDirect: typeof connect
connectRelay: typeof connectMobileRelayForPairing
resolveInviteDirector: typeof resolvePairingInviteThroughDirector
getNextHostName: typeof getNextHostName
saveHost: typeof saveHost
saveJournal: typeof saveMobileRelayPairingJournal
@@ -49,6 +52,7 @@ type Dependencies = {
const defaultDependencies: Dependencies = {
connectDirect: connect,
connectRelay: connectMobileRelayForPairing,
resolveInviteDirector: resolvePairingInviteThroughDirector,
getNextHostName,
saveHost,
saveJournal: saveMobileRelayPairingJournal,
@@ -150,10 +154,30 @@ async function runPairing(
clients.add(directClient)
const candidates: PairingCandidate[] = [{ path: 'direct', client: directClient }]
if (journal) {
const relayClient = dependencies.connectRelay({
relay: offer.relay!,
deviceToken: offer.deviceToken,
desktopPublicKeyB64: offer.publicKeyB64
const relayClient = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) =>
dependencies.connectRelay({
relay,
deviceToken: offer.deviceToken,
desktopPublicKeyB64: offer.publicKeyB64
}),
resolveDirector: (relay) => dependencies.resolveInviteDirector({ relay }),
persistMove: async (relay) => {
journal = {
...journal!,
metadata: {
...journal!.metadata,
relay: {
...journal!.metadata.relay,
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch
}
}
}
await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata)
},
now: dependencies.now
})
clients.add(relayClient)
candidates.push({ path: 'relay', client: relayClient })
+16
View File
@@ -33,3 +33,19 @@ export const RelayPhoneHelloSchema = z.union([
])
export type RelayPhoneHello = z.infer<typeof RelayPhoneHelloSchema>
export const RelayMovedSchema = z
.object({
type: z.literal('relay-moved'),
v: z.literal(1),
cellUrl: z.string().refine((value) => {
try {
const parsed = new URL(value)
return parsed.protocol === 'https:' && parsed.origin === value
} catch {
return false
}
}),
assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
})
.strict()