mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -51,7 +51,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -5,12 +5,10 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
|
||||
import {
|
||||
startPairingConnectionAttempt,
|
||||
type PairingConnectionAttempt
|
||||
} from '../src/transport/pairing-connection-attempt'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types'
|
||||
startPreProfilePairing,
|
||||
type PreProfilePairingAttempt
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry } from '../src/transport/types'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
|
||||
@@ -35,7 +33,7 @@ export default function PairConfirmScreen() {
|
||||
// batch fewer setState calls when entries arrive in bursts.
|
||||
const logsRef = useRef<ConnectionLogEntry[]>([])
|
||||
const mountedRef = useRef(true)
|
||||
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
|
||||
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
|
||||
|
||||
const routeState = resolvePairConfirmRouteState(params.code)
|
||||
const offer = routeState.offer
|
||||
@@ -79,20 +77,12 @@ export default function PairConfirmScreen() {
|
||||
setStatus('connecting')
|
||||
logsRef.current = []
|
||||
setLogs([])
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
activePairingAttemptRef.current?.dispose()
|
||||
|
||||
// Why: split the try/catch around the network call vs the local save
|
||||
// so a Keychain or AsyncStorage failure doesn't masquerade as a
|
||||
// "Cannot connect" error.
|
||||
let response: RpcResponse
|
||||
const attempt = startPairingConnectionAttempt({
|
||||
const attempt = startPreProfilePairing({
|
||||
offer,
|
||||
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
|
||||
closeClient: () => client?.close()
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
|
||||
connectOptions: {
|
||||
onLog: (entry) => {
|
||||
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
|
||||
return
|
||||
@@ -100,8 +90,11 @@ export default function PairConfirmScreen() {
|
||||
logsRef.current = [...logsRef.current, entry]
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
})
|
||||
response = await client.sendRequest('status.get')
|
||||
}
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
const { hostId } = await attempt.result
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
attempt.dispose()
|
||||
if (activePairingAttemptRef.current === attempt) {
|
||||
@@ -110,6 +103,7 @@ export default function PairConfirmScreen() {
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
const timedOut = attempt.timedOut
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
@@ -125,47 +119,7 @@ export default function PairConfirmScreen() {
|
||||
setErrorMessage(
|
||||
timedOut
|
||||
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
|
||||
: 'Cannot connect — check that your computer is on the same network'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
response.error.code === 'unauthorized'
|
||||
? 'Authentication failed — token may be expired'
|
||||
: `Server error: ${response.error.message}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair-confirm] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
|
||||
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-66
@@ -14,12 +14,10 @@ import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
|
||||
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
|
||||
import {
|
||||
startPairingConnectionAttempt,
|
||||
type PairingConnectionAttempt
|
||||
} from '../src/transport/pairing-connection-attempt'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
|
||||
startPreProfilePairing,
|
||||
type PreProfilePairingAttempt
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
@@ -54,7 +52,7 @@ export default function PairScanScreen() {
|
||||
const logsRef = useRef<ConnectionLogEntry[]>([])
|
||||
const processingRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
|
||||
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
|
||||
|
||||
const setPairScanRootRef = useCallback((node: View | null): void => {
|
||||
if (node !== null) {
|
||||
@@ -123,21 +121,12 @@ export default function PairScanScreen() {
|
||||
setStatus('connecting')
|
||||
logsRef.current = []
|
||||
setLogs([])
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
activePairingAttemptRef.current?.dispose()
|
||||
|
||||
// Why: split the try/catch around the network call vs the local save
|
||||
// so a Keychain or AsyncStorage failure doesn't masquerade as a
|
||||
// "Cannot connect — same network?" error. Pairing reached the
|
||||
// desktop fine; the failure is local persistence.
|
||||
let response: RpcResponse
|
||||
const attempt = startPairingConnectionAttempt({
|
||||
const attempt = startPreProfilePairing({
|
||||
offer,
|
||||
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
|
||||
closeClient: () => client?.close()
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
|
||||
connectOptions: {
|
||||
onLog: (entry) => {
|
||||
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
|
||||
return
|
||||
@@ -145,8 +134,11 @@ export default function PairScanScreen() {
|
||||
logsRef.current = [...logsRef.current, entry]
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
})
|
||||
response = await client.sendRequest('status.get')
|
||||
}
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
const { hostId } = await attempt.result
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
attempt.dispose()
|
||||
if (activePairingAttemptRef.current === attempt) {
|
||||
@@ -155,6 +147,7 @@ export default function PairScanScreen() {
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
const timedOut = attempt.timedOut
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
@@ -170,51 +163,7 @@ export default function PairScanScreen() {
|
||||
setErrorMessage(
|
||||
timedOut
|
||||
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
|
||||
: 'Cannot connect — check that your computer is on the same network'
|
||||
)
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (response.error.code === 'unauthorized') {
|
||||
setStatus('error')
|
||||
setErrorMessage('Authentication failed — token may be expired')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
setErrorMessage(`Server error: ${response.error.message}`)
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
|
||||
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+9
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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?.()
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user