mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
feat(mobile): upgrade direct pairings to relay
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,17 +1,9 @@
|
||||
import * as ExpoCrypto from 'expo-crypto'
|
||||
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 { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
|
||||
import { directPathForEndpoint } from './mobile-endpoint-supervisor-support'
|
||||
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 { 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
|
||||
@@ -20,45 +12,25 @@ export function openHostLogicalClient(host: HostProfile, onLog: ConnectionLogSin
|
||||
connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
|
||||
directPathForEndpoint(host, host.endpoint)
|
||||
)
|
||||
if (Platform.OS === 'web' || !host.relay) {
|
||||
if (Platform.OS === 'web') {
|
||||
return logical
|
||||
}
|
||||
|
||||
const supervisor = 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
|
||||
})
|
||||
supervisor.setForeground(AppState.currentState === 'active')
|
||||
const endpointLifecycle = startMobileEndpointLifecycle(logical, host, onLog)
|
||||
endpointLifecycle.setForeground(AppState.currentState === 'active')
|
||||
const appStateSubscription = AppState.addEventListener('change', (state) => {
|
||||
supervisor.setForeground(state === 'active')
|
||||
endpointLifecycle.setForeground(state === 'active')
|
||||
})
|
||||
const closeLogical = logical.close
|
||||
logical.close = () => {
|
||||
appStateSubscription.remove()
|
||||
supervisor.stop()
|
||||
endpointLifecycle.stop()
|
||||
closeLogical()
|
||||
}
|
||||
const notifyLogicalForeground = logical.notifyForeground
|
||||
logical.notifyForeground = () => {
|
||||
supervisor.setForeground(true)
|
||||
endpointLifecycle.setForeground(true)
|
||||
notifyLogicalForeground()
|
||||
}
|
||||
void supervisor.start()
|
||||
return logical
|
||||
}
|
||||
|
||||
@@ -35,9 +35,11 @@ vi.mock('./host-credential-cleanup', () => ({
|
||||
|
||||
import {
|
||||
loadHosts,
|
||||
MobileRelayUpgradeHostRemovedError,
|
||||
removeHost,
|
||||
renameHost,
|
||||
resetHostStoreForTests,
|
||||
saveExistingHostRelayUpgrade,
|
||||
updateLastConnected
|
||||
} from './host-store'
|
||||
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
|
||||
@@ -133,6 +135,17 @@ describe('host-store list mutations', () => {
|
||||
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(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
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'
|
||||
@@ -71,6 +72,7 @@ async function deleteDeviceToken(hostId: string): Promise<void> {
|
||||
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
|
||||
@@ -227,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) => {
|
||||
@@ -237,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
|
||||
|
||||
@@ -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,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,95 @@
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
import { Platform } from 'react-native'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { z } from 'zod'
|
||||
|
||||
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: encodeBase64Url(sha256(decodeBase64Url(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 decodeBase64Url(value: string): Uint8Array {
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = `${base64}${'='.repeat((4 - (base64.length % 4)) % 4)}`
|
||||
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0))
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
Reference in New Issue
Block a user