mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
feat(mobile): recover pairing through relay director
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user