feat: harden mobile session storage

This commit is contained in:
Saravana Achu Mac 2026-04-04 11:53:44 -07:00
parent 856a683f18
commit 4cdff95c26
7 changed files with 114 additions and 14 deletions

View File

@ -29,7 +29,7 @@ It assumes:
- [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts
- [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates
- [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution
- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, live backend polling plus websocket-backed updates, and startup/error telemetry capture
- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, live backend polling plus websocket-backed updates, startup/error telemetry capture, and secure session storage with invalidation handling
- [-] DRY cleanup completed for runtime/config/bootstrap concerns and shared Supabase bootstrap, but not yet for all auth/session internals
- [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses transitional Supabase-backed auth to stay compatible with the backend's current JWT boundary
@ -304,7 +304,7 @@ Build mobile as a real ecosystem surface, not a mock UI shell.
- [x] Add product config bootstrap
- [-] Integrate `@bytelyst/react-native-platform-sdk`
- [x] Implement auth flow and session restore
- [-] Define secure storage and session invalidation behavior
- [x] Define secure storage and session invalidation behavior
- [x] Implement launch-time kill-switch and maintenance handling
- [x] Add telemetry startup and error capture
- [x] Define initial mobile scope

View File

@ -0,0 +1,44 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
export const MOBILE_SESSION_STORAGE_KEY = 'invttrdg-auth';
async function getFallbackValue(key: string) {
return AsyncStorage.getItem(key);
}
export const secureSessionStorage = {
async getItem(key: string) {
try {
const value = await SecureStore.getItemAsync(key);
if (value != null) {
return value;
}
} catch {
// Fall back when SecureStore is unavailable in the current runtime.
}
return getFallbackValue(key);
},
async setItem(key: string, value: string) {
try {
await SecureStore.setItemAsync(key, value);
await AsyncStorage.removeItem(key);
return;
} catch {
await AsyncStorage.setItem(key, value);
}
},
async removeItem(key: string) {
await Promise.allSettled([
SecureStore.deleteItemAsync(key),
AsyncStorage.removeItem(key),
]);
},
};
export async function clearMobileSessionStorage() {
await secureSessionStorage.removeItem(MOBILE_SESSION_STORAGE_KEY);
}

View File

@ -1,6 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
import { getMobileSupabaseConfig } from '../../shared/supabase-config.js';
import { MOBILE_SESSION_STORAGE_KEY, secureSessionStorage } from '@/lib/secureSessionStorage';
const supabaseConfig = getMobileSupabaseConfig();
@ -13,7 +13,8 @@ export const mobileSupabase = createClient(
supabaseConfig.anonKey,
{
auth: {
storage: AsyncStorage,
storage: secureSessionStorage,
storageKey: MOBILE_SESSION_STORAGE_KEY,
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: false,

View File

@ -30,6 +30,7 @@
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.8",
"expo-secure-store": "~15.0.7",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",

View File

@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from '
import type { ReactNode } from 'react';
import type { Session, User } from '@supabase/supabase-js';
import { mobileSupabase } from '@/lib/supabase';
import { clearMobileSessionStorage } from '@/lib/secureSessionStorage';
import { tableNameUsers } from '@/lib/tables';
import { mobileTelemetry, trackMobileError } from '@/lib/telemetry';
@ -22,6 +23,7 @@ interface MobileAuthContextValue {
error: string | null;
signIn: (email: string, password: string) => Promise<{ error?: string }>;
signOut: () => Promise<void>;
invalidateSession: (reason: string) => Promise<void>;
refreshProfile: () => Promise<void>;
accessToken: string | null;
}
@ -63,7 +65,7 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) {
void bootstrap();
const { data: authListener } = mobileSupabase.auth.onAuthStateChange((_event, nextSession) => {
const { data: authListener } = mobileSupabase.auth.onAuthStateChange((event, nextSession) => {
setSession(nextSession);
setUser(nextSession?.user ?? null);
if (nextSession?.user) {
@ -72,6 +74,9 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) {
});
void fetchProfile(nextSession.user.id);
} else {
if (event === 'SIGNED_OUT') {
mobileTelemetry.trackEvent('info', 'auth', 'session_signed_out');
}
setProfile(null);
setLoading(false);
}
@ -124,14 +129,39 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) {
async function signOut() {
setLoading(true);
await mobileSupabase.auth.signOut();
mobileTelemetry.trackEvent('info', 'auth', 'sign_out_succeeded', {
try {
await mobileSupabase.auth.signOut();
mobileTelemetry.trackEvent('info', 'auth', 'sign_out_succeeded', {
userId: user?.id,
});
} finally {
await clearMobileSessionStorage();
setProfile(null);
setSession(null);
setUser(null);
setLoading(false);
}
}
async function invalidateSession(reason: string) {
setLoading(true);
mobileTelemetry.trackEvent('warn', 'auth', 'session_invalidated', {
message: reason,
userId: user?.id,
});
setProfile(null);
setSession(null);
setUser(null);
setLoading(false);
try {
await mobileSupabase.auth.signOut({ scope: 'local' });
} catch (invalidateError) {
trackMobileError('auth', 'session_invalidation_signout_failed', invalidateError);
} finally {
await clearMobileSessionStorage();
setProfile(null);
setSession(null);
setUser(null);
setError(reason);
setLoading(false);
}
}
async function refreshProfile() {
@ -151,6 +181,7 @@ export function MobileAuthProvider({ children }: { children: ReactNode }) {
error,
signIn,
signOut,
invalidateSession,
refreshProfile,
accessToken: session?.access_token ?? null,
}),

View File

@ -137,7 +137,7 @@ const EMPTY_STATE: TradingPortfolioSummary = {
};
export function TradingDataProvider({ children }: { children: ReactNode }) {
const { accessToken, user } = useMobileAuth();
const { accessToken, user, invalidateSession } = useMobileAuth();
const [botState, setBotState] = useState<BotState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -160,6 +160,10 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
await invalidateSession(`Trading session expired (${response.status})`);
return;
}
throw new Error(`Trading state request failed (${response.status})`);
}
@ -174,7 +178,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
} finally {
setLoading(false);
}
}, [accessToken, user]);
}, [accessToken, user, invalidateSession]);
useEffect(() => {
void fetchState();
@ -221,6 +225,9 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
socket.on('connect_error', (socketError) => {
setError(socketError.message);
trackMobileError('realtime', 'socket_connect_failed', socketError);
if (socketError.message.toLowerCase().includes('unauthorized')) {
void invalidateSession(socketError.message);
}
});
socket.on('state', (nextState: BotState) => {
@ -313,6 +320,10 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
const body = await response.json().catch(() => ({} as { error?: string }));
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
await invalidateSession(body.error || `Trading control unauthorized (${response.status})`);
return { error: body.error || `Request failed (${response.status})` };
}
mobileTelemetry.trackEvent('warn', 'controls', 'trading_action_failed', {
message: body.error || `Request failed (${response.status})`,
tags: { path },
@ -329,7 +340,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
return { error: actionError instanceof Error ? actionError.message : 'Trading action failed' };
}
},
[accessToken, fetchState]
[accessToken, fetchState, invalidateSession]
);
const portfolio = useMemo<TradingPortfolioSummary>(() => {

12
pnpm-lock.yaml generated
View File

@ -142,6 +142,9 @@ importers:
expo-router:
specifier: ~6.0.8
version: 6.0.23(68e2fe297303e98ef2913faa2068e740)
expo-secure-store:
specifier: ~15.0.7
version: 15.0.8(expo@54.0.33)
expo-splash-screen:
specifier: ~31.0.10
version: 31.0.13(expo@54.0.33)
@ -3601,6 +3604,11 @@ packages:
react-server-dom-webpack:
optional: true
expo-secure-store@15.0.8:
resolution: {integrity: sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==}
peerDependencies:
expo: '*'
expo-server@1.0.5:
resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==}
engines: {node: '>=20.16.0'}
@ -10208,6 +10216,10 @@ snapshots:
- '@types/react-dom'
- supports-color
expo-secure-store@15.0.8(expo@54.0.33):
dependencies:
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-server@1.0.5: {}
expo-splash-screen@31.0.13(expo@54.0.33):