mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
refactor(mobile): relay learners own only relay routing
Follow-up to the field-ownership split. The supervisor keeps its host
read-only and holds the relay in a small owner that persists moves through
setRelayRouting; the direct upgrade returns { relay, bundle } and the
lifecycle composes the profile once. With one direct endpoint left, the probe
takes a bound openDirect and the lifecycle computes the direct path once.
One name per writer: setRelayRouting and savePairedHost are also the
dependency keys, and the removed-host error is RelayRoutingHostRemovedError.
The census now counts real value imports of savePairedHost, not mentions.
Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
co-authored by
mmarabel
Neil
parent
6ba444bc91
commit
c5c1477ad4
@@ -51,7 +51,7 @@ export function pairingJournalMountAdapters(
|
||||
effect('bundle-written', { version: written.current.version })
|
||||
},
|
||||
loadHosts: async () => [],
|
||||
saveHost: async () => {
|
||||
savePairedHost: async () => {
|
||||
effect('host-saved', HOST_ID)
|
||||
},
|
||||
connectRelay: () => candidateClient(client, effect, 'relay'),
|
||||
@@ -95,7 +95,7 @@ export function pairingJournalMountAdapters(
|
||||
connectRelay: () => candidateClient(client, effect, 'relay'),
|
||||
resolveInviteDirector: async () => pairingRelay(),
|
||||
resolveHostIdentity: async () => ({ id: HOST_ID, name: 'Fixture host' }),
|
||||
saveHost: async (host: { relay?: { relayHostId: string } }) => {
|
||||
savePairedHost: async (host: { relay?: { relayHostId: string } }) => {
|
||||
savedHost = host.relay?.relayHostId ?? 'direct-only'
|
||||
effect('host-saved', savedHost)
|
||||
},
|
||||
|
||||
@@ -106,7 +106,7 @@ export function relayCredentialMountAdapters(
|
||||
writeBundle: async (written: { current: { version: number } }) => {
|
||||
effect('bundle-written', { version: written.current.version })
|
||||
},
|
||||
saveRelayRouting: async () => {
|
||||
setRelayRouting: async () => {
|
||||
effect('host-saved', HOST_ID)
|
||||
},
|
||||
deleteBundle: async () => {
|
||||
@@ -116,7 +116,7 @@ export function relayCredentialMountAdapters(
|
||||
})
|
||||
started.then(
|
||||
(result) => {
|
||||
outcome = result === null ? 'declined' : result.host.relay?.relayHostId
|
||||
outcome = result === null ? 'declined' : result.relay.relayHostId
|
||||
},
|
||||
(error: unknown) => {
|
||||
outcome = `failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as SecureStore from 'expo-secure-store'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
loadHosts,
|
||||
MobileRelayUpgradeHostRemovedError,
|
||||
RelayRoutingHostRemovedError,
|
||||
resetHostStoreForTests,
|
||||
setRelayRouting,
|
||||
updateHostNameAndEndpoint
|
||||
@@ -211,7 +211,7 @@ describe('relay routing after a host edit', () => {
|
||||
storage.set(OVERLAY_KEY, '[]')
|
||||
|
||||
await expect(setRelayRouting('host-1', relay)).rejects.toBeInstanceOf(
|
||||
MobileRelayUpgradeHostRemovedError
|
||||
RelayRoutingHostRemovedError
|
||||
)
|
||||
|
||||
expect(writesTo(OVERLAY_KEY)).toEqual([])
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock('./host-credential-cleanup', () => ({
|
||||
import {
|
||||
loadHostCatalog,
|
||||
loadHosts,
|
||||
MobileRelayUpgradeHostRemovedError,
|
||||
RelayRoutingHostRemovedError,
|
||||
removeHost,
|
||||
resolvePairingHostIdentity,
|
||||
resetHostStoreForTests,
|
||||
@@ -564,7 +564,7 @@ describe('host-store list mutations', () => {
|
||||
storedHostsRaw = JSON.stringify([HOST_TWO])
|
||||
|
||||
await expect(setRelayRouting(HOST_ONE.id, HOST_ONE_RELAY)).rejects.toBeInstanceOf(
|
||||
MobileRelayUpgradeHostRemovedError
|
||||
RelayRoutingHostRemovedError
|
||||
)
|
||||
|
||||
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
|
||||
|
||||
@@ -149,7 +149,7 @@ function removeOrphanOverlayIfUnpaired(hostId: string): Promise<void> {
|
||||
// The page's host-store sibling keeps its own no-op, so only the native store reaches persistence.
|
||||
export { updateHostDescriptor } from './host-descriptor-persistence'
|
||||
|
||||
export class MobileRelayUpgradeHostRemovedError extends Error {}
|
||||
export class RelayRoutingHostRemovedError extends Error {}
|
||||
|
||||
/**
|
||||
* Relay routing learned after pairing (director re-resolution, rotation, direct upgrade). Takes no
|
||||
@@ -161,7 +161,7 @@ export async function setRelayRouting(hostId: string, relay: MobileRelayEndpoint
|
||||
const hosts = await readStoredHostProfilesForMutation()
|
||||
if (!hosts.some(({ id }) => id === hostId)) {
|
||||
// Why: an in-flight relay learner must not resurrect a host the user removed.
|
||||
throw new MobileRelayUpgradeHostRemovedError('mobile relay host was removed')
|
||||
throw new RelayRoutingHostRemovedError('mobile relay host was removed')
|
||||
}
|
||||
wrote = await saveMobileRelayHostRouting(hostId, relay)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
|
||||
import type { ConnectionState, HostProfile, RpcResponse } from './types'
|
||||
import {
|
||||
directPathForEndpoint,
|
||||
openAuthenticatedDirectEndpoint
|
||||
} from './mobile-direct-endpoint-probe'
|
||||
import type { ConnectionState, RpcResponse } from './types'
|
||||
|
||||
class FakeClient implements RpcClient {
|
||||
readonly sendRequest = vi.fn(async (): Promise<RpcResponse> => ({
|
||||
@@ -34,33 +37,14 @@ class FakeClient implements RpcClient {
|
||||
}
|
||||
}
|
||||
|
||||
const host: HostProfile = {
|
||||
id: 'host-1',
|
||||
name: 'Blue Whale',
|
||||
endpoint: 'ws://100.64.0.2:6768',
|
||||
deviceToken: 'device-token',
|
||||
publicKeyB64: 'A'.repeat(44),
|
||||
lastConnected: 1
|
||||
}
|
||||
|
||||
describe('mobile direct endpoint probe', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('dials only the saved endpoint and reports its path', async () => {
|
||||
const openDirect = vi.fn(() => {
|
||||
const client = new FakeClient('connecting')
|
||||
setTimeout(() => client.publishState('connected'), 100)
|
||||
return client
|
||||
})
|
||||
|
||||
const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
const result = await probing
|
||||
|
||||
expect(openDirect.mock.calls).toEqual([[host.endpoint]])
|
||||
expect(result?.path).toBe('tailscale')
|
||||
expect(result?.client.close).not.toHaveBeenCalled()
|
||||
it('classifies a tailnet endpoint apart from a LAN one', () => {
|
||||
expect(directPathForEndpoint('ws://100.64.0.2:6768')).toBe('tailscale')
|
||||
expect(directPathForEndpoint('wss://desk.tail1234.ts.net:6768')).toBe('tailscale')
|
||||
expect(directPathForEndpoint('ws://192.168.1.10:6768')).toBe('lan')
|
||||
})
|
||||
|
||||
it('fails a whole dead LAN in seconds instead of holding the 12s bound', async () => {
|
||||
@@ -75,7 +59,7 @@ describe('mobile direct endpoint probe', () => {
|
||||
return client
|
||||
})
|
||||
|
||||
const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000)
|
||||
const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await expect(probing).resolves.toBeNull()
|
||||
@@ -89,41 +73,37 @@ describe('mobile direct endpoint probe', () => {
|
||||
it('rides out one access-point flap that the first redial recovers', async () => {
|
||||
// 'reconnecting' is published on any socket close, so a single RST on the first
|
||||
// dial must not book a direct failure and its 60s cooldown.
|
||||
const openDirect = vi.fn((endpoint: string) => {
|
||||
const openDirect = vi.fn(() => {
|
||||
const client = new FakeClient('connecting')
|
||||
if (endpoint.includes('100.64.0.2')) {
|
||||
setTimeout(() => client.publishState('reconnecting'), 20)
|
||||
setTimeout(() => client.publishState('connected'), 600)
|
||||
}
|
||||
setTimeout(() => client.publishState('reconnecting'), 20)
|
||||
setTimeout(() => client.publishState('connected'), 600)
|
||||
return client
|
||||
})
|
||||
|
||||
const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000)
|
||||
const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000)
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
const result = await probing
|
||||
|
||||
expect(result?.path).toBe('tailscale')
|
||||
expect(result?.client.close).not.toHaveBeenCalled()
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('extends the grace once when the redial reaches a handshake', async () => {
|
||||
// The redial fires at 500ms, but 'connected' waits on the Noise handshake and a
|
||||
// capability RPC, so real work needs more than one grace window.
|
||||
const openDirect = vi.fn((endpoint: string) => {
|
||||
const openDirect = vi.fn(() => {
|
||||
const client = new FakeClient('connecting')
|
||||
if (endpoint.includes('100.64.0.2')) {
|
||||
setTimeout(() => client.publishState('reconnecting'), 20)
|
||||
setTimeout(() => client.publishState('handshaking'), 1_500)
|
||||
// Past the first grace window: only the re-arm keeps this probe alive.
|
||||
setTimeout(() => client.publishState('connected'), 3_000)
|
||||
}
|
||||
setTimeout(() => client.publishState('reconnecting'), 20)
|
||||
setTimeout(() => client.publishState('handshaking'), 1_500)
|
||||
// Past the first grace window: only the re-arm keeps this probe alive.
|
||||
setTimeout(() => client.publishState('connected'), 3_000)
|
||||
return client
|
||||
})
|
||||
|
||||
const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000)
|
||||
const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000)
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
|
||||
expect((await probing)?.path).toBe('tailscale')
|
||||
expect(await probing).not.toBeNull()
|
||||
})
|
||||
|
||||
it('fails a handshake that stalls, one grace after it started', async () => {
|
||||
@@ -136,7 +116,7 @@ describe('mobile direct endpoint probe', () => {
|
||||
return client
|
||||
})
|
||||
|
||||
const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000)
|
||||
const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000)
|
||||
await vi.advanceTimersByTimeAsync(3_499)
|
||||
let settled = false
|
||||
void probing.then(() => {
|
||||
@@ -157,7 +137,7 @@ describe('mobile direct endpoint probe', () => {
|
||||
return client
|
||||
})
|
||||
|
||||
const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000)
|
||||
const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000)
|
||||
await vi.advanceTimersByTimeAsync(2_019)
|
||||
let settled = false
|
||||
void probing.then(() => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
import type { HostProfile } from './types'
|
||||
|
||||
export function directPathForEndpoint(endpoint: string): Exclude<MobileConnectionPath, 'relay'> {
|
||||
try {
|
||||
@@ -85,17 +84,16 @@ function waitForAuthenticatedSession(
|
||||
}
|
||||
|
||||
export async function openAuthenticatedDirectEndpoint(
|
||||
host: HostProfile,
|
||||
openDirect: (endpoint: string) => RpcClient,
|
||||
openDirect: () => RpcClient,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ client: RpcClient; path: Exclude<MobileConnectionPath, 'relay'> } | null> {
|
||||
): Promise<RpcClient | null> {
|
||||
if (signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
let client: RpcClient
|
||||
try {
|
||||
client = openDirect(host.endpoint)
|
||||
client = openDirect()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -109,5 +107,5 @@ export async function openAuthenticatedDirectEndpoint(
|
||||
client.close()
|
||||
return null
|
||||
}
|
||||
return { client, path: directPathForEndpoint(host.endpoint) }
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
|
||||
import type { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ScheduleTimer } from './timer-scheduler'
|
||||
import type { HostProfile } from './types'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
|
||||
const DIRECT_PROBE_INTERVAL_MS = 15_000
|
||||
@@ -20,11 +19,11 @@ export class DirectReturnProbe {
|
||||
now: () => number
|
||||
setTimer: ScheduleTimer
|
||||
clearTimer: typeof clearTimeout
|
||||
openDirect: (endpoint: string) => RpcClient
|
||||
openDirect: () => RpcClient
|
||||
directPath: Exclude<MobileConnectionPath, 'relay'>
|
||||
},
|
||||
private readonly hooks: {
|
||||
hysteresis: MobileEndpointHysteresis
|
||||
host: () => HostProfile
|
||||
canSchedule: () => boolean
|
||||
canAttempt: () => boolean
|
||||
beginOperation: () => void
|
||||
@@ -75,7 +74,6 @@ export class DirectReturnProbe {
|
||||
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = null
|
||||
try {
|
||||
successful = await openAuthenticatedDirectEndpoint(
|
||||
this.hooks.host(),
|
||||
this.deps.openDirect,
|
||||
12_000,
|
||||
controller.signal
|
||||
@@ -88,14 +86,14 @@ export class DirectReturnProbe {
|
||||
return
|
||||
}
|
||||
if (!this.hooks.hysteresis.recordDirectSuccess(this.deps.now())) {
|
||||
successful.client.close()
|
||||
successful.close()
|
||||
return
|
||||
}
|
||||
const candidate = successful
|
||||
// Migration owns the candidate, including closing it if cutover is canceled.
|
||||
successful = null
|
||||
try {
|
||||
await this.hooks.migrate(candidate.client, candidate.path, () => this.stopped)
|
||||
await this.hooks.migrate(candidate, this.deps.directPath, () => this.stopped)
|
||||
} catch (error) {
|
||||
if (this.stopped) {
|
||||
return
|
||||
@@ -109,7 +107,7 @@ export class DirectReturnProbe {
|
||||
await this.hooks.onDirectMigrated()
|
||||
} finally {
|
||||
this.activeProbe = null
|
||||
successful?.client.close()
|
||||
successful?.close()
|
||||
// Why: a relay drop or backoff timer can arrive while the probe owns the
|
||||
// operation mutex; afterProbe releases it and replays deferred recovery.
|
||||
this.hooks.afterProbe()
|
||||
|
||||
@@ -86,6 +86,10 @@ async function startWithPendingResolution(): Promise<{
|
||||
return { logical, lifecycle, settle: (value) => settle(value) }
|
||||
}
|
||||
|
||||
function overlayWrites(): number {
|
||||
return asyncStorageMock.setItem.mock.calls.filter(([key]) => key === OVERLAY_KEY).length
|
||||
}
|
||||
|
||||
async function expectEditKept(expectedRelay: MobileRelayEndpoint): Promise<void> {
|
||||
const [saved] = await loadHosts()
|
||||
expect(saved).toMatchObject({
|
||||
@@ -162,8 +166,6 @@ describe('mobile endpoint lifecycle host edits', () => {
|
||||
mockCredentialRotation(logical)
|
||||
const lifecycle = startMobileEndpointLifecycle(logical, host, () => {})
|
||||
await updateHostNameAndEndpoint(host.id, { personalName: 'Renamed', endpoint: EDITED_ENDPOINT })
|
||||
const overlayWrites = (): number =>
|
||||
asyncStorageMock.setItem.mock.calls.filter(([key]) => key === OVERLAY_KEY).length
|
||||
|
||||
logical.publishState('connected')
|
||||
await vi.waitFor(() => expect(overlayWrites()).toBe(1))
|
||||
@@ -172,6 +174,34 @@ describe('mobile endpoint lifecycle host edits', () => {
|
||||
lifecycle.stop()
|
||||
})
|
||||
|
||||
it('does not persist relay routing from a rotation that finishes after stop', async () => {
|
||||
readBundleMock.mockResolvedValue({
|
||||
...bundle,
|
||||
current: { ...bundle.current, expiresAt: Date.now() + 60_000 }
|
||||
})
|
||||
let finishCredentialWrite: () => void = () => {}
|
||||
writeBundleMock.mockResolvedValueOnce().mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finishCredentialWrite = resolve
|
||||
})
|
||||
)
|
||||
const logical = new FakeLogicalClient('connected', 'lan')
|
||||
mockCredentialRotation(logical)
|
||||
const lifecycle = startMobileEndpointLifecycle(logical, host, () => {})
|
||||
await updateHostNameAndEndpoint(host.id, { personalName: 'Renamed', endpoint: EDITED_ENDPOINT })
|
||||
|
||||
logical.publishState('connected')
|
||||
await vi.waitFor(() => expect(writeBundleMock).toHaveBeenCalledTimes(2))
|
||||
lifecycle.stop()
|
||||
finishCredentialWrite()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
// The rotated bundle itself stays durable; only the stale relay routing write is skipped.
|
||||
expect(writeBundleMock).toHaveBeenCalledTimes(2)
|
||||
expect(overlayWrites()).toBe(0)
|
||||
await expectEditKept(relay)
|
||||
})
|
||||
|
||||
it('keeps an edit made while a direct-only host was being upgraded to relay', async () => {
|
||||
storage.set(OVERLAY_KEY, '[]')
|
||||
const { relay: _relay, ...directHost } = host
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from './mobile-relay-credential-bundle'
|
||||
import { setRelayRouting } from './host-store'
|
||||
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
|
||||
import { directPathForEndpoint } from './mobile-direct-endpoint-probe'
|
||||
import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller'
|
||||
import { defaultCancelTimer, defaultScheduleTimer } from './timer-scheduler'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
@@ -55,7 +56,7 @@ export function startMobileEndpointLifecycle(
|
||||
host,
|
||||
dependencies: { randomBytes: ExpoCrypto.getRandomBytes }
|
||||
}),
|
||||
onUpgraded: ({ host }) => startSupervisor(host)
|
||||
onUpgraded: ({ relay }) => startSupervisor({ ...initialHost, relay })
|
||||
})
|
||||
void owner.start()
|
||||
}
|
||||
@@ -86,7 +87,8 @@ function createSupervisor(
|
||||
onLog: ConnectionLogSink
|
||||
): MobileEndpointSupervisor {
|
||||
return new MobileEndpointSupervisor(logical, host, {
|
||||
openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
|
||||
openDirect: () => connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
|
||||
directPath: directPathForEndpoint(host.endpoint),
|
||||
openRelay: (relay, credential, confirmReqId, onHostCloseReason) =>
|
||||
connectMobileRelayRpcSession({
|
||||
relay,
|
||||
@@ -101,7 +103,7 @@ function createSupervisor(
|
||||
resolveRelay: resolveMobileRelayEndpoint,
|
||||
readBundle: readMobileRelayCredentialBundle,
|
||||
writeBundle: writeMobileRelayCredentialBundle,
|
||||
saveRelayRouting: setRelayRouting,
|
||||
setRelayRouting,
|
||||
onLog,
|
||||
now: Date.now,
|
||||
randomBytes: ExpoCrypto.getRandomBytes,
|
||||
|
||||
@@ -4,11 +4,13 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund
|
||||
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
import type { ScheduleTimer } from './timer-scheduler'
|
||||
import type { ConnectionLogSink } from './types'
|
||||
|
||||
export type MobileEndpointSupervisorDependencies = {
|
||||
openDirect: (endpoint: string) => RpcClient
|
||||
openDirect: () => RpcClient
|
||||
directPath: Exclude<MobileConnectionPath, 'relay'>
|
||||
openRelay: (
|
||||
relay: MobileRelayEndpoint,
|
||||
credential: { token: string; version: number },
|
||||
@@ -18,7 +20,7 @@ export type MobileEndpointSupervisorDependencies = {
|
||||
resolveRelay: typeof resolveMobileRelayEndpoint
|
||||
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
|
||||
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
|
||||
saveRelayRouting: (hostId: string, relay: MobileRelayEndpoint) => Promise<void>
|
||||
setRelayRouting: (hostId: string, relay: MobileRelayEndpoint) => Promise<void>
|
||||
now: () => number
|
||||
randomBytes: (length: number) => Uint8Array
|
||||
setTimer: ScheduleTimer
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import {
|
||||
bundle,
|
||||
dependencies,
|
||||
FakeLogicalClient,
|
||||
host,
|
||||
mockCredentialRotation
|
||||
} from './mobile-endpoint-supervisor-test-fakes'
|
||||
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
|
||||
|
||||
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) }))
|
||||
|
||||
describe('mobile endpoint supervisor credential rotation after stop', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not persist relay routing from a rotation that finishes after stop', async () => {
|
||||
const logical = new FakeLogicalClient('connected', 'lan')
|
||||
let finishCredentialWrite: (() => void) | undefined
|
||||
const credentialWritePending = new Promise<void>((resolve) => {
|
||||
finishCredentialWrite = resolve
|
||||
})
|
||||
const writeBundle = vi
|
||||
.fn<(value: MobileRelayCredentialBundle) => Promise<void>>()
|
||||
.mockResolvedValue()
|
||||
.mockResolvedValueOnce()
|
||||
.mockReturnValueOnce(credentialWritePending)
|
||||
mockCredentialRotation(logical)
|
||||
const deps = dependencies({
|
||||
readBundle: vi.fn(async () => ({
|
||||
...bundle,
|
||||
current: { ...bundle.current, expiresAt: Date.now() + 60_000 }
|
||||
})),
|
||||
writeBundle
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
logical.publishState('connected')
|
||||
await vi.waitFor(() => expect(writeBundle).toHaveBeenCalledTimes(2))
|
||||
supervisor.stop()
|
||||
finishCredentialWrite?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// The rotated bundle itself stays durable; only the stale relay routing write is skipped.
|
||||
expect(writeBundle).toHaveBeenCalledTimes(2)
|
||||
expect(deps.saveRelayRouting).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -81,18 +81,32 @@ export function isDirectorResolutionFailure(error: Error): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a stopped supervisor's host may be removed or re-paired; its successor owns routing.
|
||||
export async function adoptRelayRouting(
|
||||
host: HostProfile,
|
||||
relay: MobileRelayEndpoint,
|
||||
dependencies: Pick<MobileEndpointSupervisorDependencies, 'saveRelayRouting'>,
|
||||
stopped: boolean
|
||||
): Promise<HostProfile> {
|
||||
if (stopped) {
|
||||
return host
|
||||
/** The relay a supervisor dials; a learned move is persisted as routing, never as a profile. */
|
||||
export class SupervisedRelayRouting {
|
||||
private relay: MobileRelayEndpoint | undefined
|
||||
private readonly hostId: string
|
||||
|
||||
constructor(
|
||||
host: Pick<HostProfile, 'id' | 'relay'>,
|
||||
private readonly dependencies: Pick<MobileEndpointSupervisorDependencies, 'setRelayRouting'>,
|
||||
private readonly isStopped: () => boolean
|
||||
) {
|
||||
this.relay = host.relay
|
||||
this.hostId = host.id
|
||||
}
|
||||
|
||||
current(): MobileRelayEndpoint | undefined {
|
||||
return this.relay
|
||||
}
|
||||
|
||||
async adopt(relay: MobileRelayEndpoint): Promise<void> {
|
||||
// Why: a stopped supervisor's host may be removed or re-paired; its successor owns routing.
|
||||
if (this.isStopped()) {
|
||||
return
|
||||
}
|
||||
await this.dependencies.setRelayRouting(this.hostId, relay)
|
||||
this.relay = relay
|
||||
}
|
||||
await dependencies.saveRelayRouting(host.id, relay)
|
||||
return { ...host, relay }
|
||||
}
|
||||
|
||||
export function encodeBase64Url(value: Uint8Array): string {
|
||||
|
||||
@@ -210,7 +210,8 @@ export function dependencies(
|
||||
resolveRelay: vi.fn(async ({ relay }) => relay),
|
||||
readBundle: vi.fn(async () => bundle),
|
||||
writeBundle: vi.fn(async () => {}),
|
||||
saveRelayRouting: vi.fn(async () => {}),
|
||||
setRelayRouting: vi.fn(async () => {}),
|
||||
directPath: 'lan',
|
||||
now: Date.now,
|
||||
randomBytes: (length) => new Uint8Array(length).fill(1),
|
||||
setTimer: defaultScheduleTimer,
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('mobile endpoint supervisor', () => {
|
||||
expect.any(String),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(deps.saveRelayRouting).toHaveBeenCalledWith(host.id, resolved)
|
||||
expect(deps.setRelayRouting).toHaveBeenCalledWith(host.id, resolved)
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
@@ -664,7 +664,7 @@ describe('mobile endpoint supervisor', () => {
|
||||
await vi.waitFor(() => expect(deps.resolveRelay).toHaveBeenCalledOnce())
|
||||
supervisor.setForeground(false)
|
||||
finishResolve?.(relay)
|
||||
await vi.waitFor(() => expect(deps.saveRelayRouting).toHaveBeenCalledOnce())
|
||||
await vi.waitFor(() => expect(deps.setRelayRouting).toHaveBeenCalledOnce())
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(openRelay).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -4,8 +4,8 @@ import { RelayReconnectController } from './mobile-relay-reconnect-controller'
|
||||
import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer'
|
||||
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
|
||||
import {
|
||||
adoptRelayRouting,
|
||||
liveRelayLeaseExpiry,
|
||||
SupervisedRelayRouting,
|
||||
suspendRelayIfStillConnected
|
||||
} from './mobile-endpoint-supervisor-support'
|
||||
import { selectDialableRelayCredentials } from './mobile-relay-credential-selection'
|
||||
@@ -36,6 +36,7 @@ const FAILURE_COOLDOWN_MS = 60_000
|
||||
|
||||
export class MobileEndpointSupervisor {
|
||||
private bundle: MobileRelayCredentialBundle | null = null
|
||||
private readonly relayRouting: SupervisedRelayRouting
|
||||
private stopped = false
|
||||
private operationInFlight = false
|
||||
private pendingReplace = false
|
||||
@@ -54,9 +55,10 @@ export class MobileEndpointSupervisor {
|
||||
|
||||
constructor(
|
||||
private readonly logical: StableLogicalRpcClient,
|
||||
private host: HostProfile,
|
||||
private readonly host: HostProfile,
|
||||
private readonly dependencies: MobileEndpointSupervisorDependencies
|
||||
) {
|
||||
this.relayRouting = new SupervisedRelayRouting(host, dependencies, () => this.stopped)
|
||||
this.hysteresis = new MobileEndpointHysteresis(dependencies.now(), {
|
||||
directSuccessesRequired: 3,
|
||||
directObservationMs: DIRECT_OBSERVATION_MS,
|
||||
@@ -93,11 +95,9 @@ export class MobileEndpointSupervisor {
|
||||
writeBundle: dependencies.writeBundle,
|
||||
isActive: () => this.isActive(),
|
||||
isForeground: () => this.backgroundGrace.isForeground(),
|
||||
relay: () => this.host.relay,
|
||||
relay: () => this.relayRouting.current(),
|
||||
resolveRelay: dependencies.resolveRelay,
|
||||
persistResolvedRelay: async (resolved) => {
|
||||
this.host = await adoptRelayRouting(this.host, resolved, dependencies, this.stopped)
|
||||
},
|
||||
persistResolvedRelay: (resolved) => this.relayRouting.adopt(resolved),
|
||||
bundle: () => this.bundle,
|
||||
adoptBundle: (bundle) => (this.bundle = bundle),
|
||||
recordMigration: () => {
|
||||
@@ -116,7 +116,6 @@ export class MobileEndpointSupervisor {
|
||||
})
|
||||
this.directProbe = new DirectReturnProbe(dependencies, {
|
||||
hysteresis: this.hysteresis,
|
||||
host: () => this.host,
|
||||
canSchedule: () => this.isActive() && this.logical.getActivePath() === 'relay',
|
||||
canAttempt: () => this.isActive() && !this.operationInFlight,
|
||||
beginOperation: () => (this.operationInFlight = true),
|
||||
@@ -149,7 +148,7 @@ export class MobileEndpointSupervisor {
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null)
|
||||
if (this.stopped || !this.host.relay) {
|
||||
if (this.stopped || !this.relayRouting.current()) {
|
||||
return
|
||||
}
|
||||
if (!this.bundle) {
|
||||
@@ -211,7 +210,7 @@ export class MobileEndpointSupervisor {
|
||||
// shared cooldown and any session left stale-'connected' by a half-open socket
|
||||
// comes down; lease rotation clears it because armRetry owns its own retry.
|
||||
private async recoverRelay(forceReplacement = false, ownsRecovery = false): Promise<void> {
|
||||
if (!this.isActive() || !this.host.relay) {
|
||||
if (!this.isActive() || !this.relayRouting.current()) {
|
||||
return
|
||||
}
|
||||
if (this.operationInFlight) {
|
||||
@@ -325,7 +324,7 @@ export class MobileEndpointSupervisor {
|
||||
this.bundle = result.bundle
|
||||
// Why: a scheduled rotation can finish after the old credential enters the rejection gate.
|
||||
credentialRefreshed = true
|
||||
this.host = await adoptRelayRouting(this.host, result.relay, this.dependencies, this.stopped)
|
||||
await this.relayRouting.adopt(result.relay)
|
||||
} catch {
|
||||
// Why: pending material remains durable; the next authenticated direct
|
||||
// opportunity must reconcile it before creating another install key.
|
||||
|
||||
@@ -5,7 +5,3 @@ export function relayConnectWebSocketUrl(baseUrl: string, relayHostId: string):
|
||||
url.pathname = `/v1/connect/${encodeURIComponent(relayHostId)}`
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function relayWebSocketUrl(relay: { cellUrl: string; relayHostId: string }): string {
|
||||
return relayConnectWebSocketUrl(relay.cellUrl, relay.relayHostId)
|
||||
}
|
||||
|
||||
@@ -14,16 +14,13 @@ const directHost: HostProfile = {
|
||||
}
|
||||
|
||||
const upgraded = {
|
||||
host: {
|
||||
...directHost,
|
||||
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
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
|
||||
import { MobileRelayUpgradeHostRemovedError } from './host-store'
|
||||
import { RelayRoutingHostRemovedError } from './host-store'
|
||||
import {
|
||||
createMobileRelayDirectUpgradeJournal,
|
||||
type MobileRelayDirectUpgradeJournal
|
||||
@@ -64,7 +64,7 @@ function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) {
|
||||
}),
|
||||
writeBundle: vi.fn(async () => {}),
|
||||
deleteBundle: vi.fn(async () => {}),
|
||||
saveRelayRouting: vi.fn(async () => {}),
|
||||
setRelayRouting: vi.fn(async () => {}),
|
||||
randomBytes: (length: number) => new Uint8Array(length).fill(7)
|
||||
}
|
||||
}
|
||||
@@ -113,9 +113,9 @@ describe('existing direct pairing relay upgrade', () => {
|
||||
reqId: journal!.reqId,
|
||||
newResumeTokenHash: journal!.pendingResumeTokenHash
|
||||
})
|
||||
expect(deps.writeBundle).toHaveBeenCalledBefore(deps.saveRelayRouting)
|
||||
expect(deps.saveRelayRouting).toHaveBeenCalledWith(host.id, relay)
|
||||
expect(result?.host).toEqual({ ...host, relay })
|
||||
expect(deps.writeBundle).toHaveBeenCalledBefore(deps.setRelayRouting)
|
||||
expect(deps.setRelayRouting).toHaveBeenCalledWith(host.id, relay)
|
||||
expect(result?.relay).toEqual(relay)
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
|
||||
})
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('existing direct pairing relay upgrade', () => {
|
||||
await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull()
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
|
||||
expect(deps.writeBundle).not.toHaveBeenCalled()
|
||||
expect(deps.saveRelayRouting).not.toHaveBeenCalled()
|
||||
expect(deps.setRelayRouting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why 'forbidden': a desktop that predates pairing.getEndpoints has it on neither its mobile
|
||||
@@ -177,7 +177,7 @@ describe('existing direct pairing relay upgrade', () => {
|
||||
await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull()
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
|
||||
expect(deps.writeBundle).not.toHaveBeenCalled()
|
||||
expect(deps.saveRelayRouting).not.toHaveBeenCalled()
|
||||
expect(deps.setRelayRouting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the durable journal when relay registration is temporarily unavailable', async () => {
|
||||
@@ -197,8 +197,8 @@ describe('existing direct pairing relay upgrade', () => {
|
||||
)
|
||||
const committed = installed(journal)
|
||||
const deps = dependencies(journal)
|
||||
deps.saveRelayRouting.mockRejectedValue(
|
||||
new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
|
||||
deps.setRelayRouting.mockRejectedValue(
|
||||
new RelayRoutingHostRemovedError('mobile relay upgrade host was removed')
|
||||
)
|
||||
const client = clientWith([
|
||||
success({
|
||||
@@ -210,7 +210,7 @@ describe('existing direct pairing relay upgrade', () => {
|
||||
|
||||
await expect(
|
||||
upgradeDirectMobileRelay({ client, host, dependencies: deps })
|
||||
).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
|
||||
).rejects.toBeInstanceOf(RelayRoutingHostRemovedError)
|
||||
expect(deps.deleteBundle).toHaveBeenCalledWith(host.id)
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as ExpoCrypto from 'expo-crypto'
|
||||
import type {
|
||||
DeviceCredentialInstalled,
|
||||
MobileRelayEndpoint,
|
||||
PairingGetEndpointsResult
|
||||
} from '../../../src/shared/mobile-relay-credential-contract'
|
||||
import { MobileRelayUpgradeHostRemovedError, setRelayRouting } from './host-store'
|
||||
import { RelayRoutingHostRemovedError, setRelayRouting } from './host-store'
|
||||
import {
|
||||
MobileRelayCredentialBundleSchema,
|
||||
deleteMobileRelayCredentialBundle,
|
||||
@@ -26,7 +27,7 @@ import type { HostProfile } from './types'
|
||||
import { isPairingRelayRpcUnavailable } from './pairing-relay-rpc-unavailable'
|
||||
|
||||
export type MobileRelayDirectUpgradeResult = {
|
||||
host: HostProfile
|
||||
relay: MobileRelayEndpoint
|
||||
bundle: MobileRelayCredentialBundle
|
||||
}
|
||||
|
||||
@@ -35,7 +36,7 @@ type Dependencies = {
|
||||
writeJournal: typeof writeMobileRelayDirectUpgradeJournal
|
||||
clearJournal: typeof deleteMobileRelayDirectUpgradeJournal
|
||||
writeBundle: typeof writeMobileRelayCredentialBundle
|
||||
saveRelayRouting: typeof setRelayRouting
|
||||
setRelayRouting: typeof setRelayRouting
|
||||
deleteBundle: typeof deleteMobileRelayCredentialBundle
|
||||
randomBytes: (length: number) => Uint8Array
|
||||
}
|
||||
@@ -53,7 +54,7 @@ export async function upgradeDirectMobileRelay(args: {
|
||||
writeJournal: writeMobileRelayDirectUpgradeJournal,
|
||||
clearJournal: deleteMobileRelayDirectUpgradeJournal,
|
||||
writeBundle: writeMobileRelayCredentialBundle,
|
||||
saveRelayRouting: setRelayRouting,
|
||||
setRelayRouting,
|
||||
deleteBundle: deleteMobileRelayCredentialBundle,
|
||||
randomBytes: ExpoCrypto.getRandomBytes,
|
||||
...args.dependencies
|
||||
@@ -120,16 +121,16 @@ async function publishCommitted(
|
||||
// Why: the overlay must never advertise relay without its matching credential.
|
||||
await dependencies.writeBundle(bundle)
|
||||
try {
|
||||
await dependencies.saveRelayRouting(host.id, endpoints.relay)
|
||||
await dependencies.setRelayRouting(host.id, endpoints.relay)
|
||||
} catch (error) {
|
||||
if (error instanceof MobileRelayUpgradeHostRemovedError) {
|
||||
if (error instanceof RelayRoutingHostRemovedError) {
|
||||
await dependencies.deleteBundle(host.id)
|
||||
await dependencies.clearJournal(host.id)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await dependencies.clearJournal(host.id)
|
||||
return { host: { ...host, relay: endpoints.relay }, bundle }
|
||||
return { relay: endpoints.relay, bundle }
|
||||
}
|
||||
|
||||
async function getEndpoints(
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
|
||||
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
import { relayWebSocketUrl } from './mobile-relay-connect-url'
|
||||
import { relayConnectWebSocketUrl } from './mobile-relay-connect-url'
|
||||
|
||||
// Native WebSockets normally emit close immediately after error; bound the
|
||||
// missing-close case so a dead socket cannot leave recovery pending forever.
|
||||
@@ -55,7 +55,7 @@ export class MobileRelayE2eeLink {
|
||||
constructor(options: MobileRelayE2eeLinkOptions) {
|
||||
this.options = options
|
||||
this.socket = (options.createSocket ?? ((url) => new WebSocket(url)))(
|
||||
relayWebSocketUrl(options.endpoint)
|
||||
relayConnectWebSocketUrl(options.endpoint.cellUrl, options.endpoint.relayHostId)
|
||||
)
|
||||
const session = MobileE2EEV2ClientSession.create({
|
||||
desktopPublicKeyB64: options.desktopPublicKeyB64,
|
||||
|
||||
@@ -132,14 +132,6 @@ describe('mobile relay host overlay store', () => {
|
||||
expect(JSON.parse(stored!)).toEqual([RELAY_ONLY_OVERLAY])
|
||||
})
|
||||
|
||||
it('does not rewrite storage when the routing is unchanged', async () => {
|
||||
stored = JSON.stringify([RELAY_ONLY_OVERLAY])
|
||||
|
||||
await expect(saveMobileRelayHostRouting('host-1', RELAY)).resolves.toBe(false)
|
||||
|
||||
expect(asyncStorage.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never overlays or resurrects a host whose legacy base was removed', async () => {
|
||||
stored = JSON.stringify([LEGACY_OVERLAY])
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from './mobile-relay-host-overlay'
|
||||
|
||||
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
|
||||
let overlayMutation: Promise<void> = Promise.resolve()
|
||||
let overlayMutation: Promise<unknown> = Promise.resolve()
|
||||
|
||||
function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null {
|
||||
if (raw === null) {
|
||||
@@ -39,15 +39,16 @@ async function readOverlaysForMutation(): Promise<MobileRelayHostOverlay[]> {
|
||||
|
||||
async function mutateOverlays(
|
||||
update: (overlays: MobileRelayHostOverlay[]) => MobileRelayHostOverlay[]
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
const mutation = overlayMutation.then(async () => {
|
||||
const current = await readOverlaysForMutation()
|
||||
const next = update(current)
|
||||
// Why: direct-only saves commonly have no overlay to remove; avoid a full
|
||||
// AsyncStorage write when cleanup leaves the durable list unchanged.
|
||||
if (next !== current) {
|
||||
await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next))
|
||||
// Why: an update handing back the list it read changed nothing; skip the full AsyncStorage write.
|
||||
if (next === current) {
|
||||
return false
|
||||
}
|
||||
await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next))
|
||||
return true
|
||||
})
|
||||
overlayMutation = mutation.catch(() => {})
|
||||
return mutation
|
||||
@@ -73,16 +74,14 @@ export async function loadMobileRelayHostOverlayState(
|
||||
}
|
||||
|
||||
/** Resolves whether storage changed. */
|
||||
export async function saveMobileRelayHostRouting(
|
||||
export function saveMobileRelayHostRouting(
|
||||
hostId: string,
|
||||
relay: MobileRelayEndpoint
|
||||
): Promise<boolean> {
|
||||
const validated = toStoredMobileRelayHostOverlay(hostId, relay)
|
||||
let wrote = false
|
||||
await mutateOverlays((overlays) => {
|
||||
return mutateOverlays((overlays) => {
|
||||
const index = overlays.findIndex((overlay) => overlay.hostId === hostId)
|
||||
if (index === -1) {
|
||||
wrote = true
|
||||
return [...overlays, validated]
|
||||
}
|
||||
// Why: a failing relay loop re-resolves the same cell every retry. Both sides are this
|
||||
@@ -90,30 +89,21 @@ export async function saveMobileRelayHostRouting(
|
||||
if (JSON.stringify(overlays[index]) === JSON.stringify(validated)) {
|
||||
return overlays
|
||||
}
|
||||
wrote = true
|
||||
const next = overlays.slice()
|
||||
next[index] = validated
|
||||
return next
|
||||
})
|
||||
return wrote
|
||||
}
|
||||
|
||||
export function removeMobileRelayHostOverlay(hostId: string): Promise<void> {
|
||||
return removeMobileRelayHostOverlays([hostId])
|
||||
}
|
||||
|
||||
export function removeMobileRelayHostOverlays(hostIds: readonly string[]): Promise<void> {
|
||||
export async function removeMobileRelayHostOverlays(hostIds: readonly string[]): Promise<void> {
|
||||
const targets = new Set(hostIds)
|
||||
let removed = false
|
||||
return mutateOverlays((overlays) => {
|
||||
const next = overlays.filter((overlay) => {
|
||||
if (!targets.has(overlay.hostId)) {
|
||||
return true
|
||||
}
|
||||
removed = true
|
||||
return false
|
||||
})
|
||||
return removed ? next : overlays
|
||||
await mutateOverlays((overlays) => {
|
||||
const next = overlays.filter((overlay) => !targets.has(overlay.hostId))
|
||||
return next.length === overlays.length ? overlays : next
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
MobileRelayEndpointSchema,
|
||||
type MobileRelayEndpoint
|
||||
} from '../../../src/shared/mobile-relay-credential-contract'
|
||||
import { relayWebSocketUrl } from './mobile-relay-connect-url'
|
||||
import { relayConnectWebSocketUrl } from './mobile-relay-connect-url'
|
||||
|
||||
const MobileAccessEndpointSchema = z
|
||||
.object({
|
||||
@@ -61,7 +61,13 @@ export function toStoredMobileRelayHostOverlay(
|
||||
return MobileRelayHostOverlaySchema.parse({
|
||||
v: 2,
|
||||
hostId,
|
||||
endpoints: [{ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) }],
|
||||
endpoints: [
|
||||
{
|
||||
id: 'relay-primary',
|
||||
kind: 'relay',
|
||||
url: relayConnectWebSocketUrl(relay.cellUrl, relay.relayHostId)
|
||||
}
|
||||
],
|
||||
relayHostId: relay.relayHostId,
|
||||
relay
|
||||
})
|
||||
|
||||
@@ -103,7 +103,7 @@ function dependencies(args: {
|
||||
readCredentialBundle: vi.fn(async () => args.bundle ?? null),
|
||||
writeCredentialBundle: vi.fn(async () => {}),
|
||||
loadHosts: vi.fn(async (): Promise<HostProfile[]> => args.hosts ?? []),
|
||||
saveHost: vi.fn(async () => {}),
|
||||
savePairedHost: vi.fn(async () => {}),
|
||||
connectRelay: args.connectRelay,
|
||||
resolveInviteDirector: vi.fn(async () => {
|
||||
throw new Error('director not needed')
|
||||
@@ -140,7 +140,7 @@ describe('mobile relay pairing recovery', () => {
|
||||
})
|
||||
)
|
||||
expect(deps.writeCredentialBundle).toHaveBeenCalledOnce()
|
||||
expect(deps.saveHost).toHaveBeenCalledOnce()
|
||||
expect(deps.savePairedHost).toHaveBeenCalledOnce()
|
||||
expect(deps.clearJournal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('mobile relay pairing recovery', () => {
|
||||
|
||||
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered')
|
||||
expect(connectRelay).not.toHaveBeenCalled()
|
||||
expect(deps.saveHost).not.toHaveBeenCalled()
|
||||
expect(deps.savePairedHost).not.toHaveBeenCalled()
|
||||
expect(deps.clearJournal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ type RecoveryDependencies = {
|
||||
readCredentialBundle: typeof readMobileRelayCredentialBundle
|
||||
writeCredentialBundle: typeof writeMobileRelayCredentialBundle
|
||||
loadHosts: typeof loadHosts
|
||||
saveHost: typeof savePairedHost
|
||||
savePairedHost: typeof savePairedHost
|
||||
connectRelay: typeof connectMobileRelayForPairing
|
||||
resolveInviteDirector: typeof resolvePairingInviteThroughDirector
|
||||
now: () => number
|
||||
@@ -52,7 +52,7 @@ const defaultDependencies: RecoveryDependencies = {
|
||||
readCredentialBundle: readMobileRelayCredentialBundle,
|
||||
writeCredentialBundle: writeMobileRelayCredentialBundle,
|
||||
loadHosts,
|
||||
saveHost: savePairedHost,
|
||||
savePairedHost,
|
||||
connectRelay: connectMobileRelayForPairing,
|
||||
resolveInviteDirector: resolvePairingInviteThroughDirector,
|
||||
now: Date.now,
|
||||
@@ -265,7 +265,7 @@ async function publishCommitted(
|
||||
await dependencies.writeCredentialBundle(
|
||||
promotePairingJournalCredential({ journal: reconciledJournal, installed })
|
||||
)
|
||||
await dependencies.saveHost(relayHost(reconciledJournal, endpoints.relay))
|
||||
await dependencies.savePairedHost(relayHost(reconciledJournal, endpoints.relay))
|
||||
await dependencies.clearJournal(journal.metadata.journalId)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isRpcResponse } from './rpc-response-shape'
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogSink, RpcResponse } from './types'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
import { relayWebSocketUrl } from './mobile-relay-connect-url'
|
||||
import { relayConnectWebSocketUrl } from './mobile-relay-connect-url'
|
||||
export { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
|
||||
@@ -35,7 +35,7 @@ export function connectMobileRelayForPairing(args: {
|
||||
onLog?: ConnectionLogSink
|
||||
}): PairingCandidateClient {
|
||||
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
|
||||
const socketUrl = relayWebSocketUrl(args.relay)
|
||||
const socketUrl = relayConnectWebSocketUrl(args.relay.cellUrl, args.relay.relayHostId)
|
||||
const log = createPairingRelayLogger(args.onLog)
|
||||
const cellHost = redactSocketEndpoint(socketUrl)
|
||||
log('info', 'Relay: dialing cell', cellHost)
|
||||
|
||||
@@ -225,7 +225,8 @@ function dependencies(
|
||||
resolveRelay: vi.fn(async ({ relay }) => relay),
|
||||
readBundle: vi.fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)),
|
||||
writeBundle: vi.fn(async () => {}),
|
||||
saveRelayRouting: vi.fn(async () => {}),
|
||||
setRelayRouting: vi.fn(async () => {}),
|
||||
directPath: 'lan',
|
||||
now: Date.now,
|
||||
randomBytes: (length: number) => new Uint8Array(length),
|
||||
setTimer: (handler, ms) => setTimeout(handler, ms),
|
||||
|
||||
@@ -14,24 +14,26 @@ import { censusSourceFiles } from '../test-support/census-source-files'
|
||||
const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url))
|
||||
|
||||
const PAIRING_WRITERS = [
|
||||
'src/transport/host-store.ts',
|
||||
'src/transport/host-store.web.ts',
|
||||
'src/transport/mobile-relay-pairing-recovery.ts',
|
||||
'src/transport/pre-profile-pairing-coordinator.ts'
|
||||
]
|
||||
|
||||
function referencingFiles(): string[] {
|
||||
// A value import of `savePairedHost` from the host store, at any relative depth.
|
||||
const IMPORTS_PAIRED_HOST_WRITER =
|
||||
/import\s*\{[^}]*\bsavePairedHost\b[^}]*\}\s*from\s*['"](?:\.{1,2}\/)+(?:[\w-]+\/)*host-store['"]/
|
||||
|
||||
function importingFiles(): string[] {
|
||||
return ['src', 'app']
|
||||
.flatMap((directory) => censusSourceFiles(join(MOBILE_DIR, directory)))
|
||||
.filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file))
|
||||
.filter((file) => /\bsavePairedHost\b/.test(readFileSync(file, 'utf8')))
|
||||
.filter((file) => IMPORTS_PAIRED_HOST_WRITER.test(readFileSync(file, 'utf8')))
|
||||
.map((file) => relative(MOBILE_DIR, file))
|
||||
.sort()
|
||||
}
|
||||
|
||||
describe('full host profile writes', () => {
|
||||
it('are reachable only from pairing', () => {
|
||||
// Also the presence precondition: the store itself must match, or the matcher is broken.
|
||||
expect(referencingFiles()).toEqual(PAIRING_WRITERS)
|
||||
// Also the presence precondition: both creators must match, or the matcher is broken.
|
||||
expect(importingFiles()).toEqual(PAIRING_WRITERS)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -108,7 +108,7 @@ function dependencies(client: RpcClient, events: string[]) {
|
||||
id: hostId,
|
||||
name: 'Blue Whale'
|
||||
})),
|
||||
saveHost: vi.fn(async (_host: HostProfile) => {
|
||||
savePairedHost: vi.fn(async (_host: HostProfile) => {
|
||||
events.push('save-host')
|
||||
}),
|
||||
saveJournal: vi.fn(async (_journal: MobileRelayPairingJournal) => {
|
||||
@@ -171,7 +171,7 @@ describe('pre-profile pairing coordinator', () => {
|
||||
})
|
||||
|
||||
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
|
||||
expect(deps.saveHost).toHaveBeenCalledWith({
|
||||
expect(deps.savePairedHost).toHaveBeenCalledWith({
|
||||
id: `host-${now}`,
|
||||
name: 'Blue Whale',
|
||||
endpoint: directOffer.endpoint,
|
||||
@@ -201,7 +201,7 @@ describe('pre-profile pairing coordinator', () => {
|
||||
})
|
||||
|
||||
await expect(attempt.result).resolves.toEqual({ hostId: 'host-existing' })
|
||||
expect(deps.saveHost).toHaveBeenCalledWith({
|
||||
expect(deps.savePairedHost).toHaveBeenCalledWith({
|
||||
id: 'host-existing',
|
||||
name: 'Studio Mac',
|
||||
endpoint: directOffer.endpoint,
|
||||
@@ -238,7 +238,7 @@ describe('pre-profile pairing coordinator', () => {
|
||||
})
|
||||
|
||||
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
|
||||
expect(deps.saveHost).toHaveBeenCalledOnce()
|
||||
expect(deps.savePairedHost).toHaveBeenCalledOnce()
|
||||
expect(deps.recordDescriptorFromStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -256,7 +256,7 @@ describe('pre-profile pairing coordinator', () => {
|
||||
})
|
||||
|
||||
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
|
||||
expect(deps.saveHost).toHaveBeenCalledOnce()
|
||||
expect(deps.savePairedHost).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('journals before connecting and publishes only after authoritative direct install', async () => {
|
||||
@@ -327,7 +327,7 @@ describe('pre-profile pairing coordinator', () => {
|
||||
reqId: journal!.metadata.installReqId,
|
||||
newResumeTokenHash: journal!.metadata.pendingResumeTokenHash
|
||||
})
|
||||
expect(deps.saveHost).toHaveBeenCalledWith(
|
||||
expect(deps.savePairedHost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: `host-${now}`,
|
||||
endpoint: directOffer.endpoint,
|
||||
@@ -348,7 +348,7 @@ describe('pre-profile pairing coordinator', () => {
|
||||
})
|
||||
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
|
||||
|
||||
expect(deps.saveHost).toHaveBeenCalledWith(
|
||||
expect(deps.savePairedHost).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ relay: expect.anything() })
|
||||
)
|
||||
expect(events).toEqual([
|
||||
@@ -380,7 +380,7 @@ describe('pre-profile pairing coordinator', () => {
|
||||
})
|
||||
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
|
||||
|
||||
expect(deps.saveHost).toHaveBeenCalledWith(
|
||||
expect(deps.savePairedHost).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ relay: expect.anything() })
|
||||
)
|
||||
expect(events).toEqual([
|
||||
@@ -542,6 +542,6 @@ describe('pre-profile pairing coordinator', () => {
|
||||
|
||||
await expect(attempt.result).rejects.toThrow(/cancelled/)
|
||||
expect(client.close).toHaveBeenCalledOnce()
|
||||
expect(deps.saveHost).not.toHaveBeenCalled()
|
||||
expect(deps.savePairedHost).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ type Dependencies = {
|
||||
connectRelay: typeof connectMobileRelayForPairing
|
||||
resolveInviteDirector: typeof resolvePairingInviteThroughDirector
|
||||
resolveHostIdentity: typeof resolvePairingHostIdentity
|
||||
saveHost: typeof savePairedHost
|
||||
savePairedHost: typeof savePairedHost
|
||||
saveJournal: typeof saveMobileRelayPairingJournal
|
||||
updateJournal: typeof updateMobileRelayPairingJournal
|
||||
clearJournal: typeof clearMobileRelayPairingJournal
|
||||
@@ -60,7 +60,7 @@ const defaultDependencies: Dependencies = {
|
||||
connectRelay: connectMobileRelayForPairing,
|
||||
resolveInviteDirector: resolvePairingInviteThroughDirector,
|
||||
resolveHostIdentity: resolvePairingHostIdentity,
|
||||
saveHost: savePairedHost,
|
||||
savePairedHost,
|
||||
saveJournal: saveMobileRelayPairingJournal,
|
||||
updateJournal: updateMobileRelayPairingJournal,
|
||||
clearJournal: clearMobileRelayPairingJournal,
|
||||
@@ -206,7 +206,7 @@ async function runPairing(
|
||||
assertActive(isDisposed)
|
||||
|
||||
if (!journal) {
|
||||
await dependencies.saveHost(baseHost(offer, hostId, hostName, now))
|
||||
await dependencies.savePairedHost(baseHost(offer, hostId, hostName, now))
|
||||
recordWinnerDescriptor(dependencies, hostId, winner.status)
|
||||
return { hostId }
|
||||
}
|
||||
@@ -231,7 +231,7 @@ async function runPairing(
|
||||
// Why: this commits a LAN-only host instead of failing, so the refusal code is the only
|
||||
// record of why the phone never got a relay endpoint.
|
||||
log('info', 'Relay: desktop will not serve relay pairing', provision.error.code)
|
||||
await dependencies.saveHost(baseHost(offer, hostId, hostName, now))
|
||||
await dependencies.savePairedHost(baseHost(offer, hostId, hostName, now))
|
||||
await dependencies.clearJournal(journal.metadata.journalId)
|
||||
recordWinnerDescriptor(dependencies, hostId, winner.status)
|
||||
return { hostId }
|
||||
@@ -247,7 +247,7 @@ async function runPairing(
|
||||
}
|
||||
assertActive(isDisposed)
|
||||
await dependencies.writeCredentialBundle(promotePairingJournalCredential({ journal, installed }))
|
||||
await dependencies.saveHost(relayHost(journal, endpoints.relay))
|
||||
await dependencies.savePairedHost(relayHost(journal, endpoints.relay))
|
||||
await dependencies.clearJournal(journal.metadata.journalId)
|
||||
recordWinnerDescriptor(dependencies, hostId, winner.status)
|
||||
return { hostId }
|
||||
|
||||
Reference in New Issue
Block a user