Add Orca Relay desktop and mobile transport (#8536)

* feat(mobile): define relay protocol groundwork

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): implement replay-safe E2EE v2 sessions

Co-authored-by: Orca <help@stably.ai>

* test(auth): lock cloud refresh single-flight

Co-authored-by: Orca <help@stably.ai>

* test(mobile): complete E2EE v2 adversarial coverage

Co-authored-by: Orca <help@stably.ai>

* refactor(runtime): unify mobile socket wiring

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay control and data clients

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): coordinate desktop relay sessions

Co-authored-by: Orca <help@stably.ai>

* fix(auth): fence stale cloud session mutations

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay pairing and durable revoke

Co-authored-by: Orca <help@stably.ai>

* feat(runtime): add relay credential pairing RPCs

Co-authored-by: Orca <help@stably.ai>

* feat(settings): show Orca Relay sign-in status

Co-authored-by: Orca <help@stably.ai>

* test(relay): prove desktop lifecycle and E2EE splice

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): persist relay pairing state

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): race direct and relay pairing

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): recover pairing through relay director

Co-authored-by: Orca <help@stably.ai>

* fix(relay): preserve origin controls during drain

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): recover interrupted relay pairing

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): add stable relay RPC sessions

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): supervise direct and relay endpoints

Co-authored-by: Orca <help@stably.ai>

* Cover mobile relay director fallback matrix

Co-authored-by: Orca <help@stably.ai>

* Fix relay settings component test isolation

Co-authored-by: Orca <help@stably.ai>

* Remove unrelated merge formatting drift

Co-authored-by: Orca <help@stably.ai>

* Update runtime connection count integration assertion

Co-authored-by: Orca <help@stably.ai>

* Run mobile typecheck through pnpm

Co-authored-by: Orca <help@stably.ai>

* feat(relay): gate desktop controls on mobile demand

Co-authored-by: Orca <help@stably.ai>

* test(mobile): cover served relay recovery

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): upgrade direct pairings to relay

Co-authored-by: Orca <help@stably.ai>

* fix(relay): harden mobile reconnect and teardown

Co-authored-by: Orca <help@stably.ai>

* fix(auth): clarify account sign-in state

Co-authored-by: Orca <help@stably.ai>

* fix(auth): polish sign-in completion flow

Co-authored-by: Orca <help@stably.ai>

* fix(auth): clarify sign-out confirmation

Co-authored-by: Orca <help@stably.ai>

* fix(auth): simplify sign-in completion page

Co-authored-by: Orca <help@stably.ai>

* feat(mobile): add per-device pairing connection mode

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): stabilize pairing option layout

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): give pairing choices stable space

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): stabilize pairing QR regeneration

Co-authored-by: Orca <help@stably.ai>

* Animate mobile pairing flow height

Co-authored-by: Orca <help@stably.ai>

* Configure auth in packaged builds

Co-authored-by: Orca <help@stably.ai>

* Make Orca Relay pairing an opt-in beta

Co-authored-by: Orca <help@stably.ai>

* Show Relay beta details on hover

Co-authored-by: Orca <help@stably.ai>

* Refine mobile relay pairing choice

Co-authored-by: Orca <help@stably.ai>

* Polish Orca Relay pairing controls

Co-authored-by: Orca <help@stably.ai>

