Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 21 additions & 75 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import {
getCacheStatus,
getRevalidatingCacheKeys,
subscribeToCacheStatus

Check failure on line 31 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Module '"./src/services/api"' has no exported member 'subscribeToCacheStatus'. Did you mean to use 'import subscribeToCacheStatus from "./src/services/api"' instead?
} from './src/services/api';
import { warmCriticalCaches } from './src/services/cacheWarming';
import { crashReportingService } from './src/services/crashReporting';
Expand All @@ -52,13 +52,12 @@
import { syncService } from './src/services/syncService'; // Fixed naming convention from the merge conflict
import { useAppStore, useDeviceStore, useNotificationStore } from './src/store'; // Added missing store imports
import { waitForHydration } from './src/store/createStore';
import { useDegradationStore } from './src/store/degradationStore';
import {
consumeHydrationResetToast,
subscribeToHydrationResetToast,
} from './src/store/persistence';
import { handleCacheVersionUpdate } from './src/utils/cacheVersioning';
import { requireEnvVariables } from './src/utils/env';

Check failure on line 60 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find module './src/utils/env' or its corresponding type declarations.
import { appLogger } from './src/utils/logger';

// Keep the splash screen visible while we fetch resources
Expand All @@ -73,7 +72,10 @@
// Initialize centralized logging on app start
initializeLogging().catch(err => {
if (__DEV__) {
appLogger.errorSync('[App] Failed to initialize logging:', err instanceof Error ? err : new Error(String(err)));
appLogger.errorSync(
'[App] Failed to initialize logging:',
err instanceof Error ? err : new Error(String(err))
);
}
});

Expand Down Expand Up @@ -215,10 +217,7 @@
const allFonts = [...CRITICAL_FONTS, ...SECONDARY_FONTS];
const fontStart = Date.now();
try {
await Promise.all([
fontService.loadFonts(allFonts),
Asset.loadAsync(CRITICAL_ASSETS),
]);
await Promise.all([fontService.loadFonts(allFonts), Asset.loadAsync(CRITICAL_ASSETS)]);

Check failure on line 220 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Argument of type 'readonly [any, any, any]' is not assignable to parameter of type 'string | number | string[] | number[]'.
} catch (e: any) {
crashReportingService.reportError(e, 'font-loading-error');
}
Expand All @@ -241,8 +240,6 @@
prepareApp();
}, []);



