feat(mobile): upgrade direct pairings to relay

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-13 14:34:48 -04:00
co-authored by Orca
parent 8fa640792e
commit b2c61b7fd0
9 changed files with 784 additions and 35 deletions
+7 -35
View File
@@ -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
}
+13
View File
@@ -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(
+16
View File
@@ -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'
}