* Keep mobile contract fallback test additive

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-07-14 11:47:05 -07:00
committed by GitHub
co-authored by Orca
parent ccc449b6ba
commit 77b154d5dd
198 changed files with 17961 additions and 996 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Typecheck
run: npx tsc --noEmit
run: pnpm typecheck
- name: Test
run: pnpm test
+7
View File
@@ -11,6 +11,7 @@ import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
import { loadHosts } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
// Why: keeps the native splash screen visible until the React tree is mounted
// and ready to render. Without this the user sees a blank white/black frame
@@ -35,6 +36,12 @@ export default function RootLayout() {
const router = useRouter()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
// Why: pairing publication is journaled across process death; startup must
// reconcile the server result before another scan can replace that journal.
void recoverMobileRelayPairing()
}, [])
// Why: route `orca://pair?...` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
+2 -2
View File
@@ -9,11 +9,11 @@ import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { loadHosts } from '../src/transport/host-store'
import { connectionLogStore } from '../src/transport/connection-log-buffer'
import { useHostClient } from '../src/transport/client-context'
import {
useHostClient,
useLastConnectedAt,
useReconnectAttempt
} from '../src/transport/client-context'
} from '../src/transport/client-context-connection-metrics'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
+5 -3
View File
@@ -34,11 +34,13 @@ import { removeHostAndCloseClient } from '../../../src/transport/host-removal-li
import {
useHostClient,
useCloseHost,
useForceReconnect,
useReconnectAttempt,
useLastConnectedAt
useForceReconnect
} from '../../../src/transport/client-context'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import {
useLastConnectedAt,
useReconnectAttempt
} from '../../../src/transport/client-context-connection-metrics'
import {
classifyConnection,
type ConnectionVerdict
@@ -56,12 +56,11 @@ import {
saveTerminalTextScale,
type MobileTerminalLinkOpenMode
} from '../../../../src/storage/preferences'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import {
useHostClient,
useForceReconnect,
useReconnectAttempt,
useLastConnectedAt
} from '../../../../src/transport/client-context'
useLastConnectedAt,
useReconnectAttempt
} from '../../../../src/transport/client-context-connection-metrics'
import {
classifyConnection,
verdictDisplayLabel
+2 -2
View File
@@ -34,11 +34,11 @@ import {
} from 'lucide-react-native'
import type { RpcClient } from '../../../src/transport/rpc-client'
import type { RpcSuccess } from '../../../src/transport/types'
import { useHostClient } from '../../../src/transport/client-context'
import {
useHostClient,
useLastConnectedAt,
useReconnectAttempt
} from '../../../src/transport/client-context'
} from '../../../src/transport/client-context-connection-metrics'
import { classifyConnection } from '../../../src/transport/connection-health'
import { StatusDot } from '../../../src/components/StatusDot'
import { ActionSheetModal } from '../../../src/components/ActionSheetModal'
+17 -93
View File
@@ -3,7 +3,6 @@ import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import {
Monitor,
QrCode,
Settings,
ChevronRight,
@@ -35,12 +34,12 @@ import {
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { StatusDot } from '../src/components/StatusDot'
import { MobileHostCard } from '../src/components/MobileHostCard'
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
import { TextInputModal } from '../src/components/TextInputModal'
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
@@ -324,6 +323,10 @@ export default function HomeScreen() {
// docs/mobile-shared-client-per-host.md.
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const allClients = useAllHostClients(hostIds)
const hostPaths = useMemo(
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
[allClients]
)
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
const primeHosts = usePrimeHosts()
@@ -824,7 +827,6 @@ export default function HomeScreen() {
const state = hostStates[item.id] ?? 'connecting'
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const connected = state === 'connected'
const info = worktreeInfo[item.id]
const verdict = classifyConnection({
state,
@@ -832,45 +834,21 @@ export default function HomeScreen() {
lastConnectedAt,
endpoint: item.endpoint
})
const isError =
verdict.kind === 'warning' ||
verdict.kind === 'unreachable' ||
verdict.kind === 'auth-failed'
return (
<Pressable
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
<MobileHostCard
host={item}
state={state}
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeCounts={
info ? { total: info.totalWorktrees, active: info.activeCount } : undefined
}
onPress={() => router.push(`/h/${item.id}`)}
onLongPress={() => {
triggerMediumImpact()
setActionTarget(item)
}}
delayLongPress={400}
>
<View style={styles.hostIcon}>
<Monitor
size={20}
color={connected ? colors.textPrimary : colors.textSecondary}
/>
</View>
<View style={styles.hostMain}>
<Text
style={[styles.hostName, !connected && { color: colors.textSecondary }]}
numberOfLines={1}
>
{item.name}
</Text>
<View style={styles.hostMeta}>
<StatusDot state={state} verdict={verdict} />
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
{verdictDisplayLabel(verdict)}
{connected && info
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
: ''}
</Text>
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
/>
)
}}
ListFooterComponent={
@@ -1081,16 +1059,16 @@ export default function HomeScreen() {
items.push({
label: 'Rename',
icon: Edit3,
closeBeforePress: true,
onPress: () => {
setActionTarget(null)
setRenameTarget(host)
}
})
items.push({
label: 'Remove',
destructive: true,
closeBeforePress: true,
onPress: () => {
setActionTarget(null)
setConfirmRemove(host)
}
})
@@ -1244,63 +1222,9 @@ const styles = StyleSheet.create({
},
/* ─── Host cards ─── */
hostCard: {
flexDirection: 'row',
alignItems: 'center',
paddingLeft: spacing.md,
paddingRight: spacing.md,
paddingVertical: 12,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
hostCardPressed: {
backgroundColor: colors.bgRaised
},
hostIcon: {
width: 46,
height: 46,
borderRadius: 13,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
marginRight: 14,
position: 'relative'
},
hostMain: {
flex: 1,
minWidth: 0,
marginRight: spacing.sm
},
hostName: {
color: colors.textPrimary,
fontSize: 15,
fontWeight: '600',
lineHeight: 20
},
hostMeta: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 3
},
hostMetaItem: {
fontSize: 12,
color: colors.textSecondary
},
hostMetaDot: {
width: 3,
height: 3,
borderRadius: 1.5,
backgroundColor: colors.textMuted,
marginHorizontal: 8
},
statusDot: {
width: 7,
height: 7,
borderRadius: 3.5
},
/* ─── Resume card ─── */
resumeCard: {
+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
}
+2
View File
@@ -9,6 +9,7 @@
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "oxlint",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
@@ -17,6 +18,7 @@
"start:emulator": "node scripts/start-emulator.mjs"
},
"dependencies": {
"@noble/hashes": "1.8.0",
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
"@react-native-async-storage/async-storage": "^2.2.0",
"@xterm/addon-unicode11": "0.10.0-beta.285",
+9
View File
@@ -11,6 +11,9 @@ importers:
.:
dependencies:
'@noble/hashes':
specifier: 1.8.0
version: 1.8.0
'@orca/expo-two-way-audio':
specifier: file:./packages/expo-two-way-audio
version: file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -1878,6 +1881,10 @@ packages:
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio':
resolution: {directory: packages/expo-two-way-audio, type: directory}
peerDependencies:
@@ -8835,6 +8842,8 @@ snapshots:
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
optional: true
'@noble/hashes@1.8.0': {}
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
+32 -3
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import { useRef, type ReactNode } from 'react'
import { ActivityIndicator, View, Text, Pressable, StyleSheet } from 'react-native'
import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
@@ -13,6 +13,7 @@ export type ActionSheetAction = {
hint?: string
loading?: boolean
skipAutoClose?: boolean
closeBeforePress?: boolean
onPress: () => void
}
@@ -107,9 +108,37 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
}
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
const pendingActionRef = useRef<(() => void) | null>(null)
const sequencedActions = actions.map((action) =>
action.closeBeforePress
? {
...action,
onPress: () => {
pendingActionRef.current = action.onPress
}
}
: action
)
return (
<BottomDrawer visible={visible} onClose={onClose} dragContentToDismiss>
<ActionSheetContent title={title} message={message} actions={actions} onClose={onClose} />
<BottomDrawer
visible={visible}
onClose={onClose}
onAfterClose={() => {
// Why: iOS cannot present a second native modal until the action
// sheet's native window has fully unmounted.
const pendingAction = pendingActionRef.current
pendingActionRef.current = null
pendingAction?.()
}}
dragContentToDismiss
>
<ActionSheetContent
title={title}
message={message}
actions={sequencedActions}
onClose={onClose}
/>
</BottomDrawer>
)
}
+6 -1
View File
@@ -40,6 +40,7 @@ const TOP_SCROLL_EPSILON = 1
type Props = {
visible: boolean
onClose: () => void
onAfterClose?: () => void
children: ReactNode
dragContentToDismiss?: boolean
contentScrollable?: boolean
@@ -49,6 +50,7 @@ type Props = {
export function BottomDrawer({
visible,
onClose,
onAfterClose,
children,
dragContentToDismiss = true,
contentScrollable = true,
@@ -73,7 +75,10 @@ export function BottomDrawer({
<MountedBottomDrawer
visible={visible}
onClose={onClose}
onHidden={() => setMounted(false)}
onHidden={() => {
setMounted(false)
onAfterClose?.()
}}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
zIndex={zIndex}
+91
View File
@@ -0,0 +1,91 @@
import { ChevronRight, Monitor } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { ConnectionVerdict } from '../transport/connection-health'
import { verdictDisplayLabel } from '../transport/connection-health'
import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from '../transport/types'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { StatusDot } from './StatusDot'
export function MobileHostCard(props: {
host: HostProfile
state: ConnectionState
verdict: ConnectionVerdict
path: MobileConnectionPath
worktreeCounts?: { total: number; active: number }
onPress: () => void
onLongPress: () => void
}) {
const connected = props.state === 'connected'
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
return (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={props.onPress}
onLongPress={props.onLongPress}
delayLongPress={400}
>
<View style={styles.icon}>
<Monitor size={20} color={connected ? colors.textPrimary : colors.textSecondary} />
</View>
<View style={styles.main}>
<Text
style={[styles.name, !connected && { color: colors.textSecondary }]}
numberOfLines={1}
>
{props.host.name}
</Text>
<View style={styles.meta}>
<StatusDot state={props.state} verdict={props.verdict} />
<Text style={[styles.metaText, isError && { color: colors.statusRed }]}>
{verdictDisplayLabel(props.verdict)}
{connected ? ` · ${mobileConnectionPathLabel(props.path)}` : ''}
{connected && props.worktreeCounts
? ` · ${props.worktreeCounts.total} worktree${props.worktreeCounts.total !== 1 ? 's' : ''}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}`
: ''}
</Text>
</View>
{props.verdict.kind === 'unreachable' && !props.host.relay ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
Update desktop Orca and sign in to connect from anywhere
</Text>
) : null}
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: 12,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
cardPressed: { backgroundColor: colors.bgRaised },
icon: {
width: 46,
height: 46,
borderRadius: 13,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
marginRight: 14
},
main: { flex: 1, minWidth: 0, marginRight: spacing.sm },
name: { color: colors.textPrimary, fontSize: 15, fontWeight: '600', lineHeight: 20 },
meta: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 },
metaText: { fontSize: 12, color: colors.textSecondary },
discoveryHint: {
marginTop: spacing.xs,
fontSize: 11,
lineHeight: 15,
color: colors.textMuted
}
})
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import {
@@ -67,6 +68,26 @@ describe('rpc wrappers', () => {
expect(client.calls[0]).toEqual({ method: 'speech.models.list', params: null })
})
it('retries the idempotent setup read once after logical-client cutover', async () => {
const setup: MobileSpeechSetup = { enabled: false, selectedModelId: '', models: [] }
const sendRequest = vi
.fn()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(ok(setup))
await expect(fetchDictationSetup({ sendRequest })).resolves.toEqual(setup)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'speech.models.list', null)
expect(sendRequest).toHaveBeenNthCalledWith(2, 'speech.models.list', null)
})
it('does not retry unrelated setup-read failures', async () => {
const sendRequest = vi.fn().mockRejectedValue(new Error('offline'))
await expect(fetchDictationSetup({ sendRequest })).rejects.toThrow('offline')
expect(sendRequest).toHaveBeenCalledOnce()
})
it('starts a download', async () => {
const client = clientWith([ok({ started: true })])
await downloadDictationModel(client, 'm1')
+15 -1
View File
@@ -1,5 +1,6 @@
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcSuccess } from '../transport/types'
export type MobileSpeechSetup = RuntimeSpeechSetupState
@@ -31,7 +32,7 @@ export function isDictationSetupRequiredError(message: string): boolean {
export async function fetchDictationSetup(
client: Pick<RpcClient, 'sendRequest'>
): Promise<MobileSpeechSetup> {
const response = await client.sendRequest('speech.models.list', null)
const response = await fetchDictationSetupResponse(client)
if (!response.ok) {
if (isLegacyDesktopSpeechSetupError(response.error)) {
throw new Error(LEGACY_DESKTOP_SPEECH_SETUP_MESSAGE)
@@ -41,6 +42,19 @@ export async function fetchDictationSetup(
return (response as RpcSuccess).result as MobileSpeechSetup
}
async function fetchDictationSetupResponse(client: Pick<RpcClient, 'sendRequest'>) {
try {
return await client.sendRequest('speech.models.list', null)
} catch (error) {
if (!(error instanceof LogicalClientCutoverError)) {
throw error
}
// Why: this read can safely repeat on the authenticated replacement; mutation
// RPCs must still surface cutover so callers never replay unknown commits.
return client.sendRequest('speech.models.list', null)
}
}
export async function downloadDictationModel(
client: Pick<RpcClient, 'sendRequest'>,
modelId: string
@@ -8,7 +8,8 @@ import {
getMobilePrCreateBlockMessage,
mobileRepoSelectorFromWorktreeId,
resolveMobilePrPrefill,
shouldPushBeforeMobilePrCreate
shouldPushBeforeMobilePrCreate,
type MobilePrPrefill
} from './mobile-pr-create'
function ok(result: unknown): RpcSuccess {
@@ -122,6 +123,19 @@ describe('mobile create form gating parity', () => {
expect(mobileAllowsComposer).toBe(desktopAllowsComposer)
})
it('stays safely blocked for a reason added by a newer desktop contract', () => {
expect(
getMobilePrCreateBlockMessage({
provider: 'github',
base: 'main',
title: 'Add feature',
body: '',
canCreate: false,
blockedReason: 'future_desktop_reason' as unknown as MobilePrPrefill['blockedReason']
})
).toBe('This branch is not ready for a pull request yet.')
})
})
describe('resolveMobilePrPrefill', () => {
@@ -72,5 +72,9 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string
case null:
case undefined:
return `This branch is not ready for a ${copy.reviewLabel} yet.`
default:
// Why: desktop can add blocked reasons before a long-lived mobile branch
// catches up; remain safely blocked while preserving merge-ref typechecks.
return `This branch is not ready for a ${copy.reviewLabel} yet.`
}
}
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
export function useReconnectAttempt(hostId: string | undefined): number {
return useHostMetric(hostId, (context, id) => context.getReconnectAttempt(id), 0)
}
export function useLastConnectedAt(hostId: string | undefined): number | null {
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
}
function useHostMetric<T>(
hostId: string | undefined,
read: (context: RpcClientContextValue, hostId: string) => T,
fallback: T
): T {
const context = useRpcClientContext()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) {
return
}
return context.subscribeHostState(hostId, () => force((count) => count + 1))
}, [context, hostId])
return hostId ? read(context, hostId) : fallback
}
+4 -1
View File
@@ -10,6 +10,9 @@ const loadHostsMock = vi.fn()
vi.mock('./rpc-client', () => ({
connect: (...args: unknown[]) => connectMock(...args)
}))
vi.mock('./host-logical-client', () => ({
openHostLogicalClient: (...args: unknown[]) => connectMock(...args)
}))
vi.mock('./host-store', () => ({
loadHosts: () => loadHostsMock()
}))
@@ -131,7 +134,7 @@ describe('useHostClient', () => {
loadHostsMock.mockResolvedValue([HOST])
const harness = await renderHarness(HOST.id)
expect(harness.hook.client).toBe(fake)
expect(harness.hook.client).not.toBeNull()
expect(harness.hook.state).toBe('connected')
// Regression (STA-1511): closeHost deletes the entry; before the fix the
+36 -51
View File
@@ -19,11 +19,13 @@ import {
useState,
type ReactNode
} from 'react'
import { connect, type RpcClient } from './rpc-client'
import type { RpcClient } from './rpc-client'
import { connectionLogStore } from './connection-log-buffer'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { HostClientOpenRegistry } from './host-client-open-registry'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
type StoreEntry = {
@@ -33,7 +35,7 @@ type StoreEntry = {
unsubState: () => void
}
type ContextValue = {
export type RpcClientContextValue = {
acquire: (hostId: string, host?: HostProfile) => RpcClient | null
release: (hostId: string) => void
forceReconnect: (hostId: string) => Promise<void>
@@ -45,6 +47,7 @@ type ContextValue = {
// Used by the UI to escalate "Reconnecting…" into a "host appears
// unreachable, re-pair?" prompt.
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => Array<{ hostId: string; client: RpcClient }>
subscribeAllHosts: (listener: () => void) => () => void
@@ -54,7 +57,7 @@ type ContextValue = {
primeHosts: (hosts: HostProfile[]) => void
}
const Ctx = createContext<ContextValue | null>(null)
const Ctx = createContext<RpcClientContextValue | null>(null)
export function RpcClientProvider({ children }: { children: ReactNode }) {
// Why: entries live in a ref so updates don't force re-renders of the
@@ -155,12 +158,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
let client: RpcClient
try {
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, {
// Why: retain reconnect lifecycle events for the Connection Log
// screen — without this the reasons a host is stuck live only in
// console.log, which users can't see or share.
onLog: (entry) => connectionLogStore.append(hostId, entry)
})
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
} catch {
// Why: connect() can throw synchronously if the public key is
// malformed or the endpoint URL is invalid. Notify so the UI
@@ -279,6 +277,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
return storeRef.current.get(hostId)?.client.getLastConnectedAt() ?? null
}, [])
const getActivePath = useCallback((hostId: string): MobileConnectionPath => {
return clientActivePath(storeRef.current.get(hostId)?.client)
}, [])
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) => {
let set = stateListenersRef.current.get(hostId)
@@ -348,7 +350,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
})
}, [])
const value = useMemo<ContextValue>(
const value = useMemo<RpcClientContextValue>(
() => ({
acquire,
release,
@@ -357,6 +359,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
getState,
getReconnectAttempt,
getLastConnectedAt,
getActivePath,
subscribeHostState,
getAllClients,
subscribeAllHosts,
@@ -370,6 +373,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
getState,
getReconnectAttempt,
getLastConnectedAt,
getActivePath,
subscribeHostState,
getAllClients,
subscribeAllHosts,
@@ -380,7 +384,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
function useCtx(): ContextValue {
export function useRpcClientContext(): RpcClientContextValue {
const ctx = useContext(Ctx)
if (!ctx) {
throw new Error('useHostClient must be used inside <RpcClientProvider>')
@@ -395,7 +399,7 @@ export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
state: ConnectionState
} {
const ctx = useCtx()
const ctx = useRpcClientContext()
const [, force] = useState(0)
const [state, setState] = useState<ConnectionState>(() =>
hostId ? ctx.getState(hostId) : 'disconnected'
@@ -453,8 +457,9 @@ export function useAllHostClients(hostIds: string[]): Array<{
hostId: string
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
}> {
const ctx = useCtx()
const ctx = useRpcClientContext()
// Stable key so we don't tear down on every render of the array.
const key = useMemo(() => [...hostIds].sort().join(','), [hostIds])
const [tick, setTick] = useState(0)
@@ -483,11 +488,21 @@ export function useAllHostClients(hostIds: string[]): Array<{
}, [key])
return useMemo(() => {
const out: Array<{ hostId: string; client: RpcClient; state: ConnectionState }> = []
const out: Array<{
hostId: string
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
}> = []
for (const id of hostIds) {
const all = ctx.getAllClients().find((entry) => entry.hostId === id)
if (all) {
out.push({ hostId: id, client: all.client, state: ctx.getState(id) })
out.push({
hostId: id,
client: all.client,
state: ctx.getState(id),
path: ctx.getActivePath(id)
})
}
}
return out
@@ -499,13 +514,13 @@ export function useAllHostClients(hostIds: string[]): Array<{
// host-store has no React-side handle. Expose a hook that lets callers
// close a host after removal.
export function useCloseHost(): (hostId: string) => void {
const ctx = useCtx()
const ctx = useRpcClientContext()
return ctx.closeHost
}
// Why: future-proof "Connection issues — try again" affordance.
export function useForceReconnect(): (hostId: string) => Promise<void> {
const ctx = useCtx()
const ctx = useRpcClientContext()
return ctx.forceReconnect
}
@@ -513,41 +528,11 @@ export function useForceReconnect(): (hostId: string) => Promise<void> {
// provider can skip its own loadHosts() pass when it eventually opens
// each host — collapses two serial Keychain reads on cold-start into one.
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useCtx()
const ctx = useRpcClientContext()
return ctx.primeHosts
}
// Why: lets the home/host-detail UI escalate "Reconnecting…" to a more
// alarming "Can't connect" once the rpc-client has cycled enough times to
// indicate something's actually wrong (wrong port, server down, network
// loss). Reads through the context so it stays in sync with the live
// rpc-client instance even after forceReconnect swaps the underlying
// client.
export function useReconnectAttempt(hostId: string | undefined): number {
const ctx = useCtx()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) {
return
}
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
}, [ctx, hostId])
return hostId ? ctx.getReconnectAttempt(hostId) : 0
}
// Why: timestamp of last successful connect for this host, or null if
// the client has never connected. Combined with reconnectAttempt this
// distinguishes "transient blip" (recently connected) from "host
// appears unreachable" (never connected, or hasn't connected in N
// seconds despite many retry attempts).
export function useLastConnectedAt(hostId: string | undefined): number | null {
const ctx = useCtx()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) {
return
}
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
}, [ctx, hostId])
return hostId ? ctx.getLastConnectedAt(hostId) : null
function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
const logical = client as Partial<StableLogicalRpcClient> | undefined
return typeof logical?.getActivePath === 'function' ? logical.getActivePath() : 'lan'
}
@@ -0,0 +1,36 @@
import { AppState, Platform } from 'react-native'
import { connect, type RpcClient } from './rpc-client'
import { createStableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionLogSink, HostProfile } from './types'
import { directPathForEndpoint } from './mobile-direct-endpoint-probe'
import { startMobileEndpointLifecycle } from './mobile-endpoint-lifecycle'
export function openHostLogicalClient(host: HostProfile, onLog: ConnectionLogSink): RpcClient {
// Why: the stable facade owns app-visible RPC/subscription state while the
// direct socket remains a replaceable first physical generation.
const logical = createStableLogicalRpcClient(
connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
directPathForEndpoint(host, host.endpoint)
)
if (Platform.OS === 'web') {
return logical
}
const endpointLifecycle = startMobileEndpointLifecycle(logical, host, onLog)
endpointLifecycle.setForeground(AppState.currentState === 'active')
const appStateSubscription = AppState.addEventListener('change', (state) => {
endpointLifecycle.setForeground(state === 'active')
})
const closeLogical = logical.close
logical.close = () => {
appStateSubscription.remove()
endpointLifecycle.stop()
closeLogical()
}
const notifyLogicalForeground = logical.notifyForeground
logical.notifyForeground = () => {
endpointLifecycle.setForeground(true)
notifyLogicalForeground()
}
return logical
}
+66 -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,19 @@ vi.mock('./host-credential-cleanup', () => ({
retryPendingHostCredentialCleanups: vi.fn()
}))
import { removeHost, renameHost, resetHostStoreForTests, updateLastConnected } from './host-store'
import {
loadHosts,
MobileRelayUpgradeHostRemovedError,
removeHost,
renameHost,
resetHostStoreForTests,
saveExistingHostRelayUpgrade,
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 +63,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 +87,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 +101,51 @@ 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('refuses to resurrect a removed host during relay upgrade publication', async () => {
storedHostsRaw = JSON.stringify([HOST_TWO])
await expect(
saveExistingHostRelayUpgrade({ ...HOST_ONE, deviceToken: 'token-1' })
).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled()
})
it('awaits cleanup scheduling after metadata commit', async () => {
let resolveSchedule: (() => void) | null = null
scheduleCleanupMock.mockReturnValue(
+65 -3
View File
@@ -12,6 +12,14 @@ import {
retryPendingHostCredentialCleanups,
scheduleHostCredentialCleanup
} from './host-credential-cleanup'
import {
loadMobileRelayHostOverlayState,
removeMobileRelayHostOverlay,
saveMobileRelayHostOverlay
} from './mobile-relay-host-overlay-store'
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal'
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
const STORAGE_KEY = 'orca:hosts'
// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected.
@@ -61,6 +69,12 @@ 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)
await deleteMobileRelayDirectUpgradeJournal(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 +134,14 @@ async function doLoadHosts(): Promise<HostProfile[]> {
if (!storedHosts) {
return []
}
const overlayState = await loadMobileRelayHostOverlayState(
new Set(storedHosts.map(({ id }) => id))
)
await scheduleOrphanedMobileRelayCleanup({
hostIds: overlayState.orphanHostIds,
deleteCredential: deleteHostCredentials
})
const overlays = overlayState.overlays
const out: HostProfile[] = []
for (const stored of storedHosts) {
@@ -144,7 +166,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
}
@@ -196,7 +229,17 @@ function toStored(host: HostProfile): StoredHostProfile {
}
}
export class MobileRelayUpgradeHostRemovedError extends Error {}
export async function saveHost(host: HostProfile): Promise<void> {
await persistHost(host, false)
}
export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise<void> {
await persistHost(host, true)
}
async function persistHost(host: HostProfile, requireExisting: boolean): Promise<void> {
const validated = HostProfileSchema.parse(host)
const stored = toStored(validated)
await mutateStoredHosts((hosts) => {
@@ -206,6 +249,10 @@ export async function saveHost(host: HostProfile): Promise<void> {
next[index] = stored
return next
}
if (requireExisting) {
// Why: an in-flight relay upgrade must not resurrect a host the user removed.
throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
}
return [...hosts, stored]
})
// Why: write metadata BEFORE the keychain token so a crash between the two
@@ -215,15 +262,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 +296,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,10 @@
import { describe, expect, it } from 'vitest'
import { mobileConnectionPathLabel } from './mobile-connection-path-label'
describe('mobile connection path label', () => {
it('distinguishes LAN, Tailscale, and the relay without exposing transport errors', () => {
expect(mobileConnectionPathLabel('lan')).toBe('Direct · LAN')
expect(mobileConnectionPathLabel('tailscale')).toBe('Direct · Tailscale')
expect(mobileConnectionPathLabel('relay')).toBe('Orca Relay')
})
})
@@ -0,0 +1,8 @@
import type { MobileConnectionPath } from './stable-logical-rpc-client'
export function mobileConnectionPathLabel(path: MobileConnectionPath): string {
if (path === 'relay') {
return 'Orca Relay'
}
return path === 'tailscale' ? 'Direct · Tailscale' : 'Direct · LAN'
}
@@ -0,0 +1,71 @@
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
function directEndpointUrls(host: HostProfile): string[] {
const endpoints =
host.endpoints?.filter(({ kind }) => kind !== 'relay').map(({ url }) => url) ?? []
return [...new Set([host.endpoint, ...endpoints])]
}
export function directPathForEndpoint(
host: HostProfile,
endpoint: string
): Exclude<MobileConnectionPath, 'relay'> {
const configured = host.endpoints?.find((candidate) => candidate.url === endpoint)
if (configured?.kind === 'tailscale') {
return 'tailscale'
}
try {
const hostname = new URL(endpoint).hostname
if (hostname.endsWith('.ts.net') || /^100\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname)) {
return 'tailscale'
}
} catch {}
return 'lan'
}
function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Promise<void> {
if (session.getState() === 'connected') {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null
const unsubscribe = session.onStateChange((state) => {
if (state === 'connected') {
finish()
resolve()
} else if (state === 'disconnected' || state === 'auth-failed') {
finish()
reject(new Error(`probe session ${state}`))
}
})
timer = setTimeout(() => {
finish()
reject(new Error('probe session authentication timed out'))
}, timeoutMs)
function finish(): void {
if (timer) {
clearTimeout(timer)
}
unsubscribe()
}
})
}
export async function openAuthenticatedDirectEndpoint(
host: HostProfile,
openDirect: (endpoint: string) => RpcClient,
timeoutMs: number
): Promise<{ client: RpcClient; path: Exclude<MobileConnectionPath, 'relay'> } | null> {
for (const endpoint of directEndpointUrls(host)) {
const client = openDirect(endpoint)
try {
await waitForAuthenticatedSession(client, timeoutMs)
return { client, path: directPathForEndpoint(host, endpoint) }
} catch {
client.close()
}
}
return null
}
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import { MOBILE_E2EE_LEGACY_FIXTURE } from '../../../src/shared/mobile-e2ee-legacy-fixtures'
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
import { decrypt, decryptBytes, deriveSharedKey } from './e2ee'
describe('mobile legacy E2EE fixtures', () => {
it('matches the captured desktop key and text/binary frames', () => {
const fixture = MOBILE_E2EE_LEGACY_FIXTURE
const server = nacl.box.keyPair.fromSecretKey(fixture.serverSecretKey)
const client = nacl.box.keyPair.fromSecretKey(fixture.clientSecretKey)
const shared = deriveSharedKey(client.secretKey, server.publicKey)
expect(hex(shared)).toBe(fixture.sharedKeyHex)
expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext)
expect(decryptBytes(fromHex(fixture.binaryFrameHex), shared)).toEqual(fixture.binaryPlaintext)
})
})
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function fromHex(value: string): Uint8Array {
return Uint8Array.from(value.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16))
}
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake,
type MobileE2EEV2Ready
} from '../../../src/shared/mobile-e2ee-v2-contract'
import { sealMobileE2EEV2Frame } from '../../../src/shared/mobile-e2ee-v2-framing'
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
import { deriveSharedKey } from './e2ee'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
function setup() {
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
transport: 'relay',
relayHostId: 'AbCdEf0123_-xyZ9',
clientNonce: new Uint8Array(32).fill(3),
clientKeyPair: client
})
const ready: MobileE2EEV2Ready = {
type: 'e2ee_ready',
v: 2,
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
clientNonceB64: session.hello.clientNonceB64,
desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'),
selection: { framing: 2, payloadKinds: ['text', 'binary'] },
context: session.hello.context
}
return { session, ready }
}
describe('mobile E2EE v2 client session', () => {
it('pins the desktop key and accepts the exact transcript', () => {
const { session, ready } = setup()
expect(session.acceptReady(ready)).toBe(true)
expect(session.transcriptHashB64).toHaveLength(44)
expect(
session.acceptReady({
...ready,
desktopPublicKeyB64: Buffer.from(new Uint8Array(32).fill(8)).toString('base64')
})
).toBe(false)
})
it('seals auth at counter zero and rejects replayed desktop frames', () => {
const { session, ready } = setup()
expect(session.acceptReady(ready)).toBe(true)
const auth = JSON.stringify({
type: 'e2ee_auth',
v: 2,
transcriptHashB64: session.transcriptHashB64,
deviceToken: 'token'
})
const authFrame = Buffer.from(session.sealText(auth), 'base64')
expect(authFrame.subarray(16, 24)).toEqual(Buffer.alloc(8, 0))
const handshake = validateMobileE2EEV2Handshake(session.hello, ready)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey),
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
const response = sealMobileE2EEV2Frame({
payload: new TextEncoder().encode('authenticated'),
key: schedule.desktopToMobileKey,
sessionId: schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind: 'text',
counter: 0n
})
const encoded = Buffer.from(response).toString('base64')
expect(session.openText(encoded)).toBe('authenticated')
expect(session.openText(encoded)).toBeNull()
})
})
@@ -0,0 +1,167 @@
import * as ExpoCrypto from 'expo-crypto'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake,
type MobileE2EETransport,
type MobileE2EEV2Hello
} from '../../../src/shared/mobile-e2ee-v2-contract'
import {
openMobileE2EEV2Frame,
sealMobileE2EEV2Frame
} from '../../../src/shared/mobile-e2ee-v2-framing'
import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
export class MobileE2EEV2ClientSession {
readonly hello: MobileE2EEV2Hello
private inboundCounter = 0n
private outboundCounter = 0n
private schedule: ReturnType<typeof deriveMobileE2EEV2KeySchedule> | null = null
private transcriptHashB64Value: string | null = null
private constructor(
private readonly clientSecretKey: Uint8Array,
private readonly pinnedDesktopPublicKey: Uint8Array,
hello: MobileE2EEV2Hello
) {
this.hello = hello
}
static create(args: {
desktopPublicKeyB64: string
transport: MobileE2EETransport
relayHostId?: string
clientNonce?: Uint8Array
clientKeyPair?: { publicKey: Uint8Array; secretKey: Uint8Array }
}): MobileE2EEV2ClientSession {
const keyPair = args.clientKeyPair ?? generateKeyPair()
const clientNonce = args.clientNonce ?? ExpoCrypto.getRandomBytes(32)
if (clientNonce.length !== 32) {
throw new Error(`Invalid client nonce length: ${clientNonce.length}`)
}
return new MobileE2EEV2ClientSession(
keyPair.secretKey,
publicKeyFromBase64(args.desktopPublicKeyB64),
{
type: 'e2ee_hello',
v: 2,
clientPublicKeyB64: publicKeyToBase64(keyPair.publicKey),
clientNonceB64: encodeBase64(clientNonce),
capabilities: { framing: [2], payloadKinds: ['text', 'binary'] },
context: {
protocol: 'orca-mobile-e2ee',
initiator: 'mobile',
responder: 'desktop',
transport: args.transport,
...(args.relayHostId ? { relayHostId: args.relayHostId } : {})
}
}
)
}
acceptReady(ready: unknown): boolean {
const handshake = validateMobileE2EEV2Handshake(this.hello, ready)
if (!handshake || !equalBytes(handshake.desktopPublicKey, this.pinnedDesktopPublicKey)) {
return false
}
this.schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret: deriveSharedKey(this.clientSecretKey, this.pinnedDesktopPublicKey),
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
this.transcriptHashB64Value = encodeBase64(this.schedule.transcriptHash)
return true
}
get transcriptHashB64(): string {
if (!this.transcriptHashB64Value) {
throw new Error('E2EE v2 ready has not been accepted')
}
return this.transcriptHashB64Value
}
openText(frameB64: string): string | null {
const frame = decodeCanonicalBase64(frameB64)
if (!frame) {
return null
}
const plaintext = this.open(frame, 'text')
return plaintext ? new TextDecoder().decode(plaintext) : null
}
openBinary(frame: Uint8Array): Uint8Array | null {
return this.open(frame, 'binary')
}
sealText(plaintext: string): string {
return encodeBase64(this.seal(new TextEncoder().encode(plaintext), 'text'))
}
sealBinary(plaintext: Uint8Array): Uint8Array {
return this.seal(plaintext, 'binary')
}
private open(frame: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array | null {
if (!this.schedule) {
return null
}
const plaintext = openMobileE2EEV2Frame({
frame,
key: this.schedule.desktopToMobileKey,
sessionId: this.schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind,
expectedCounter: this.inboundCounter
})
if (plaintext) {
this.inboundCounter++
}
return plaintext
}
private seal(plaintext: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array {
if (!this.schedule) {
throw new Error('E2EE v2 ready has not been accepted')
}
const frame = sealMobileE2EEV2Frame({
payload: plaintext,
key: this.schedule.mobileToDesktopKey,
sessionId: this.schedule.sessionId,
direction: 'mobile-to-desktop',
payloadKind,
counter: this.outboundCounter
})
this.outboundCounter++
return frame
}
}
function encodeBase64(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
function decodeCanonicalBase64(value: string): Uint8Array | null {
try {
const binary = atob(value)
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
return encodeBase64(bytes) === value ? bytes : null
} catch {
return null
}
}
function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) {
return false
}
let difference = 0
for (let index = 0; index < left.length; index++) {
difference |= left[index]! ^ right[index]!
}
return difference === 0
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake
} from '../../../src/shared/mobile-e2ee-v2-contract'
import {
createMobileE2EEV2Fixture,
MOBILE_E2EE_V2_VECTOR
} from '../../../src/shared/mobile-e2ee-v2-fixtures'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
describe('mobile E2EE v2 key schedule', () => {
it('matches the desktop normative HKDF vector', () => {
const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture()
const handshake = validateMobileE2EEV2Handshake(hello, ready)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret,
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
expect(hex(schedule.mobileToDesktopKey)).toBe(MOBILE_E2EE_V2_VECTOR.mobileToDesktopKeyHex)
expect(hex(schedule.desktopToMobileKey)).toBe(MOBILE_E2EE_V2_VECTOR.desktopToMobileKeyHex)
expect(hex(schedule.sessionId)).toBe(MOBILE_E2EE_V2_VECTOR.sessionIdHex)
expect(hex(schedule.transcriptHash)).toBe(MOBILE_E2EE_V2_VECTOR.transcriptHashHex)
})
it('derives unique direction keys and session IDs across fresh client nonces', () => {
const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture()
const fingerprints = new Set<string>()
for (let index = 0; index < 128; index++) {
const nonce = new Uint8Array(32)
new DataView(nonce.buffer).setUint32(28, index, false)
const clientNonceB64 = Buffer.from(nonce).toString('base64')
const handshake = validateMobileE2EEV2Handshake(
{ ...hello, clientNonceB64 },
{ ...ready, clientNonceB64 }
)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret,
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
fingerprints.add(
[schedule.mobileToDesktopKey, schedule.desktopToMobileKey, schedule.sessionId]
.map(hex)
.join(':')
)
}
expect(fingerprints.size).toBe(128)
})
})
@@ -0,0 +1,48 @@
import { hkdf } from '@noble/hashes/hkdf'
import { sha256 } from '@noble/hashes/sha256'
const SALT_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/salt\0')
const INFO_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/session\0')
export function deriveMobileE2EEV2KeySchedule(args: {
sharedSecret: Uint8Array
transcript: Uint8Array
clientNonce: Uint8Array
desktopNonce: Uint8Array
}): {
mobileToDesktopKey: Uint8Array
desktopToMobileKey: Uint8Array
sessionId: Uint8Array
transcriptHash: Uint8Array
} {
requireLength(args.sharedSecret, 32, 'shared secret')
requireLength(args.clientNonce, 32, 'client nonce')
requireLength(args.desktopNonce, 32, 'desktop nonce')
const transcriptHash = sha256(args.transcript)
const salt = sha256(concatBytes([SALT_LABEL, args.clientNonce, args.desktopNonce]))
const info = concatBytes([INFO_LABEL, transcriptHash])
const expanded = hkdf(sha256, args.sharedSecret, salt, info, 96)
return {
mobileToDesktopKey: expanded.slice(0, 32),
desktopToMobileKey: expanded.slice(32, 64),
sessionId: expanded.slice(64, 96),
transcriptHash
}
}
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
let offset = 0
for (const part of parts) {
result.set(part, offset)
offset += part.length
}
return result
}
function requireLength(bytes: Uint8Array, expected: number, label: string): void {
if (bytes.length !== expected) {
throw new Error(`Invalid ${label}: expected ${expected} bytes, got ${bytes.length}`)
}
}
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake,
type MobileE2EEV2Ready
} from '../../../src/shared/mobile-e2ee-v2-contract'
import {
openMobileE2EEV2Frame,
sealMobileE2EEV2Frame
} from '../../../src/shared/mobile-e2ee-v2-framing'
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
import { deriveSharedKey } from './e2ee'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
import {
MobileE2EEAuthenticationError,
MobileE2EEV2PhysicalChannel,
type MobileE2EEV2Socket
} from './mobile-e2ee-v2-physical-channel'
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
function setup(decodeBinary: (raw: unknown) => Promise<Uint8Array | null>) {
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
transport: 'relay',
relayHostId: 'AbCdEf0123_-xyZ9',
clientNonce: new Uint8Array(32).fill(3),
clientKeyPair: client
})
const sent: (string | Uint8Array)[] = []
const socket = {
OPEN: 1,
readyState: 1,
bufferedAmount: 0,
send: (frame: string | Uint8Array) => sent.push(frame)
} satisfies MobileE2EEV2Socket
const events: string[] = []
const onAuthenticated = vi.fn(() => events.push('authenticated'))
const onError = vi.fn()
const channel = new MobileE2EEV2PhysicalChannel({
session,
socket,
deviceToken: 'valid-token',
decodeBinary,
onAuthenticated,
onText: (plaintext) => events.push(`text:${plaintext}`),
onBinary: (plaintext) => events.push(`binary:${plaintext[0]}`),
onError
})
channel.start()
const ready: MobileE2EEV2Ready = {
type: 'e2ee_ready',
v: 2,
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
clientNonceB64: session.hello.clientNonceB64,
desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'),
selection: { framing: 2, payloadKinds: ['text', 'binary'] },
context: session.hello.context
}
const handshake = validateMobileE2EEV2Handshake(session.hello, ready)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey),
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
return { channel, session, socket, sent, events, onAuthenticated, onError, ready, schedule }
}
function serverFrame(
payload: Uint8Array,
kind: 'text' | 'binary',
counter: bigint,
schedule: ReturnType<typeof setup>['schedule']
): Uint8Array {
return sealMobileE2EEV2Frame({
payload,
key: schedule.desktopToMobileKey,
sessionId: schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind: kind,
counter
})
}
async function authenticate(ctx: ReturnType<typeof setup>): Promise<void> {
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
const response = serverFrame(
new TextEncoder().encode(
JSON.stringify({
type: 'e2ee_authenticated',
v: 2,
transcriptHashB64: ctx.session.transcriptHashB64
})
),
'text',
0n,
ctx.schedule
)
await ctx.channel.handleMessage(Buffer.from(response).toString('base64'))
}
describe('mobile E2EE v2 physical channel', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('sends hello/auth and confirms the exact transcript', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
expect(JSON.parse(ctx.sent[0] as string)).toEqual(ctx.session.hello)
expect(typeof ctx.sent[1]).toBe('string')
expect(ctx.onAuthenticated).toHaveBeenCalledOnce()
expect(ctx.onError).not.toHaveBeenCalled()
})
it('classifies the encrypted desktop device-token rejection as global auth failure', async () => {
const ctx = setup(async () => null)
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
const rejection = serverFrame(
new TextEncoder().encode(
JSON.stringify({ type: 'e2ee_error', error: { code: 'unauthorized' } })
),
'text',
0n,
ctx.schedule
)
await ctx.channel.handleMessage(Buffer.from(rejection).toString('base64'))
expect(ctx.onError.mock.calls[0]![0]).toBeInstanceOf(MobileE2EEAuthenticationError)
expect(ctx.onAuthenticated).not.toHaveBeenCalled()
})
it('serializes delayed binary conversion before a later text counter', async () => {
let releaseBinary!: (bytes: Uint8Array) => void
const pendingBinary = new Promise<Uint8Array>((resolve) => (releaseBinary = resolve))
const ctx = setup(async () => pendingBinary)
await authenticate(ctx)
ctx.events.length = 0
const binary = serverFrame(new Uint8Array([7]), 'binary', 1n, ctx.schedule)
const text = serverFrame(new TextEncoder().encode('later'), 'text', 2n, ctx.schedule)
const first = ctx.channel.handleMessage({ delayedBlob: true })
const second = ctx.channel.handleMessage(Buffer.from(text).toString('base64'))
await Promise.resolve()
expect(ctx.events).toEqual([])
releaseBinary(binary)
await Promise.all([first, second])
expect(ctx.events).toEqual(['binary:7', 'text:later'])
expect(ctx.onError).not.toHaveBeenCalled()
})
it('queues outbound text and binary in one counter order', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
ctx.socket.bufferedAmount = 9 * 1024 * 1024
expect(ctx.channel.sendText('one')).toBe(true)
expect(ctx.channel.sendBinary(new Uint8Array([2]))).toBe(true)
expect(ctx.sent).toHaveLength(2)
ctx.socket.bufferedAmount = 0
vi.runOnlyPendingTimers()
expect(typeof ctx.sent[2]).toBe('string')
expect(ctx.sent[3]).toBeInstanceOf(Uint8Array)
expect(
openMobileE2EEV2Frame({
frame: Buffer.from(ctx.sent[2] as string, 'base64'),
key: ctx.schedule.mobileToDesktopKey,
sessionId: ctx.schedule.sessionId,
direction: 'mobile-to-desktop',
payloadKind: 'text',
expectedCounter: 1n
})
).toEqual(new TextEncoder().encode('one'))
expect(
openMobileE2EEV2Frame({
frame: ctx.sent[3] as Uint8Array,
key: ctx.schedule.mobileToDesktopKey,
sessionId: ctx.schedule.sessionId,
direction: 'mobile-to-desktop',
payloadKind: 'binary',
expectedCounter: 2n
})
).toEqual(new Uint8Array([2]))
})
it('bounds the unified outbound queue and reports a wedged link', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
ctx.socket.bufferedAmount = 9 * 1024 * 1024
const megabyte = new Uint8Array(1024 * 1024)
for (let index = 0; index < 65; index++) {
expect(ctx.channel.sendBinary(megabyte)).toBe(true)
}
expect(ctx.onError).toHaveBeenCalledOnce()
expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 outbound buffer overflow')
})
})
@@ -0,0 +1,185 @@
import {
createWsOutboundBackpressureQueue,
type WsOutboundBackpressureQueue
} from '../../../src/shared/ws-outbound-backpressure-queue'
import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready'
type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array }
export class MobileE2EEAuthenticationError extends Error {
constructor() {
super('E2EE device authentication rejected')
}
}
export type MobileE2EEV2Socket = {
readonly OPEN: number
readonly readyState: number
readonly bufferedAmount: number
send: (frame: string | Uint8Array) => void
}
export class MobileE2EEV2PhysicalChannel {
private state: ChannelState = 'awaiting-ready'
private generation = 0
private inboundChain: Promise<void> = Promise.resolve()
private readonly outboundQueue: WsOutboundBackpressureQueue<OutboundItem>
constructor(
private readonly args: {
session: MobileE2EEV2ClientSession
socket: MobileE2EEV2Socket
deviceToken: string
decodeBinary: (raw: unknown) => Promise<Uint8Array | null>
onAuthenticated: () => void
onText: (plaintext: string) => void
onBinary: (plaintext: Uint8Array) => void
onError: (error: Error) => void
}
) {
this.outboundQueue = createWsOutboundBackpressureQueue<OutboundItem>({
// Why: encryption happens only when an admitted item reaches the wire,
// so a bounded-queue rejection cannot burn an ordered v2 counter.
send: (item) => {
args.socket.send(
item.kind === 'text'
? args.session.sealText(item.plaintext)
: args.session.sealBinary(item.plaintext)
)
},
byteLengthOf: (item) =>
(item.kind === 'text'
? new TextEncoder().encode(item.plaintext).length
: item.plaintext.length) + 82,
getBufferedAmount: () => args.socket.bufferedAmount,
isWritable: () => args.socket.readyState === args.socket.OPEN,
onOverflow: () => args.onError(new Error('E2EE v2 outbound buffer overflow'))
})
}
start(): void {
this.args.socket.send(JSON.stringify(this.args.session.hello))
}
handleMessage(raw: unknown): Promise<void> {
const generation = this.generation
this.inboundChain = this.inboundChain
.then(() => this.processMessage(raw, generation))
.catch((error: unknown) => {
if (generation === this.generation) {
this.args.onError(error instanceof Error ? error : new Error(String(error)))
}
})
return this.inboundChain
}
sendText(plaintext: string): boolean {
return this.enqueueReady({ kind: 'text', plaintext })
}
sendBinary(plaintext: Uint8Array): boolean {
return this.enqueueReady({ kind: 'binary', plaintext })
}
dispose(): void {
this.generation++
this.outboundQueue.dispose()
}
private async processMessage(raw: unknown, generation: number): Promise<void> {
if (generation !== this.generation) {
return
}
if (this.state === 'awaiting-ready') {
this.acceptReady(raw)
return
}
const plaintext =
typeof raw === 'string'
? this.args.session.openText(raw)
: await this.openBinary(raw, generation)
if (generation !== this.generation || plaintext === null) {
return
}
if (this.state === 'awaiting-authenticated') {
if (typeof plaintext === 'string' && isAuthenticationRejection(plaintext)) {
throw new MobileE2EEAuthenticationError()
}
if (typeof plaintext !== 'string' || !this.isAuthenticated(plaintext)) {
throw new Error('Invalid E2EE v2 authenticated response')
}
this.state = 'ready'
this.args.onAuthenticated()
} else if (typeof plaintext === 'string') {
this.args.onText(plaintext)
} else {
this.args.onBinary(plaintext)
}
}
private acceptReady(raw: unknown): void {
if (typeof raw !== 'string') {
throw new Error('Expected plaintext E2EE v2 ready')
}
let ready: unknown
try {
ready = JSON.parse(raw)
} catch {
throw new Error('Invalid E2EE v2 ready JSON')
}
if (!this.args.session.acceptReady(ready)) {
throw new Error('Invalid E2EE v2 ready')
}
this.state = 'awaiting-authenticated'
this.outboundQueue.enqueue({
kind: 'text',
plaintext: JSON.stringify({
type: 'e2ee_auth',
v: 2,
transcriptHashB64: this.args.session.transcriptHashB64,
deviceToken: this.args.deviceToken
})
})
}
private async openBinary(raw: unknown, generation: number): Promise<Uint8Array | null> {
const bytes = await this.args.decodeBinary(raw)
if (!bytes || generation !== this.generation) {
return null
}
return this.args.session.openBinary(bytes)
}
private isAuthenticated(plaintext: string): boolean {
try {
const message = JSON.parse(plaintext) as Record<string, unknown>
return (
Object.keys(message).sort().join(',') === 'transcriptHashB64,type,v' &&
message.type === 'e2ee_authenticated' &&
message.v === 2 &&
message.transcriptHashB64 === this.args.session.transcriptHashB64
)
} catch {
return false
}
}
private enqueueReady(item: OutboundItem): boolean {
if (this.state !== 'ready') {
return false
}
this.outboundQueue.enqueue(item)
return true
}
}
function isAuthenticationRejection(plaintext: string): boolean {
try {
const message = JSON.parse(plaintext) as Record<string, unknown>
return message.type === 'e2ee_error'
} catch {
return false
}
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
const options = {
directSuccessesRequired: 3,
directObservationMs: 30_000,
failureCooldownMs: 60_000,
minimumDwellMs: 60_000
}
describe('mobile endpoint hysteresis', () => {
it('requires three authenticated direct successes across the observation and dwell windows', () => {
const policy = new MobileEndpointHysteresis(0, options)
expect(policy.recordDirectSuccess(60_000)).toBe(false)
expect(policy.recordDirectSuccess(75_000)).toBe(false)
expect(policy.recordDirectSuccess(90_000)).toBe(true)
})
it('resets progress and observes cooldown after a failure', () => {
const policy = new MobileEndpointHysteresis(0, options)
policy.recordDirectSuccess(60_000)
policy.recordDirectFailure(61_000)
expect(policy.canProbe(120_999)).toBe(false)
expect(policy.canProbe(121_000)).toBe(true)
expect(policy.recordDirectSuccess(121_000)).toBe(false)
expect(policy.recordDirectSuccess(136_000)).toBe(false)
expect(policy.recordDirectSuccess(151_000)).toBe(true)
})
})
@@ -0,0 +1,52 @@
export type EndpointHysteresisOptions = {
directSuccessesRequired: number
directObservationMs: number
failureCooldownMs: number
minimumDwellMs: number
}
export class MobileEndpointHysteresis {
private consecutiveDirectSuccesses = 0
private directObservationStartedAt: number | null = null
private cooldownUntil = 0
private lastMigrationAt: number
constructor(
startedAt: number,
private readonly options: EndpointHysteresisOptions
) {
this.lastMigrationAt = startedAt
}
recordDirectSuccess(now: number): boolean {
if (now < this.cooldownUntil) {
return false
}
if (this.consecutiveDirectSuccesses === 0) {
this.directObservationStartedAt = now
}
this.consecutiveDirectSuccesses += 1
return (
this.consecutiveDirectSuccesses >= this.options.directSuccessesRequired &&
this.directObservationStartedAt !== null &&
now - this.directObservationStartedAt >= this.options.directObservationMs &&
now - this.lastMigrationAt >= this.options.minimumDwellMs
)
}
recordDirectFailure(now: number): void {
this.consecutiveDirectSuccesses = 0
this.directObservationStartedAt = null
this.cooldownUntil = now + this.options.failureCooldownMs
}
recordMigration(now: number): void {
this.lastMigrationAt = now
this.consecutiveDirectSuccesses = 0
this.directObservationStartedAt = null
}
canProbe(now: number): boolean {
return now >= this.cooldownUntil
}
}
@@ -0,0 +1,98 @@
import * as ExpoCrypto from 'expo-crypto'
import type { ConnectionLogSink, HostProfile } from './types'
import { connect } from './rpc-client'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
import {
readMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import { saveHost } from './host-store'
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
type EndpointLifecycle = {
setForeground(foreground: boolean): void
stop(): void
}
type EndpointOwner = EndpointLifecycle & {
start(): Promise<void>
}
export function startMobileEndpointLifecycle(
logical: StableLogicalRpcClient,
initialHost: HostProfile,
onLog: ConnectionLogSink
): EndpointLifecycle {
let stopped = false
let foreground = true
let owner: EndpointOwner
const startSupervisor = async (host: HostProfile): Promise<void> => {
if (stopped) {
return
}
const supervisor = createSupervisor(logical, host, onLog)
owner.stop()
owner = supervisor
supervisor.setForeground(foreground)
await supervisor.start()
}
if (initialHost.relay) {
owner = createSupervisor(logical, initialHost, onLog)
void owner.start()
} else {
owner = new MobileRelayDirectUpgradeController(logical, initialHost, {
upgrade: (client, host) =>
upgradeDirectMobileRelay({
client,
host,
dependencies: { randomBytes: ExpoCrypto.getRandomBytes }
}),
onUpgraded: ({ host }) => startSupervisor(host)
})
void owner.start()
}
return {
setForeground(next) {
foreground = next
owner.setForeground(next)
},
stop() {
stopped = true
owner.stop()
}
}
}
function createSupervisor(
logical: StableLogicalRpcClient,
host: HostProfile,
onLog: ConnectionLogSink
): MobileEndpointSupervisor {
return new MobileEndpointSupervisor(logical, host, {
openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
openRelay: (relay, credential, confirmReqId) =>
connectMobileRelayRpcSession({
relay,
resumeToken: credential.token,
resumeCredentialVersion: credential.version,
resumeConfirmReqId: confirmReqId,
deviceToken: host.deviceToken,
desktopPublicKeyB64: host.publicKeyB64
}),
resolveRelay: resolveMobileRelayEndpoint,
readBundle: readMobileRelayCredentialBundle,
writeBundle: writeMobileRelayCredentialBundle,
saveHost,
now: Date.now,
randomBytes: ExpoCrypto.getRandomBytes,
setTimer: setTimeout,
clearTimer: clearTimeout
})
}
@@ -0,0 +1,44 @@
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import type { HostProfile } from './types'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
export function isDirectorResolutionFailure(error: Error): boolean {
return (
!(error instanceof MobileE2EEAuthenticationError) &&
(!(error instanceof RelayOuterError) || [4409, 4503, 1006].includes(error.code))
)
}
export function relayWebSocketUrl(relay: { cellUrl: string; relayHostId: string }): string {
const url = new URL(relay.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
export async function persistRelayHost(
host: HostProfile,
relay: MobileRelayEndpoint,
saveHost: (host: HostProfile) => Promise<void>
): Promise<HostProfile> {
const endpoints = [
...(host.endpoints ?? [{ id: 'direct-primary', kind: 'lan' as const, url: host.endpoint }])
].filter(({ kind }) => kind !== 'relay')
endpoints.push({ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) })
const updated = { ...host, endpoints, relayHostId: relay.relayHostId, relay }
await saveHost(updated)
return updated
}
export function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import {
MobileEndpointSupervisor,
type MobileEndpointSupervisorDependencies
} from './mobile-endpoint-supervisor'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile, RpcResponse } from './types'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
class FakeSession implements RpcClient {
readonly sendRequest = vi.fn(
async (): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly notifyForeground = vi.fn()
readonly close = vi.fn()
private readonly listeners = new Set<(state: ConnectionState) => void>()
constructor(private state: ConnectionState) {}
getState = () => this.state
getReconnectAttempt = () => 0
getLastConnectedAt = () => null
onStateChange = (listener: (state: ConnectionState) => void) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
publishState(state: ConnectionState): void {
this.state = state
for (const listener of this.listeners) {
listener(state)
}
}
}
class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
constructor(
state: ConnectionState,
private readonly failure: Error | null = null,
private readonly lease = Date.now() + 120_000
) {
super(state)
}
getLeaseExpiresAt = () => this.lease
getResumeConfirmation = () => ({
v: 1 as const,
reqId: 'confirm-1',
currentVersion: 2,
acceptedAs: 'current' as const,
renewed: true,
resumeExpiresAt: Date.now() + 300_000
})
getFailure = () => this.failure
}
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private generation = 1
constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
this.path = path
}
migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => {
if (session.getState() !== 'connected') {
session.close()
throw new Error(`replacement session ${session.getState()}`)
}
this.path = path
this.generation += 1
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
getGeneration = () => this.generation
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
const host: HostProfile = {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 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/id' }
],
relayHostId: relay.relayHostId,
relay
}
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 2,
expiresAt: Number.MAX_SAFE_INTEGER
}
}
function dependencies(
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
): MobileEndpointSupervisorDependencies {
return {
openDirect: vi.fn(() => new FakeSession('connected')),
openRelay: vi.fn(() => new FakeRelaySession('connected')),
resolveRelay: vi.fn(async ({ relay }) => relay),
readBundle: vi.fn(async () => bundle),
writeBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
now: Date.now,
randomBytes: (length) => new Uint8Array(length).fill(1),
setTimer: setTimeout,
clearTimer: clearTimeout,
...overrides
}
}
describe('mobile endpoint supervisor', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('fails over to a confirmed relay session and persists its renewed expiry', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
expect(logical.getActivePath()).toBe('relay')
expect(deps.writeBundle).toHaveBeenCalledWith(
expect.objectContaining({ current: expect.objectContaining({ version: 2 }) })
)
supervisor.stop()
})
it('fails over when the direct retry loop publishes reconnecting', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.publishState('reconnecting')
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
supervisor.stop()
})
it('fails over when direct is already reconnecting before startup completes', async () => {
const logical = new FakeLogicalClient('reconnecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('uses POST resolve for wrong-cell recovery and persists the authoritative target', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
.fn()
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4409)))
.mockReturnValueOnce(new FakeRelaySession('connected'))
const resolved = { ...relay, cellUrl: 'https://relay-c2.onorca.dev', assignmentEpoch: 8 }
const deps = dependencies({
openRelay,
resolveRelay: vi.fn(async () => resolved)
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(deps.resolveRelay).toHaveBeenCalledOnce()
expect(openRelay).toHaveBeenLastCalledWith(resolved, expect.any(Object), expect.any(String))
expect(deps.saveHost).toHaveBeenCalledWith(
expect.objectContaining({ relay: resolved, endpoint: host.endpoint })
)
supervisor.stop()
})
it('promotes direct only after repeated foreground authenticated probes and dwell', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(45_000)
expect(logical.getActivePath()).toBe('relay')
await vi.advanceTimersByTimeAsync(15_000)
expect(logical.getActivePath()).toBe('lan')
expect(deps.openDirect).toHaveBeenCalledTimes(4)
supervisor.stop()
})
it('releases a background relay session and reconnects it on foreground', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('disconnected')
supervisor.setForeground(true)
await vi.waitFor(() => expect(logical.migrateTo).toHaveBeenCalled())
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
})
@@ -0,0 +1,322 @@
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import {
encodeBase64Url,
isDirectorResolutionFailure,
persistRelayHost,
toError
} from './mobile-endpoint-supervisor-support'
import {
applyResumeConfirmation,
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
import type { RpcClient } from './rpc-client'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
const DIRECT_PROBE_INTERVAL_MS = 15_000
const DIRECT_OBSERVATION_MS = 30_000
const MINIMUM_DWELL_MS = 60_000
const FAILURE_COOLDOWN_MS = 60_000
const LEASE_ROTATION_MARGIN_MS = 30_000
export type MobileEndpointSupervisorDependencies = {
openDirect: (endpoint: string) => RpcClient
openRelay: (
relay: MobileRelayEndpoint,
credential: { token: string; version: number },
confirmReqId: string
) => MobileRelayRpcSession
resolveRelay: typeof resolveMobileRelayEndpoint
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
saveHost: (host: HostProfile) => Promise<void>
now: () => number
randomBytes: (length: number) => Uint8Array
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
}
export class MobileEndpointSupervisor {
private host: HostProfile
private bundle: MobileRelayCredentialBundle | null = null
private stopped = false
private foreground = true
private operationInFlight = false
private credentialRotationInFlight = false
private relayRotationPending = false
private probeTimer: ReturnType<typeof setTimeout> | null = null
private leaseTimer: ReturnType<typeof setTimeout> | null = null
private unsubscribeState: (() => void) | null = null
private readonly hysteresis: MobileEndpointHysteresis
constructor(
private readonly logical: StableLogicalRpcClient,
host: HostProfile,
private readonly dependencies: MobileEndpointSupervisorDependencies
) {
this.host = host
this.hysteresis = new MobileEndpointHysteresis(dependencies.now(), {
directSuccessesRequired: 3,
directObservationMs: DIRECT_OBSERVATION_MS,
failureCooldownMs: FAILURE_COOLDOWN_MS,
minimumDwellMs: MINIMUM_DWELL_MS
})
}
async start(): Promise<void> {
this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null)
if (this.stopped || !this.bundle || !this.host.relay) {
return
}
this.unsubscribeState = this.logical.onStateChange((state) => {
if (state === 'connected') {
if (this.logical.getActivePath() !== 'relay') {
void this.rotateCredentialIfNeeded()
}
this.scheduleDirectProbe()
} else if (state === 'reconnecting' || state === 'disconnected' || state === 'auth-failed') {
// Why: the direct client enters reconnecting after its first failed
// dial and may never publish disconnected while its retry loop lives.
void this.recoverRelay()
}
})
const initialState = this.logical.getState()
if (
initialState === 'reconnecting' ||
initialState === 'disconnected' ||
initialState === 'auth-failed'
) {
// Why: the first direct dial can fail while encrypted relay credentials
// are still loading, before the supervisor subscribes to state changes.
await this.recoverRelay()
} else {
this.scheduleDirectProbe()
}
}
setForeground(foreground: boolean): void {
this.foreground = foreground
if (foreground) {
void this.recoverRelay(this.relayRotationPending)
this.scheduleDirectProbe(0)
} else {
if (this.logical.getActivePath() === 'relay') {
// Why: background phones must not hold billed relay data splices; the
// stable client keeps subscriptions for authenticated foreground replay.
this.logical.suspendActiveSession()
}
if (this.probeTimer) {
this.dependencies.clearTimer(this.probeTimer)
this.probeTimer = null
}
}
}
stop(): void {
this.stopped = true
this.unsubscribeState?.()
this.unsubscribeState = null
if (this.probeTimer) {
this.dependencies.clearTimer(this.probeTimer)
this.probeTimer = null
}
this.clearLeaseTimer()
}
private async recoverRelay(forceReplacement = false): Promise<void> {
if (
this.stopped ||
!this.foreground ||
this.operationInFlight ||
!this.bundle ||
!this.host.relay ||
(!forceReplacement && this.logical.getState() === 'connected')
) {
return
}
this.operationInFlight = true
try {
const credentials = [this.bundle.current, this.bundle.grace].filter(
(credential): credential is NonNullable<typeof credential> =>
Boolean(credential && credential.expiresAt > this.dependencies.now())
)
for (const credential of credentials) {
if (await this.tryRelayCredential(credential)) {
return
}
}
} finally {
this.operationInFlight = false
if (forceReplacement && this.relayRotationPending && !this.stopped && !this.leaseTimer) {
this.leaseTimer = this.dependencies.setTimer(() => {
this.leaseTimer = null
void this.recoverRelay(true)
}, 5000)
}
}
}
private async tryRelayCredential(credential: {
token: string
version: number
}): Promise<boolean> {
const first = await this.openAndMigrateRelay(credential)
if (first.ok) {
return true
}
if (!isDirectorResolutionFailure(first.error) || !this.host.relay) {
return false
}
try {
const resolved = await this.dependencies.resolveRelay({
relay: this.host.relay,
resumeToken: credential.token
})
this.host = await persistRelayHost(this.host, resolved, this.dependencies.saveHost)
return (await this.openAndMigrateRelay(credential)).ok
} catch {
return false
}
}
private async openAndMigrateRelay(credential: {
token: string
version: number
}): Promise<{ ok: true } | { ok: false; error: Error }> {
if (!this.host.relay || !this.bundle) {
return { ok: false, error: new Error('relay state missing') }
}
const session = this.dependencies.openRelay(
this.host.relay,
credential,
`confirm-${encodeBase64Url(this.dependencies.randomBytes(16))}`
)
try {
await this.logical.migrateTo(session, 'relay')
if (!this.foreground) {
this.logical.suspendActiveSession()
}
this.relayRotationPending = false
this.hysteresis.recordMigration(this.dependencies.now())
const confirmation = session.getResumeConfirmation()
if (confirmation) {
this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation)
await this.dependencies.writeBundle(this.bundle)
}
this.scheduleLeaseRotation(session)
this.scheduleDirectProbe()
return { ok: true }
} catch (error) {
return { ok: false, error: session.getFailure() ?? toError(error) }
}
}
private scheduleDirectProbe(delayMs = DIRECT_PROBE_INTERVAL_MS): void {
if (
this.stopped ||
!this.foreground ||
this.logical.getActivePath() !== 'relay' ||
this.probeTimer
) {
return
}
this.probeTimer = this.dependencies.setTimer(() => {
this.probeTimer = null
void this.probeDirect()
}, delayMs)
}
private async probeDirect(): Promise<void> {
if (
this.stopped ||
!this.foreground ||
this.operationInFlight ||
!this.hysteresis.canProbe(this.dependencies.now())
) {
this.scheduleDirectProbe()
return
}
this.operationInFlight = true
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = null
try {
const openDirect = this.dependencies.openDirect
successful = await openAuthenticatedDirectEndpoint(this.host, openDirect, 12_000)
if (!successful) {
this.hysteresis.recordDirectFailure(this.dependencies.now())
return
}
if (!this.hysteresis.recordDirectSuccess(this.dependencies.now())) {
successful.client.close()
return
}
await this.logical.migrateTo(successful.client, successful.path)
successful = null
this.hysteresis.recordMigration(this.dependencies.now())
this.clearLeaseTimer()
this.relayRotationPending = false
await this.rotateCredentialIfNeeded()
} finally {
successful?.client.close()
this.operationInFlight = false
if (this.relayRotationPending) {
void this.recoverRelay(true)
}
this.scheduleDirectProbe()
}
}
private async rotateCredentialIfNeeded(): Promise<void> {
if (
this.stopped ||
this.credentialRotationInFlight ||
!this.bundle ||
this.logical.getActivePath() === 'relay' ||
!mobileRelayCredentialNeedsRotation(this.bundle, this.dependencies.now())
) {
return
}
this.credentialRotationInFlight = true
try {
const result = await rotateMobileRelayCredential({
client: this.logical,
bundle: this.bundle,
writeBundle: this.dependencies.writeBundle,
randomBytes: this.dependencies.randomBytes
})
this.bundle = result.bundle
this.host = await persistRelayHost(this.host, result.relay, this.dependencies.saveHost)
} catch {
// Why: pending material remains durable; the next authenticated direct
// opportunity must reconcile it before creating another install key.
} finally {
this.credentialRotationInFlight = false
}
}
private scheduleLeaseRotation(session: MobileRelayRpcSession): void {
this.clearLeaseTimer()
const deadline = session.getLeaseExpiresAt()
if (!deadline) {
return
}
const delay = Math.max(1000, deadline - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS)
this.leaseTimer = this.dependencies.setTimer(() => {
this.leaseTimer = null
this.relayRotationPending = true
void this.recoverRelay(true)
}, delay)
}
private clearLeaseTimer(): void {
if (this.leaseTimer) {
this.dependencies.clearTimer(this.leaseTimer)
this.leaseTimer = null
}
}
}
@@ -0,0 +1,118 @@
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',
resumeConfirmReqId: 'confirm-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,10 @@
import { describe, expect, it } from 'vitest'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
describe('mobile relay credential hash', () => {
it('hashes the base64url wire token text rather than its decoded bytes', () => {
expect(hashMobileRelayCredential('BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc')).toBe(
'3Ev4DHdHPRMPoN6GukAY_pi7IUAF5qWJHRK6kURvnoE'
)
})
})
@@ -0,0 +1,15 @@
import { sha256 } from '@noble/hashes/sha256'
// Why: the relay stores a digest of the serialized base64url bearer, not the
// random bytes it encodes, so every installer must hash the wire token text.
export function hashMobileRelayCredential(token: string): string {
return encodeBase64Url(sha256(new TextEncoder().encode(token)))
}
function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
@@ -0,0 +1,191 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import {
applyResumeConfirmation,
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import type { RpcResponse } from './types'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: 'host-1',
deviceToken: 'device-token',
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 2,
expiresAt: 50_000
}
}
function success(result: unknown): RpcResponse {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function install(reqId: string) {
return {
v: 1 as const,
reqId,
authorizationMode: 'authenticated-direct' as const,
currentVersion: 3,
resumeExpiresAt: 100_000,
graceExpiresAt: 70_000
}
}
function fakeClient(responses: RpcResponse[]): RpcClient {
return {
sendRequest: vi.fn(async () => responses.shift()!),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => 1,
onStateChange: () => () => {},
notifyForeground: vi.fn(),
close: vi.fn()
}
}
describe('mobile relay credential rotation', () => {
it('persists pending material before install and promotes only committed status', async () => {
const installed = install('rotate-CAgICAgICAgICAgICAgICA')
const client = fakeClient([
success({ v: 1, relay, installStatus: { v: 1, reqId: installed.reqId, state: 'not-found' } }),
success(installed),
success({
v: 1,
relay,
installStatus: { v: 1, reqId: installed.reqId, state: 'committed', result: installed }
})
])
const writes: MobileRelayCredentialBundle[] = []
const result = await rotateMobileRelayCredential({
client,
bundle,
writeBundle: async (value) => {
writes.push(value)
},
randomBytes: (length) => new Uint8Array(length).fill(length === 32 ? 7 : 8)
})
expect(writes).toHaveLength(2)
expect(writes[0]!.pending).toMatchObject({ reqId: installed.reqId })
expect(writes[0]!.pending!.hash).toBe('3Ev4DHdHPRMPoN6GukAY_pi7IUAF5qWJHRK6kURvnoE')
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', {
reqId: installed.reqId,
newResumeTokenHash: writes[0]!.pending!.hash,
expectedCurrentHash: bundle.current.hash
})
expect(result.bundle).toMatchObject({
current: { version: 3, expiresAt: 100_000 },
grace: { version: 2, expiresAt: 70_000 }
})
expect(result.bundle.pending).toBeUndefined()
})
it('repairs legacy decoded-byte hashes before the normal rotation window', () => {
const now = 1_000
const valid = {
...bundle,
current: {
...bundle.current,
hash: hashMobileRelayCredential(bundle.current.token),
expiresAt: now + 30 * 24 * 60 * 60 * 1000
}
}
expect(mobileRelayCredentialNeedsRotation(valid, now)).toBe(false)
expect(mobileRelayCredentialNeedsRotation(bundle, now)).toBe(true)
expect(
mobileRelayCredentialNeedsRotation(
{ ...valid, pending: { token: 'C'.repeat(43), hash: 'D'.repeat(43), reqId: 'pending' } },
now
)
).toBe(true)
})
it('reconciles a committed lost response without issuing a second install', async () => {
const pendingBundle: MobileRelayCredentialBundle = {
...bundle,
pending: { token: 'C'.repeat(43), hash: 'D'.repeat(43), reqId: 'rotate-existing' }
}
const installed = install('rotate-existing')
const client = fakeClient([
success({
v: 1,
relay,
installStatus: { v: 1, reqId: installed.reqId, state: 'committed', result: installed }
})
])
const writeBundle = vi.fn(async () => {})
await rotateMobileRelayCredential({ client, bundle: pendingBundle, writeBundle })
expect(client.sendRequest).toHaveBeenCalledOnce()
expect(client.sendRequest).toHaveBeenCalledWith('pairing.getEndpoints', {
installReqId: 'rotate-existing'
})
expect(writeBundle).toHaveBeenCalledOnce()
})
it('applies only authoritative current or grace confirmation expiries', () => {
const withGrace: MobileRelayCredentialBundle = {
...bundle,
grace: { token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1, expiresAt: 40_000 }
}
const renewed = applyResumeConfirmation(withGrace, 2, {
v: 1,
reqId: 'confirm-current',
currentVersion: 2,
acceptedAs: 'current',
renewed: true,
resumeExpiresAt: 120_000,
graceExpiresAt: 40_000
})
const grace = applyResumeConfirmation(renewed, 1, {
v: 1,
reqId: 'confirm-grace',
currentVersion: 2,
acceptedAs: 'grace',
renewed: false,
resumeExpiresAt: 120_000,
graceExpiresAt: 45_000
})
expect(renewed.current.expiresAt).toBe(120_000)
expect(grace.grace?.expiresAt).toBe(45_000)
expect(applyResumeConfirmation(grace, 99, { ...graceConfirmation(), reqId: 'other' })).toBe(
grace
)
})
})
function graceConfirmation() {
return {
v: 1 as const,
reqId: 'confirm',
currentVersion: 2,
acceptedAs: 'grace' as const,
renewed: false,
resumeExpiresAt: 120_000,
graceExpiresAt: 45_000
}
}
@@ -0,0 +1,147 @@
import * as ExpoCrypto from 'expo-crypto'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceResumeConfirmed,
type MobileRelayEndpoint
} from '../../../src/shared/mobile-relay-credential-contract'
import {
MobileRelayCredentialBundleSchema,
type MobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import type { RpcClient } from './rpc-client'
const CREDENTIAL_ROTATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
type RotationResult = {
bundle: MobileRelayCredentialBundle
relay: MobileRelayEndpoint
}
export async function rotateMobileRelayCredential(args: {
client: RpcClient
bundle: MobileRelayCredentialBundle
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
randomBytes?: (length: number) => Uint8Array
}): Promise<RotationResult> {
let bundle = args.bundle
if (!bundle.pending) {
const randomBytes = args.randomBytes ?? ExpoCrypto.getRandomBytes
const token = encodeBase64Url(randomBytes(32))
bundle = MobileRelayCredentialBundleSchema.parse({
...bundle,
pending: {
token,
hash: hashMobileRelayCredential(token),
reqId: `rotate-${encodeBase64Url(randomBytes(16))}`
}
})
// Why: a crash or lost response must leave enough material to query the
// one global install key before any second authorization attempt.
await args.writeBundle(bundle)
}
const pending = bundle.pending
if (!pending) {
throw new Error('relay credential rotation pending state missing')
}
let endpoints = await getEndpoints(args.client, pending.reqId)
if (endpoints.installStatus?.state !== 'committed') {
const response = await args.client.sendRequest('pairing.provisionRelay', {
reqId: pending.reqId,
newResumeTokenHash: pending.hash,
expectedCurrentHash: bundle.current.hash
})
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
const installed = DeviceCredentialInstalledSchema.parse(response.result)
endpoints = await getEndpoints(args.client, pending.reqId)
if (
endpoints.installStatus?.state !== 'committed' ||
JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed)
) {
throw new Error('relay credential rotation was not authoritatively committed')
}
}
if (!endpoints.relay || endpoints.installStatus?.state !== 'committed') {
throw new Error('relay credential rotation endpoint state missing')
}
const installed = endpoints.installStatus.result
const next = MobileRelayCredentialBundleSchema.parse({
...bundle,
current: {
token: pending.token,
hash: pending.hash,
version: installed.currentVersion,
expiresAt: installed.resumeExpiresAt
},
...(installed.graceExpiresAt
? { grace: { ...bundle.current, expiresAt: installed.graceExpiresAt } }
: { grace: undefined }),
pending: undefined
})
await args.writeBundle(next)
return { bundle: next, relay: endpoints.relay }
}
export function mobileRelayCredentialNeedsRotation(
bundle: MobileRelayCredentialBundle,
now: number
): boolean {
// Why: pre-release clients hashed decoded random bytes. Direct connectivity
// can safely replace that unusable cloud credential using its stored hash.
const malformedCurrentHash =
bundle.current.hash !== hashMobileRelayCredential(bundle.current.token)
return (
Boolean(bundle.pending) ||
malformedCurrentHash ||
bundle.current.expiresAt - now <= CREDENTIAL_ROTATION_WINDOW_MS
)
}
export function applyResumeConfirmation(
bundle: MobileRelayCredentialBundle,
usedCredentialVersion: number,
confirmation: DeviceResumeConfirmed
): MobileRelayCredentialBundle {
if (
confirmation.acceptedAs === 'current' &&
confirmation.renewed &&
bundle.current.version === usedCredentialVersion &&
confirmation.currentVersion === usedCredentialVersion
) {
return MobileRelayCredentialBundleSchema.parse({
...bundle,
current: { ...bundle.current, expiresAt: confirmation.resumeExpiresAt }
})
}
if (
confirmation.acceptedAs === 'grace' &&
bundle.grace?.version === usedCredentialVersion &&
confirmation.graceExpiresAt
) {
return MobileRelayCredentialBundleSchema.parse({
...bundle,
grace: { ...bundle.grace, expiresAt: confirmation.graceExpiresAt }
})
}
return bundle
}
async function getEndpoints(client: RpcClient, installReqId: string) {
const response = await client.sendRequest('pairing.getEndpoints', { installReqId })
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return PairingGetEndpointsResultSchema.parse(response.result)
}
function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller'
import type { MobileRelayDirectUpgradeResult } from './mobile-relay-direct-upgrade'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
const directHost: HostProfile = {
id: 'host-direct',
name: 'Host 4',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
}
const upgraded = {
host: {
...directHost,
relayHostId: 'AbCdEf0123_-xyZ9',
relay: {
v: 1 as const,
directorUrl: 'https://relay-staging.onorca.dev',
cellUrl: 'https://c1.relay-staging.onorca.dev',
assignmentEpoch: 4,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
},
bundle: {
v: 1 as const,
hostId: directHost.id,
deviceToken: directHost.deviceToken,
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 1,
expiresAt: 99_999_999
}
}
} satisfies MobileRelayDirectUpgradeResult
function logicalClient(initial: ConnectionState) {
let state = initial
const listeners = new Set<(state: ConnectionState) => void>()
return {
client: {
getState: () => state,
onStateChange: (listener: (next: ConnectionState) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
} as unknown as StableLogicalRpcClient,
setState(next: ConnectionState) {
state = next
for (const listener of listeners) {
listener(next)
}
}
}
}
describe('direct pairing upgrade controller', () => {
it('upgrades immediately after an authenticated direct connection', async () => {
const logical = logicalClient('connected')
const upgrade = vi.fn(async () => upgraded)
const onUpgraded = vi.fn(async () => {})
const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, {
upgrade,
onUpgraded
})
await controller.start()
expect(upgrade).toHaveBeenCalledWith(logical.client, directHost)
expect(onUpgraded).toHaveBeenCalledWith(upgraded)
})
it('retries a deferred upgrade on the next foreground transition', async () => {
const logical = logicalClient('connected')
const upgrade = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(upgraded)
const onUpgraded = vi.fn(async () => {})
const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, {
upgrade,
onUpgraded
})
await controller.start()
controller.setForeground(false)
controller.setForeground(true)
await vi.waitFor(() => expect(onUpgraded).toHaveBeenCalledOnce())
expect(upgrade).toHaveBeenCalledTimes(2)
})
it('fences a completed request after the host client closes', async () => {
const logical = logicalClient('connecting')
let resolveUpgrade!: (result: MobileRelayDirectUpgradeResult) => void
const upgrade = vi.fn(
() =>
new Promise<MobileRelayDirectUpgradeResult>((resolve) => {
resolveUpgrade = resolve
})
)
const onUpgraded = vi.fn(async () => {})
const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, {
upgrade,
onUpgraded
})
await controller.start()
logical.setState('connected')
controller.stop()
resolveUpgrade(upgraded)
await Promise.resolve()
expect(onUpgraded).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,69 @@
import type { MobileRelayDirectUpgradeResult } from './mobile-relay-direct-upgrade'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
type Dependencies = {
upgrade: (
client: StableLogicalRpcClient,
host: HostProfile
) => Promise<MobileRelayDirectUpgradeResult | null>
onUpgraded: (result: MobileRelayDirectUpgradeResult) => Promise<void>
}
export class MobileRelayDirectUpgradeController {
private foreground = true
private stopped = false
private inFlight = false
private unsubscribe: (() => void) | null = null
constructor(
private readonly logical: StableLogicalRpcClient,
private readonly host: HostProfile,
private readonly dependencies: Dependencies
) {}
async start(): Promise<void> {
this.unsubscribe = this.logical.onStateChange((state) => {
if (state === 'connected') {
void this.tryUpgrade()
}
})
if (this.logical.getState() === 'connected') {
await this.tryUpgrade()
}
}
setForeground(foreground: boolean): void {
this.foreground = foreground
if (foreground && this.logical.getState() === 'connected') {
void this.tryUpgrade()
}
}
stop(): void {
this.stopped = true
this.unsubscribe?.()
this.unsubscribe = null
}
private async tryUpgrade(): Promise<void> {
if (this.stopped || !this.foreground || this.inFlight) {
return
}
this.inFlight = true
try {
const result = await this.dependencies.upgrade(this.logical, this.host)
if (!result || this.stopped) {
return
}
this.unsubscribe?.()
this.unsubscribe = null
await this.dependencies.onUpgraded(result)
} catch {
// Why: the journal survives transient auth/control failure; retry only on
// the next authenticated reconnect or foreground transition.
} finally {
this.inFlight = false
}
}
}
@@ -0,0 +1,89 @@
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import { z } from 'zod'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
export const MobileRelayDirectUpgradeJournalSchema = z
.object({
v: z.literal(1),
hostId: z.string().min(1),
reqId: z.string().min(1).max(128),
pendingResumeToken: Base64Url32ByteSchema,
pendingResumeTokenHash: Base64Url32ByteSchema
})
.strict()
export type MobileRelayDirectUpgradeJournal = z.infer<typeof MobileRelayDirectUpgradeJournalSchema>
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function journalKey(hostId: string): string {
return `orca.mobile-relay.direct-upgrade.${hostId}`
}
export function createMobileRelayDirectUpgradeJournal(
hostId: string,
randomBytes: (length: number) => Uint8Array
): MobileRelayDirectUpgradeJournal {
const pendingResumeToken = encodeBase64Url(randomBytes(32))
return MobileRelayDirectUpgradeJournalSchema.parse({
v: 1,
hostId,
reqId: `upgrade-${encodeBase64Url(randomBytes(16))}`,
pendingResumeToken,
pendingResumeTokenHash: hashMobileRelayCredential(pendingResumeToken)
})
}
export async function readMobileRelayDirectUpgradeJournal(
hostId: string
): Promise<MobileRelayDirectUpgradeJournal | null> {
requireNativeSecretStore()
const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS)
if (!raw) {
return null
}
try {
const parsed = MobileRelayDirectUpgradeJournalSchema.safeParse(JSON.parse(raw))
return parsed.success && parsed.data.hostId === hostId ? parsed.data : null
} catch {
return null
}
}
export async function writeMobileRelayDirectUpgradeJournal(
journal: MobileRelayDirectUpgradeJournal
): Promise<void> {
requireNativeSecretStore()
const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal)
await SecureStore.setItemAsync(
journalKey(parsed.hostId),
JSON.stringify(parsed),
KEYCHAIN_OPTIONS
)
}
export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS)
}
function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function requireNativeSecretStore(): void {
if (Platform.OS === 'web') {
throw new Error('Orca Relay upgrade state requires a native secret store')
}
}
@@ -0,0 +1,193 @@
import { describe, expect, it, vi } from 'vitest'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import { MobileRelayUpgradeHostRemovedError } from './host-store'
import {
createMobileRelayDirectUpgradeJournal,
type MobileRelayDirectUpgradeJournal
} from './mobile-relay-direct-upgrade-journal'
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
import type { RpcClient } from './rpc-client'
import type { HostProfile, RpcResponse } from './types'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
const relay: MobileRelayEndpoint = {
v: 1,
directorUrl: 'https://relay-staging.onorca.dev',
cellUrl: 'https://c1.relay-staging.onorca.dev',
assignmentEpoch: 4,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2
}
const host: HostProfile = {
id: 'host-direct',
name: 'Host 4',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
}
function success(result: unknown): RpcResponse {
return { id: 'rpc', ok: true, result, _meta: { runtimeId: 'runtime' } }
}
function clientWith(responses: RpcResponse[]) {
return {
sendRequest: vi.fn(async () => responses.shift()!),
getState: () => 'connected'
} as unknown as RpcClient
}
function installed(journal: MobileRelayDirectUpgradeJournal) {
return {
v: 1 as const,
reqId: journal.reqId,
authorizationMode: 'authenticated-direct' as const,
currentVersion: 1,
resumeExpiresAt: 9_999_999
}
}
function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) {
let stored = journal
return {
readJournal: vi.fn(async () => stored),
writeJournal: vi.fn(async (next: MobileRelayDirectUpgradeJournal) => {
stored = next
}),
clearJournal: vi.fn(async () => {
stored = null
}),
writeBundle: vi.fn(async () => {}),
deleteBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
randomBytes: (length: number) => new Uint8Array(length).fill(7)
}
}
describe('existing direct pairing relay upgrade', () => {
it('persists pending material before install and publishes only after committed status', async () => {
const deps = dependencies()
let journal: MobileRelayDirectUpgradeJournal | null = null
deps.writeJournal.mockImplementation(async (next) => {
journal = next
})
const client = clientWith([
success({ v: 1, relay }),
success({
v: 1,
reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw',
authorizationMode: 'authenticated-direct',
currentVersion: 1,
resumeExpiresAt: 9_999_999
}),
success({
v: 1,
relay,
installStatus: {
v: 1,
reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw',
state: 'committed',
result: {
v: 1,
reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw',
authorizationMode: 'authenticated-direct',
currentVersion: 1,
resumeExpiresAt: 9_999_999
}
}
})
])
const result = await upgradeDirectMobileRelay({ client, host, dependencies: deps })
expect(journal).not.toBeNull()
expect(deps.writeJournal.mock.invocationCallOrder[0]).toBeLessThan(
client.sendRequest.mock.invocationCallOrder[0]!
)
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', {
reqId: journal!.reqId,
newResumeTokenHash: journal!.pendingResumeTokenHash
})
expect(deps.writeBundle).toHaveBeenCalledBefore(deps.saveHost)
expect(result?.host.relay).toEqual(relay)
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
})
it('recovers an already committed install without authorizing a second one', async () => {
const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) =>
new Uint8Array(length).fill(3)
)
const committed = installed(journal)
const deps = dependencies(journal)
const client = clientWith([
success({
v: 1,
relay,
installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed }
})
])
const result = await upgradeDirectMobileRelay({ client, host, dependencies: deps })
expect(client.sendRequest).toHaveBeenCalledOnce()
expect(result?.bundle.current.version).toBe(1)
expect(deps.writeBundle).toHaveBeenCalledOnce()
})
it('cleans pending state and leaves direct access unchanged for an old desktop', async () => {
const deps = dependencies()
const client = clientWith([
{
id: 'rpc',
ok: false,
error: { code: 'method_not_found', message: 'unsupported' },
_meta: { runtimeId: 'runtime' }
}
])
await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull()
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
expect(deps.writeBundle).not.toHaveBeenCalled()
expect(deps.saveHost).not.toHaveBeenCalled()
})
it('retains the durable journal when relay registration is temporarily unavailable', async () => {
const deps = dependencies()
const client = clientWith([success({ v: 1, relay: null })])
await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).rejects.toThrow(
'relay endpoint unavailable'
)
expect(deps.writeJournal).toHaveBeenCalledOnce()
expect(deps.clearJournal).not.toHaveBeenCalled()
})
it('cleans newly installed secrets instead of resurrecting a removed host', async () => {
const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) =>
new Uint8Array(length).fill(5)
)
const committed = installed(journal)
const deps = dependencies(journal)
deps.saveHost.mockRejectedValue(
new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
)
const client = clientWith([
success({
v: 1,
relay,
installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed }
})
])
await expect(
upgradeDirectMobileRelay({ client, host, dependencies: deps })
).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
expect(deps.deleteBundle).toHaveBeenCalledWith(host.id)
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
})
})
@@ -0,0 +1,175 @@
import * as ExpoCrypto from 'expo-crypto'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceCredentialInstalled,
type PairingGetEndpointsResult
} from '../../../src/shared/mobile-relay-credential-contract'
import { MobileRelayUpgradeHostRemovedError, saveExistingHostRelayUpgrade } from './host-store'
import { persistRelayHost } from './mobile-endpoint-supervisor-support'
import {
MobileRelayCredentialBundleSchema,
deleteMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle,
type MobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import {
createMobileRelayDirectUpgradeJournal,
deleteMobileRelayDirectUpgradeJournal,
readMobileRelayDirectUpgradeJournal,
writeMobileRelayDirectUpgradeJournal,
type MobileRelayDirectUpgradeJournal
} from './mobile-relay-direct-upgrade-journal'
import type { RpcClient } from './rpc-client'
import type { HostProfile, RpcResponse } from './types'
export type MobileRelayDirectUpgradeResult = {
host: HostProfile
bundle: MobileRelayCredentialBundle
}
type Dependencies = {
readJournal: typeof readMobileRelayDirectUpgradeJournal
writeJournal: typeof writeMobileRelayDirectUpgradeJournal
clearJournal: typeof deleteMobileRelayDirectUpgradeJournal
writeBundle: typeof writeMobileRelayCredentialBundle
saveHost: typeof saveExistingHostRelayUpgrade
deleteBundle: typeof deleteMobileRelayCredentialBundle
randomBytes: (length: number) => Uint8Array
}
export async function upgradeDirectMobileRelay(args: {
client: RpcClient
host: HostProfile
dependencies?: Partial<Dependencies>
}): Promise<MobileRelayDirectUpgradeResult | null> {
if (args.host.relay) {
return null
}
const dependencies: Dependencies = {
readJournal: readMobileRelayDirectUpgradeJournal,
writeJournal: writeMobileRelayDirectUpgradeJournal,
clearJournal: deleteMobileRelayDirectUpgradeJournal,
writeBundle: writeMobileRelayCredentialBundle,
saveHost: saveExistingHostRelayUpgrade,
deleteBundle: deleteMobileRelayCredentialBundle,
randomBytes: ExpoCrypto.getRandomBytes,
...args.dependencies
}
let journal = await dependencies.readJournal(args.host.id)
if (!journal) {
journal = createMobileRelayDirectUpgradeJournal(args.host.id, dependencies.randomBytes)
// Why: the stable reqId and pending secret must survive a lost install response.
await dependencies.writeJournal(journal)
}
const initial = await getEndpoints(args.client, journal.reqId)
if (initial === 'method-not-found') {
await dependencies.clearJournal(args.host.id)
return null
}
if (initial.installStatus?.state === 'committed') {
return publishCommitted(args.host, journal, initial, dependencies)
}
if (!initial.relay) {
throw new Error('relay endpoint unavailable for direct pairing upgrade')
}
const provisionResponse = await args.client.sendRequest('pairing.provisionRelay', {
reqId: journal.reqId,
newResumeTokenHash: journal.pendingResumeTokenHash
})
if (isMethodNotFound(provisionResponse)) {
await dependencies.clearJournal(args.host.id)
return null
}
const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provisionResponse))
assertDirectInstall(journal, installed)
const reconciled = await getEndpoints(args.client, journal.reqId)
if (reconciled === 'method-not-found') {
throw new Error('relay endpoint reconciliation became unavailable')
}
assertCommitted(reconciled, installed)
return publishCommitted(args.host, journal, reconciled, dependencies)
}
async function publishCommitted(
host: HostProfile,
journal: MobileRelayDirectUpgradeJournal,
endpoints: PairingGetEndpointsResult,
dependencies: Dependencies
): Promise<MobileRelayDirectUpgradeResult> {
if (endpoints.installStatus?.state !== 'committed' || !endpoints.relay) {
throw new Error('direct pairing upgrade was not authoritatively committed')
}
const installed = endpoints.installStatus.result
assertDirectInstall(journal, installed)
const bundle = MobileRelayCredentialBundleSchema.parse({
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: journal.pendingResumeToken,
hash: journal.pendingResumeTokenHash,
version: installed.currentVersion,
expiresAt: installed.resumeExpiresAt
}
})
// Why: the overlay must never advertise relay without its matching credential.
await dependencies.writeBundle(bundle)
let updatedHost: HostProfile
try {
updatedHost = await persistRelayHost(host, endpoints.relay, dependencies.saveHost)
} catch (error) {
if (error instanceof MobileRelayUpgradeHostRemovedError) {
await dependencies.deleteBundle(host.id)
await dependencies.clearJournal(host.id)
}
throw error
}
await dependencies.clearJournal(host.id)
return { host: updatedHost, bundle }
}
async function getEndpoints(
client: RpcClient,
installReqId: string
): Promise<PairingGetEndpointsResult | 'method-not-found'> {
const response = await client.sendRequest('pairing.getEndpoints', { installReqId })
if (isMethodNotFound(response)) {
return 'method-not-found'
}
return PairingGetEndpointsResultSchema.parse(requireSuccess(response))
}
function assertDirectInstall(
journal: MobileRelayDirectUpgradeJournal,
installed: DeviceCredentialInstalled
): void {
if (installed.reqId !== journal.reqId || installed.authorizationMode !== 'authenticated-direct') {
throw new Error('relay credential install does not match direct upgrade journal')
}
}
function assertCommitted(
endpoints: PairingGetEndpointsResult,
installed: DeviceCredentialInstalled
): void {
if (
endpoints.installStatus?.state !== 'committed' ||
JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed)
) {
throw new Error('relay credential install was not authoritatively reconciled')
}
}
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'
}
@@ -0,0 +1,151 @@
import {
RelayPhoneHelloSchema,
type RelayPhoneHello
} from '../../../src/shared/mobile-relay-phone-protocol'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
export class RelayOuterError extends Error {
constructor(readonly code: number) {
super(`relay_outer_${code}`)
}
}
type MobileRelayE2eeLinkOptions = {
endpoint: { cellUrl: string; relayHostId: string }
credential: string
expectedCredentialKind: 'invite' | 'resume'
deviceToken: string
desktopPublicKeyB64: string
onAuthenticated: () => void
onText: (plaintext: string) => void
onBinary: (plaintext: Uint8Array) => void
onHello?: (hello: Extract<RelayPhoneHello, { ok: true }>) => void
onError: (error: Error) => void
createSocket?: (url: string) => WebSocket
}
export class MobileRelayE2eeLink {
private readonly options: MobileRelayE2eeLinkOptions
private readonly socket: WebSocket
private readonly channel: MobileE2EEV2PhysicalChannel
private outerReady = false
private closed = false
private inboundChain: Promise<void> = Promise.resolve()
constructor(options: MobileRelayE2eeLinkOptions) {
this.options = options
this.socket = (options.createSocket ?? ((url) => new WebSocket(url)))(
relaySocketUrl(options.endpoint)
)
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: options.desktopPublicKeyB64,
transport: 'relay',
relayHostId: options.endpoint.relayHostId
})
this.channel = new MobileE2EEV2PhysicalChannel({
session,
socket: this.socket,
deviceToken: options.deviceToken,
decodeBinary: websocketPayloadToUint8,
onAuthenticated: options.onAuthenticated,
onText: options.onText,
onBinary: options.onBinary,
onError: (error) => this.fail(error)
})
this.bindSocket()
}
sendText(plaintext: string): boolean {
return !this.closed && this.channel.sendText(plaintext)
}
sendBinary(plaintext: Uint8Array): boolean {
return !this.closed && this.channel.sendBinary(plaintext)
}
close(): void {
if (this.closed) {
return
}
this.closed = true
this.channel.dispose()
this.socket.close()
}
private bindSocket(): void {
this.socket.onopen = () => {
this.socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: this.options.credential
})
)
}
this.socket.onmessage = (event) => {
this.inboundChain = this.inboundChain
.then(async () => {
if (this.closed) {
return
}
if (!this.outerReady) {
this.acceptHello(event.data)
} else {
await this.channel.handleMessage(event.data)
}
})
.catch((error: unknown) => this.fail(asError(error)))
}
this.socket.onerror = () => this.fail(new Error('relay transport error'))
this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006))
}
private acceptHello(raw: unknown): void {
if (typeof raw !== 'string') {
throw new Error('expected plaintext relay hello')
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
throw new Error('invalid relay hello JSON')
}
const parsed = RelayPhoneHelloSchema.safeParse(value)
if (!parsed.success) {
throw new Error('invalid relay hello')
}
if (!parsed.data.ok) {
throw new RelayOuterError(parsed.data.code)
}
if (parsed.data.credentialKind !== this.options.expectedCredentialKind) {
throw new Error('relay credential resolved as an unexpected credential kind')
}
this.outerReady = true
this.options.onHello?.(parsed.data)
this.channel.start()
}
private fail(error: Error): void {
if (this.closed) {
return
}
this.closed = true
this.channel.dispose()
this.options.onError(error)
this.socket.close()
}
}
function relaySocketUrl(endpoint: { cellUrl: string; relayHostId: string }): string {
const url = new URL(endpoint.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(endpoint.relayHostId)}`
return url.toString()
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
@@ -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,94 @@
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>> {
return (await loadMobileRelayHostOverlayState(existingHostIds)).overlays
}
export async function loadMobileRelayHostOverlayState(
existingHostIds: ReadonlySet<string>
): Promise<{ overlays: Map<string, MobileRelayHostOverlay>; orphanHostIds: string[] }> {
await overlayMutation
const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY)) ?? []
const active = new Map<string, MobileRelayHostOverlay>()
const orphanHostIds: string[] = []
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)
} else {
orphanHostIds.push(overlay.hostId)
}
}
return { overlays: active, orphanHostIds }
}
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,80 @@
import { describe, expect, it } from 'vitest'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
class FakeSocket {
sent: string[] = []
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number }) => void) | null = null
send(value: string): void {
this.sent.push(value)
}
close(): void {}
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: Date.now() + 300_000,
e2eeFraming: 2 as const
}
describe('pairing invite director resolution', () => {
it('authenticates only to the configured director and accepts a strictly newer move', async () => {
const socket = new FakeSocket()
let url = ''
const resolving = resolvePairingInviteThroughDirector({
relay,
createSocket: (value) => {
url = value
return socket as unknown as WebSocket
}
})
socket.onopen?.()
expect(url).toBe('wss://relay.onorca.dev/v1/connect/AbCdEf0123_-xyZ9')
expect(url).not.toContain('?')
expect(JSON.parse(socket.sent[0]!)).toEqual({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: relay.inviteToken
})
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
})
})
await expect(resolving).resolves.toMatchObject({
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
})
})
it('rejects same/older epochs and untrusted extra fields', async () => {
const socket = new FakeSocket()
const resolving = resolvePairingInviteThroughDirector({
relay,
createSocket: () => socket as unknown as WebSocket
})
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 7,
targetFromCell: true
})
})
await expect(resolving).rejects.toThrow(/not strictly newer/)
})
})
@@ -0,0 +1,78 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol'
export function resolvePairingInviteThroughDirector(args: {
relay: PairingRelay
timeoutMs?: number
createSocket?: (url: string) => WebSocket
}): Promise<PairingRelay> {
const socket = (args.createSocket ?? ((url) => new WebSocket(url)))(
directorWebSocketUrl(args.relay)
)
return new Promise((resolve, reject) => {
let settled = false
const timeout = setTimeout(
() => finish(new Error('relay director resolution timed out')),
args.timeoutMs ?? 5_000
)
socket.onopen = () => {
socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: args.relay.inviteToken
})
)
}
socket.onmessage = (event) => {
if (typeof event.data !== 'string') {
finish(new Error('invalid relay director move'))
return
}
let value: unknown
try {
value = JSON.parse(event.data)
} catch {
finish(new Error('invalid relay director move'))
return
}
const moved = RelayMovedSchema.safeParse(value)
if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
finish(new Error('relay director move was not strictly newer'))
return
}
settled = true
clearTimeout(timeout)
socket.close()
resolve({
...args.relay,
cellUrl: moved.data.cellUrl,
assignmentEpoch: moved.data.assignmentEpoch
})
}
socket.onerror = () => finish(new Error('relay director transport error'))
socket.onclose = (event) => {
if (!settled) {
finish(new Error(`relay director closed before move: ${event.code || 1006}`))
}
}
function finish(error: Error): void {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
socket.close()
reject(error)
}
})
}
export function directorWebSocketUrl(relay: PairingRelay): string {
const url = new URL(relay.directorUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
describe('mobile relay orphan cleanup', () => {
it('durably schedules credential deletion before removing an orphan overlay pointer', async () => {
const order: string[] = []
const deleteCredential = vi.fn(async () => {})
const scheduleCleanup = vi.fn(async (hostId: string) => {
order.push(`schedule:${hostId}`)
})
const removeOverlay = vi.fn(async (hostId: string) => {
order.push(`overlay:${hostId}`)
})
await scheduleOrphanedMobileRelayCleanup({
hostIds: ['host-1', 'host-1'],
deleteCredential,
scheduleCleanup,
removeOverlay
})
expect(order).toEqual(['schedule:host-1', 'overlay:host-1'])
expect(scheduleCleanup).toHaveBeenCalledWith('host-1', deleteCredential)
})
it('retains the overlay pointer when durable cleanup scheduling fails', async () => {
const removeOverlay = vi.fn(async () => {})
await scheduleOrphanedMobileRelayCleanup({
hostIds: ['host-1'],
deleteCredential: vi.fn(async () => {}),
scheduleCleanup: vi.fn(async () => {
throw new Error('storage unavailable')
}),
removeOverlay
})
expect(removeOverlay).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,22 @@
import { scheduleHostCredentialCleanup } from './host-credential-cleanup'
import { removeMobileRelayHostOverlay } from './mobile-relay-host-overlay-store'
export async function scheduleOrphanedMobileRelayCleanup(args: {
hostIds: string[]
deleteCredential: (hostId: string) => Promise<void>
scheduleCleanup?: typeof scheduleHostCredentialCleanup
removeOverlay?: typeof removeMobileRelayHostOverlay
}): Promise<void> {
const scheduleCleanup = args.scheduleCleanup ?? scheduleHostCredentialCleanup
const removeOverlay = args.removeOverlay ?? removeMobileRelayHostOverlay
for (const hostId of new Set(args.hostIds)) {
try {
// Why: an older build may remove the legacy host while retaining the v2
// namespace; persist keychain cleanup intent before dropping that pointer.
await scheduleCleanup(hostId, args.deleteCredential)
await removeOverlay(hostId)
} catch {
// Retain the overlay pointer if durable cleanup intent could not be recorded.
}
}
}
@@ -0,0 +1,173 @@
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('cleans mismatched secret records and refuses to replace a recoverable journal', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
metadataRaw = JSON.stringify(journal.metadata)
secretRaw = JSON.stringify({ ...journal.secrets, journalId: 'different-journal' })
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
expect(secretRaw).toBeNull()
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/recovery pending/)
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal)
})
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,136 @@
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 () => {
const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
const existing = existingRaw ? parseMetadata(existingRaw) : null
if (existing && existing.journalId !== metadata.journalId) {
throw new Error('mobile relay pairing recovery pending')
}
// 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) {
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
}
const secrets = parseSecrets(rawSecrets)
if (!secrets || secrets.journalId !== metadata.journalId) {
await removeIncompleteJournal()
return null
}
return { metadata, secrets }
}
async function removeIncompleteJournal(): Promise<void> {
// Why: metadata is the discoverable cleanup pointer; remove it before the
// native secret so a second crash can only leave a self-cleaning orphan.
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
}
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,111 @@
import * as ExpoCrypto from 'expo-crypto'
import { sha256 } from '@noble/hashes/sha256'
import { z } from 'zod'
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import type { PairingOffer } 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),
resumeConfirmReqId: 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 resumeConfirmReqId = `confirm-${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,
resumeConfirmReqId,
pendingResumeTokenHash: hashMobileRelayCredential(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(/=+$/, '')
}
@@ -0,0 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMobileRelayPairingFixtures,
encodePairingFixturePayload
} from '../../../src/shared/mobile-relay-pairing-fixtures'
import { decodePairingUrl } from './pairing'
describe('mobile relay pairing contract', () => {
const now = Date.UTC(2026, 6, 12, 16)
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(now)
})
afterEach(() => {
vi.useRealTimers()
})
for (const fixture of createMobileRelayPairingFixtures(now)) {
it(fixture.name, () => {
expect(decodePairingUrl(encodePairingFixturePayload(fixture.payload))).toEqual(
fixture.expected
)
})
}
})
@@ -0,0 +1,223 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import {
recoverMobileRelayPairing,
resetMobileRelayPairingRecoveryForTests
} from './mobile-relay-pairing-recovery'
import type { PairingCandidateClient } from './mobile-relay-physical-client'
import type { PairingOffer, RpcResponse } from './types'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({ getRandomBytes: vi.fn() }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED' }))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} }))
const now = Date.UTC(2026, 6, 13)
const offer = {
v: 2,
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
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
function response(result: unknown): RpcResponse {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function installed(
journal: ReturnType<typeof journal>,
mode: 'authenticated-direct' | 'relay-basis'
) {
return {
v: 1 as const,
reqId: journal.metadata.installReqId,
authorizationMode: mode,
currentVersion: 1,
resumeExpiresAt: now + 86_400_000
}
}
function endpoints(
journal: ReturnType<typeof journal>,
state: { state: 'not-found' } | { state: 'committed'; result: ReturnType<typeof installed> }
) {
return {
v: 1 as const,
relay: {
v: 1 as const,
directorUrl: offer.relay.directorUrl,
cellUrl: offer.relay.cellUrl,
assignmentEpoch: offer.relay.assignmentEpoch,
relayHostId: offer.relay.relayHostId,
e2eeFraming: 2 as const
},
installStatus: { v: 1 as const, reqId: journal.metadata.installReqId, ...state }
}
}
function journal(mode: 'authenticated-direct' | 'relay-basis' = 'authenticated-direct') {
const value = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
now,
randomBytes: (length) => new Uint8Array(length).fill(length)
})
return {
...value,
metadata: {
...value.metadata,
winner: mode === 'authenticated-direct' ? ('direct' as const) : ('relay' as const),
authorizationMode: mode
}
}
}
function client(handler: (method: string, params: unknown) => Promise<RpcResponse>) {
return { sendRequest: vi.fn(handler), close: vi.fn() } satisfies PairingCandidateClient
}
function dependencies(args: {
journal: ReturnType<typeof journal>
connectRelay: ReturnType<typeof vi.fn>
bundle?: MobileRelayCredentialBundle | null
}) {
return {
loadJournal: vi.fn(async () => args.journal),
updateJournal: vi.fn(async (_id, update) => {
Object.assign(args.journal.metadata, update(args.journal.metadata))
}),
clearJournal: vi.fn(async () => {}),
readCredentialBundle: vi.fn(async () => args.bundle ?? null),
writeCredentialBundle: vi.fn(async () => {}),
loadHosts: vi.fn(async () => []),
saveHost: vi.fn(async () => {}),
connectRelay: args.connectRelay,
resolveInviteDirector: vi.fn(async () => {
throw new Error('director not needed')
}),
now: () => now,
platform: 'ios'
}
}
describe('mobile relay pairing recovery', () => {
beforeEach(() => {
resetMobileRelayPairingRecoveryForTests()
})
it('recovers a lost direct-install response with the pending credential first', async () => {
const saved = journal()
const committed = installed(saved, 'authenticated-direct')
const pending = client(async (method, params) => {
expect(method).toBe('pairing.getEndpoints')
expect(params).toEqual({
installReqId: saved.metadata.installReqId,
resumeConfirmReqId: saved.metadata.resumeConfirmReqId
})
return response(endpoints(saved, { state: 'committed', result: committed }))
})
const connectRelay = vi.fn(() => pending)
const deps = dependencies({ journal: saved, connectRelay })
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered')
expect(connectRelay).toHaveBeenCalledWith(
expect.objectContaining({
credential: saved.secrets.pendingResumeToken,
expectedCredentialKind: 'resume'
})
)
expect(deps.writeCredentialBundle).toHaveBeenCalledOnce()
expect(deps.saveHost).toHaveBeenCalledOnce()
expect(deps.clearJournal).toHaveBeenCalledOnce()
})
it('tries pending then current before an unexpired invite and transitions after not-found', async () => {
const saved = journal()
const currentToken = 'C'.repeat(43)
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: 'host-1',
deviceToken: offer.deviceToken,
current: {
token: currentToken,
hash: 'D'.repeat(43),
version: 1,
expiresAt: now + 60_000
}
}
const failed = () =>
client(async () => {
throw new Error('resume rejected')
})
const relayInstalled = installed(saved, 'relay-basis')
let statusCalls = 0
const invite = client(async (method) => {
if (method === 'pairing.getEndpoints') {
statusCalls += 1
return response(
statusCalls === 1
? endpoints(saved, { state: 'not-found' })
: endpoints(saved, { state: 'committed', result: relayInstalled })
)
}
expect(saved.metadata.authorizationMode).toBe('relay-basis')
return response(relayInstalled)
})
const seenCredentials: (string | undefined)[] = []
const connectRelay = vi.fn((args) => {
seenCredentials.push(args.credential)
return args.credential ? failed() : invite
})
const deps = dependencies({ journal: saved, connectRelay, bundle })
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered')
expect(seenCredentials).toEqual([saved.secrets.pendingResumeToken, currentToken, undefined])
expect(deps.updateJournal).toHaveBeenCalledWith(saved.metadata.journalId, expect.any(Function))
expect(invite.sendRequest).toHaveBeenCalledWith('pairing.provisionRelay', {
reqId: saved.metadata.installReqId,
newResumeTokenHash: saved.metadata.pendingResumeTokenHash
})
})
it('accepts the one late direct result after invite fallback observed not-found', async () => {
const saved = journal()
const directInstalled = installed(saved, 'authenticated-direct')
const failedPending = client(async () => {
throw new Error('pending unavailable')
})
let statusCalls = 0
const invite = client(async (method) => {
if (method === 'pairing.getEndpoints') {
statusCalls += 1
return response(
statusCalls === 1
? endpoints(saved, { state: 'not-found' })
: endpoints(saved, { state: 'committed', result: directInstalled })
)
}
return response(directInstalled)
})
const deps = dependencies({
journal: saved,
connectRelay: vi.fn((args) => (args.credential ? failedPending : invite))
})
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered')
const written = deps.writeCredentialBundle.mock.calls[0]![0]
expect(written.current.token).toBe(saved.secrets.pendingResumeToken)
expect(saved.metadata.authorizationMode).toBe('authenticated-direct')
expect(deps.updateJournal).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,296 @@
import { Platform } from 'react-native'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceCredentialInstalled,
type MobileRelayEndpoint
} from '../../../src/shared/mobile-relay-credential-contract'
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { loadHosts, saveHost } from './host-store'
import {
promotePairingJournalCredential,
readMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle,
type MobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import {
clearMobileRelayPairingJournal,
loadMobileRelayPairingJournal,
updateMobileRelayPairingJournal
} from './mobile-relay-pairing-journal-store'
import {
connectMobileRelayForPairing,
type PairingCandidateClient
} from './mobile-relay-physical-client'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import type { HostProfile, RpcResponse } from './types'
export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred'
type RecoveryDependencies = {
loadJournal: typeof loadMobileRelayPairingJournal
updateJournal: typeof updateMobileRelayPairingJournal
clearJournal: typeof clearMobileRelayPairingJournal
readCredentialBundle: typeof readMobileRelayCredentialBundle
writeCredentialBundle: typeof writeMobileRelayCredentialBundle
loadHosts: typeof loadHosts
saveHost: typeof saveHost
connectRelay: typeof connectMobileRelayForPairing
resolveInviteDirector: typeof resolvePairingInviteThroughDirector
now: () => number
platform: string
}
const defaultDependencies: RecoveryDependencies = {
loadJournal: loadMobileRelayPairingJournal,
updateJournal: updateMobileRelayPairingJournal,
clearJournal: clearMobileRelayPairingJournal,
readCredentialBundle: readMobileRelayCredentialBundle,
writeCredentialBundle: writeMobileRelayCredentialBundle,
loadHosts,
saveHost,
connectRelay: connectMobileRelayForPairing,
resolveInviteDirector: resolvePairingInviteThroughDirector,
now: Date.now,
platform: Platform.OS
}
let recoveryPromise: Promise<MobileRelayPairingRecoveryResult> | null = null
export function recoverMobileRelayPairing(
overrides: Partial<RecoveryDependencies> = {}
): Promise<MobileRelayPairingRecoveryResult> {
if (recoveryPromise) {
return recoveryPromise
}
const dependencies = { ...defaultDependencies, ...overrides }
recoveryPromise = runRecovery(dependencies).finally(() => {
recoveryPromise = null
})
return recoveryPromise
}
async function runRecovery(
dependencies: RecoveryDependencies
): Promise<MobileRelayPairingRecoveryResult> {
if (dependencies.platform === 'web') {
return 'none'
}
let journal: MobileRelayPairingJournal | null
try {
journal = await dependencies.loadJournal()
} catch {
return 'deferred'
}
if (!journal) {
return 'none'
}
const bundle = await dependencies.readCredentialBundle(journal.metadata.host.id).catch(() => null)
const hosts = await dependencies.loadHosts().catch(() => [])
const existing = hosts.find(({ id }) => id === journal!.metadata.host.id)
if (existing?.relayHostId === journal.metadata.relay.relayHostId && bundle) {
await dependencies.clearJournal(journal.metadata.journalId)
return 'recovered'
}
const credentials = recoveryCredentials(journal, bundle, dependencies.now())
for (const credential of credentials) {
let client: PairingCandidateClient | null = null
try {
client =
credential.kind === 'invite'
? createInviteClient(journal, dependencies, (next) => {
journal = next
})
: dependencies.connectRelay({
relay: pairingRelay(journal),
deviceToken: journal.secrets.deviceToken,
desktopPublicKeyB64: journal.metadata.host.publicKeyB64,
credential: credential.token,
expectedCredentialKind: 'resume'
})
const endpoints = await getRecoveryStatus(client, journal, credential.kind)
if (endpoints.installStatus?.state === 'committed') {
await publishCommitted(journal, endpoints, dependencies)
return 'recovered'
}
if (credential.kind === 'invite' && endpoints.installStatus?.state === 'not-found') {
journal = await transitionToInviteAuthorization(journal, dependencies)
const installed = DeviceCredentialInstalledSchema.parse(
requireSuccess(
await client.sendRequest('pairing.provisionRelay', {
reqId: journal.metadata.installReqId,
newResumeTokenHash: journal.metadata.pendingResumeTokenHash
})
)
)
const reconciled = await getRecoveryStatus(client, journal, 'invite')
assertCommitted(reconciled, installed)
await publishCommitted(journal, reconciled, dependencies)
return 'recovered'
}
} catch {
// Why: ambiguous pairing state advances only by credential priority and
// authoritative status; a transport failure never rewrites the journal.
} finally {
client?.close()
}
}
return 'deferred'
}
function recoveryCredentials(
journal: MobileRelayPairingJournal,
bundle: MobileRelayCredentialBundle | null,
now: number
): { kind: 'resume' | 'invite'; token: string }[] {
const credentials: { kind: 'resume' | 'invite'; token: string }[] = [
{ kind: 'resume', token: journal.secrets.pendingResumeToken }
]
if (bundle?.current.token && bundle.current.token !== journal.secrets.pendingResumeToken) {
credentials.push({ kind: 'resume', token: bundle.current.token })
}
if (journal.metadata.relay.inviteExpiresAt > now) {
credentials.push({ kind: 'invite', token: journal.secrets.inviteToken })
}
return credentials
}
function createInviteClient(
journal: MobileRelayPairingJournal,
dependencies: RecoveryDependencies,
replaceJournal: (journal: MobileRelayPairingJournal) => void
): PairingCandidateClient {
return createRecoveringPairingRelayCandidate({
journal,
connect: (relay) =>
dependencies.connectRelay({
relay,
deviceToken: journal.secrets.deviceToken,
desktopPublicKeyB64: journal.metadata.host.publicKeyB64
}),
resolveDirector: (relay) => dependencies.resolveInviteDirector({ relay }),
persistMove: async (relay) => {
const next = {
...journal,
metadata: {
...journal.metadata,
relay: {
...journal.metadata.relay,
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch
}
}
}
await dependencies.updateJournal(journal.metadata.journalId, () => next.metadata)
replaceJournal(next)
},
now: dependencies.now
})
}
async function getRecoveryStatus(
client: PairingCandidateClient,
journal: MobileRelayPairingJournal,
kind: 'resume' | 'invite'
) {
return PairingGetEndpointsResultSchema.parse(
requireSuccess(
await client.sendRequest('pairing.getEndpoints', {
installReqId: journal.metadata.installReqId,
...(kind === 'resume' ? { resumeConfirmReqId: journal.metadata.resumeConfirmReqId } : {})
})
)
)
}
async function transitionToInviteAuthorization(
journal: MobileRelayPairingJournal,
dependencies: RecoveryDependencies
): Promise<MobileRelayPairingJournal> {
const next: MobileRelayPairingJournal = {
...journal,
metadata: {
...journal.metadata,
winner: 'relay',
authorizationMode: 'relay-basis'
}
}
// Why: the branch change becomes durable only after authoritative not-found.
await dependencies.updateJournal(journal.metadata.journalId, () => next.metadata)
return next
}
async function publishCommitted(
journal: MobileRelayPairingJournal,
endpoints: ReturnType<typeof PairingGetEndpointsResultSchema.parse>,
dependencies: RecoveryDependencies
): Promise<void> {
if (endpoints.installStatus?.state !== 'committed' || !endpoints.relay) {
throw new Error('relay pairing recovery was not committed')
}
const installed = endpoints.installStatus.result
const reconciledJournal: MobileRelayPairingJournal = {
...journal,
metadata: {
...journal.metadata,
winner: installed.authorizationMode === 'authenticated-direct' ? 'direct' : 'relay',
authorizationMode: installed.authorizationMode
}
}
if (journal.metadata.authorizationMode !== installed.authorizationMode) {
await dependencies.updateJournal(journal.metadata.journalId, () => reconciledJournal.metadata)
}
await dependencies.writeCredentialBundle(
promotePairingJournalCredential({ journal: reconciledJournal, installed })
)
await dependencies.saveHost(relayHost(reconciledJournal, endpoints.relay))
await dependencies.clearJournal(journal.metadata.journalId)
}
function relayHost(journal: MobileRelayPairingJournal, relay: MobileRelayEndpoint): HostProfile {
const host = journal.metadata.host
const url = new URL(relay.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return {
...host,
deviceToken: journal.secrets.deviceToken,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: host.endpoint },
{ id: 'relay-primary', kind: 'relay', url: url.toString() }
],
relayHostId: relay.relayHostId,
relay
}
}
function pairingRelay(journal: MobileRelayPairingJournal): PairingRelay {
return { ...journal.metadata.relay, inviteToken: journal.secrets.inviteToken }
}
function requireSuccess(response: RpcResponse): unknown {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
}
function assertCommitted(
endpoints: ReturnType<typeof PairingGetEndpointsResultSchema.parse>,
installed: DeviceCredentialInstalled
): void {
if (
endpoints.installStatus?.state !== 'committed' ||
JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed)
) {
throw new Error('relay pairing recovery install was not authoritatively committed')
}
}
/** Test-only: clear the startup single-flight between cases. */
export function resetMobileRelayPairingRecoveryForTests(): void {
recoveryPromise = null
}
@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fakes = vi.hoisted(() => ({
channelOptions: null as null | {
onAuthenticated(): void
onText(value: string): void
},
start: vi.fn(),
handleMessage: vi.fn(),
sendText: vi.fn(() => true),
dispose: vi.fn()
}))
vi.mock('./mobile-e2ee-v2-client-session', () => ({
MobileE2EEV2ClientSession: { create: vi.fn(() => ({ hello: {} })) }
}))
vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
MobileE2EEV2PhysicalChannel: class {
constructor(options: NonNullable<typeof fakes.channelOptions>) {
fakes.channelOptions = options
}
start = fakes.start
handleMessage = fakes.handleMessage
sendText = fakes.sendText
dispose = fakes.dispose
}
}))
import { connectMobileRelayForPairing, RelayOuterError } from './mobile-relay-physical-client'
class FakeSocket {
readonly OPEN = 1
readyState = 1
bufferedAmount = 0
sent: unknown[] = []
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number }) => void) | null = null
send(value: unknown): void {
this.sent.push(value)
}
close(): void {}
receive(data: unknown): void {
this.onmessage?.({ data })
}
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: Date.now() + 300_000,
e2eeFraming: 2 as const
}
describe('mobile relay physical pairing client', () => {
beforeEach(() => {
vi.clearAllMocks()
fakes.channelOptions = null
fakes.sendText.mockReturnValue(true)
})
it('uses first-frame outer auth, waits for host attach, then carries RPC over E2EE v2', async () => {
const socket = new FakeSocket()
let openedUrl = ''
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: (url) => {
openedUrl = url
return socket as unknown as WebSocket
}
})
socket.onopen?.()
expect(openedUrl).toBe('wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9')
expect(openedUrl).not.toContain('?')
expect(JSON.parse(socket.sent[0] as string)).toEqual({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: relay.inviteToken
})
socket.receive(
JSON.stringify({
type: 'relay-hello',
ok: true,
credentialKind: 'invite',
leaseExpiresAt: Date.now() + 60_000
})
)
await vi.waitFor(() => expect(fakes.start).toHaveBeenCalledOnce())
fakes.channelOptions!.onAuthenticated()
const responsePromise = client.sendRequest('status.get')
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string)
expect(request).toEqual({
id: 'relay-pair-1',
deviceToken: 'device-token',
method: 'status.get'
})
fakes.channelOptions!.onText(
JSON.stringify({
id: request.id,
ok: true,
result: { path: 'relay' },
_meta: { runtimeId: 'runtime-1' }
})
)
await expect(responsePromise).resolves.toMatchObject({ ok: true, result: { path: 'relay' } })
})
it('surfaces a typed endpoint-scoped outer rejection before E2EE', async () => {
const socket = new FakeSocket()
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: () => socket as unknown as WebSocket
})
const status = client.sendRequest('status.get')
socket.receive(JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 }))
await expect(status).rejects.toEqual(new RelayOuterError(4404))
expect(fakes.start).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,182 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { RelayPhoneHelloSchema } from '../../../src/shared/mobile-relay-phone-protocol'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { isRpcResponse } from './rpc-response-shape'
import type { RpcResponse } from './types'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
export { RelayOuterError } from './mobile-relay-e2ee-link'
import { RelayOuterError } from './mobile-relay-e2ee-link'
type PendingRequest = {
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
export type PairingCandidateClient = {
sendRequest(method: string, params?: unknown): Promise<RpcResponse>
close(): void
}
export function connectMobileRelayForPairing(args: {
relay: PairingRelay
deviceToken: string
desktopPublicKeyB64: string
credential?: string
expectedCredentialKind?: 'invite' | 'resume'
requestTimeoutMs?: number
createSocket?: (url: string) => WebSocket
}): PairingCandidateClient {
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
const socketUrl = relayPhoneWebSocketUrl(args.relay)
const socket = (args.createSocket ?? ((url) => new WebSocket(url)))(socketUrl)
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: args.desktopPublicKeyB64,
transport: 'relay',
relayHostId: args.relay.relayHostId
})
const pending = new Map<string, PendingRequest>()
let requestCounter = 0
let closed = false
let outerReady = false
let authenticated = false
let resolveAuthenticated!: () => void
let rejectAuthenticated!: (error: Error) => void
const authenticatedPromise = new Promise<void>((resolve, reject) => {
resolveAuthenticated = resolve
rejectAuthenticated = reject
})
const channel = new MobileE2EEV2PhysicalChannel({
session,
socket,
deviceToken: args.deviceToken,
decodeBinary: websocketPayloadToUint8,
onAuthenticated: () => {
authenticated = true
resolveAuthenticated()
},
onText: (plaintext) => {
let value: unknown
try {
value = JSON.parse(plaintext)
} catch {
return
}
if (!isRpcResponse(value)) {
return
}
const request = pending.get(value.id)
if (request) {
clearTimeout(request.timer)
pending.delete(value.id)
request.resolve(value)
}
},
onBinary: () => {},
onError: fail
})
socket.onopen = () => {
socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: args.credential ?? args.relay.inviteToken
})
)
}
let inboundChain: Promise<void> = Promise.resolve()
socket.onmessage = (event) => {
inboundChain = inboundChain
.then(async () => {
if (closed) {
return
}
if (!outerReady) {
acceptRelayHello(event.data)
return
}
await channel.handleMessage(event.data)
})
.catch((error: unknown) => fail(asError(error)))
}
socket.onerror = () => fail(new Error('relay transport error'))
socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006))
function acceptRelayHello(raw: unknown): void {
if (typeof raw !== 'string') {
throw new Error('expected plaintext relay hello')
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
throw new Error('invalid relay hello JSON')
}
const parsed = RelayPhoneHelloSchema.safeParse(value)
if (!parsed.success) {
throw new Error('invalid relay hello')
}
if (!parsed.data.ok) {
throw new RelayOuterError(parsed.data.code)
}
if (parsed.data.credentialKind !== (args.expectedCredentialKind ?? 'invite')) {
throw new Error('relay credential resolved as an unexpected credential kind')
}
outerReady = true
channel.start()
}
function fail(error: Error): void {
if (closed) {
return
}
closed = true
channel.dispose()
rejectAuthenticated(error)
for (const request of pending.values()) {
clearTimeout(request.timer)
request.reject(error)
}
pending.clear()
socket.close()
}
return {
async sendRequest(method, params) {
await authenticatedPromise
if (closed || !authenticated) {
throw new Error('relay pairing client closed')
}
const id = `relay-pair-${++requestCounter}`
return new Promise<RpcResponse>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id)
reject(new Error(`relay pairing RPC timed out: ${method}`))
}, requestTimeoutMs)
pending.set(id, { resolve, reject, timer })
if (
!channel.sendText(JSON.stringify({ id, deviceToken: args.deviceToken, method, params }))
) {
clearTimeout(timer)
pending.delete(id)
reject(new Error('relay E2EE channel not ready'))
}
})
},
close: () => fail(new Error('relay pairing client closed'))
}
}
export function relayPhoneWebSocketUrl(relay: PairingRelay): string {
const url = new URL(relay.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-old.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
describe('mobile relay resume director', () => {
it('uses a bounded POST body and never puts the bearer in the URL', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({
v: 1,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8,
leaseExpiresAt: Date.now() + 60_000
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
await expect(
resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl })
).resolves.toMatchObject({ cellUrl: 'https://relay-c2.onorca.dev', assignmentEpoch: 8 })
const [url, init] = fetchImpl.mock.calls[0]!
expect(url).toBe('https://relay.onorca.dev/v1/resolve')
expect(url).not.toContain('A'.repeat(43))
expect(init).toMatchObject({ method: 'POST' })
expect(JSON.parse(init!.body as string)).toEqual({
v: 1,
relayHostId: relay.relayHostId,
resumeToken: 'A'.repeat(43)
})
})
it('rejects non-canonical targets and oversized bodies', async () => {
const badTarget = vi.fn(
async () =>
new Response(
JSON.stringify({
v: 1,
cellUrl: 'http://relay-c2.onorca.dev',
assignmentEpoch: 8,
leaseExpiresAt: 1
})
)
)
await expect(
resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: badTarget })
).rejects.toThrow()
const oversized = vi.fn(
async () =>
new Response('x'.repeat(16 * 1024 + 1), { headers: { 'content-length': '16385' } })
)
await expect(
resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: oversized })
).rejects.toThrow(/too large/)
})
})
@@ -0,0 +1,63 @@
import { z } from 'zod'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
const MAX_RESPONSE_BYTES = 16 * 1024
const ResolveResponseSchema = z
.object({
v: z.literal(1),
cellUrl: z.string().refine(isCanonicalHttpsOrigin),
assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
leaseExpiresAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
})
.strict()
export async function resolveMobileRelayEndpoint(args: {
relay: MobileRelayEndpoint
resumeToken: string
fetchImpl?: typeof fetch
timeoutMs?: number
}): Promise<MobileRelayEndpoint> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), args.timeoutMs ?? 5000)
try {
const url = new URL('/v1/resolve', args.relay.directorUrl)
const response = await (args.fetchImpl ?? fetch)(url.toString(), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
v: 1,
relayHostId: args.relay.relayHostId,
resumeToken: args.resumeToken
}),
signal: controller.signal
})
if (!response.ok) {
throw new Error(`relay director resolve failed (${response.status})`)
}
const declaredLength = Number(response.headers.get('content-length') ?? 0)
if (declaredLength > MAX_RESPONSE_BYTES) {
throw new Error('relay director resolve response too large')
}
const raw = await response.text()
if (new TextEncoder().encode(raw).byteLength > MAX_RESPONSE_BYTES) {
throw new Error('relay director resolve response too large')
}
const resolved = ResolveResponseSchema.parse(JSON.parse(raw) as unknown)
return {
...args.relay,
cellUrl: resolved.cellUrl,
assignmentEpoch: resolved.assignmentEpoch
}
} finally {
clearTimeout(timer)
}
}
function isCanonicalHttpsOrigin(value: string): boolean {
try {
const parsed = new URL(value)
return parsed.protocol === 'https:' && parsed.origin === value
} catch {
return false
}
}
@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
BrowserScreencastOpcode,
encodeBrowserScreencastFrame
} from '../../../src/shared/browser-screencast-protocol'
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
const fakes = vi.hoisted(() => ({
linkOptions: null as null | {
endpoint: { cellUrl: string; relayHostId: string }
credential: string
expectedCredentialKind: string
onHello(value: unknown): void
onAuthenticated(): void
onText(value: string): void
onBinary(value: Uint8Array): void
onError(error: Error): void
},
sendText: vi.fn(() => true),
close: vi.fn()
}))
vi.mock('./mobile-relay-e2ee-link', () => ({
MobileRelayE2eeLink: class {
constructor(options: NonNullable<typeof fakes.linkOptions>) {
fakes.linkOptions = options
}
sendText = fakes.sendText
close = fakes.close
}
}))
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
function openSession() {
return connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
resumeCredentialVersion: 3,
resumeConfirmReqId: 'confirm-1',
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 1000
})
}
async function authenticateSession() {
const session = openSession()
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
credentialKind: 'resume',
leaseExpiresAt: Date.now() + 60_000,
acceptedCredentialVersion: 3,
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
expect(session.getState()).toBe('handshaking')
fakes.linkOptions!.onAuthenticated()
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as {
id: string
method: string
params: unknown
}
fakes.linkOptions!.onText(
JSON.stringify({
id: request.id,
ok: true,
result: {
v: 1,
relay,
resumeConfirmation: {
v: 1,
reqId: 'confirm-1',
currentVersion: 3,
acceptedAs: 'current',
renewed: true,
resumeExpiresAt: Date.now() + 300_000
}
},
_meta: { runtimeId: 'runtime-1' }
})
)
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
fakes.sendText.mockClear()
return { session, confirmationRequest: request }
}
describe('mobile relay RPC session', () => {
beforeEach(() => {
vi.clearAllMocks()
fakes.linkOptions = null
fakes.sendText.mockReturnValue(true)
})
it('requires exact resume observations and confirms by request ID before becoming connected', async () => {
const { session, confirmationRequest } = await authenticateSession()
expect(fakes.linkOptions).toMatchObject({
endpoint: relay,
credential: 'resume-secret',
expectedCredentialKind: 'resume'
})
expect(confirmationRequest).toMatchObject({
method: 'pairing.getEndpoints',
params: { resumeConfirmReqId: 'confirm-1' },
deviceToken: 'device-token'
})
expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId')
expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion')
expect(session.getLeaseExpiresAt()).toEqual(expect.any(Number))
})
it('rejects a mismatched outer credential version and closes the physical link', () => {
const session = openSession()
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
credentialKind: 'resume',
leaseExpiresAt: Date.now() + 60_000,
acceptedCredentialVersion: 2,
acceptedAs: 'grace',
resumeExpiresAt: Date.now() + 300_000
})
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
expect(fakes.sendText).not.toHaveBeenCalled()
})
it('routes terminal and browser binary streams after confirmation', async () => {
const { session } = await authenticateSession()
const terminalListener = vi.fn()
session.subscribe('terminal.subscribe', { terminal: 'term-1' }, terminalListener)
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const terminalRequest = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as {
id: string
}
fakes.linkOptions!.onText(
JSON.stringify({
id: terminalRequest.id,
ok: true,
result: { streamId: 42 },
_meta: { runtimeId: 'runtime-1' }
})
)
fakes.linkOptions!.onBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId: 42,
seq: 1,
payload: new TextEncoder().encode('hello')
})
)
expect(terminalListener).toHaveBeenLastCalledWith({
type: 'data',
streamId: 42,
chunk: 'hello'
})
fakes.sendText.mockClear()
const onBinaryFrame = vi.fn()
session.subscribe('browser.screencast', {}, vi.fn(), { onBinaryFrame })
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const browserRequest = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as { id: string }
fakes.linkOptions!.onText(
JSON.stringify({
id: browserRequest.id,
ok: true,
result: { subscriptionId: 'browser-1' },
_meta: { runtimeId: 'runtime-1' }
})
)
fakes.linkOptions!.onBinary(
encodeBrowserScreencastFrame({
opcode: BrowserScreencastOpcode.Frame,
seq: 9,
format: 'jpeg',
metadata: { imageWidth: 800 },
image: new Uint8Array([1, 2, 3])
})
)
expect(onBinaryFrame).toHaveBeenCalledWith(
expect.objectContaining({ seq: 9, format: 'jpeg', image: new Uint8Array([1, 2, 3]) })
)
})
it('rejects pending RPC work when the physical link fails', async () => {
const { session } = await authenticateSession()
const pending = session.sendRequest('status.get')
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
fakes.linkOptions!.onError(new Error('relay transport error'))
await expect(pending).rejects.toThrow('relay transport error')
expect(session.getState()).toBe('disconnected')
})
})
@@ -0,0 +1,254 @@
import {
PairingGetEndpointsResultSchema,
type DeviceResumeConfirmed,
type MobileRelayEndpoint
} from '../../../src/shared/mobile-relay-credential-contract'
import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link'
import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import { isRpcResponse } from './rpc-response-shape'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, RpcResponse } from './types'
type PendingRequest = {
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
export type MobileRelayRpcSession = RpcClient & {
getLeaseExpiresAt(): number | null
getResumeConfirmation(): DeviceResumeConfirmed | null
getFailure(): Error | null
}
export function connectMobileRelayRpcSession(args: {
relay: MobileRelayEndpoint
resumeToken: string
resumeCredentialVersion: number
resumeConfirmReqId: string
deviceToken: string
desktopPublicKeyB64: string
requestTimeoutMs?: number
createSocket?: (url: string) => WebSocket
}): MobileRelayRpcSession {
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
const pending = new Map<string, PendingRequest>()
const stateListeners = new Set<(state: ConnectionState) => void>()
let state: ConnectionState = 'connecting'
let requestCounter = 0
let lastConnectedAt: number | null = null
let leaseExpiresAt: number | null = null
let resumeConfirmation: DeviceResumeConfirmed | null = null
let failure: Error | null = null
let closed = false
const streams = new MobileRelayRpcStreams({
nextId,
sendFrame,
waitForConnected: () => waitForConnected()
})
const link = new MobileRelayE2eeLink({
endpoint: args.relay,
credential: args.resumeToken,
expectedCredentialKind: 'resume',
deviceToken: args.deviceToken,
desktopPublicKeyB64: args.desktopPublicKeyB64,
createSocket: args.createSocket,
onHello: (hello) => {
if (
hello.credentialKind !== 'resume' ||
hello.acceptedCredentialVersion !== args.resumeCredentialVersion
) {
fail(new Error('relay resume credential version mismatch'))
return
}
leaseExpiresAt = hello.leaseExpiresAt
publishState('handshaking')
},
onAuthenticated: () => void confirmResume(),
onText: handleText,
onBinary: handleBinary,
onError: fail
})
const client: MobileRelayRpcSession = {
async sendRequest(method, params, options) {
await waitForConnected(options?.timeoutMs)
return sendRpc(method, params, options?.timeoutMs)
},
subscribe(method, params, listener, options) {
if (closed) {
return () => {}
}
return streams.subscribe(method, params, listener, options)
},
updateTerminalSubscriptionViewport(terminal, viewport) {
streams.updateTerminalViewport(terminal, viewport)
},
getState: () => state,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => lastConnectedAt,
onStateChange(listener) {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
},
notifyForeground: () => {},
close() {
if (closed) {
return
}
closed = true
link.close()
rejectPending(new Error('Client closed'))
streams.clear()
publishState('disconnected')
},
getLeaseExpiresAt: () => leaseExpiresAt,
getResumeConfirmation: () => resumeConfirmation,
getFailure: () => failure
}
return client
async function confirmResume(): Promise<void> {
try {
const response = await sendRpc(
'pairing.getEndpoints',
{ resumeConfirmReqId: args.resumeConfirmReqId },
requestTimeoutMs,
true
)
if (!response.ok) {
throw new Error(response.error.code)
}
const result = PairingGetEndpointsResultSchema.parse(response.result)
if (!result.resumeConfirmation || result.relay?.relayHostId !== args.relay.relayHostId) {
throw new Error('relay resume confirmation missing')
}
resumeConfirmation = result.resumeConfirmation
lastConnectedAt = Date.now()
publishState('connected')
} catch (error) {
fail(asError(error))
}
}
function sendRpc(
method: string,
params: unknown,
timeoutMs = requestTimeoutMs,
beforeConnected = false
): Promise<RpcResponse> {
if (closed || (!beforeConnected && state !== 'connected')) {
return Promise.reject(new Error('relay session not connected'))
}
const id = nextId()
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id)
reject(new Error(`relay RPC timed out: ${method}`))
}, timeoutMs)
pending.set(id, { resolve, reject, timer })
if (!sendFrame({ id, method, params })) {
clearTimeout(timer)
pending.delete(id)
reject(new Error('relay E2EE channel not ready'))
}
})
}
function sendFrame(request: { id: string; method: string; params?: unknown }): boolean {
return link.sendText(JSON.stringify({ ...request, deviceToken: args.deviceToken }))
}
function handleText(plaintext: string): void {
let value: unknown
try {
value = JSON.parse(plaintext)
} catch {
return
}
if (!isRpcResponse(value)) {
return
}
const request = pending.get(value.id)
if (request) {
clearTimeout(request.timer)
pending.delete(value.id)
request.resolve(value)
return
}
streams.handleResponse(value)
}
function handleBinary(bytes: Uint8Array): void {
streams.handleBinary(bytes)
}
function waitForConnected(timeoutMs = requestTimeoutMs): Promise<void> {
if (state === 'connected') {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null
const unsubscribe = client.onStateChange((next) => {
if (next === 'connected') {
finish()
resolve()
} else if (next === 'disconnected' || next === 'auth-failed') {
finish()
reject(new Error(`relay session ${next}`))
}
})
timer = setTimeout(() => {
finish()
reject(new Error('relay session connection timed out'))
}, timeoutMs)
function finish(): void {
if (timer) {
clearTimeout(timer)
}
unsubscribe()
}
})
}
function publishState(next: ConnectionState): void {
if (state === next) {
return
}
state = next
for (const listener of stateListeners) {
listener(next)
}
}
function fail(error: Error): void {
if (closed) {
return
}
closed = true
failure = error
link.close()
rejectPending(error)
publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected')
}
function rejectPending(error: Error): void {
for (const request of pending.values()) {
clearTimeout(request.timer)
request.reject(error)
}
pending.clear()
}
function nextId(): string {
return `relay-rpc-${++requestCounter}-${Date.now()}`
}
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
@@ -0,0 +1,165 @@
import { decodeBrowserScreencastFrame } from './browser-screencast-protocol'
import {
handleTerminalBinaryFrame,
type TerminalSnapshotState
} from './rpc-client-terminal-binary-frame'
import {
buildTerminalUnsubscribeParams,
updateTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
import type { RpcClient } from './rpc-client'
import type { RpcResponse, RpcSuccess } from './types'
type StreamRecord = {
method: string
params: unknown
listener: (result: unknown) => void
onBinaryFrame?: Parameters<RpcClient['subscribe']>[3] extends
| { onBinaryFrame?: infer Listener }
| undefined
? Listener
: never
streamIds: Set<number>
subscriptionId?: string
cancelled: boolean
}
type StreamManagerOptions = {
nextId: () => string
sendFrame: (request: { id: string; method: string; params?: unknown }) => boolean
waitForConnected: () => Promise<void>
}
export class MobileRelayRpcStreams {
private readonly streams = new Map<string, StreamRecord>()
private readonly terminalListeners = new Map<number, (result: unknown) => void>()
private readonly terminalSnapshots = new Map<number, TerminalSnapshotState>()
private activeBrowserStream: StreamRecord | null = null
constructor(private readonly options: StreamManagerOptions) {}
subscribe(
method: string,
params: unknown,
listener: (result: unknown) => void,
subscribeOptions?: Parameters<RpcClient['subscribe']>[3]
): () => void {
const id = this.options.nextId()
const stream: StreamRecord = {
method,
params,
listener,
onBinaryFrame: subscribeOptions?.onBinaryFrame,
streamIds: new Set(),
cancelled: false
}
this.streams.set(id, stream)
void this.options
.waitForConnected()
.then(() => {
if (!stream.cancelled && !this.options.sendFrame({ id, method, params: stream.params })) {
this.remove(id)
}
})
.catch(() => this.remove(id))
return () => this.cancel(id)
}
updateTerminalViewport(terminal: string, viewport: { cols: number; rows: number }): void {
updateTerminalSubscriptionViewport(this.streams.values(), terminal, viewport)
}
handleResponse(response: RpcResponse): boolean {
const stream = this.streams.get(response.id)
if (!stream) {
return false
}
if (!response.ok) {
this.remove(response.id)
return true
}
const result = (response as RpcSuccess).result
if (result && typeof result === 'object') {
const metadata = result as { subscriptionId?: unknown; streamId?: unknown; type?: unknown }
if (typeof metadata.subscriptionId === 'string') {
stream.subscriptionId = metadata.subscriptionId
}
if (typeof metadata.streamId === 'number') {
stream.streamIds.add(metadata.streamId)
this.terminalListeners.set(metadata.streamId, stream.listener)
}
if (stream.method === 'browser.screencast') {
this.activeBrowserStream = stream
}
if (metadata.type === 'end') {
stream.listener(result)
this.remove(response.id)
return true
}
}
if (!stream.cancelled) {
stream.listener(result)
}
return true
}
handleBinary(bytes: Uint8Array): void {
const browserFrame = decodeBrowserScreencastFrame(bytes)
if (browserFrame && this.activeBrowserStream?.onBinaryFrame) {
this.activeBrowserStream.onBinaryFrame(browserFrame)
return
}
handleTerminalBinaryFrame(bytes, {
terminalSnapshots: this.terminalSnapshots,
getListener: (streamId) => this.terminalListeners.get(streamId),
recordValidatedInboundTraffic: () => {}
})
}
clear(): void {
this.streams.clear()
this.terminalListeners.clear()
this.terminalSnapshots.clear()
this.activeBrowserStream = null
}
private cancel(id: string): void {
const stream = this.streams.get(id)
if (!stream || stream.cancelled) {
return
}
stream.cancelled = true
if (stream.method === 'terminal.subscribe') {
const params = buildTerminalUnsubscribeParams(stream.params)
if (params) {
this.options.sendFrame({
id: this.options.nextId(),
method: 'terminal.unsubscribe',
params
})
}
} else if (stream.subscriptionId) {
this.options.sendFrame({
id: this.options.nextId(),
method: stream.method.replace(/\.subscribe$/, '.unsubscribe'),
params: { subscriptionId: stream.subscriptionId }
})
}
this.remove(id)
}
private remove(id: string): void {
const stream = this.streams.get(id)
if (!stream) {
return
}
for (const streamId of stream.streamIds) {
this.terminalListeners.delete(streamId)
this.terminalSnapshots.delete(streamId)
}
if (this.activeBrowserStream === stream) {
this.activeBrowserStream = null
}
this.streams.delete(id)
}
}
@@ -0,0 +1,59 @@
import type { PairingCandidateClient } from './mobile-relay-physical-client'
export type PairingCandidate = {
path: 'direct' | 'relay'
client: PairingCandidateClient
}
export function racePairingCandidates(
candidates: readonly PairingCandidate[]
): Promise<PairingCandidate> {
return new Promise((resolve, reject) => {
const successes: PairingCandidate[] = []
let failures = 0
let settled = false
let selectionQueued = false
for (const candidate of candidates) {
void candidate.client.sendRequest('status.get').then(
(response) => {
if (!response.ok) {
failures++
rejectIfFinished()
return
}
successes.push(candidate)
if (selectionQueued) {
return
}
selectionQueued = true
// Why: defer one microtask so simultaneous successes are visible and
// direct deterministically wins the exact tie regardless of callback order.
queueMicrotask(() => {
if (settled) {
return
}
settled = true
const winner = successes.find(({ path }) => path === 'direct') ?? successes[0]!
for (const loser of candidates) {
if (loser !== winner) {
loser.client.close()
}
}
resolve(winner)
})
},
() => {
failures++
rejectIfFinished()
}
)
}
function rejectIfFinished(): void {
if (!settled && failures === candidates.length && successes.length === 0) {
settled = true
reject(new Error('direct and relay pairing paths both failed'))
}
}
})
}
@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from 'vitest'
import type { PairingCandidateClient } from './mobile-relay-physical-client'
import { RelayOuterError } from './mobile-relay-physical-client'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(length)
}))
const journal = {
metadata: {
v: 1,
journalId: 'pair-1',
offerFingerprint: 'A'.repeat(43),
host: {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
},
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteExpiresAt: 10_000,
e2eeFraming: 2
},
installReqId: 'install-1',
resumeConfirmReqId: 'confirm-1',
pendingResumeTokenHash: 'B'.repeat(43)
},
secrets: {
v: 1,
journalId: 'pair-1',
deviceToken: 'device-token',
inviteToken: 'C'.repeat(43),
pendingResumeToken: 'D'.repeat(43)
}
} satisfies MobileRelayPairingJournal
function client(
result: Promise<never> | Promise<ReturnType<typeof success>>
): PairingCandidateClient {
return { sendRequest: vi.fn(() => result), close: vi.fn() }
}
function success() {
return {
id: 'rpc-1',
ok: true as const,
result: { path: 'relay' },
_meta: { runtimeId: 'runtime-1' }
}
}
describe('recovering pairing relay candidate', () => {
it('persists a strictly-newer director move before retrying the target', async () => {
const events: string[] = []
const stale = client(Promise.reject(new RelayOuterError(4409)))
const target = client(Promise.resolve(success()))
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) => {
events.push(`connect:${relay.assignmentEpoch}`)
return connects++ === 0 ? stale : target
},
resolveDirector: async (relay) => ({
...relay,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
}),
persistMove: async (relay) => {
events.push(`persist:${relay.assignmentEpoch}`)
},
now: () => 1,
random: () => 0,
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(events).toEqual(['connect:7', 'persist:8', 'connect:8'])
expect(stale.close).toHaveBeenCalledOnce()
})
it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => {
const offline = client(Promise.reject(new RelayOuterError(4404)))
const resolveDirector = vi.fn()
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => offline,
resolveDirector,
persistMove: vi.fn(),
now: () => 1
})
await expect(candidate.sendRequest('status.get')).rejects.toEqual(new RelayOuterError(4404))
expect(resolveDirector).not.toHaveBeenCalled()
})
it.each([
['wrong cell', new RelayOuterError(4409)],
['planned drain', new RelayOuterError(4503)],
['opaque close', new RelayOuterError(1006)],
['HTTP 502', new Error('HTTP 502')],
['HTTP 503', new Error('HTTP 503')],
['HTTP 504', new Error('HTTP 504')],
['transport failure', new Error('relay transport error')]
])('uses the configured director after %s before E2EE', async (_name, failure) => {
const stale = client(Promise.reject(failure))
const target = client(Promise.resolve(success()))
const resolveDirector = vi.fn(async (relay) => ({
...relay,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
}))
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => (connects++ === 0 ? stale : target),
resolveDirector,
persistMove: vi.fn(async () => {}),
now: () => 1,
random: () => 0,
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(resolveDirector).toHaveBeenCalledOnce()
})
it('bounds director recovery and applies full jitter to failures and target retries', async () => {
const stale = client(Promise.reject(new Error('HTTP 503')))
const target = client(Promise.resolve(success()))
const resolveDirector = vi
.fn()
.mockRejectedValueOnce(new Error('HTTP 504'))
.mockRejectedValueOnce(new RelayOuterError(1006))
.mockImplementationOnce(async (relay) => ({
...relay,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
}))
const sleep = vi.fn(async () => {})
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => (connects++ === 0 ? stale : target),
resolveDirector,
persistMove: vi.fn(async () => {}),
now: () => 1,
random: () => 0.5,
sleep,
maxRecoveryAttempts: 3
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(resolveDirector).toHaveBeenCalledTimes(3)
expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([50, 100, 200])
})
})
@@ -0,0 +1,90 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client'
export function createRecoveringPairingRelayCandidate(args: {
journal: MobileRelayPairingJournal
connect: (relay: PairingRelay) => PairingCandidateClient
resolveDirector: (relay: PairingRelay) => Promise<PairingRelay>
persistMove: (relay: PairingRelay) => Promise<void>
now: () => number
random?: () => number
sleep?: (delayMs: number) => Promise<void>
maxRecoveryAttempts?: number
}): PairingCandidateClient {
let relay = pairingRelayFromJournal(args.journal)
let client = args.connect(relay)
let closed = false
return {
async sendRequest(method, params) {
try {
return await client.sendRequest(method, params)
} catch (error) {
if (
method !== 'status.get' ||
closed ||
relay.inviteExpiresAt <= args.now() ||
!isDirectorRecoverable(error)
) {
throw error
}
return recoverThroughDirector(method, params, error)
}
},
close() {
closed = true
client.close()
}
}
async function recoverThroughDirector(method: string, params: unknown, initialError: unknown) {
const maxAttempts = args.maxRecoveryAttempts ?? 3
const random = args.random ?? Math.random
const sleep =
args.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)))
let lastError = initialError
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (closed || relay.inviteExpiresAt <= args.now()) {
throw lastError
}
try {
const moved = await args.resolveDirector(relay)
// Why: the authenticated newer assignment must be durable before a
// target dial so a crash cannot revert to the known-stale cell.
await args.persistMove(moved)
client.close()
relay = moved
const capMs = Math.min(2_000, 100 * 2 ** attempt)
await sleep(Math.floor(random() * (capMs + 1)))
if (closed) {
throw new Error('relay pairing client closed')
}
client = args.connect(relay)
return await client.sendRequest(method, params)
} catch (error) {
lastError = error
if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) {
throw error
}
const capMs = Math.min(2_000, 100 * 2 ** attempt)
await sleep(Math.floor(random() * (capMs + 1)))
}
}
throw lastError
}
}
function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {
return {
...journal.metadata.relay,
inviteToken: journal.secrets.inviteToken
}
}
function isDirectorRecoverable(error: unknown): boolean {
if (!(error instanceof RelayOuterError)) {
return true
}
return error.code === 4409 || error.code === 4503 || error.code === 1006
}
@@ -0,0 +1,159 @@
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WebSocket as NodeWebSocket, WebSocketServer } from 'ws'
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import {
connectMobileRelayForPairing,
type PairingCandidateClient
} from './mobile-relay-physical-client'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(length)
}))
const servers: Server[] = []
afterEach(async () => {
await Promise.all(
servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve())))
)
})
describe('served relay pairing recovery', () => {
it('uses the configured director after an HTTP 502 WebSocket upgrade response', async () => {
const cellUrl = await serveUpgradeFailure(502)
const journal = createJournal(cellUrl)
const resolvedRelay = {
...relayFromJournal(journal),
cellUrl: 'https://c2.relay-staging.onorca.dev',
assignmentEpoch: 8
}
const resolveDirector = vi.fn(async () => resolvedRelay)
const persistMove = vi.fn(async () => {})
const target = successfulClient()
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) => (relay.assignmentEpoch === 7 ? servedPhysicalClient(relay) : target),
resolveDirector,
persistMove,
now: () => 1,
random: () => 0,
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).resolves.toMatchObject({ ok: true })
expect(resolveDirector).toHaveBeenCalledOnce()
expect(persistMove).toHaveBeenCalledWith(resolvedRelay)
})
it('keeps a served 4404 host-offline close scoped to the failed endpoint', async () => {
const cellUrl = await serveCloseCode(4404)
const journal = createJournal(cellUrl)
const resolveDirector = vi.fn()
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: servedPhysicalClient,
resolveDirector,
persistMove: vi.fn(async () => {}),
now: () => 1
})
await expect(candidate.sendRequest('status.get')).rejects.toMatchObject({ code: 4404 })
expect(resolveDirector).not.toHaveBeenCalled()
})
})
function servedPhysicalClient(relay: PairingRelay): PairingCandidateClient {
return connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: Buffer.alloc(32, 7).toString('base64'),
createSocket: (url) => {
// Why: production cells require TLS; the black-box test only substitutes
// a loopback plaintext transport while preserving the served upgrade path.
return new NodeWebSocket(url.replace('wss:', 'ws:')) as unknown as WebSocket
}
})
}
function successfulClient(): PairingCandidateClient {
return {
sendRequest: async () => ({
id: 'rpc-1',
ok: true,
result: { path: 'relay' },
_meta: { runtimeId: 'runtime-1' }
}),
close: vi.fn()
}
}
async function serveUpgradeFailure(status: number): Promise<string> {
const server = createServer()
server.on('upgrade', (_request, socket) => {
socket.end(`HTTP/1.1 ${status} Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
})
return listen(server)
}
async function serveCloseCode(code: number): Promise<string> {
const server = createServer()
const sockets = new WebSocketServer({ server })
sockets.on('connection', (socket) => socket.close(code, 'host offline'))
return listen(server)
}
async function listen(server: Server): Promise<string> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
servers.push(server)
const address = server.address() as AddressInfo
return `http://127.0.0.1:${address.port}`
}
function createJournal(cellUrl: string): MobileRelayPairingJournal {
return {
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: Buffer.alloc(32, 7).toString('base64'),
lastConnected: 1
},
relay: {
v: 1,
directorUrl: 'https://relay-staging.onorca.dev',
cellUrl,
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteExpiresAt: 10_000,
e2eeFraming: 2
},
installReqId: 'install-1',
resumeConfirmReqId: 'confirm-1',
pendingResumeTokenHash: 'B'.repeat(43)
},
secrets: {
v: 1,
journalId: 'pair-1',
deviceToken: 'device-token',
inviteToken: 'C'.repeat(43),
pendingResumeToken: 'D'.repeat(43)
}
}
}
function relayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {
return {
...journal.metadata.relay,
inviteToken: journal.secrets.inviteToken
}
}
@@ -0,0 +1,330 @@
import { describe, expect, it, vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { racePairingCandidates } from './pairing-candidate-race'
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[]) {
const unavailableRelay = fakeClient([])
;(unavailableRelay.sendRequest as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('relay unavailable')
)
return {
connectDirect: vi.fn(() => (events.push('connect'), client)),
connectRelay: vi.fn(() => unavailableRelay),
resolveInviteDirector: vi.fn(async () => {
throw new Error('director unavailable')
}),
getNextHostName: vi.fn(async () => 'Blue Whale'),
saveHost: vi.fn(async (_host: HostProfile) => {
events.push('save-host')
}),
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('chooses direct when both post-E2EE status successes settle in the same turn', async () => {
let resolveDirect!: (response: RpcResponse) => void
let resolveRelay!: (response: RpcResponse) => void
const direct = fakeClient([])
const relay = fakeClient([])
;(direct.sendRequest as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise<RpcResponse>((resolve) => {
resolveDirect = resolve
})
)
;(relay.sendRequest as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise<RpcResponse>((resolve) => {
resolveRelay = resolve
})
)
const racing = racePairingCandidates([
{ path: 'direct', client: direct },
{ path: 'relay', client: relay }
])
resolveRelay(success({ path: 'relay' }))
resolveDirect(success({ path: 'direct' }))
await expect(racing).resolves.toMatchObject({ path: 'direct' })
expect(relay.close).toHaveBeenCalledOnce()
expect(direct.close).not.toHaveBeenCalled()
})
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('uses relay-basis provisioning when only the relay reaches post-E2EE status', async () => {
const direct = fakeClient([])
;(direct.sendRequest as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('LAN down'))
let journal: MobileRelayPairingJournal | null = null
const relay = {
sendRequest: vi.fn(async (method: string) => {
if (method === 'status.get') {
return success({ path: 'relay' })
}
const installed = {
v: 1 as const,
reqId: journal!.metadata.installReqId,
authorizationMode: 'relay-basis' 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(direct, [])
deps.connectRelay.mockReturnValue(relay)
deps.saveJournal.mockImplementation(async (value) => {
journal = value
})
const attempt = startPreProfilePairing({
offer: relayOffer,
timeoutMs: 5_000,
dependencies: deps
})
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
expect(direct.close).toHaveBeenCalled()
expect(relay.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', {
reqId: journal!.metadata.installReqId,
newResumeTokenHash: journal!.metadata.pendingResumeTokenHash
})
expect(deps.writeCredentialBundle).toHaveBeenCalledWith(
expect.objectContaining({ current: expect.objectContaining({ version: 1 }) })
)
})
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,301 @@
import { Platform } from 'react-native'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceCredentialInstalled,
type MobileRelayEndpoint
} from '../../../src/shared/mobile-relay-credential-contract'
import { connect, type ConnectOptions } 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'
import {
connectMobileRelayForPairing,
type PairingCandidateClient
} from './mobile-relay-physical-client'
import { racePairingCandidates, type PairingCandidate } from './pairing-candidate-race'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
export type PreProfilePairingAttempt = {
readonly result: Promise<{ hostId: string }>
readonly timedOut: boolean
dispose(): void
}
type Dependencies = {
connectDirect: typeof connect
connectRelay: typeof connectMobileRelayForPairing
resolveInviteDirector: typeof resolvePairingInviteThroughDirector
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,
connectRelay: connectMobileRelayForPairing,
resolveInviteDirector: resolvePairingInviteThroughDirector,
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<PairingCandidateClient>()
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<PairingCandidateClient>,
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 directClient = dependencies.connectDirect(
offer.endpoint,
offer.deviceToken,
offer.publicKeyB64,
connectOptions
)
clients.add(directClient)
const candidates: PairingCandidate[] = [{ path: 'direct', client: directClient }]
if (journal) {
const relayClient = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) =>
dependencies.connectRelay({
relay,
deviceToken: offer.deviceToken,
desktopPublicKeyB64: offer.publicKeyB64
}),
resolveDirector: (relay) => dependencies.resolveInviteDirector({ relay }),
persistMove: async (relay) => {
journal = {
...journal!,
metadata: {
...journal!.metadata,
relay: {
...journal!.metadata.relay,
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch
}
}
}
await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata)
},
now: dependencies.now
})
clients.add(relayClient)
candidates.push({ path: 'relay', client: relayClient })
}
const winner = await racePairingCandidates(candidates)
assertActive(isDisposed)
if (!journal) {
await dependencies.saveHost(baseHost(offer, hostId, hostName, now))
return { hostId }
}
journal = {
...journal,
metadata: {
...journal.metadata,
winner: winner.path,
authorizationMode: winner.path === 'direct' ? 'authenticated-direct' : 'relay-basis'
}
}
await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata)
const provision = await winner.client.sendRequest('pairing.provisionRelay', {
reqId: journal.metadata.installReqId,
newResumeTokenHash: journal.metadata.pendingResumeTokenHash
})
if (isMethodNotFound(provision)) {
if (winner.path !== 'direct') {
throw new Error('relay pairing RPC unavailable after relay path authentication')
}
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 winner.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')
}
}
@@ -0,0 +1,159 @@
import { describe, expect, it, vi } from 'vitest'
import type { ConnectionState, RpcResponse } from './types'
import type { RpcClient } from './rpc-client'
import {
createStableLogicalRpcClient,
LogicalClientCutoverError
} from './stable-logical-rpc-client'
class FakeSession implements RpcClient {
readonly sendRequest =
vi.fn<
(method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise<RpcResponse>
>()
readonly subscribe = vi.fn<RpcClient['subscribe']>()
readonly updateTerminalSubscriptionViewport =
vi.fn<RpcClient['updateTerminalSubscriptionViewport']>()
readonly notifyForeground = vi.fn()
readonly close = vi.fn()
private state: ConnectionState
private readonly stateListeners = new Set<(state: ConnectionState) => void>()
private readonly streamListeners = new Set<(result: unknown) => void>()
constructor(state: ConnectionState) {
this.state = state
this.subscribe.mockImplementation((_method, _params, listener) => {
this.streamListeners.add(listener)
return () => this.streamListeners.delete(listener)
})
}
getState = (): ConnectionState => this.state
getReconnectAttempt = (): number => 0
getLastConnectedAt = (): number | null => null
onStateChange = (listener: (state: ConnectionState) => void): (() => void) => {
this.stateListeners.add(listener)
return () => this.stateListeners.delete(listener)
}
setState(state: ConnectionState): void {
this.state = state
for (const listener of this.stateListeners) {
listener(state)
}
}
emitStream(value: unknown): void {
for (const listener of this.streamListeners) {
listener(value)
}
}
}
function success(value: unknown): RpcResponse {
return { id: 'rpc-1', ok: true, result: value, _meta: { runtimeId: 'runtime-1' } }
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('stable logical RPC client', () => {
it('makes before break, rejects in-flight work, and replays subscriptions', async () => {
const oldSession = new FakeSession('connected')
const nextSession = new FakeSession('connecting')
const pending = deferred<RpcResponse>()
oldSession.sendRequest.mockReturnValue(pending.promise)
nextSession.sendRequest.mockResolvedValue(success('next'))
const client = createStableLogicalRpcClient(oldSession, 'lan')
const stream = vi.fn()
client.subscribe('terminal.subscribe', { terminal: 'term-1' }, stream)
const request = client.sendRequest('worktree.create', { name: 'new' })
const migrating = client.migrateTo(nextSession, 'relay')
expect(oldSession.close).not.toHaveBeenCalled()
expect(nextSession.subscribe).not.toHaveBeenCalled()
nextSession.setState('connected')
await migrating
await expect(request).rejects.toBeInstanceOf(LogicalClientCutoverError)
expect(nextSession.subscribe).toHaveBeenCalledWith(
'terminal.subscribe',
{ terminal: 'term-1' },
expect.any(Function),
undefined
)
expect(oldSession.close).toHaveBeenCalledOnce()
expect(client.getActivePath()).toBe('relay')
expect(client.getGeneration()).toBe(2)
oldSession.emitStream('stale')
nextSession.emitStream('current')
expect(stream).toHaveBeenCalledOnce()
expect(stream).toHaveBeenCalledWith('current')
pending.resolve(success('late'))
})
it('keeps replies that commit before cutover and carries viewport state into replay', async () => {
const oldSession = new FakeSession('connected')
const nextSession = new FakeSession('connected')
oldSession.sendRequest.mockResolvedValue(success('old'))
const client = createStableLogicalRpcClient(oldSession, 'lan')
client.subscribe(
'terminal.subscribe',
{ terminal: 'term-1', viewport: { cols: 80, rows: 24 } },
vi.fn()
)
client.updateTerminalSubscriptionViewport('term-1', { cols: 120, rows: 40 })
await expect(client.sendRequest('status.get')).resolves.toEqual(success('old'))
await client.migrateTo(nextSession, 'relay')
expect(nextSession.subscribe).toHaveBeenCalledWith(
'terminal.subscribe',
{ terminal: 'term-1', viewport: { cols: 120, rows: 40 } },
expect.any(Function),
undefined
)
})
it('suspends one physical session and replays subscriptions on foreground replacement', async () => {
const oldSession = new FakeSession('connected')
const nextSession = new FakeSession('connected')
nextSession.sendRequest.mockResolvedValue(success('next'))
const client = createStableLogicalRpcClient(oldSession, 'relay')
client.subscribe('session.tabs.subscribe', { worktree: 'id:wt-1' }, vi.fn())
client.suspendActiveSession()
expect(oldSession.close).toHaveBeenCalledOnce()
expect(client.getState()).toBe('disconnected')
await expect(client.sendRequest('status.get')).rejects.toThrow('Client suspended')
await client.migrateTo(nextSession, 'relay')
expect(nextSession.subscribe).toHaveBeenCalledWith(
'session.tabs.subscribe',
{ worktree: 'id:wt-1' },
expect.any(Function),
undefined
)
await expect(client.sendRequest('status.get')).resolves.toEqual(success('next'))
})
it('closes a replacement that fails authentication and preserves the active session', async () => {
const oldSession = new FakeSession('connected')
const replacement = new FakeSession('connecting')
const client = createStableLogicalRpcClient(oldSession, 'lan')
const migrating = client.migrateTo(replacement, 'relay')
replacement.setState('auth-failed')
await expect(migrating).rejects.toThrow(/auth-failed/)
expect(replacement.close).toHaveBeenCalledOnce()
expect(oldSession.close).not.toHaveBeenCalled()
expect(client.getActivePath()).toBe('lan')
expect(client.getGeneration()).toBe(1)
})
})
@@ -0,0 +1,293 @@
import type { ConnectionState, RpcResponse } from './types'
import type { RpcClient } from './rpc-client'
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
export class LogicalClientCutoverError extends Error {
constructor() {
super('RPC interrupted by connection migration')
}
}
type SubscriptionRecord = {
method: string
params: unknown
listener: (result: unknown) => void
options?: Parameters<RpcClient['subscribe']>[3]
disposePhysical: (() => void) | null
cancelled: boolean
}
type PendingRequest = {
reject: (error: Error) => void
}
export type StableLogicalRpcClient = RpcClient & {
migrateTo(session: RpcClient, path: MobileConnectionPath, timeoutMs?: number): Promise<void>
suspendActiveSession(): void
getActivePath(): MobileConnectionPath
getGeneration(): number
}
export function createStableLogicalRpcClient(
initialSession: RpcClient,
initialPath: MobileConnectionPath
): StableLogicalRpcClient {
let activeSession = initialSession
let activePath = initialPath
let generation = 1
let closed = false
let suspended = false
let nextSubscriptionId = 0
let activeStateUnsubscribe: (() => void) | null = null
const subscriptions = new Map<number, SubscriptionRecord>()
const pendingRequests = new Set<PendingRequest>()
const stateListeners = new Set<(state: ConnectionState) => void>()
let state = initialSession.getState()
bindActiveState(initialSession, generation)
const logical: StableLogicalRpcClient = {
sendRequest(method, params, options) {
if (closed) {
return Promise.reject(new Error('Client closed'))
}
if (suspended) {
return Promise.reject(new Error('Client suspended'))
}
const requestGeneration = generation
const session = activeSession
return new Promise<RpcResponse>((resolve, reject) => {
const pending = { reject }
pendingRequests.add(pending)
void session.sendRequest(method, params, options).then(
(response) => {
pendingRequests.delete(pending)
if (closed) {
reject(new Error('Client closed'))
} else if (requestGeneration !== generation) {
reject(new LogicalClientCutoverError())
} else {
resolve(response)
}
},
(error: unknown) => {
pendingRequests.delete(pending)
reject(error)
}
)
})
},
subscribe(method, params, listener, options) {
if (closed) {
return () => {}
}
const id = ++nextSubscriptionId
const record: SubscriptionRecord = {
method,
params,
listener,
options,
disposePhysical: null,
cancelled: false
}
subscriptions.set(id, record)
if (!suspended) {
attachSubscription(record, activeSession, generation)
}
return () => {
if (record.cancelled) {
return
}
record.cancelled = true
record.disposePhysical?.()
record.disposePhysical = null
subscriptions.delete(id)
}
},
updateTerminalSubscriptionViewport(terminal, viewport) {
for (const record of subscriptions.values()) {
if (
record.params &&
typeof record.params === 'object' &&
'terminal' in record.params &&
record.params.terminal === terminal
) {
record.params = { ...record.params, viewport }
}
}
if (!suspended) {
activeSession.updateTerminalSubscriptionViewport(terminal, viewport)
}
},
getState: () => state,
getReconnectAttempt: () => activeSession.getReconnectAttempt(),
getLastConnectedAt: () => activeSession.getLastConnectedAt(),
onStateChange(listener) {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
},
notifyForeground: () => {
if (!suspended) {
activeSession.notifyForeground()
}
},
close() {
if (closed) {
return
}
closed = true
activeStateUnsubscribe?.()
activeStateUnsubscribe = null
for (const pending of pendingRequests) {
pending.reject(new Error('Client closed'))
}
pendingRequests.clear()
for (const record of subscriptions.values()) {
record.disposePhysical?.()
}
subscriptions.clear()
activeSession.close()
publishState('disconnected')
},
suspendActiveSession() {
if (closed || suspended) {
return
}
suspended = true
activeStateUnsubscribe?.()
activeStateUnsubscribe = null
for (const pending of pendingRequests) {
pending.reject(new Error('Client suspended'))
}
pendingRequests.clear()
for (const record of subscriptions.values()) {
record.disposePhysical?.()
record.disposePhysical = null
}
activeSession.close()
publishState('disconnected')
},
async migrateTo(nextSession, path, timeoutMs = 12_000) {
if (closed) {
nextSession.close()
throw new Error('Client closed')
}
try {
await waitForAuthenticated(nextSession, timeoutMs)
} catch (error) {
nextSession.close()
throw error
}
if (closed) {
nextSession.close()
throw new Error('Client closed')
}
const previous = activeSession
const previousStateUnsubscribe = activeStateUnsubscribe
const nextGeneration = generation + 1
// Why: replay on the authenticated replacement before closing the old
// session, but fence callbacks until the generation becomes current.
for (const record of subscriptions.values()) {
const disposePrevious = record.disposePhysical
attachSubscription(record, nextSession, nextGeneration)
disposePrevious?.()
}
generation = nextGeneration
activeSession = nextSession
activePath = path
suspended = false
previousStateUnsubscribe?.()
bindActiveState(nextSession, nextGeneration)
for (const pending of pendingRequests) {
pending.reject(new LogicalClientCutoverError())
}
pendingRequests.clear()
state = nextSession.getState()
for (const listener of stateListeners) {
listener(state)
}
previous.close()
},
getActivePath: () => activePath,
getGeneration: () => generation
}
return logical
function attachSubscription(
record: SubscriptionRecord,
session: RpcClient,
subscriptionGeneration: number
): void {
record.disposePhysical = session.subscribe(
record.method,
record.params,
(result) => {
if (!closed && !record.cancelled && generation === subscriptionGeneration) {
record.listener(result)
}
},
record.options
)
}
function bindActiveState(session: RpcClient, sessionGeneration: number): void {
activeStateUnsubscribe = session.onStateChange((next) => {
if (!closed && generation === sessionGeneration && session === activeSession) {
publishState(next)
}
})
}
function publishState(next: ConnectionState): void {
if (state === next) {
return
}
state = next
for (const listener of stateListeners) {
listener(next)
}
}
}
function waitForAuthenticated(session: RpcClient, timeoutMs: number): Promise<void> {
if (session.getState() === 'connected') {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
let settled = false
let timer: ReturnType<typeof setTimeout> | null = null
const unsubscribe = session.onStateChange((state) => {
if (state === 'connected') {
finish()
resolve()
} else if (state === 'auth-failed' || state === 'disconnected') {
finish()
reject(new Error(`replacement session ${state}`))
}
})
timer = setTimeout(() => {
finish()
reject(new Error('replacement session authentication timed out'))
}, timeoutMs)
function finish(): void {
if (settled) {
return
}
settled = true
if (timer) {
clearTimeout(timer)
}
unsubscribe()
}
})
}
+23 -12
View File
@@ -1,4 +1,17 @@
import { z } from 'zod'
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 }
export type RpcRequest = {
id: string
@@ -24,17 +37,6 @@ export type RpcFailure = {
export type RpcResponse = RpcSuccess | RpcFailure
const PAIRING_OFFER_VERSION = 2
export const PairingOfferSchema = z.object({
v: z.literal(PAIRING_OFFER_VERSION),
endpoint: z.string().min(1),
deviceToken: z.string().min(1),
publicKeyB64: z.string().min(1)
})
export type PairingOffer = z.infer<typeof PairingOfferSchema>
export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error'
export type ConnectionLogEntry = {
@@ -64,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({
@@ -72,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
+43 -2
View File
@@ -15,6 +15,8 @@ import {
migrateMobilePairingDataToCanonicalUserDataPath
} from './persistence'
import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store'
import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config'
import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths'
import { applyAppIcon } from './app-icon'
import { StatsCollector, initStatsPath } from './stats/collector'
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
@@ -41,6 +43,8 @@ import { resolveConsent } from './telemetry/consent'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { DesktopRelayService } from './runtime/relay/desktop-relay-service'
import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker'
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
import { ensureMainI18n, setMainUiLanguage } from './i18n/main-i18n'
@@ -215,6 +219,8 @@ let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null
let runtime: OrcaRuntimeService | null = null
let rateLimits: RateLimitService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
let desktopRelayService: DesktopRelayService | null = null
let desktopRelayStatus: RelayBrokerStatus = 'offline'
// Why: set during early startup; gates whether headless serve installs the
// offscreen browser backend (and thus advertises browser pane support).
let headlessBrowserDisplayAvailable = false
@@ -977,8 +983,11 @@ function openMainWindow(): BrowserWindow {
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
onBeforeRelaunch: async () => {
isQuitting = true
desktopRelayService?.fenceAndCloseNow()
await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
}
},
onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(),
onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow()
}
)
automations.setWebContents(window.webContents)
@@ -2109,7 +2118,7 @@ app.whenReady().then(async () => {
...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}),
webClientRoot: getBundledWebClientRoot()
})
registerMobileHandlers(runtimeRpc)
registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus })
startTerminalRuntimeStartupServices()
app.on('activate', requestDesktopActivation)
@@ -2215,6 +2224,36 @@ app.whenReady().then(async () => {
})
])
const cloudAuth = getOrcaCloudAuthConfig()
if (cloudAuth.configured) {
try {
const relayService = new DesktopRelayService({
authConfig: cloudAuth.config,
userDataPath: getProfileUserDataPath(),
appVersion: app.getVersion(),
runtimeRpc,
onStatus: (status) => {
desktopRelayStatus = status
mainWindow?.webContents.send('mobile:relayStatusChanged', status)
}
})
desktopRelayService = relayService
runtimeRpc.setMobileRelayPairingProvider({
createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId),
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item),
onDemandStateChanged: () => relayService.demandStateChanged(),
getEndpoints: (context, params) => relayService.getEndpoints(context, params),
provisionRelay: (context, params) => relayService.provisionRelay(context, params)
})
relayService.start()
} catch (error) {
console.warn(
'[relay] Desktop relay startup unavailable:',
error instanceof Error ? error.message : String(error)
)
}
}
// Why: the macOS notification permission dialog must fire after the window
// is visible and focused. If it fires before the window exists, the system
// dialog either doesn't appear or gets immediately covered by the maximized
@@ -2239,6 +2278,8 @@ app.on('before-quit', () => {
})
}
isQuitting = true
desktopRelayService?.fenceAndCloseNow()
runtimeRpc?.setMobileRelayPairingProvider(null)
unsubscribeSystemResumeBroadcast?.()
unsubscribeSystemResumeBroadcast = null
unsubscribeAgentAwakeStatusChanges?.()
+45 -5
View File
@@ -64,13 +64,13 @@ describe('registerMobileHandlers', () => {
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }],
utun4: [{ family: 'IPv4', internal: false, address: '100.102.47.57' }]
})
const createPairingOffer = vi.fn().mockReturnValue({
const createMobilePairingOffer = vi.fn().mockResolvedValue({
available: true,
pairingUrl: 'orca://pair#mobile',
endpoint: 'ws://100.102.47.57:6768',
deviceId: 'mobile-1'
})
const rpcServer = { createPairingOffer }
const rpcServer = { createMobilePairingOffer }
registerMobileHandlers(rpcServer as never)
@@ -81,14 +81,33 @@ describe('registerMobileHandlers', () => {
deviceId: 'mobile-1'
})
expect(createPairingOffer).toHaveBeenCalledWith({
expect(createMobilePairingOffer).toHaveBeenCalledWith({
address: '100.102.47.57',
connectionMode: undefined,
rotate: undefined,
name: expect.stringMatching(/^Mobile /),
scope: 'mobile'
name: expect.stringMatching(/^Mobile /)
})
})
it('forwards an explicit local-only pairing choice', async () => {
networkInterfacesMock.mockReturnValue({
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }]
})
const createMobilePairingOffer = vi.fn().mockResolvedValue({
available: true,
pairingUrl: 'orca://pair#local',
endpoint: 'ws://192.168.1.24:6768',
deviceId: 'mobile-local'
})
registerMobileHandlers({ createMobilePairingOffer } as never)
await handlers.get('mobile:getPairingQR')?.(null, { connectionMode: 'local-only' })
expect(createMobilePairingOffer).toHaveBeenCalledWith(
expect.objectContaining({ connectionMode: 'local-only' })
)
})
it('lists only paired mobile-scoped devices', () => {
const rpcServer = {
getDeviceRegistry: () => ({
@@ -229,6 +248,27 @@ describe('registerMobileHandlers', () => {
expect(revokeRuntimeAccess).toHaveBeenCalledWith('runtime-1')
})
it('awaits mobile device revocation before replying', async () => {
const revokeMobileDevice = vi.fn().mockResolvedValue(true)
const rpcServer = {
getDeviceRegistry: () => ({}),
revokeMobileDevice
}
registerMobileHandlers(rpcServer as never)
await expect(
handlers.get('mobile:revokeDevice')?.(null, { deviceId: 'mobile-1' })
).resolves.toEqual({ revoked: true })
expect(revokeMobileDevice).toHaveBeenCalledWith('mobile-1')
})
it('reports the current relay broker status without exposing a toggle', () => {
registerMobileHandlers({} as never, { getRelayStatus: () => 'registered' })
expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'registered' })
})
it('inspects and repairs the current packaged Windows websocket port', async () => {
const runPowerShell = vi
.fn()
+20 -6
View File
@@ -2,9 +2,11 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import { networkInterfaces } from 'node:os'
import QRCode from 'qrcode'
import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants'
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
import { isTailnetIPv4Address } from '../../shared/tailnet-address'
import type { DeviceEntry } from '../runtime/device-registry'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker'
import {
getWebSocketPort,
inspectWindowsMobileFirewall,
@@ -60,6 +62,7 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
export type MobileHandlerDependencies = {
firewallEnvironment?: WindowsMobileFirewallEnvironment
openWindowsNetworkSettings?: () => Promise<void>
getRelayStatus?: () => RelayBrokerStatus
}
export function registerMobileHandlers(
@@ -78,7 +81,14 @@ export function registerMobileHandlers(
ipcMain.handle(
'mobile:getPairingQR',
async (_event, args?: { address?: string; rotate?: boolean }) => {
async (
_event,
args?: {
address?: string
connectionMode?: MobilePairingConnectionMode
rotate?: boolean
}
) => {
// Why: allow the caller to specify which network interface address to
// embed in the QR code. This supports overlay networks (Tailscale,
// ZeroTier) where the default LAN IP isn't reachable from the phone.
@@ -94,11 +104,11 @@ export function registerMobileHandlers(
// `rotate: true` (explicit "Regenerate" intent because the prior token
// may have been exposed), we discard any pending token and mint a fresh
// one so the new QR carries a different credential.
const offer = rpcServer.createPairingOffer({
const offer = await rpcServer.createMobilePairingOffer({
address: ip,
connectionMode: args?.connectionMode,
rotate: args?.rotate,
name: `Mobile ${new Date().toLocaleDateString()}`,
scope: 'mobile'
name: `Mobile ${new Date().toLocaleDateString()}`
})
if (!offer.available) {
return { available: false as const }
@@ -187,12 +197,12 @@ export function registerMobileHandlers(
}
})
ipcMain.handle('mobile:revokeDevice', (_event, args: { deviceId: string }) => {
ipcMain.handle('mobile:revokeDevice', async (_event, args: { deviceId: string }) => {
const registry = rpcServer.getDeviceRegistry()
if (!registry) {
return { revoked: false }
}
return { revoked: rpcServer.revokeMobileDevice(args.deviceId) }
return { revoked: await rpcServer.revokeMobileDevice(args.deviceId) }
})
ipcMain.handle('mobile:revokeRuntimeAccess', (_event, args: { deviceId: string }) => {
@@ -234,6 +244,10 @@ export function registerMobileHandlers(
await openSettings()
return true
})
ipcMain.handle('mobile:getRelayStatus', () => ({
status: dependencies.getRelayStatus?.() ?? 'offline'
}))
}
function isWindowRenderer(event: IpcMainInvokeEvent): boolean {
+46 -8
View File
@@ -25,6 +25,10 @@ import {
seedNewOrcaProfileTelemetryConsent,
setActiveOrcaProfile
} from '../orca-profiles/profile-index-store'
import {
cloudSessionIdentity,
recordCloudSessionIdentityMutation
} from '../orca-profiles/profile-cloud-session-mutation'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope'
import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer'
@@ -42,6 +46,8 @@ import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members
type RegisterOrcaProfileHandlersOptions = {
onBeforeRelaunch?: () => void | Promise<void>
onAuthMutation?: () => void
onBeforeSignOut?: () => void
}
function profileIdFromArgs(args: unknown): string {
@@ -192,6 +198,17 @@ export function registerOrcaProfileHandlers(
return { status: 'already-active' }
}
const activeProfile = current.profiles.find(
(profile) => profile.id === current.activeProfileId
)
if (activeProfile?.cloud) {
// Why: profile selection changes the expected identity synchronously;
// stale refresh saves must fail even before relaunch teardown finishes.
recordCloudSessionIdentityMutation(
cloudSessionIdentity(activeProfile.id, activeProfile.cloud),
getProfileUserDataPath()
)
}
// Why: the current profile must be persisted before the global index
// points startup at the target profile.
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
@@ -248,8 +265,13 @@ export function registerOrcaProfileHandlers(
ipcMain.handle(
'orcaProfiles:connectCurrent',
async (): Promise<ConnectCurrentOrcaProfileResult> =>
connectCurrentOrcaProfile(getProfileUserDataPath())
async (): Promise<ConnectCurrentOrcaProfileResult> => {
const result = await connectCurrentOrcaProfile(getProfileUserDataPath())
if (result.status === 'connected') {
options.onAuthMutation?.()
}
return result
}
)
ipcMain.handle(
@@ -264,6 +286,7 @@ export function registerOrcaProfileHandlers(
)
if (result.status === 'created') {
seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry)
options.onAuthMutation?.()
}
return result
}
@@ -271,20 +294,35 @@ export function registerOrcaProfileHandlers(
ipcMain.handle(
'orcaProfiles:refreshAuth',
async (): Promise<RefreshCurrentOrcaProfileAuthResult> =>
refreshCurrentOrcaProfileAuth(getProfileUserDataPath())
async (): Promise<RefreshCurrentOrcaProfileAuthResult> => {
const result = await refreshCurrentOrcaProfileAuth(getProfileUserDataPath())
if (result.status === 'refreshed') {
options.onAuthMutation?.()
}
return result
}
)
ipcMain.handle(
'orcaProfiles:signOutCurrent',
async (): Promise<SignOutCurrentOrcaProfileResult> =>
signOutCurrentOrcaProfile(getProfileUserDataPath())
async (): Promise<SignOutCurrentOrcaProfileResult> => {
options.onBeforeSignOut?.()
return signOutCurrentOrcaProfile(getProfileUserDataPath())
}
)
ipcMain.handle(
'orcaProfiles:selectOrg',
async (_event, rawArgs: SelectOrcaProfileOrgArgs): Promise<SelectOrcaProfileOrgResult> =>
selectCurrentOrcaProfileOrg(getProfileUserDataPath(), orgIdFromUnknown(rawArgs))
async (_event, rawArgs: SelectOrcaProfileOrgArgs): Promise<SelectOrcaProfileOrgResult> => {
const result = await selectCurrentOrcaProfileOrg(
getProfileUserDataPath(),
orgIdFromUnknown(rawArgs)
)
if (result.status === 'selected') {
options.onAuthMutation?.()
}
return result
}
)
registerOrcaProfileOrgMemberHandlers()
+5 -1
View File
@@ -82,6 +82,8 @@ let registered = false
type CoreHandlerLifecycleOptions = {
onBeforeRelaunch?: () => void | Promise<void>
onOrcaProfileAuthMutation?: () => void
onBeforeOrcaProfileSignOut?: () => void
getAdditionalAiVaultCodexHomePaths?: () => readonly string[]
}
@@ -161,7 +163,9 @@ export function registerCoreHandlers(
}
registerTelemetryHandlers(store)
registerOrcaProfileHandlers(store, {
onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch
onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch,
onAuthMutation: lifecycleOptions.onOrcaProfileAuthMutation,
onBeforeSignOut: lifecycleOptions.onBeforeOrcaProfileSignOut
})
registerBrowserHandlers()
registerShellHandlers()
@@ -36,12 +36,34 @@ describe('Orca cloud auth config', () => {
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
relayTokenEndpoint: 'https://orca-cloud.example/v1/desktop/auth/relay-token',
relayDirectorUrl: 'https://relay.onorca.dev',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
})
})
it('uses first-party production endpoints without runtime env in packaged builds', () => {
expect(getOrcaCloudAuthConfig({}, true)).toEqual({
configured: true,
config: {
apiBaseUrl: 'https://login.onorca.dev',
authorizeEndpoint: 'https://login.onorca.dev/v1/desktop/auth/authorize',
sessionEndpoint: 'https://login.onorca.dev/v1/desktop/auth/session',
refreshEndpoint: 'https://login.onorca.dev/v1/desktop/auth/refresh',
capabilitiesEndpoint: 'https://login.onorca.dev/v1/desktop/auth/capabilities',
profileEndpoint: 'https://login.onorca.dev/v1/desktop/auth/profile',
orgEndpoint: 'https://login.onorca.dev/v1/desktop/auth/org',
logoutEndpoint: 'https://login.onorca.dev/v1/desktop/auth/logout',
relayTokenEndpoint: 'https://login.onorca.dev/v1/desktop/auth/relay-token',
relayDirectorUrl: 'https://relay.onorca.dev',
clientId: 'orca-desktop',
scope: 'openid profile email offline_access'
}
})
})
it('allows loopback HTTP endpoints for local desktop auth development', () => {
const state = getOrcaCloudAuthConfig({
ORCA_CLOUD_API_URL: 'http://localhost:4100',
@@ -9,11 +9,16 @@ export type OrcaCloudAuthConfig = {
profileEndpoint: string
orgEndpoint: string
logoutEndpoint: string
relayTokenEndpoint: string
relayDirectorUrl: string
clientId: string
scope: string
}
const DEFAULT_SCOPE = 'openid profile email offline_access'
const PRODUCTION_API_BASE_URL = 'https://login.onorca.dev'
const PRODUCTION_CLIENT_ID = 'orca-desktop'
const PRODUCTION_RELAY_DIRECTOR_URL = 'https://relay.onorca.dev'
// Why: packaged main bundles never define NODE_ENV, so packaged-ness is the
// only reliable production signal for gating dev-only auth escape hatches.
@@ -49,6 +54,15 @@ function endpoint(baseUrl: string, path: string): string {
return new URL(path, `${baseUrl}/`).toString()
}
function cleanOrigin(value: string | undefined, allowLoopbackHttp: boolean): string | null {
const cleaned = cleanUrl(value, allowLoopbackHttp)
if (!cleaned) {
return null
}
const parsed = new URL(cleaned)
return parsed.pathname === '/' && !parsed.search && !parsed.hash ? parsed.origin : null
}
export function getOrcaCloudAuthConfig(
env: NodeJS.ProcessEnv = process.env,
packaged: boolean = isPackagedOrcaBuild()
@@ -58,8 +72,15 @@ export function getOrcaCloudAuthConfig(
const allowLoopbackHttp = !packaged
const cleanEndpointUrl = (value: string | undefined): string | null =>
cleanUrl(value, allowLoopbackHttp)
const apiBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_API_URL)
const clientId = env.ORCA_CLOUD_CLIENT_ID?.trim()
const configuredApiBaseUrl = env.ORCA_CLOUD_API_URL?.trim()
// Why: packaged releases cannot depend on launch-time environment injection;
// these first-party endpoints and the public OAuth client ID are not secrets.
const apiBaseUrl = configuredApiBaseUrl
? cleanEndpointUrl(configuredApiBaseUrl)
: packaged
? PRODUCTION_API_BASE_URL
: null
const clientId = env.ORCA_CLOUD_CLIENT_ID?.trim() || (packaged ? PRODUCTION_CLIENT_ID : undefined)
if (!apiBaseUrl || !clientId) {
return {
configured: false,
@@ -92,6 +113,11 @@ export function getOrcaCloudAuthConfig(
logoutEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_LOGOUT_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/logout'),
relayTokenEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_RELAY_TOKEN_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/relay-token'),
relayDirectorUrl:
cleanOrigin(env.ORCA_RELAY_URL, allowLoopbackHttp) ?? PRODUCTION_RELAY_DIRECTOR_URL,
clientId,
scope: env.ORCA_CLOUD_AUTH_SCOPE?.trim() || DEFAULT_SCOPE
}
@@ -0,0 +1,89 @@
export const ORCA_CLOUD_CALLBACK_RESPONSE_HEADERS = {
'cache-control': 'no-store',
'content-security-policy':
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
'content-type': 'text/html; charset=utf-8',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff'
} as const
// Why: the loopback callback cannot load Orca's renderer bundle, so this
// standalone page mirrors its canonical light/dark tokens without external assets.
export const ORCA_CLOUD_CALLBACK_SUCCESS_PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Signed in to Orca</title>
<style>
:root {
--background: #fff;
--foreground: #0a0a0a;
--muted-foreground: #737373;
--border: #e5e5e5;
--success: #15803d;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #fafafa;
--muted-foreground: #a1a1a1;
--border: rgb(255 255 255 / 0.07);
--success: #86efac;
}
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
background: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0.01em;
}
main {
width: min(440px, calc(100% - 48px));
padding: 48px 0;
text-align: center;
}
.success-mark {
width: 44px;
height: 44px;
margin: 0 auto 20px;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--success);
}
.success-mark svg { width: 22px; height: 22px; }
h1 {
margin: 0;
font-size: 28px;
font-weight: 650;
line-height: 1.2;
letter-spacing: -0.02em;
}
p {
margin: 12px 0 0;
color: var(--muted-foreground);
font-size: 15px;
line-height: 1.5;
}
</style>
</head>
<body>
<main>
<div class="success-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m5 12 4 4L19 6"></path>
</svg>
</div>
<h1>Signed in to Orca</h1>
<p>You can close this tab and return to the app.</p>
</main>
</body>
</html>`
@@ -0,0 +1,110 @@
import type { RefreshCurrentOrcaProfileAuthResult } from '../../shared/orca-profiles'
import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config'
import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status'
import { refreshOrcaCloudCapabilities } from './profile-cloud-client'
import { linkOrcaProfileToCloud } from './profile-cloud-index'
import { ensureActiveOrcaProfile, getOrcaProfileListState } from './profile-index-store'
import { refreshDevOrcaCloudProfile } from './profile-cloud-dev-service'
import {
captureCloudSessionMutation,
cloudSessionIdentity,
recordCloudSessionIdentityMutationIfCurrent
} from './profile-cloud-session-mutation'
import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh'
import { readOrcaCloudSession, saveOrcaCloudSessionIfCurrent } from './profile-cloud-session-store'
export async function refreshCurrentOrcaProfileAuth(
userDataPath: string
): Promise<RefreshCurrentOrcaProfileAuthResult> {
const active = ensureActiveOrcaProfile(userDataPath)
const auth = () => getOrcaProfileAuthStatusFromProfile(active, userDataPath)
if (!active.profile.cloud) {
return { status: 'local', auth: auth() }
}
if (isOrcaCloudDevAuthEnabled()) {
const result = refreshDevOrcaCloudProfile(active, userDataPath)
if (result.status !== 'updated') {
return { status: 'reconnect-required', auth: auth() }
}
return {
status: 'refreshed',
auth: auth(),
activeProfileId: result.list.activeProfileId,
profiles: result.list.profiles
}
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured', auth: auth() }
}
try {
const identity = cloudSessionIdentity(active.profile.id, active.profile.cloud)
let mutationSnapshot = captureCloudSessionMutation(identity, userDataPath)
const operation = await runWithFreshOrcaCloudSession(
configState.config,
active,
userDataPath,
(session) => refreshOrcaCloudCapabilities(configState.config, session)
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required', auth: auth() }
}
const refresh = operation.value
if (refresh.cloud) {
const refreshedIdentity = cloudSessionIdentity(active.profile.id, refresh.cloud)
if (
refreshedIdentity.cloudUserId !== identity.cloudUserId ||
refreshedIdentity.cloudProfileId !== identity.cloudProfileId
) {
throw new Error('orca_cloud_identity_changed_during_capability_refresh')
}
if (refreshedIdentity.organizationId !== identity.organizationId) {
const advanced = recordCloudSessionIdentityMutationIfCurrent(
refreshedIdentity,
userDataPath,
mutationSnapshot
)
if (!advanced) {
return { status: 'reconnect-required', auth: auth() }
}
mutationSnapshot = advanced
}
}
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required', auth: auth() }
}
if (
saveOrcaCloudSessionIfCurrent(
active.profile.id,
userDataPath,
{
...session.session,
organizations: refresh.organizations ?? session.session.organizations,
capabilities: refresh.capabilities
},
mutationSnapshot
) === null
) {
return { status: 'reconnect-required', auth: auth() }
}
const list = refresh.cloud
? linkOrcaProfileToCloud(active.profile.id, refresh.cloud, userDataPath)
: getOrcaProfileListState(userDataPath)
return {
status: 'refreshed',
auth: getOrcaProfileAuthStatusFromProfile(
ensureActiveOrcaProfile(userDataPath),
userDataPath
),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
} catch (error) {
return {
status: 'failed',
auth: auth(),
error: error instanceof Error ? error.message : String(error)
}
}
}
@@ -20,6 +20,8 @@ const config: OrcaCloudAuthConfig = {
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
relayTokenEndpoint: 'https://orca-cloud.example/v1/desktop/auth/relay-token',
relayDirectorUrl: 'https://relay.example',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
@@ -21,6 +21,8 @@ const config: OrcaCloudAuthConfig = {
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
relayTokenEndpoint: 'https://orca-cloud.example/v1/desktop/auth/relay-token',
relayDirectorUrl: 'https://relay.example',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
@@ -0,0 +1,92 @@
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import {
OrcaCloudRequestError,
refreshOrcaCloudSession,
selectOrcaCloudOrg
} from './profile-cloud-client'
import { linkOrcaProfileToCloud } from './profile-cloud-index'
import type { ActiveOrcaProfileState } from './profile-index-store'
import {
cloudSessionIdentity,
recordCloudSessionIdentityMutation,
recordCloudSessionIdentityMutationIfCurrent
} from './profile-cloud-session-mutation'
import {
readOrcaCloudSession,
saveOrcaCloudSessionIfCurrent,
type OrcaCloudSession
} from './profile-cloud-session-store'
export async function selectCloudOrgWithMutationFence(input: {
config: OrcaCloudAuthConfig
active: ActiveOrcaProfileState
userDataPath: string
orgId: string
}): Promise<ReturnType<typeof linkOrcaProfileToCloud> | null> {
const cloud = input.active.profile.cloud
const stored = readOrcaCloudSession(input.active.profile.id, input.userDataPath)
if (!cloud || stored.status !== 'found') {
return null
}
const oldIdentity = cloudSessionIdentity(input.active.profile.id, cloud)
const targetIdentity = {
...oldIdentity,
organizationId: input.orgId
}
// Why: advance the durable identity fence before the first request. An old
// refresh may finish, but its compare-and-save can no longer publish.
const snapshot = recordCloudSessionIdentityMutation(targetIdentity, input.userDataPath)
let workingSession: OrcaCloudSession = stored.session
try {
let selected
try {
selected = await selectOrcaCloudOrg(input.config, workingSession, input.orgId)
} catch (error) {
if (!(error instanceof OrcaCloudRequestError) || error.statusCode !== 401) {
throw error
}
const refreshed = await refreshOrcaCloudSession(input.config, workingSession)
if (
refreshed.cloud.userId !== cloud.userId ||
refreshed.cloud.cloudProfileId !== cloud.cloudProfileId
) {
throw new Error('orca_cloud_identity_changed_during_org_selection')
}
workingSession = {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
expiresAt: refreshed.expiresAt,
organizations: refreshed.organizations,
capabilities: refreshed.capabilities
}
selected = await selectOrcaCloudOrg(input.config, workingSession, input.orgId)
}
if (
selected.cloud.userId !== cloud.userId ||
selected.cloud.cloudProfileId !== cloud.cloudProfileId ||
selected.cloud.activeOrgId !== input.orgId
) {
throw new Error('orca_cloud_org_selection_identity_mismatch')
}
const nextSession: OrcaCloudSession = {
...workingSession,
organizations: selected.organizations ?? workingSession.organizations,
capabilities: selected.capabilities
}
if (
saveOrcaCloudSessionIfCurrent(
input.active.profile.id,
input.userDataPath,
nextSession,
snapshot
) === null
) {
throw new Error('stale_cloud_session_mutation')
}
const list = linkOrcaProfileToCloud(input.active.profile.id, selected.cloud, input.userDataPath)
return list
} catch (error) {
recordCloudSessionIdentityMutationIfCurrent(oldIdentity, input.userDataPath, snapshot)
throw error
}
}
@@ -1,4 +1,5 @@
import { get } from 'node:http'
import type { IncomingHttpHeaders } from 'node:http'
import { describe, expect, it, beforeEach, vi } from 'vitest'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
@@ -16,6 +17,7 @@ import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce'
type HttpResponse = {
body: string
headers: IncomingHttpHeaders
statusCode: number | undefined
}
@@ -28,6 +30,8 @@ const config: OrcaCloudAuthConfig = {
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
relayTokenEndpoint: 'https://orca-cloud.example/v1/desktop/auth/relay-token',
relayDirectorUrl: 'https://relay.example',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
@@ -41,7 +45,7 @@ function readHttp(url: string): Promise<HttpResponse> {
body += chunk
})
response.on('end', () => {
resolve({ body, statusCode: response.statusCode })
resolve({ body, headers: response.headers, statusCode: response.statusCode })
})
})
request.on('error', reject)
@@ -91,6 +95,11 @@ describe('Orca cloud PKCE flow', () => {
const validResponse = await readHttp(callbackUrl(redirectUri, { code: 'real-code', state }))
expect(validResponse.statusCode).toBe(200)
expect(validResponse.headers['cache-control']).toBe('no-store')
expect(validResponse.headers['content-security-policy']).toContain("default-src 'none'")
expect(validResponse.body).toContain('<h1>Signed in to Orca</h1>')
expect(validResponse.body).toContain('You can close this tab and return to the app.')
expect(validResponse.body).not.toContain('class="brand"')
await expect(flow).resolves.toMatchObject({
code: 'real-code',
redirectUri,
+6 -2
View File
@@ -2,6 +2,10 @@ import { createHash, randomBytes } from 'node:crypto'
import { createServer, type Server, type ServerResponse } from 'node:http'
import { shell } from 'electron'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import {
ORCA_CLOUD_CALLBACK_RESPONSE_HEADERS,
ORCA_CLOUD_CALLBACK_SUCCESS_PAGE
} from './profile-cloud-callback-page'
export type OrcaCloudAuthorizationCode = {
code: string
@@ -102,8 +106,8 @@ export function beginOrcaCloudPkceFlow(
writeInvalidCallback(response)
return
}
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
response.end('<!doctype html><title>Orca</title><p>You can return to Orca.</p>')
response.writeHead(200, ORCA_CLOUD_CALLBACK_RESPONSE_HEADERS)
response.end(ORCA_CLOUD_CALLBACK_SUCCESS_PAGE)
resolveFlow(code)
} catch (error) {
rejectFlow(error instanceof Error ? error : new Error('orca_cloud_auth_callback_failed'))
+20 -86
View File
@@ -3,24 +3,21 @@ import type {
CreateCloudLinkedOrcaProfileArgs,
CreateCloudLinkedOrcaProfileResult,
OrcaProfileAuthStatus,
RefreshCurrentOrcaProfileAuthResult,
SelectOrcaProfileOrgResult,
SignOutCurrentOrcaProfileResult
} from '../../shared/orca-profiles'
import { ensureActiveOrcaProfile, getOrcaProfileListState } from './profile-index-store'
import { ensureActiveOrcaProfile } from './profile-index-store'
import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config'
import {
clearOrcaCloudSession,
readOrcaCloudSession,
saveOrcaCloudSession,
saveOrcaCloudSessionExchange
} from './profile-cloud-session-store'
import { cloudSessionIdentity, tombstoneCloudSession } from './profile-cloud-session-mutation'
import {
createOrcaCloudProfile,
exchangeOrcaCloudAuthCode,
refreshOrcaCloudCapabilities,
revokeOrcaCloudSession,
selectOrcaCloudOrg
revokeOrcaCloudSession
} from './profile-cloud-client'
import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce'
import {
@@ -32,10 +29,12 @@ import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh'
import {
connectDevOrcaCloudProfile,
createDevCloudLinkedOrcaProfile,
refreshDevOrcaCloudProfile,
selectDevOrcaCloudOrg
} from './profile-cloud-dev-service'
import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status'
import { selectCloudOrgWithMutationFence } from './profile-cloud-org-selection'
export { refreshCurrentOrcaProfileAuth } from './profile-cloud-capability-refresh'
function isUserCancelledAuthError(message: string): boolean {
return message === 'orca_cloud_auth_timeout' || message === 'orca_cloud_auth_denied'
@@ -110,6 +109,14 @@ export async function signOutCurrentOrcaProfile(
const active = ensureActiveOrcaProfile(userDataPath)
const configState = getOrcaCloudAuthConfig()
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (active.profile.cloud) {
// Why: persist the destructive fence before logout network I/O so a
// refresh already in flight cannot save after explicit sign-out.
tombstoneCloudSession(
cloudSessionIdentity(active.profile.id, active.profile.cloud),
userDataPath
)
}
if (!isOrcaCloudDevAuthEnabled() && configState.configured && session.status === 'found') {
await revokeOrcaCloudSession(configState.config, session.session).catch(() => undefined)
}
@@ -179,68 +186,6 @@ export async function createCloudLinkedOrcaProfile(
}
}
export async function refreshCurrentOrcaProfileAuth(
userDataPath: string
): Promise<RefreshCurrentOrcaProfileAuthResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (!active.profile.cloud) {
return { status: 'local', auth: activeAuth(active, userDataPath) }
}
if (isOrcaCloudDevAuthEnabled()) {
const result = refreshDevOrcaCloudProfile(active, userDataPath)
if (result.status !== 'updated') {
return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) }
}
return {
status: 'refreshed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: result.list.activeProfileId,
profiles: result.list.profiles
}
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured', auth: activeAuth(active, userDataPath) }
}
try {
const operation = await runWithFreshOrcaCloudSession(
configState.config,
active,
userDataPath,
(session) => refreshOrcaCloudCapabilities(configState.config, session)
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) }
}
const refresh = operation.value
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) }
}
saveOrcaCloudSession(active.profile.id, userDataPath, {
...session.session,
organizations: refresh.organizations ?? session.session.organizations,
capabilities: refresh.capabilities
})
const list = refresh.cloud
? linkOrcaProfileToCloud(active.profile.id, refresh.cloud, userDataPath)
: getOrcaProfileListState(userDataPath)
return {
status: 'refreshed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
} catch (error) {
return {
status: 'failed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
error: error instanceof Error ? error.message : String(error)
}
}
}
export async function selectCurrentOrcaProfileOrg(
userDataPath: string,
orgId: string
@@ -264,26 +209,15 @@ export async function selectCurrentOrcaProfileOrg(
return { status: 'unconfigured', auth: activeAuth(active, userDataPath) }
}
try {
const operation = await runWithFreshOrcaCloudSession(
configState.config,
const list = await selectCloudOrgWithMutationFence({
config: configState.config,
active,
userDataPath,
(session) => selectOrcaCloudOrg(configState.config, session, orgId)
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
const selected = operation.value
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
saveOrcaCloudSession(active.profile.id, userDataPath, {
...session.session,
organizations: selected.organizations ?? session.session.organizations,
capabilities: selected.capabilities
orgId
})
const list = linkOrcaProfileToCloud(active.profile.id, selected.cloud, userDataPath)
if (!list) {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
return {
status: 'selected',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
@@ -0,0 +1,62 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
captureCloudSessionMutation,
isCloudSessionMutationCurrent,
recordCloudSessionIdentityMutation,
recordSuccessfulCloudSessionLogin,
tombstoneCloudSession,
type CloudSessionIdentity
} from './profile-cloud-session-mutation'
describe('cloud session mutation fence', () => {
let userDataPath: string
const identity: CloudSessionIdentity = {
localProfileId: 'local-1',
cloudUserId: 'user-1',
cloudProfileId: 'profile-1',
organizationId: 'org-1'
}
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-session-mutation-'))
})
afterEach(() => rmSync(userDataPath, { recursive: true, force: true }))
it('invalidates a captured refresh before destructive sign-out', () => {
const snapshot = captureCloudSessionMutation(identity, userDataPath)
expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, snapshot)).toBe(
true
)
tombstoneCloudSession(identity, userDataPath)
expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, snapshot)).toBe(
false
)
})
it('clears only the matching tombstone after explicit successful login', () => {
tombstoneCloudSession(identity, userDataPath)
const login = recordSuccessfulCloudSessionLogin(identity, userDataPath)
expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, login)).toBe(true)
})
it('invalidates old work when the expected org changes without tombstoning either identity', () => {
const old = captureCloudSessionMutation(identity, userDataPath)
const next = recordCloudSessionIdentityMutation(
{ ...identity, organizationId: 'org-2' },
userDataPath
)
expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, old)).toBe(false)
expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, next)).toBe(true)
})
it('persists the fence across module-independent reads', () => {
const snapshot = recordSuccessfulCloudSessionLogin(identity, userDataPath)
expect(isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, snapshot)).toBe(
true
)
})
})
@@ -0,0 +1,175 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { writeSecureJsonFile } from '../../shared/secure-file'
import type { OrcaProfileCloudSummary } from '../../shared/orca-profiles'
import { getOrcaProfileDirectory } from './profile-storage-paths'
const MUTATION_STATE_VERSION = 1
export type CloudSessionIdentity = {
localProfileId: string
cloudUserId: string
cloudProfileId: string
organizationId: string
}
export type CloudSessionMutationSnapshot = {
epoch: number
identityKey: string
}
type CloudSessionMutationState = {
version: 1
epoch: number
expectedIdentityKey: string
tombstonedIdentityKeys: string[]
}
function identityKey(identity: CloudSessionIdentity): string {
return `${identity.localProfileId}\0${identity.cloudUserId}\0${identity.cloudProfileId}\0${identity.organizationId}`
}
function statePath(profileId: string, userDataPath: string): string {
return join(getOrcaProfileDirectory(profileId, userDataPath), 'account-session-mutation.json')
}
function isState(value: unknown): value is CloudSessionMutationState {
if (!value || typeof value !== 'object') {
return false
}
const candidate = value as Partial<CloudSessionMutationState>
return (
candidate.version === MUTATION_STATE_VERSION &&
Number.isSafeInteger(candidate.epoch) &&
Number(candidate.epoch) >= 0 &&
typeof candidate.expectedIdentityKey === 'string' &&
Array.isArray(candidate.tombstonedIdentityKeys) &&
candidate.tombstonedIdentityKeys.every((key) => typeof key === 'string')
)
}
function readState(profileId: string, userDataPath: string): CloudSessionMutationState | null {
const path = statePath(profileId, userDataPath)
if (!existsSync(path)) {
return null
}
try {
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
if (!isState(parsed)) {
throw new Error('invalid_cloud_session_mutation_state')
}
return parsed
} catch {
throw new Error('invalid_cloud_session_mutation_state')
}
}
function saveState(
profileId: string,
userDataPath: string,
state: CloudSessionMutationState
): void {
writeSecureJsonFile(statePath(profileId, userDataPath), state)
}
export function cloudSessionIdentity(
localProfileId: string,
cloud: OrcaProfileCloudSummary
): CloudSessionIdentity {
return {
localProfileId,
cloudUserId: cloud.userId,
cloudProfileId: cloud.cloudProfileId,
organizationId: cloud.activeOrgId ?? ''
}
}
export function captureCloudSessionMutation(
identity: CloudSessionIdentity,
userDataPath: string
): CloudSessionMutationSnapshot {
const key = identityKey(identity)
let state = readState(identity.localProfileId, userDataPath)
if (!state) {
state = {
version: MUTATION_STATE_VERSION,
epoch: 0,
expectedIdentityKey: key,
tombstonedIdentityKeys: []
}
saveState(identity.localProfileId, userDataPath, state)
}
return { epoch: state.epoch, identityKey: key }
}
export function recordSuccessfulCloudSessionLogin(
identity: CloudSessionIdentity,
userDataPath: string
): CloudSessionMutationSnapshot {
const key = identityKey(identity)
const previous = readState(identity.localProfileId, userDataPath)
const state: CloudSessionMutationState = {
version: MUTATION_STATE_VERSION,
epoch: (previous?.epoch ?? -1) + 1,
expectedIdentityKey: key,
tombstonedIdentityKeys: (previous?.tombstonedIdentityKeys ?? []).filter(
(candidate) => candidate !== key
)
}
saveState(identity.localProfileId, userDataPath, state)
return { epoch: state.epoch, identityKey: key }
}
export function recordCloudSessionIdentityMutation(
identity: CloudSessionIdentity,
userDataPath: string
): CloudSessionMutationSnapshot {
const key = identityKey(identity)
const previous = readState(identity.localProfileId, userDataPath)
const state: CloudSessionMutationState = {
version: MUTATION_STATE_VERSION,
epoch: (previous?.epoch ?? -1) + 1,
expectedIdentityKey: key,
tombstonedIdentityKeys: previous?.tombstonedIdentityKeys ?? []
}
saveState(identity.localProfileId, userDataPath, state)
return { epoch: state.epoch, identityKey: key }
}
export function tombstoneCloudSession(identity: CloudSessionIdentity, userDataPath: string): void {
const key = identityKey(identity)
const previous = readState(identity.localProfileId, userDataPath)
const tombstones = new Set(previous?.tombstonedIdentityKeys ?? [])
tombstones.add(key)
saveState(identity.localProfileId, userDataPath, {
version: MUTATION_STATE_VERSION,
epoch: (previous?.epoch ?? -1) + 1,
expectedIdentityKey: key,
tombstonedIdentityKeys: [...tombstones]
})
}
export function isCloudSessionMutationCurrent(
profileId: string,
userDataPath: string,
snapshot: CloudSessionMutationSnapshot
): boolean {
const state = readState(profileId, userDataPath)
return Boolean(
state &&
state.epoch === snapshot.epoch &&
state.expectedIdentityKey === snapshot.identityKey &&
!state.tombstonedIdentityKeys.includes(snapshot.identityKey)
)
}
export function recordCloudSessionIdentityMutationIfCurrent(
identity: CloudSessionIdentity,
userDataPath: string,
snapshot: CloudSessionMutationSnapshot
): CloudSessionMutationSnapshot | null {
if (!isCloudSessionMutationCurrent(identity.localProfileId, userDataPath, snapshot)) {
return null
}
return recordCloudSessionIdentityMutation(identity, userDataPath)
}
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type * as ProfileCloudClient from './profile-cloud-client'
import type { ActiveOrcaProfileState } from './profile-index-store'
const { readMock, saveIfCurrentMock, clearMock, refreshMock, linkMock } = vi.hoisted(() => ({
readMock: vi.fn(),
saveIfCurrentMock: vi.fn((): string | null => 'memory-only'),
clearMock: vi.fn(),
refreshMock: vi.fn(),
linkMock: vi.fn()
}))
vi.mock('./profile-cloud-session-store', () => ({
readOrcaCloudSession: readMock,
saveOrcaCloudSessionIfCurrent: saveIfCurrentMock,
clearOrcaCloudSession: clearMock
}))
vi.mock('./profile-cloud-session-mutation', () => ({
captureCloudSessionMutation: vi.fn(() => ({ epoch: 1, identityKey: 'identity' })),
cloudSessionIdentity: vi.fn((localProfileId, cloud) => ({
localProfileId,
cloudUserId: cloud.userId,
cloudProfileId: cloud.cloudProfileId,
organizationId: cloud.activeOrgId ?? ''
})),
tombstoneCloudSession: vi.fn()
}))
vi.mock('./profile-cloud-client', async (importOriginal) => {
const original = await importOriginal<typeof ProfileCloudClient>()
return { ...original, refreshOrcaCloudSession: refreshMock }
})
vi.mock('./profile-cloud-index', () => ({ linkOrcaProfileToCloud: linkMock }))
import { readFreshOrcaCloudSession } from './profile-cloud-session-refresh'
const config = {} as OrcaCloudAuthConfig
const active = {
profile: {
id: 'profile-1',
cloud: {
userId: 'user-1',
cloudProfileId: 'cloud-profile-1',
activeOrgId: 'org-1'
}
}
} as ActiveOrcaProfileState
const staleSession = {
accessToken: 'old-access',
refreshToken: 'one-use-refresh',
expiresAt: 1,
organizations: [],
capabilities: { flags: {}, refreshedAt: 1 }
}
describe('profile cloud session refresh', () => {
beforeEach(() => {
vi.clearAllMocks()
saveIfCurrentMock.mockReturnValue('memory-only')
readMock.mockReturnValue({ status: 'found', session: staleSession, persistence: 'memory-only' })
})
it('does not publish a refresh whose persistent mutation snapshot became stale', async () => {
let resolveRefresh!: (value: Record<string, unknown>) => void
refreshMock.mockReturnValue(new Promise((resolve) => (resolveRefresh = resolve)))
const refreshing = readFreshOrcaCloudSession(config, active, '/data')
saveIfCurrentMock.mockReturnValue(null)
resolveRefresh({
accessToken: 'stale-access',
refreshToken: 'stale-refresh',
expiresAt: Date.now() + 600_000,
organizations: [],
capabilities: { flags: { 'relay.use': true }, refreshedAt: 2 },
cloud: {
userId: 'user-1',
cloudProfileId: 'cloud-profile-1',
activeOrgId: 'org-1'
}
})
await expect(refreshing).rejects.toThrow('stale_cloud_session_mutation')
expect(linkMock).not.toHaveBeenCalled()
})
it('single-flights concurrent rotating refresh-token use per profile and store', async () => {
let resolveRefresh!: (value: Record<string, unknown>) => void
refreshMock.mockReturnValue(new Promise((resolve) => (resolveRefresh = resolve)))
const first = readFreshOrcaCloudSession(config, active, '/data')
const second = readFreshOrcaCloudSession(config, active, '/data')
expect(refreshMock).toHaveBeenCalledTimes(1)
resolveRefresh({
accessToken: 'new-access',
refreshToken: 'new-refresh',
expiresAt: Date.now() + 600_000,
organizations: [],
capabilities: { flags: { 'relay.use': true }, refreshedAt: 2 },
cloud: {
userId: 'user-1',
cloudProfileId: 'cloud-profile-1',
activeOrgId: 'org-1'
}
})
const [firstResult, secondResult] = await Promise.all([first, second])
expect(firstResult).toEqual(secondResult)
expect(saveIfCurrentMock).toHaveBeenCalledTimes(1)
expect(linkMock).toHaveBeenCalledTimes(1)
})
})
@@ -4,10 +4,15 @@ import {
clearOrcaCloudSession,
type OrcaCloudSession,
readOrcaCloudSession,
saveOrcaCloudSession
saveOrcaCloudSessionIfCurrent
} from './profile-cloud-session-store'
import { OrcaCloudRequestError, refreshOrcaCloudSession } from './profile-cloud-client'
import { linkOrcaProfileToCloud } from './profile-cloud-index'
import {
captureCloudSessionMutation,
cloudSessionIdentity,
tombstoneCloudSession
} from './profile-cloud-session-mutation'
const CLOUD_SESSION_REFRESH_SKEW_MS = 60_000
@@ -31,6 +36,12 @@ export function isOrcaCloudAuthFailure(error: unknown): boolean {
const inflightCloudSessionRefreshes = new Map<string, Promise<OrcaCloudSession>>()
class StaleCloudSessionMutationError extends Error {
constructor() {
super('stale_cloud_session_mutation')
}
}
function cloudSessionRefreshKey(profileId: string, userDataPath: string): string {
return `${userDataPath}\0${profileId}`
}
@@ -41,12 +52,19 @@ function cloudSessionRefreshKey(profileId: string, userDataPath: string): string
function clearCloudSessionIfUnchanged(
profileId: string,
userDataPath: string,
failed: OrcaCloudSession
failed: OrcaCloudSession,
active: ActiveOrcaProfileState
): void {
const current = readOrcaCloudSession(profileId, userDataPath)
if (current.status === 'found' && current.session.refreshToken !== failed.refreshToken) {
return
}
if (active.profile.cloud) {
tombstoneCloudSession(
cloudSessionIdentity(active.profile.id, active.profile.cloud),
userDataPath
)
}
clearOrcaCloudSession(profileId, userDataPath)
}
@@ -70,7 +88,20 @@ async function refreshStoredCloudSession(
// Another caller already rotated this session; reuse its result.
return current.session
}
if (!active.profile.cloud) {
throw new StaleCloudSessionMutationError()
}
const expectedIdentity = cloudSessionIdentity(active.profile.id, active.profile.cloud)
const snapshot = captureCloudSessionMutation(expectedIdentity, userDataPath)
const refreshed = await refreshOrcaCloudSession(config, session)
const refreshedIdentity = cloudSessionIdentity(active.profile.id, refreshed.cloud)
if (
refreshedIdentity.cloudUserId !== expectedIdentity.cloudUserId ||
refreshedIdentity.cloudProfileId !== expectedIdentity.cloudProfileId ||
refreshedIdentity.organizationId !== expectedIdentity.organizationId
) {
throw new StaleCloudSessionMutationError()
}
const nextSession = {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
@@ -78,7 +109,11 @@ async function refreshStoredCloudSession(
organizations: refreshed.organizations,
capabilities: refreshed.capabilities
}
saveOrcaCloudSession(active.profile.id, userDataPath, nextSession)
if (
saveOrcaCloudSessionIfCurrent(active.profile.id, userDataPath, nextSession, snapshot) === null
) {
throw new StaleCloudSessionMutationError()
}
linkOrcaProfileToCloud(active.profile.id, refreshed.cloud, userDataPath)
return nextSession
})()
@@ -109,7 +144,7 @@ export async function readFreshOrcaCloudSession(
}
} catch (error) {
if (isOrcaCloudAuthFailure(error)) {
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session.session)
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session.session, active)
return { status: 'reconnect-required' }
}
throw error
@@ -129,7 +164,7 @@ export async function forceRefreshOrcaCloudSession(
}
} catch (error) {
if (isOrcaCloudAuthFailure(error)) {
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session)
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session, active)
return { status: 'reconnect-required' }
}
throw error
@@ -169,7 +204,7 @@ export async function runWithFreshOrcaCloudSession<T>(
// the user out for it would destroy a valid session, so let it surface
// as a failed operation instead.
if (retryError instanceof OrcaCloudRequestError && retryError.statusCode === 401) {
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, refreshed.session)
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, refreshed.session, active)
return { status: 'reconnect-required' }
}
throw retryError

Some files were not shown because too many files have changed in this diff Show More