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