fix(mobile): recover from rejected relay resume tokens

This commit is contained in:
Jinwoo-H
2026-08-25 16:02:18 -04:00
parent 6fae9ea163
commit c7dde52ddf
5 changed files with 77 additions and 6 deletions
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import {
bundle,
dependencies,
FakeLogicalClient,
FakeRelaySession,
host
} from './mobile-endpoint-supervisor-test-fakes'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
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 Relay director credential fallback', () => {
it('uses grace when the director rejects the current resume credential', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(
(_relay, credential: { version: number }) =>
new FakeRelaySession(
credential.version === bundle.current.version ? 'disconnected' : 'connected',
credential.version === bundle.current.version ? new RelayOuterError(4409) : null
)
)
const deps = dependencies({
readBundle: vi.fn(async () => ({
...bundle,
grace: { ...bundle.current, token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1 }
})),
openRelay,
resolveRelay: ({ relay, resumeToken }) =>
resolveMobileRelayEndpoint({
relay,
resumeToken,
fetchImpl: vi.fn(async () => new Response('', { status: 401 }))
})
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(openRelay).toHaveBeenCalledTimes(2)
expect(openRelay.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ version: 1 }))
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
})
@@ -5,17 +5,14 @@ import {
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
import { RelayOuterError } from './relay-outer-error'
export { RelayOuterError } from './relay-outer-error'
// Native WebSockets normally emit close immediately after error; bound the
// missing-close case so a dead socket cannot leave recovery pending forever.
const RELAY_ERROR_CLOSE_GRACE_MS = 250
export class RelayOuterError extends Error {
constructor(readonly code: number) {
super(`relay_outer_${code}`)
}
}
type MobileRelayE2eeLinkOptions = {
endpoint: { cellUrl: string; relayHostId: string }
credential: string
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
const relay = {
@@ -11,6 +12,20 @@ const relay = {
}
describe('mobile relay resume director', () => {
it('classifies a rejected resume credential for grace fallback', async () => {
const rejected = vi.fn(async () => new Response('', { status: 401 }))
const resolution = resolveMobileRelayEndpoint({
relay,
resumeToken: 'A'.repeat(43),
fetchImpl: rejected
})
await expect(resolution).rejects.toMatchObject({
code: MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL
})
})
it('uses a bounded POST body and never puts the bearer in the URL', async () => {
const fetchImpl = vi.fn(
async () =>
@@ -1,5 +1,7 @@
import { z } from 'zod'
import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import { RelayOuterError } from './relay-outer-error'
const MAX_RESPONSE_BYTES = 16 * 1024
const ResolveResponseSchema = z
@@ -32,6 +34,9 @@ export async function resolveMobileRelayEndpoint(args: {
signal: controller.signal
})
if (!response.ok) {
if (response.status === 401) {
throw new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL)
}
throw new Error(`relay director resolve failed (${response.status})`)
}
const declaredLength = Number(response.headers.get('content-length') ?? 0)
@@ -0,0 +1,5 @@
export class RelayOuterError extends Error {
constructor(readonly code: number) {
super(`relay_outer_${code}`)
}
}