feat(mobile): persist relay pairing state

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-13 04:53:10 -04:00
co-authored by Orca
parent b54fdbc5e5
commit 50fa63e2d5
15 changed files with 1453 additions and 132 deletions
+15 -61
View File
@@ -5,12 +5,10 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
import {
startPairingConnectionAttempt,
type PairingConnectionAttempt
} from '../src/transport/pairing-connection-attempt'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types'
startPreProfilePairing,
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
@@ -35,7 +33,7 @@ export default function PairConfirmScreen() {
// batch fewer setState calls when entries arrive in bursts.
const logsRef = useRef<ConnectionLogEntry[]>([])
const mountedRef = useRef(true)
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
const routeState = resolvePairConfirmRouteState(params.code)
const offer = routeState.offer
@@ -79,20 +77,12 @@ export default function PairConfirmScreen() {
setStatus('connecting')
logsRef.current = []
setLogs([])
let client: ReturnType<typeof connect> | null = null
activePairingAttemptRef.current?.dispose()
// Why: split the try/catch around the network call vs the local save
// so a Keychain or AsyncStorage failure doesn't masquerade as a
// "Cannot connect" error.
let response: RpcResponse
const attempt = startPairingConnectionAttempt({
const attempt = startPreProfilePairing({
offer,
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
closeClient: () => client?.close()
})
activePairingAttemptRef.current = attempt
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
connectOptions: {
onLog: (entry) => {
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
return
@@ -100,8 +90,11 @@ export default function PairConfirmScreen() {
logsRef.current = [...logsRef.current, entry]
setLogs(logsRef.current)
}
})
response = await client.sendRequest('status.get')
}
})
activePairingAttemptRef.current = attempt
try {
const { hostId } = await attempt.result
const attemptIsCurrent = activePairingAttemptRef.current === attempt
attempt.dispose()
if (activePairingAttemptRef.current === attempt) {
@@ -110,6 +103,7 @@ export default function PairConfirmScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
router.replace(`/h/${hostId}`)
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
@@ -125,47 +119,7 @@ export default function PairConfirmScreen() {
setErrorMessage(
timedOut
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: 'Cannot connect — check that your computer is on the same network'
)
return
}
if (!response.ok) {
if (!mountedRef.current) {
return
}
setStatus('error')
setErrorMessage(
response.error.code === 'unauthorized'
? 'Authentication failed — token may be expired'
: `Server error: ${response.error.message}`
)
return
}
try {
const hostId = `host-${Date.now()}`
const hostName = await getNextHostName()
await saveHost({
id: hostId,
name: hostName,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
if (!mountedRef.current) {
return
}
router.replace(`/h/${hostId}`)
} catch (err) {
if (!mountedRef.current) {
return
}
console.warn('[pair-confirm] save failed', err)
setStatus('error')
setErrorMessage(
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
)
}
}
+15 -66
View File
@@ -14,12 +14,10 @@ import { useRouter } from 'expo-router'
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
import {
startPairingConnectionAttempt,
type PairingConnectionAttempt
} from '../src/transport/pairing-connection-attempt'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
startPreProfilePairing,
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
@@ -54,7 +52,7 @@ export default function PairScanScreen() {
const logsRef = useRef<ConnectionLogEntry[]>([])
const processingRef = useRef(false)
const mountedRef = useRef(true)
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
const setPairScanRootRef = useCallback((node: View | null): void => {
if (node !== null) {
@@ -123,21 +121,12 @@ export default function PairScanScreen() {
setStatus('connecting')
logsRef.current = []
setLogs([])
let client: ReturnType<typeof connect> | null = null
activePairingAttemptRef.current?.dispose()
// Why: split the try/catch around the network call vs the local save
// so a Keychain or AsyncStorage failure doesn't masquerade as a
// "Cannot connect — same network?" error. Pairing reached the
// desktop fine; the failure is local persistence.
let response: RpcResponse
const attempt = startPairingConnectionAttempt({
const attempt = startPreProfilePairing({
offer,
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
closeClient: () => client?.close()
})
activePairingAttemptRef.current = attempt
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
connectOptions: {
onLog: (entry) => {
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
return
@@ -145,8 +134,11 @@ export default function PairScanScreen() {
logsRef.current = [...logsRef.current, entry]
setLogs(logsRef.current)
}
})
response = await client.sendRequest('status.get')
}
})
activePairingAttemptRef.current = attempt
try {
const { hostId } = await attempt.result
const attemptIsCurrent = activePairingAttemptRef.current === attempt
attempt.dispose()
if (activePairingAttemptRef.current === attempt) {
@@ -155,6 +147,7 @@ export default function PairScanScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
router.replace(`/h/${hostId}`)
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
@@ -170,51 +163,7 @@ export default function PairScanScreen() {
setErrorMessage(
timedOut
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: 'Cannot connect — check that your computer is on the same network'
)
processingRef.current = false
return
}
if (!response.ok) {
if (!mountedRef.current) {
return
}
if (response.error.code === 'unauthorized') {
setStatus('error')
setErrorMessage('Authentication failed — token may be expired')
processingRef.current = false
return
}
setStatus('error')
setErrorMessage(`Server error: ${response.error.message}`)
processingRef.current = false
return
}
try {
const hostId = `host-${Date.now()}`
const hostName = await getNextHostName()
await saveHost({
id: hostId,
name: hostName,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
if (!mountedRef.current) {
return
}
router.replace(`/h/${hostId}`)
} catch (err) {
if (!mountedRef.current) {
return
}
console.warn('[pair] save failed', err)
setStatus('error')
setErrorMessage(
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
)
processingRef.current = false
}
+53 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay'
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn(),
@@ -32,9 +33,17 @@ vi.mock('./host-credential-cleanup', () => ({
retryPendingHostCredentialCleanups: vi.fn()
}))
import { removeHost, renameHost, resetHostStoreForTests, updateLastConnected } from './host-store'
import {
loadHosts,
removeHost,
renameHost,
resetHostStoreForTests,
updateLastConnected
} from './host-store'
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
const HOSTS_STORAGE_KEY = 'orca:hosts'
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
const HOST_ONE = {
id: 'host-1',
name: 'Host 1',
@@ -52,17 +61,23 @@ const HOST_TWO = {
describe('host-store list mutations', () => {
let storedHostsRaw: string
let storedOverlayRaw: string | null
beforeEach(() => {
vi.clearAllMocks()
resetHostStoreForTests()
resetMobileRelayHostOverlayStoreForTests()
scheduleCleanupMock.mockReset()
scheduleCleanupMock.mockResolvedValue(undefined)
storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO])
storedOverlayRaw = null
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === HOSTS_STORAGE_KEY) {
return storedHostsRaw
}
if (key === OVERLAY_STORAGE_KEY) {
return storedOverlayRaw
}
return null
})
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
@@ -70,6 +85,9 @@ describe('host-store list mutations', () => {
storedHostsRaw = raw
}
})
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null
)
})
it('commits the removal when credential cleanup scheduling rejects', async () => {
@@ -81,6 +99,40 @@ describe('host-store list mutations', () => {
expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function))
})
it('merges v2 endpoints only onto an existing legacy base host', async () => {
const overlay: MobileRelayHostOverlay = {
v: 2,
hostId: HOST_ONE.id,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint },
{
id: 'relay-primary',
kind: 'relay',
url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9'
}
],
relayHostId: 'AbCdEf0123_-xyZ9',
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2
}
}
storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }])
const hosts = await loadHosts()
expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({
endpoints: overlay.endpoints,
relayHostId: overlay.relayHostId,
relay: overlay.relay
})
expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false)
})
it('awaits cleanup scheduling after metadata commit', async () => {
let resolveSchedule: (() => void) | null = null
scheduleCleanupMock.mockReturnValue(
+41 -3
View File
@@ -12,6 +12,12 @@ import {
retryPendingHostCredentialCleanups,
scheduleHostCredentialCleanup
} from './host-credential-cleanup'
import {
loadMobileRelayHostOverlays,
removeMobileRelayHostOverlay,
saveMobileRelayHostOverlay
} from './mobile-relay-host-overlay-store'
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
const STORAGE_KEY = 'orca:hosts'
// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected.
@@ -61,6 +67,11 @@ async function deleteDeviceToken(hostId: string): Promise<void> {
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
}
async function deleteHostCredentials(hostId: string): Promise<void> {
await deleteDeviceToken(hostId)
await deleteMobileRelayCredentialBundle(hostId)
}
// Why: SecureStore reads on Android Keystore can take 50-200ms each, and
// loadHosts() is called from every screen mount + every useFocusEffect.
// Stack with N hosts and you get N*200ms blocking every navigation, which
@@ -120,6 +131,7 @@ async function doLoadHosts(): Promise<HostProfile[]> {
if (!storedHosts) {
return []
}
const overlays = await loadMobileRelayHostOverlays(new Set(storedHosts.map(({ id }) => id)))
const out: HostProfile[] = []
for (const stored of storedHosts) {
@@ -144,7 +156,18 @@ async function doLoadHosts(): Promise<HostProfile[]> {
token = fetched
tokenCache.set(stored.id, token)
}
out.push({ ...stored, deviceToken: token })
const overlay = overlays.get(stored.id)
out.push({
...stored,
deviceToken: token,
...(overlay
? {
endpoints: overlay.endpoints,
relayHostId: overlay.relayHostId,
relay: overlay.relay
}
: {})
})
}
return out
}
@@ -215,15 +238,30 @@ export async function saveHost(host: HostProfile): Promise<void> {
// from current metadata.
await writeDeviceToken(stored.id, validated.deviceToken)
tokenCache.set(stored.id, validated.deviceToken)
if (validated.endpoints) {
await saveMobileRelayHostOverlay({
v: 2,
hostId: stored.id,
endpoints: validated.endpoints,
relayHostId: validated.relayHostId,
relay: validated.relay
})
}
}
export async function removeHost(hostId: string): Promise<void> {
await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId))
tokenCache.delete(hostId)
try {
await removeMobileRelayHostOverlay(hostId)
} catch {
// The missing legacy base is authoritative, so a retained overlay cannot
// resurrect this host and can be cleaned on a later explicit retry.
}
// Why: await only the durable cleanup intent (AsyncStorage). Native keychain
// delete can reject or stall and must not freeze removeHost / the UI.
try {
await scheduleHostCredentialCleanup(hostId, deleteDeviceToken)
await scheduleHostCredentialCleanup(hostId, deleteHostCredentials)
} catch {
// Metadata is already committed; orphan-token recovery is best-effort.
}
@@ -234,7 +272,7 @@ export async function retryPendingHostCredentialCleanup(): Promise<{
remainingIds: string[]
storageUnreadable: boolean
}> {
return retryPendingHostCredentialCleanups(deleteDeviceToken)
return retryPendingHostCredentialCleanups(deleteHostCredentials)
}
export async function renameHost(hostId: string, newName: string): Promise<void> {
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const secureStore = vi.hoisted(() => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn()
}))
const platform = vi.hoisted(() => ({ OS: 'ios' }))
vi.mock('expo-secure-store', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
...secureStore
}))
vi.mock('react-native', () => ({ Platform: platform }))
import {
deleteMobileRelayCredentialBundle,
promotePairingJournalCredential,
readMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
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),
winner: 'direct',
authorizationMode: 'authenticated-direct'
},
secrets: {
v: 1,
journalId: 'pair-1',
deviceToken: 'device-token',
inviteToken: 'C'.repeat(43),
pendingResumeToken: 'D'.repeat(43)
}
} satisfies MobileRelayPairingJournal
describe('mobile relay credential bundle', () => {
let stored: string | null
beforeEach(() => {
vi.clearAllMocks()
platform.OS = 'ios'
stored = null
secureStore.getItemAsync.mockImplementation(async () => stored)
secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => {
stored = value
})
secureStore.deleteItemAsync.mockImplementation(async () => {
stored = null
})
})
it('promotes only a matching committed install result to current', async () => {
const bundle = promotePairingJournalCredential({
journal,
installed: {
v: 1,
reqId: 'install-1',
authorizationMode: 'authenticated-direct',
currentVersion: 3,
resumeExpiresAt: 50_000
}
})
await writeMobileRelayCredentialBundle(bundle)
await expect(readMobileRelayCredentialBundle('host-1')).resolves.toEqual(bundle)
expect(stored).toContain(journal.secrets.pendingResumeToken)
expect(stored).not.toContain(journal.secrets.inviteToken)
})
it('rejects a result from another request or authorization mode', () => {
expect(() =>
promotePairingJournalCredential({
journal,
installed: {
v: 1,
reqId: 'other-request',
authorizationMode: 'relay-basis',
currentVersion: 1,
resumeExpiresAt: 50_000
}
})
).toThrow(/does not match/)
})
it('deletes the namespaced bundle and never enables it on web', async () => {
await deleteMobileRelayCredentialBundle('host-1')
expect(secureStore.deleteItemAsync).toHaveBeenCalledWith(
'orca.mobile-relay.credentials.host-1',
expect.any(Object)
)
platform.OS = 'web'
await expect(readMobileRelayCredentialBundle('host-1')).rejects.toThrow(/native secret store/)
})
})
@@ -0,0 +1,112 @@
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import { z } from 'zod'
import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
const ResumeCredentialSchema = z
.object({
token: Base64Url32ByteSchema,
hash: Base64Url32ByteSchema,
version: z.number().int().positive(),
expiresAt: z.number().int().nonnegative()
})
.strict()
export const MobileRelayCredentialBundleSchema = z
.object({
v: z.literal(1),
hostId: z.string().min(1),
deviceToken: z.string().min(1),
current: ResumeCredentialSchema,
grace: ResumeCredentialSchema.optional(),
pending: z
.object({
token: Base64Url32ByteSchema,
hash: Base64Url32ByteSchema,
reqId: z.string().min(1)
})
.strict()
.optional(),
invite: z
.object({ token: Base64Url32ByteSchema, expiresAt: z.number().int().positive() })
.strict()
.optional()
})
.strict()
export type MobileRelayCredentialBundle = z.infer<typeof MobileRelayCredentialBundleSchema>
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function credentialKey(hostId: string): string {
return `orca.mobile-relay.credentials.${hostId}`
}
export function promotePairingJournalCredential(args: {
journal: MobileRelayPairingJournal
installed: DeviceCredentialInstalled
}): MobileRelayCredentialBundle {
const { journal, installed } = args
if (
installed.reqId !== journal.metadata.installReqId ||
installed.authorizationMode !== journal.metadata.authorizationMode
) {
throw new Error('relay credential install result does not match pairing journal')
}
return MobileRelayCredentialBundleSchema.parse({
v: 1,
hostId: journal.metadata.host.id,
deviceToken: journal.secrets.deviceToken,
current: {
token: journal.secrets.pendingResumeToken,
hash: journal.metadata.pendingResumeTokenHash,
version: installed.currentVersion,
expiresAt: installed.resumeExpiresAt
}
})
}
export async function readMobileRelayCredentialBundle(
hostId: string
): Promise<MobileRelayCredentialBundle | null> {
requireNativeSecretStore()
const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
if (raw === null) {
return null
}
try {
const result = MobileRelayCredentialBundleSchema.safeParse(JSON.parse(raw))
return result.success && result.data.hostId === hostId ? result.data : null
} catch {
return null
}
}
export async function writeMobileRelayCredentialBundle(
bundle: MobileRelayCredentialBundle
): Promise<void> {
requireNativeSecretStore()
const validated = MobileRelayCredentialBundleSchema.parse(bundle)
await SecureStore.setItemAsync(
credentialKey(validated.hostId),
JSON.stringify(validated),
KEYCHAIN_OPTIONS
)
}
export async function deleteMobileRelayCredentialBundle(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
}
function requireNativeSecretStore(): void {
if (Platform.OS === 'web') {
throw new Error('Orca Relay credentials require a native secret store')
}
}
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
import {
loadMobileRelayHostOverlays,
resetMobileRelayHostOverlayStoreForTests,
saveMobileRelayHostOverlay
} from './mobile-relay-host-overlay-store'
import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay'
const STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
const OVERLAY: MobileRelayHostOverlay = {
v: 2,
hostId: 'host-1',
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' },
{
id: 'relay-primary',
kind: 'relay',
url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9'
}
],
relayHostId: 'AbCdEf0123_-xyZ9',
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2
}
}
describe('mobile relay host overlay store', () => {
let stored: string | null
beforeEach(() => {
vi.clearAllMocks()
resetMobileRelayHostOverlayStoreForTests()
stored = null
asyncStorage.getItem.mockImplementation(async (key: string) =>
key === STORAGE_KEY ? stored : null
)
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
if (key === STORAGE_KEY) {
stored = value
}
})
})
it('round-trips v2 metadata in a namespace legacy builds do not rewrite', async () => {
await saveMobileRelayHostOverlay(OVERLAY)
await expect(loadMobileRelayHostOverlays(new Set(['host-1']))).resolves.toEqual(
new Map([['host-1', OVERLAY]])
)
expect(asyncStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, expect.any(String))
})
it('never overlays or resurrects a host whose legacy base was removed', async () => {
stored = JSON.stringify([OVERLAY])
await expect(loadMobileRelayHostOverlays(new Set())).resolves.toEqual(new Map())
expect(asyncStorage.setItem).not.toHaveBeenCalled()
expect(JSON.parse(stored)).toEqual([OVERLAY])
})
it('refuses to overwrite unreadable overlay storage', async () => {
stored = '{'
await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/)
expect(asyncStorage.setItem).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,85 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
MobileRelayHostOverlaySchema,
type MobileRelayHostOverlay
} from './mobile-relay-host-overlay'
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
let overlayMutation: Promise<void> = Promise.resolve()
function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null {
if (raw === null) {
return []
}
try {
const value = JSON.parse(raw) as unknown
if (!Array.isArray(value)) {
return null
}
return value.flatMap((item) => {
const result = MobileRelayHostOverlaySchema.safeParse(item)
return result.success ? [result.data] : []
})
} catch {
return null
}
}
async function readOverlaysForMutation(): Promise<MobileRelayHostOverlay[]> {
const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY))
if (!overlays) {
// Why: never rewrite an unreadable v2 namespace as an empty list; doing so
// would destroy relay recovery data during an unrelated host mutation.
throw new Error('mobile relay host overlay storage unreadable')
}
return overlays
}
async function mutateOverlays(
update: (overlays: MobileRelayHostOverlay[]) => MobileRelayHostOverlay[]
): Promise<void> {
const mutation = overlayMutation.then(async () => {
const current = await readOverlaysForMutation()
await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(update(current)))
})
overlayMutation = mutation.catch(() => {})
return mutation
}
export async function loadMobileRelayHostOverlays(
existingHostIds: ReadonlySet<string>
): Promise<Map<string, MobileRelayHostOverlay>> {
await overlayMutation
const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY)) ?? []
const active = new Map<string, MobileRelayHostOverlay>()
for (const overlay of overlays) {
// Why: an older app can remove the legacy base without knowing this
// namespace; never let the retained overlay resurrect that host later.
if (existingHostIds.has(overlay.hostId)) {
active.set(overlay.hostId, overlay)
}
}
return active
}
export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay): Promise<void> {
const validated = MobileRelayHostOverlaySchema.parse(overlay)
return mutateOverlays((overlays) => {
const index = overlays.findIndex(({ hostId }) => hostId === validated.hostId)
if (index < 0) {
return [...overlays, validated]
}
const next = overlays.slice()
next[index] = validated
return next
})
}
export function removeMobileRelayHostOverlay(hostId: string): Promise<void> {
return mutateOverlays((overlays) => overlays.filter((overlay) => overlay.hostId !== hostId))
}
/** Test-only: drain the module mutation chain between cases. */
export function resetMobileRelayHostOverlayStoreForTests(): void {
overlayMutation = Promise.resolve()
}
@@ -0,0 +1,47 @@
import { z } from 'zod'
import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract'
export const MobileAccessEndpointSchema = z
.object({
id: z.string().min(1).max(128),
kind: z.enum(['lan', 'tailscale', 'relay']),
url: z.string().min(1).max(2048)
})
.strict()
export const MobileRelayHostOverlaySchema = z
.object({
v: z.literal(2),
hostId: z.string().min(1),
endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16),
relayHostId: z
.string()
.regex(/^[A-Za-z0-9_-]{16}$/)
.optional(),
relay: MobileRelayEndpointSchema.optional()
})
.strict()
.superRefine((overlay, context) => {
if ((overlay.relayHostId === undefined) !== (overlay.relay === undefined)) {
context.addIssue({ code: 'custom', message: 'Relay identity and endpoint must coexist' })
return
}
if (overlay.relay && overlay.relay.relayHostId !== overlay.relayHostId) {
context.addIssue({
code: 'custom',
path: ['relayHostId'],
message: 'Relay host identity mismatch'
})
}
const relayEndpointCount = overlay.endpoints.filter(({ kind }) => kind === 'relay').length
if (relayEndpointCount !== (overlay.relay ? 1 : 0)) {
context.addIssue({
code: 'custom',
path: ['endpoints'],
message: 'Expected exactly one endpoint for configured relay metadata'
})
}
})
export type MobileAccessEndpoint = z.infer<typeof MobileAccessEndpointSchema>
export type MobileRelayHostOverlay = z.infer<typeof MobileRelayHostOverlaySchema>
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}))
const secureStore = vi.hoisted(() => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn()
}))
const platform = vi.hoisted(() => ({ OS: 'ios' }))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('expo-secure-store', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
...secureStore
}))
vi.mock('expo-crypto', () => ({ getRandomBytes: vi.fn() }))
vi.mock('react-native', () => ({ Platform: platform }))
import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import {
clearMobileRelayPairingJournal,
loadMobileRelayPairingJournal,
resetMobileRelayPairingJournalStoreForTests,
saveMobileRelayPairingJournal,
updateMobileRelayPairingJournal
} from './mobile-relay-pairing-journal-store'
import type { PairingOffer } from './types'
const now = Date.UTC(2026, 6, 13)
const offer = {
v: 2,
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token-secret',
publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: now + 300_000,
e2eeFraming: 2
}
} satisfies PairingOffer
describe('mobile relay pairing journal store', () => {
let metadataRaw: string | null
let secretRaw: string | null
beforeEach(() => {
vi.clearAllMocks()
resetMobileRelayPairingJournalStoreForTests()
platform.OS = 'ios'
metadataRaw = null
secretRaw = null
asyncStorage.getItem.mockImplementation(async () => metadataRaw)
asyncStorage.setItem.mockImplementation(async (_key: string, value: string) => {
metadataRaw = value
})
asyncStorage.removeItem.mockImplementation(async () => {
metadataRaw = null
})
secureStore.getItemAsync.mockImplementation(async () => secretRaw)
secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => {
secretRaw = value
})
secureStore.deleteItemAsync.mockImplementation(async () => {
secretRaw = null
})
})
it('persists metadata before secrets and never places bearers in AsyncStorage', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
now,
randomBytes: (length) => new Uint8Array(length).fill(length)
})
await saveMobileRelayPairingJournal(journal)
expect(asyncStorage.setItem.mock.invocationCallOrder[0]).toBeLessThan(
secureStore.setItemAsync.mock.invocationCallOrder[0]!
)
expect(metadataRaw).not.toContain(offer.deviceToken)
expect(metadataRaw).not.toContain(offer.relay.inviteToken)
expect(metadataRaw).not.toContain(journal.secrets.pendingResumeToken)
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal)
})
it('records a provisional winner only for the active journal identity', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(7)
})
await saveMobileRelayPairingJournal(journal)
await updateMobileRelayPairingJournal(journal.metadata.journalId, (metadata) => ({
...metadata,
winner: 'direct',
authorizationMode: 'authenticated-direct'
}))
await expect(
updateMobileRelayPairingJournal('stale-journal', (metadata) => metadata)
).rejects.toThrow(/stale/)
expect(JSON.parse(metadataRaw!)).toMatchObject({
winner: 'direct',
authorizationMode: 'authenticated-direct'
})
})
it('treats metadata without its secret record as an incomplete crash', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(9)
})
metadataRaw = JSON.stringify(journal.metadata)
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
})
it('clears metadata before deleting its secret and keeps relay unavailable on web', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(11)
})
await saveMobileRelayPairingJournal(journal)
await clearMobileRelayPairingJournal(journal.metadata.journalId)
expect(asyncStorage.removeItem.mock.invocationCallOrder[0]).toBeLessThan(
secureStore.deleteItemAsync.mock.invocationCallOrder[0]!
)
platform.OS = 'web'
await expect(saveMobileRelayPairingJournal(journal)).rejects.toThrow(/native secret store/)
})
})
@@ -0,0 +1,122 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import {
MobileRelayPairingJournalMetadataSchema,
MobileRelayPairingJournalSecretsSchema,
type MobileRelayPairingJournal,
type MobileRelayPairingJournalMetadata
} from './mobile-relay-pairing-journal'
const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1'
const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1'
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
let journalMutation: Promise<void> = Promise.resolve()
export async function saveMobileRelayPairingJournal(
journal: MobileRelayPairingJournal
): Promise<void> {
requireNativeSecretStore()
const metadata = MobileRelayPairingJournalMetadataSchema.parse(journal.metadata)
const secrets = MobileRelayPairingJournalSecretsSchema.parse(journal.secrets)
if (metadata.journalId !== secrets.journalId) {
throw new Error('mobile relay pairing journal identity mismatch')
}
const mutation = journalMutation.then(async () => {
// 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))
await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS)
})
journalMutation = mutation.catch(() => {})
return mutation
}
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) {
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) {
return null
}
return { metadata, secrets }
}
export async function updateMobileRelayPairingJournal(
journalId: string,
update: (metadata: MobileRelayPairingJournalMetadata) => MobileRelayPairingJournalMetadata
): Promise<void> {
const mutation = journalMutation.then(async () => {
const raw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
const current = raw ? parseMetadata(raw) : null
if (!current || current.journalId !== journalId) {
throw new Error('stale mobile relay pairing journal')
}
const next = MobileRelayPairingJournalMetadataSchema.parse(update(current))
if (next.journalId !== journalId) {
throw new Error('mobile relay pairing journal identity mismatch')
}
await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(next))
})
journalMutation = mutation.catch(() => {})
return mutation
}
export async function clearMobileRelayPairingJournal(journalId: string): Promise<void> {
const mutation = journalMutation.then(async () => {
const raw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
const current = raw ? parseMetadata(raw) : null
if (current && current.journalId !== journalId) {
throw new Error('stale mobile relay pairing journal')
}
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
})
journalMutation = mutation.catch(() => {})
return mutation
}
function parseMetadata(raw: string): MobileRelayPairingJournalMetadata | null {
try {
const result = MobileRelayPairingJournalMetadataSchema.safeParse(JSON.parse(raw))
return result.success ? result.data : null
} catch {
return null
}
}
function parseSecrets(raw: string) {
try {
const result = MobileRelayPairingJournalSecretsSchema.safeParse(JSON.parse(raw))
return result.success ? result.data : null
} catch {
return null
}
}
function requireNativeSecretStore(): void {
if (Platform.OS === 'web') {
throw new Error('Orca Relay pairing requires a native secret store')
}
}
/** Test-only: drain the module mutation chain between cases. */
export function resetMobileRelayPairingJournalStoreForTests(): void {
journalMutation = Promise.resolve()
}
@@ -0,0 +1,112 @@
import * as ExpoCrypto from 'expo-crypto'
import { sha256 } from '@noble/hashes/sha256'
import { z } from 'zod'
import type { PairingOffer, PairingRelay } from './types'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
export const MobileRelayPairingJournalMetadataSchema = z
.object({
v: z.literal(1),
journalId: z.string().min(1).max(128),
offerFingerprint: Base64Url32ByteSchema,
host: z
.object({
id: z.string().min(1),
name: z.string().min(1),
endpoint: z.string().min(1),
publicKeyB64: z.string().min(1),
lastConnected: z.number().int().nonnegative()
})
.strict(),
relay: z
.object({
v: z.literal(1),
directorUrl: z.string().min(1),
cellUrl: z.string().min(1),
assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
relayHostId: z.string().regex(/^[A-Za-z0-9_-]{16}$/),
inviteExpiresAt: z.number().int().positive(),
e2eeFraming: z.literal(2)
})
.strict(),
installReqId: z.string().min(1).max(128),
pendingResumeTokenHash: Base64Url32ByteSchema,
winner: z.enum(['direct', 'relay']).optional(),
authorizationMode: z.enum(['authenticated-direct', 'relay-basis']).optional()
})
.strict()
export const MobileRelayPairingJournalSecretsSchema = z
.object({
v: z.literal(1),
journalId: z.string().min(1).max(128),
deviceToken: z.string().min(1),
inviteToken: Base64Url32ByteSchema,
pendingResumeToken: Base64Url32ByteSchema
})
.strict()
export type MobileRelayPairingJournalMetadata = z.infer<
typeof MobileRelayPairingJournalMetadataSchema
>
export type MobileRelayPairingJournalSecrets = z.infer<
typeof MobileRelayPairingJournalSecretsSchema
>
export type MobileRelayPairingJournal = {
metadata: MobileRelayPairingJournalMetadata
secrets: MobileRelayPairingJournalSecrets
}
export function createMobileRelayPairingJournal(args: {
offer: PairingOffer & { relay: PairingRelay }
hostId: string
hostName: string
now?: number
randomBytes?: (length: number) => Uint8Array
}): MobileRelayPairingJournal {
const randomBytes = args.randomBytes ?? ExpoCrypto.getRandomBytes
const pendingResumeToken = encodeBase64Url(randomBytes(32))
const journalId = `pair-${encodeBase64Url(randomBytes(16))}`
const installReqId = `install-${encodeBase64Url(randomBytes(16))}`
const { inviteToken, ...relayMetadata } = args.offer.relay
return {
metadata: MobileRelayPairingJournalMetadataSchema.parse({
v: 1,
journalId,
offerFingerprint: encodeBase64Url(sha256(JSON.stringify(args.offer))),
host: {
id: args.hostId,
name: args.hostName,
endpoint: args.offer.endpoint,
publicKeyB64: args.offer.publicKeyB64,
lastConnected: args.now ?? Date.now()
},
relay: relayMetadata,
installReqId,
pendingResumeTokenHash: encodeBase64Url(sha256(decodeBase64Url(pendingResumeToken)))
}),
secrets: {
v: 1,
journalId,
deviceToken: args.offer.deviceToken,
inviteToken,
pendingResumeToken
}
}
}
function encodeBase64Url(value: Uint8Array | string): string {
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function decodeBase64Url(value: string): Uint8Array {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = `${base64}${'='.repeat((4 - (base64.length % 4)) % 4)}`
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0))
}
@@ -0,0 +1,232 @@
import { describe, expect, it, vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { startPreProfilePairing } from './pre-profile-pairing-coordinator'
import type { HostProfile, PairingOffer, RpcResponse } from './types'
import type { RpcClient } from './rpc-client'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(length)
}))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED' }))
const now = Date.UTC(2026, 6, 13)
const directOffer: PairingOffer = {
v: 2,
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
}
const relayOffer: PairingOffer = {
...directOffer,
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: now + 300_000,
e2eeFraming: 2
}
}
function success(result: unknown): RpcResponse {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function failure(code: string): RpcResponse {
return {
id: 'rpc-1',
ok: false,
error: { code, message: code },
_meta: { runtimeId: 'runtime-1' }
}
}
function fakeClient(responses: RpcResponse[]) {
return {
sendRequest: vi.fn().mockImplementation(async () => responses.shift()!),
close: vi.fn()
} as unknown as RpcClient
}
function dependencies(client: RpcClient, events: string[]) {
return {
connectDirect: vi.fn(() => (events.push('connect'), client)),
getNextHostName: vi.fn(async () => 'Blue Whale'),
saveHost: vi.fn(async (_host: HostProfile) => {
events.push('save-host')
}),
saveJournal: vi.fn(async (_journal: MobileRelayPairingJournal) => {
events.push('save-journal')
}),
updateJournal: vi.fn(async () => {
events.push('update-journal')
}),
clearJournal: vi.fn(async () => {
events.push('clear-journal')
}),
writeCredentialBundle: vi.fn(async (_bundle: MobileRelayCredentialBundle) => {
events.push('write-credential')
}),
now: () => now,
platform: 'ios'
}
}
describe('pre-profile pairing coordinator', () => {
it('keeps a legacy offer direct-only through the shared path', async () => {
const events: string[] = []
const client = fakeClient([success({ version: '1.0.0' })])
const deps = dependencies(client, events)
const attempt = startPreProfilePairing({
offer: directOffer,
timeoutMs: 5_000,
dependencies: deps
})
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
expect(deps.saveHost).toHaveBeenCalledWith({
id: `host-${now}`,
name: 'Blue Whale',
endpoint: directOffer.endpoint,
deviceToken: directOffer.deviceToken,
publicKeyB64: directOffer.publicKeyB64,
lastConnected: now
})
expect(events).toEqual(['connect', 'save-host'])
})
it('journals before connecting and publishes only after authoritative direct install', async () => {
const events: string[] = []
let journal: MobileRelayPairingJournal | null = null
const client = {
sendRequest: vi.fn(async (method: string) => {
if (method === 'status.get') {
return success({ version: '1.0.0' })
}
if (!journal) {
throw new Error('journal was not saved before RPC')
}
const installed = {
v: 1 as const,
reqId: journal.metadata.installReqId,
authorizationMode: 'authenticated-direct' as const,
currentVersion: 1,
resumeExpiresAt: now + 86_400_000
}
if (method === 'pairing.provisionRelay') {
return success(installed)
}
return success({
v: 1,
relay: {
v: 1,
directorUrl: relayOffer.relay!.directorUrl,
cellUrl: relayOffer.relay!.cellUrl,
assignmentEpoch: 7,
relayHostId: relayOffer.relay!.relayHostId,
e2eeFraming: 2
},
installStatus: {
v: 1,
reqId: journal.metadata.installReqId,
state: 'committed',
result: installed
}
})
}),
close: vi.fn()
} as unknown as RpcClient
const deps = dependencies(client, events)
deps.saveJournal.mockImplementation(async (value) => {
journal = value
events.push('save-journal')
})
const attempt = startPreProfilePairing({
offer: relayOffer,
timeoutMs: 5_000,
dependencies: deps
})
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
expect(journal).not.toBeNull()
expect(events).toEqual([
'save-journal',
'connect',
'update-journal',
'write-credential',
'save-host',
'clear-journal'
])
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', {
reqId: journal!.metadata.installReqId,
newResumeTokenHash: journal!.metadata.pendingResumeTokenHash
})
expect(deps.saveHost).toHaveBeenCalledWith(
expect.objectContaining({
id: `host-${now}`,
endpoint: directOffer.endpoint,
relayHostId: relayOffer.relay!.relayHostId,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: directOffer.endpoint },
{
id: 'relay-primary',
kind: 'relay',
url: `wss://relay-c1.onorca.dev/v1/connect/${relayOffer.relay!.relayHostId}`
}
]
})
)
})
it('tolerates an old desktop method_not_found and commits a direct-only host', async () => {
const events: string[] = []
const client = fakeClient([success({ version: '1.0.0' }), failure('method_not_found')])
const deps = dependencies(client, events)
const attempt = startPreProfilePairing({
offer: relayOffer,
timeoutMs: 5_000,
dependencies: deps
})
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
expect(deps.saveHost).toHaveBeenCalledWith(
expect.not.objectContaining({ endpoints: expect.anything() })
)
expect(events).toEqual([
'save-journal',
'connect',
'update-journal',
'save-host',
'clear-journal'
])
})
it('cancels the disposable physical client without publishing a host', async () => {
let resolveStatus!: (response: RpcResponse) => void
const status = new Promise<RpcResponse>((resolve) => {
resolveStatus = resolve
})
const client = fakeClient([])
;(client.sendRequest as ReturnType<typeof vi.fn>).mockReturnValue(status)
const deps = dependencies(client, [])
const attempt = startPreProfilePairing({
offer: directOffer,
timeoutMs: 5_000,
dependencies: deps
})
await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledWith('status.get'))
attempt.dispose()
resolveStatus(success({ version: '1.0.0' }))
await expect(attempt.result).rejects.toThrow(/cancelled/)
expect(client.close).toHaveBeenCalledOnce()
expect(deps.saveHost).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,257 @@
import { Platform } from 'react-native'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceCredentialInstalled,
type MobileRelayEndpoint
} from '../../../src/shared/mobile-relay-credential-contract'
import { connect, type ConnectOptions, type RpcClient } from './rpc-client'
import { getNextHostName, saveHost } from './host-store'
import type { HostProfile, PairingOffer, RpcResponse } from './types'
import {
createMobileRelayPairingJournal,
type MobileRelayPairingJournal
} from './mobile-relay-pairing-journal'
import {
clearMobileRelayPairingJournal,
saveMobileRelayPairingJournal,
updateMobileRelayPairingJournal
} from './mobile-relay-pairing-journal-store'
import {
promotePairingJournalCredential,
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
export type PreProfilePairingAttempt = {
readonly result: Promise<{ hostId: string }>
readonly timedOut: boolean
dispose(): void
}
type Dependencies = {
connectDirect: typeof connect
getNextHostName: typeof getNextHostName
saveHost: typeof saveHost
saveJournal: typeof saveMobileRelayPairingJournal
updateJournal: typeof updateMobileRelayPairingJournal
clearJournal: typeof clearMobileRelayPairingJournal
writeCredentialBundle: typeof writeMobileRelayCredentialBundle
now: () => number
platform: string
}
const defaultDependencies: Dependencies = {
connectDirect: connect,
getNextHostName,
saveHost,
saveJournal: saveMobileRelayPairingJournal,
updateJournal: updateMobileRelayPairingJournal,
clearJournal: clearMobileRelayPairingJournal,
writeCredentialBundle: writeMobileRelayCredentialBundle,
now: Date.now,
platform: Platform.OS
}
export function startPreProfilePairing(args: {
offer: PairingOffer
timeoutMs: number
connectOptions?: ConnectOptions
dependencies?: Partial<Dependencies>
}): PreProfilePairingAttempt {
const dependencies = { ...defaultDependencies, ...args.dependencies }
const clients = new Set<RpcClient>()
let disposed = false
let timedOut = false
let timer: ReturnType<typeof setTimeout> | null = null
const dispose = (): void => {
if (disposed) {
return
}
disposed = true
if (timer) {
clearTimeout(timer)
timer = null
}
for (const client of clients) {
client.close()
}
clients.clear()
}
timer = setTimeout(() => {
timedOut = true
dispose()
}, args.timeoutMs)
const result = runPairing(args.offer, args.connectOptions, dependencies, clients, () => disposed)
.catch((error: unknown) => {
if (timedOut) {
throw new Error('mobile pairing timed out')
}
throw error
})
.finally(() => {
if (timer) {
clearTimeout(timer)
timer = null
}
for (const client of clients) {
client.close()
}
clients.clear()
})
return {
result,
get timedOut() {
return timedOut
},
dispose
}
}
async function runPairing(
offer: PairingOffer,
connectOptions: ConnectOptions | undefined,
dependencies: Dependencies,
clients: Set<RpcClient>,
isDisposed: () => boolean
): Promise<{ hostId: string }> {
const now = dependencies.now()
const hostId = `host-${now}`
const hostName = await dependencies.getNextHostName()
assertActive(isDisposed)
let journal: MobileRelayPairingJournal | null = null
if (offer.relay && dependencies.platform !== 'web') {
journal = createMobileRelayPairingJournal({
offer: { ...offer, relay: offer.relay },
hostId,
hostName,
now
})
await dependencies.saveJournal(journal)
assertActive(isDisposed)
}
const client = dependencies.connectDirect(
offer.endpoint,
offer.deviceToken,
offer.publicKeyB64,
connectOptions
)
clients.add(client)
requireSuccess(await client.sendRequest('status.get'))
assertActive(isDisposed)
if (!journal) {
await dependencies.saveHost(baseHost(offer, hostId, hostName, now))
return { hostId }
}
journal = {
...journal,
metadata: {
...journal.metadata,
winner: 'direct',
authorizationMode: 'authenticated-direct'
}
}
await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata)
const provision = await client.sendRequest('pairing.provisionRelay', {
reqId: journal.metadata.installReqId,
newResumeTokenHash: journal.metadata.pendingResumeTokenHash
})
if (isMethodNotFound(provision)) {
await dependencies.saveHost(baseHost(offer, hostId, hostName, now))
await dependencies.clearJournal(journal.metadata.journalId)
return { hostId }
}
const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provision))
const endpoints = PairingGetEndpointsResultSchema.parse(
requireSuccess(
await client.sendRequest('pairing.getEndpoints', {
installReqId: journal.metadata.installReqId
})
)
)
assertCommittedInstall(endpoints.installStatus, installed)
if (!endpoints.relay) {
throw new Error('desktop returned no relay endpoint after credential install')
}
assertActive(isDisposed)
await dependencies.writeCredentialBundle(promotePairingJournalCredential({ journal, installed }))
await dependencies.saveHost(relayHost(journal, endpoints.relay))
await dependencies.clearJournal(journal.metadata.journalId)
return { hostId }
}
function baseHost(
offer: PairingOffer,
hostId: string,
name: string,
lastConnected: number
): HostProfile {
return {
id: hostId,
name,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
publicKeyB64: offer.publicKeyB64,
lastConnected
}
}
function relayHost(journal: MobileRelayPairingJournal, relay: MobileRelayEndpoint): HostProfile {
const host = journal.metadata.host
return {
...host,
deviceToken: journal.secrets.deviceToken,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: host.endpoint },
{ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) }
],
relayHostId: relay.relayHostId,
relay
}
}
function relayWebSocketUrl(relay: MobileRelayEndpoint): string {
const url = new URL(relay.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
function requireSuccess(response: RpcResponse): unknown {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
}
function isMethodNotFound(response: RpcResponse): boolean {
return !response.ok && response.error.code === 'method_not_found'
}
function assertCommittedInstall(
status:
| { state: 'not-found' }
| { state: 'committed'; result: DeviceCredentialInstalled }
| undefined,
installed: DeviceCredentialInstalled
): void {
if (
!status ||
status.state !== 'committed' ||
JSON.stringify(status.result) !== JSON.stringify(installed)
) {
throw new Error('relay credential install was not authoritatively reconciled')
}
}
function assertActive(isDisposed: () => boolean): void {
if (isDisposed()) {
throw new Error('mobile pairing cancelled')
}
}
+16 -1
View File
@@ -3,6 +3,12 @@ import {
PairingOfferSchema,
type PairingOffer
} from '../../../src/shared/mobile-relay-pairing-offer'
import {
MobileAccessEndpointSchema,
type MobileAccessEndpoint,
type MobileRelayHostOverlay
} from './mobile-relay-host-overlay'
import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract'
export { PairingOfferSchema }
export type { PairingOffer }
@@ -60,6 +66,9 @@ export type HostProfile = {
deviceToken: string
publicKeyB64: string
lastConnected: number
endpoints?: MobileAccessEndpoint[]
relayHostId?: MobileRelayHostOverlay['relayHostId']
relay?: MobileRelayHostOverlay['relay']
}
export const HostProfileSchema = z.object({
@@ -68,7 +77,13 @@ export const HostProfileSchema = z.object({
endpoint: z.string().min(1),
deviceToken: z.string().min(1),
publicKeyB64: z.string().min(1),
lastConnected: z.number().finite()
lastConnected: z.number().finite(),
endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16).optional(),
relayHostId: z
.string()
.regex(/^[A-Za-z0-9_-]{16}$/)
.optional(),
relay: MobileRelayEndpointSchema.optional()
})
// Why: persisted host record after the v0.0.3 keychain split. The