diff --git a/packages/ui/src/apps/mobileConnections.test.ts b/packages/ui/src/apps/mobileConnections.test.ts index d5ba3ecb..89b60cec 100644 --- a/packages/ui/src/apps/mobileConnections.test.ts +++ b/packages/ui/src/apps/mobileConnections.test.ts @@ -1,6 +1,6 @@ import { describe, expect, mock, test } from 'bun:test'; -import { loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections'; +import { createMobilePasswordOperationTracker, loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections'; const originalFetch = globalThis.fetch; const originalWindow = globalThis.window; @@ -40,6 +40,24 @@ const testRelay: MobileRelayConfig = { }; describe('mobile connection storage', () => { + test('cancellation invalidates an in-flight password completion', async () => { + const tracker = createMobilePasswordOperationTracker(); + const operation = tracker.begin(); + let resolveLogin: () => void = () => { + throw new Error('Login was not started'); + }; + let switchedRuntime = false; + const completion = new Promise((resolve) => { resolveLogin = resolve; }).then(() => { + if (tracker.isCurrent(operation)) switchedRuntime = true; + }); + + tracker.cancel(); + resolveLogin(); + await completion; + + expect(switchedRuntime).toBe(false); + }); + test('removes inline tokens only after each secure migration succeeds', async () => { const result = await migrateLegacyInlineTokenRecords([ { id: 'ok', url: 'http://ok.example', clientToken: 'token-ok' }, diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index eb7654d7..7b50cf45 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -72,6 +72,20 @@ const MOBILE_SECURE_TIMEOUT_MS = 3000; // feels instant instead of hanging for seconds. const MOBILE_FAST_PROBE_TIMEOUT_MS = 2500; +export const createMobilePasswordOperationTracker = () => { + let current = 0; + return { + begin: (): number => { + current += 1; + return current; + }, + cancel: (): void => { + current += 1; + }, + isCurrent: (operation: number): boolean => operation === current, + }; +}; + export type MobileConnectionMode = 'direct' | 'relay'; // Persisted relay transport config. This is connection metadata, not a secret @@ -1313,6 +1327,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio const [pendingConnection, setPendingConnection] = React.useState(null); const connectionsRef = React.useRef(connections); const busyRef = React.useRef<'connect' | 'password' | 'pairing' | null>(null); + const passwordOperationRef = React.useRef(createMobilePasswordOperationTracker()); const applyConnections = React.useCallback((next: MobileSavedConnection[]) => { connectionsRef.current = next; @@ -1496,6 +1511,8 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio if (!pendingConnection || !password.trim() || busyRef.current === 'password') return; setError(null); beginBusy('password'); + const operation = passwordOperationRef.current.begin(); + const isCurrentOperation = () => passwordOperationRef.current.isCurrent(operation); const { id, label, candidates } = pendingConnection; // A chosen relay transport owns an open tunnel; close it unless the switch // adopted it as the runtime tunnel. @@ -1506,6 +1523,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio // tunnel; cookies never cross it, so an issued bearer token is mandatory // there. `issueClientToken` mints the device's token in one round-trip. chosen = await establishLiveTransport(candidates); + if (!isCurrentOperation()) return; if (!chosen) { setError(t('mobile.connect.error.unreachable')); return; @@ -1522,12 +1540,14 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio const response = chosen.kind === 'relay' ? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/auth/session', loginInit).catch(() => null)) : await requestWithTimeout(`${chosen.url}/auth/session`, loginInit); + if (!isCurrentOperation()) return; logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null }); if (!response?.ok) { setError(t('mobile.connect.error.passwordFailed')); return; } const body = await response.json().catch(() => null) as { clientToken?: unknown } | null; + if (!isCurrentOperation()) return; const issuedToken = typeof body?.clientToken === 'string' ? body.clientToken.trim() : ''; logConnect('password:token', { issued: Boolean(issuedToken) }); @@ -1548,8 +1568,11 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio // Persist the token BEFORE switching (no fire-and-forget). if (isCapacitorApp()) { + if (!isCurrentOperation()) return; await writeSecureToken(secureTokenKeyOf({ candidates }), issuedToken); + if (!isCurrentOperation()) return; } + if (!isCurrentOperation()) return; persistMetadata({ id, label, candidates, clientToken: issuedToken }); setPendingConnection(null); // A relay transport hands its live login tunnel to the runtime (adopted @@ -1560,20 +1583,24 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio { runtimeKey: secureTokenKeyOf({ candidates }) }, ); adopted = chosen.kind === 'relay'; + if (!isCurrentOperation()) return; onConnected(); } catch (error) { + if (!isCurrentOperation()) return; console.warn('[mobile-connect] password threw', error); setError(t('mobile.connect.error.passwordFailed')); } finally { if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close(); - endBusy('password'); + if (isCurrentOperation()) endBusy('password'); } }, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]); const cancelPassword = React.useCallback(() => { + passwordOperationRef.current.cancel(); + endBusy('password'); setPendingConnection(null); setError(null); - }, []); + }, [endBusy]); const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise => { setError(null);