// OTA Update check on foreground
const checkForOtaUpdate = useCallback(async () => {
try {
Expand Down Expand Up @@ -307,41 +304,23 @@
};

// Register unhandled rejection listener
if (global.onunhandledrejection === undefined) {
// @ts-ignore - Setting global error handler
global.onunhandledrejection = unhandledRejectionHandler;
if (typeof global.onunhandledrejection !== 'undefined') {
global.onunhandledrejection = (event: PromiseRejectionEvent) => {
unhandledRejectionHandler(event.reason);
};
} else {
// Fallback for environments that do not support onunhandledrejection
const ErrorUtils = require('react-native/Libraries/ErrorUtils');
ErrorUtils.setGlobalHandler((error: Error, isFatal: boolean) => {
if (!isFatal && error.message.includes('Unhandled promise rejection')) {
unhandledRejectionHandler(error);
}
});
}

// Connect to socket when app starts
socketService.connect();

// Initialize feature capability detection (non-blocking)
featureCapabilities
.checkAllCapabilities()
.then(capabilities => {
const degradationStore = useDegradationStore.getState();
appLogger.infoSync('[App] Feature capabilities checked', {
camera: capabilities.camera.status,
notifications: capabilities.pushNotifications.status,
location: capabilities.location.status,
});
// Update degradation store with current feature statuses
Object.entries(capabilities).forEach(([feature, info]) => {
if (feature !== 'checkedAt' && 'status' in info) {
// #807: isFeatureType narrows string key to FeatureType
if ((Object.values(FeatureType) as string[]).includes(feature)) {
degradationStore.setFeatureStatus(feature as FeatureType, info.status);
}
}
});
})
.catch(error => {
appLogger.errorSync(
'[App] Error checking feature capabilities',
error instanceof Error ? error : new Error(String(error))
);
});

// Push notifications are now initialized within InteractionManager.runAfterInteractions below

// ===== DEFERRED PATH — runs after user interactions complete =====
Expand All @@ -361,33 +340,6 @@
// Socket connection (network I/O)
socketService.connect();

// Feature capability detection (permission checks, async)
featureCapabilities
.checkAllCapabilities()
.then(capabilities => {
// Issue #820: read directly from store rather than closing over component state.
const degradationStore = useDegradationStore.getState();
appLogger.infoSync('[App] Feature capabilities checked', {
camera: capabilities.camera.status,
notifications: capabilities.pushNotifications.status,
location: capabilities.location.status,
});
Object.entries(capabilities).forEach(([feature, info]) => {
if (feature !== 'checkedAt' && 'status' in info) {
// #807: isFeatureType narrows string key to FeatureType
if ((Object.values(FeatureType) as string[]).includes(feature)) {
degradationStore.setFeatureStatus(feature as FeatureType, info.status);
}
}
});
})
.catch(error => {
appLogger.errorSync(
'[App] Error checking feature capabilities',
error instanceof Error ? error : new Error(String(error))
);
});

// Push notification registration and explainer logic.
// Issue #820: all state reads use store.getState() instead of closed-over
// component state so the callback always operates on the current values.
Expand Down Expand Up @@ -438,7 +390,7 @@
// Issue #820: read store directly rather than closed-over component state.
const store = useNotificationStore.getState();
store.addNotification({
id: notification.request.identifier,

Check failure on line 393 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Object literal may only specify known properties, and 'id' does not exist in type 'Omit<StoredNotification, "id" | "read" | "receivedAt">'.
type: (notification.request.content.data?.type as any) ?? 'general',
title: notification.request.content.title ?? '',
body: notification.request.content.body ?? '',
Expand Down Expand Up @@ -479,8 +431,7 @@
if (notificationSubscriptionRef.current) {
removeNotificationListener(notificationSubscriptionRef.current);
}
// @ts-ignore
global.onunhandledrejection = undefined;
global.onunhandledrejection = null;
};
}, []);

Expand All @@ -496,14 +447,8 @@
return;
}

const {
isAuthenticated,
refreshToken,
setUser,
setTokens,
setSessionExpiringSoon,
logout,
} = useAppStore.getState();
const { isAuthenticated, refreshToken, setUser, setTokens, setSessionExpiringSoon, logout } =
useAppStore.getState();

if (!isAuthenticated || !refreshToken) return;

Expand Down Expand Up @@ -588,10 +533,11 @@
<ErrorBoundary>
<AuthProvider>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
<FeatureCapabilityHandler />

Check failure on line 536 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'FeatureCapabilityHandler'.
<CacheRevalidationBanner />
<ScreenErrorBoundary screenName="AppNavigator">

Check failure on line 538 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'ScreenErrorBoundary'.
<AppNavigator />
</ScreenErrorBoundary>

Check failure on line 540 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'ScreenErrorBoundary'.
<NotificationPermissionExplanationSheet />
{showPreferencesResetToast ? <PreferencesResetToast /> : null}
<UpdatePromptModal
Expand All @@ -601,7 +547,7 @@
onUpdate={handleOtaUpdate}
onDismiss={isCriticalUpdate ? undefined : () => setShowUpdateModal(false)}
/>
<SessionExpiredModal

Check failure on line 550 in App.tsx

View workflow job for this annotation

GitHub Actions / Syntax & Type Check

Cannot find name 'SessionExpiredModal'.
visible={sessionExpired}
onClose={() => {
setSessionExpired(false);
Expand Down
40 changes: 40 additions & 0 deletions src/components/FeatureCapabilityHandler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { featureCapabilities, FeatureType } from '../services/featureCapabilities';
import { useGuardedDegradationStore } from '../store/degradationStore';
import { appLogger } from '../utils/logger';

const FeatureCapabilityHandler = () => {
const degradationStore = useGuardedDegradationStore();

useEffect(() => {
const checkCapabilities = async () => {
try {
const capabilities = await featureCapabilities.checkAllCapabilities();
appLogger.infoSync('[App] Feature capabilities checked', {
camera: capabilities.camera.status,
notifications: capabilities.pushNotifications.status,
location: capabilities.location.status,
});
// Update degradation store with current feature statuses
Object.entries(capabilities).forEach(([feature, info]) => {
if (feature !== 'checkedAt' && 'status' in info) {
if ((Object.values(FeatureType) as string[]).includes(feature)) {
degradationStore.setFeatureStatus(feature as FeatureType, info.status);
}
}
});
} catch (error) {
appLogger.errorSync(
'[App] Error checking feature capabilities',
error instanceof Error ? error : new Error(String(error))
);
}
};

checkCapabilities();
}, [degradationStore]);

return null;
};

export default FeatureCapabilityHandler;
42 changes: 25 additions & 17 deletions src/services/api/axios.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,30 +379,41 @@ apiClient.interceptors.response.use(
(error.code === 'ERR_NETWORK' || error.message === 'Network Error') &&
isCertPinFailure(error)
) {
const requestUrl = originalRequest?.url ?? '';
const authApiDomain = new URL(baseURL).hostname;
const requestDomain = requestUrl ? new URL(requestUrl, baseURL).hostname : '';
// Report to Sentry — endpoint and method only; no token, headers, or body
sentryContextService.captureException(new Error('SSL certificate pin validation failed'), {
tags: { 'security.event': 'ssl_pin_failure' },
extra: {
endpoint: originalRequest?.url,
method: originalRequest?.method?.toUpperCase(),
isAuthDomain: requestDomain === authApiDomain,
},
fingerprint: ['ssl-pin-failure'],
fingerprint: ['ssl-pin-failure', requestDomain],
});

appLogger.errorSync('SSL pin validation failed — possible MITM attack', undefined, {
endpoint: originalRequest?.url,
method: originalRequest?.method,
isAuthDomain: requestDomain === authApiDomain,
});

// Force full logout — session may be compromised
useAppStore.getState().logout();
if (requestDomain === authApiDomain) {
// Force full logout — session may be compromised
useAppStore.getState().logout();

return Promise.reject({
message:
'Secure connection could not be established. Please check your network and try again.',
code: 'SSL_PIN_FAILURE',
status: 0,
});
return Promise.reject({
message: 'A security error occurred. Please log in again.',
code: 'SSL_PIN_FAILURE',
});
} else {
// For non-auth domains, cancel the request and show a security warning
return Promise.reject({
message: 'A security error occurred with a third-party service. Please try again later.',
code: 'SSL_PIN_FAILURE_NON_AUTH',
});
}
}

// ── Queue network errors for retry ───────────────────────────────────
Expand Down Expand Up @@ -506,13 +517,10 @@ apiClient.interceptors.response.use(
const rawData = error.response?.data;
const responseData = isConflictResponseShape(rawData) ? rawData : undefined;
if (rawData !== undefined && !isConflictResponseShape(rawData)) {
sentryContextService.captureException(
new Error('409 response body has unexpected shape'),
{
extra: { rawData: String(rawData).slice(0, 200) },
tags: { 'api.error': 'conflict_shape_mismatch' },
}
);
sentryContextService.captureException(new Error('409 response body has unexpected shape'), {
extra: { rawData: String(rawData).slice(0, 200) },
tags: { 'api.error': 'conflict_shape_mismatch' },
});
}

// Extract version metadata from request headers
Expand Down Expand Up @@ -675,4 +683,4 @@ apiClient.interceptors.response.use(
}
);

export default apiClient;
export default apiClient;
63 changes: 49 additions & 14 deletions src/store/degradationStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

import { FeatureStatus, FeatureType } from '../services/featureCapabilities';
import { useFeatureFlagStore } from './featureFlagStore';
import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence';
import { FeatureStatus, FeatureType } from '../services/featureCapabilities';

export interface DegradationNotification {
id: string;
Expand Down Expand Up @@ -102,6 +102,23 @@ const createInitialDegradationState = () => ({
preferences: DEFAULT_PREFERENCES,
});

const createSafeNoOpState = (): DegradationState => ({
...createInitialDegradationState(),
setFeatureStatus: () => {},
isFeatureDegraded: () => true, // Assume degraded if not authenticated
getDegradedFeatures: () => Object.values(FeatureType),
addNotification: () => '',
dismissNotification: () => {},
clearNotifications: () => {},
getUnreadNotifications: () => [],
setShowDegradationBanners: () => {},
setAutoDismissAlerts: () => {},
setRemindPermissionRetry: () => {},
setRespectRemoteFlags: () => {},
disableFeature: () => {},
enableFeature: () => {},
});

let resetDegradationStoreAfterHydrationError = () => {};

/**
Expand Down Expand Up @@ -151,6 +168,9 @@ export const useDegradationStore = create<DegradationState>()(
}),

isFeatureDegraded: (feature: FeatureType): boolean => {
const { isAuthenticated } = useAppStore.getState();
if (!isAuthenticated) return true; // Secure by default

const status = get().featureStatuses[feature];
const hardwareDegraded =
status === FeatureStatus.PERMISSION_DENIED ||
Expand All @@ -171,6 +191,9 @@ export const useDegradationStore = create<DegradationState>()(
},

getDegradedFeatures: (): FeatureType[] => {
const { isAuthenticated } = useAppStore.getState();
if (!isAuthenticated) return Object.values(FeatureType); // Secure by default

const features: FeatureType[] = [];
for (const feature of Object.values(FeatureType)) {
if (get().isFeatureDegraded(feature as FeatureType)) {
Expand Down Expand Up @@ -278,26 +301,38 @@ export const useDegradationStore = create<DegradationState>()(
'degradation-store',
resetDegradationStoreAfterHydrationError
),
/**
* Version 2: bumped from 1 (implicit) to discard any previously-persisted
* state where `degradedFeatures` was serialised as `{}` (empty object)
* due to JSON.stringify(Set) producing `{}`.
*/
version: 2,
migrate: (_persistedState, _fromVersion) => {
// Any state written by version 1 (or earlier) had a corrupt
// `degradedFeatures: {}`. Return undefined so Zustand falls back to
// the initial state defined above.
return undefined;
version: 3,
migrate: (persistedState, fromVersion) => {
if (fromVersion < 3) {
// Versions before 3 may have included derived state.
// We can safely discard it and let it be re-computed.
const { isFeatureDegraded, getDegradedFeatures, ...rest } =
persistedState as DegradationState & {
isFeatureDegraded?: any;
getDegradedFeatures?: any;
};
return rest;
}
return persistedState;
},
partialize: state => ({
preferences: state.preferences,
notifications: state.notifications,
featureStatuses: state.featureStatuses,
// Include degradedFeatures so it survives app restarts.
// Safe to persist now that it is a plain array, not a Set.
degradedFeatures: state.degradedFeatures,
}),
}
)
);

// Selector that returns a no-op, secure-by-default state if the user is not authenticated.
export const useGuardedDegradationStore = () => {
const isAuthenticated = useAppStore(state => state.isAuthenticated);
const store = useDegradationStore();

if (!isAuthenticated) {
return createSafeNoOpState();
}

return store;
};
5 changes: 5 additions & 0 deletions src/types/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare global {
var onunhandledrejection: ((this: Window, ev: PromiseRejectionEvent) => any) | null;
}

export {};
Loading
Loading