Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ expo-env.d.ts
.dual-graph/
.mcp.json
opencode.json
/.dual-graph-pro
48 changes: 48 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,51 @@
<!-- dgc-policy-v1 -->
# Dual-Graph Context Policy

This project uses a local dual-graph MCP server (graperoot-pro) for efficient,
budget-aware context retrieval. Always prefer it over native file exploration.

## MANDATORY: Always follow this order

1. **Call `graph_continue` first** -- before any file exploration, grep, or code reading.

2. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with the
current project directory (`pwd`). Do NOT ask the user.

3. **If `graph_continue` returns `skip=true`**: project is too small for the graph to
help. Skip all graph tools and explore normally.

4. **Read `recommended_files`** using `graph_read` -- one call per file.
- `recommended_files` may contain `file::symbol` entries (e.g. `src/auth.ts::handleLogin`).
Pass them verbatim to `graph_read(file: "src/auth.ts::handleLogin")` -- it reads only
that symbol's lines, not the full file.

5. **Check `confidence` and obey the caps strictly:**
- `confidence=high` -> Stop. Do NOT grep or explore further.
- `confidence=medium` -> If recommended files are insufficient, call `fallback_rg`
at most `max_supplementary_greps` time(s) with specific terms, then `graph_read`
at most `max_supplementary_files` additional file(s). Then stop.
- `confidence=low` -> Call `fallback_rg` at most `max_supplementary_greps` time(s),
then `graph_read` at most `max_supplementary_files` file(s). Then stop.

## Exhaustive enumeration tasks

Some tasks require scanning **every file** -- e.g. "find all dead exports", "list every
.find() without a limit", "audit all test files". Use these tools first:

- **`graph_dead_exports()`** -- pre-computed at scan time. Use for any dead-export task.
- **`graph_grep_all(pattern, file_glob?, max_hits?)`** -- exhaustive grep, no call cap.

## Rules

- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue`.
- Do NOT do broad/recursive exploration at any confidence level.
- After edits, call `graph_register_edit(files: ["path/to/file"])`. The parameter is
`files` (plural, always an array). Use `file::symbol` notation when the edit targets
a specific function, class, or hook.
<!-- /dgc-policy-v1 -->

---

# Resgrid Unit — AI Coding Guidelines

> **Resgrid Unit** is a multi-platform emergency response mobile application built with TypeScript, React Native, Expo (managed + prebuild), targeting iOS, Android, Web, and Electron.
Expand Down
42 changes: 39 additions & 3 deletions plugins/withWebRTCFrameworkFix.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ const fs = require('fs');
const path = require('path');

/**
* Config plugin that patches the Podfile for Xcode 26+ compatibility with
* use_frameworks! :linkage => :static (required by @react-native-firebase).
* Config plugin that patches the Podfile and source files for Xcode 26+
* compatibility with use_frameworks! :linkage => :static (required by
* @react-native-firebase).
*
* Fixes two classes of errors:
* Fixes three classes of errors:
* 1. "include of non-modular header inside framework module" — sets
* CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES for all pods.
* 2. "declaration of 'X' must be imported from module 'Y' before it is required"
* — adds use_modular_headers! so #import statements resolve correctly across
* framework module boundaries.
* 3. "RCTPromiseRejectBlock must be imported from module 'RNFBApp.RNFBAppModule'"
* — patches RNFBMessaging source files to import RNFBAppModule explicitly.
*/
const withWebRTCFrameworkFix = (config) => {
return withDangerousMod(config, [
Expand Down Expand Up @@ -76,6 +79,39 @@ const withWebRTCFrameworkFix = (config) => {
}

fs.writeFileSync(podfilePath, contents);

// 3. Patch RNFBMessaging source files to import RNFBAppModule explicitly.
// With use_frameworks! :linkage => :static on Xcode 26, the module
// system requires RNFBMessaging to import RNFBAppModule to access
// RCTPromiseRejectBlock and RCTPromiseResolveBlock.
const projectRoot = config.modRequest.projectRoot;
const appDelegatePath = path.join(
projectRoot,
'node_modules/@react-native-firebase/messaging/ios/RNFBMessaging/RNFBMessaging+AppDelegate.h'
);

if (fs.existsSync(appDelegatePath)) {
let appDelegate = fs.readFileSync(appDelegatePath, 'utf-8');
const rnfbImport = '#import <RNFBApp/RNFBAppModule.h>';
if (!appDelegate.includes(rnfbImport)) {
// Insert after the last #import line, or at the start if none exist.
const lastImportIdx = appDelegate.lastIndexOf('#import');
let insertAt;
if (lastImportIdx === -1) {
insertAt = 0;
} else {
const endOfLine = appDelegate.indexOf('\n', lastImportIdx);
insertAt = endOfLine === -1 ? appDelegate.length : endOfLine + 1;
}
appDelegate =
appDelegate.slice(0, insertAt) +
rnfbImport +
'\n' +
appDelegate.slice(insertAt);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fs.writeFileSync(appDelegatePath, appDelegate);
}
}

return config;
},
]);
Expand Down
17 changes: 9 additions & 8 deletions src/api/common/client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, isAxiosError } from 'axios';
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';

import { refreshTokenRequest } from '@/lib/auth/api';
import { isRefreshCredentialRejection } from '@/lib/auth/token-refresh';
import { logger } from '@/lib/logging';
import { getBaseApiUrl } from '@/lib/storage/app';
import useAuthStore from '@/stores/auth/store';
Expand Down Expand Up @@ -103,19 +104,19 @@ axiosInstance.interceptors.response.use(
} catch (refreshError) {
processQueue(refreshError as Error);

// Check if it's a network error vs an invalid refresh token
const isNetworkError = isAxiosError(refreshError) && !refreshError.response;

if (!isNetworkError) {
// Only logout for non-network errors (e.g., invalid refresh token, 400/401 from token endpoint)
// Only log the user out when the token endpoint explicitly rejected the
// refresh token (400 invalid_grant / 401). Transient failures — no response
// (offline/timeout), 5xx server errors, or 429 — must preserve the session
// and retry, otherwise a backend incident logs out every active responder.
if (isRefreshCredentialRejection(refreshError)) {
logger.warn({
message: 'Token refresh failed with non-recoverable error, logging out user',
message: 'Token refresh rejected by server (invalid/expired credentials), logging out user',
context: { error: refreshError },
});
useAuthStore.getState().logout();
} else {
logger.warn({
message: 'Token refresh failed due to network error',
message: 'Token refresh failed transiently, preserving session for retry',
context: { error: refreshError },
});
}
Expand Down
36 changes: 30 additions & 6 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
import { useWeatherAlertsStore } from '@/stores/weather-alerts/store';
import { isNetworkError } from '@/utils/network';

export default function TabLayout() {
const { t } = useTranslation();
Expand Down Expand Up @@ -178,10 +179,20 @@ export default function TabLayout() {
message: 'App initialization completed successfully',
});
} catch (error) {
logger.error({
message: 'Failed to initialize app',
context: { error },
});
// Transient connectivity failures are expected (e.g. brief network loss) and
// already logged deeper in the stack, so keep them at warn to avoid reporting
// the same recoverable error to Sentry. Genuine failures still report as errors.
if (isNetworkError(error)) {
logger.warn({
message: 'Failed to initialize app due to network connectivity',
context: { error },
});
} else {
logger.error({
message: 'Failed to initialize app',
context: { error },
});
}
// Reset initialization state on error so it can be retried
hasInitialized.current = false;
setInitRetryCount((c) => c + 1);
Expand Down Expand Up @@ -234,7 +245,14 @@ export default function TabLayout() {
}
}, [status, initRetryCount]);
useEffect(() => {
const shouldInitialize = status === 'signedIn' && !hasInitialized.current && !isInitializing.current && initRetryCount < MAX_INIT_RETRIES;
// Defer initialization while the app is in the background. iOS cold-launches the
// app in the background (push wake, background fetch) where network access is
// restricted, so running init here just fails the config fetch with a transient
// "Network Error" and burns the retry budget. Wait until the app is foregrounded —
// this effect re-runs when appState changes to 'active'. (appState is always
// 'active' on web, so web behavior is unchanged.)
const isAppInBackground = Platform.OS !== 'web' && appState === 'background';
const shouldInitialize = status === 'signedIn' && !isAppInBackground && !hasInitialized.current && !isInitializing.current && initRetryCount < MAX_INIT_RETRIES;

if (shouldInitialize) {
logger.info({
Expand All @@ -246,7 +264,7 @@ export default function TabLayout() {
});
initializeApp();
}
}, [status, initializeApp, initRetryCount]);
}, [status, initializeApp, initRetryCount, appState]);

// Handle app resuming from background - separate from initialization
useEffect(() => {
Expand Down Expand Up @@ -363,6 +381,12 @@ export default function TabLayout() {
headerLeft: headerLeftMap,
tabBarButtonTestID: 'map-tab' as const,
headerRight: headerRightNotification,
// Freeze (suspend) the map screen when switching tabs instead of letting
// react-native-screens detach/destroy its native views. Tearing the map
// down while a camera event is in flight crashes natively in @rnmapbox/maps'
// Fabric event emitter (EXC_BAD_ACCESS in RNMBXMapViewEventEmitter::onCameraChanged,
// a use-after-free). Freezing preserves the native MapView, shrinking that race.
freezeOnBlur: true,
}),
[t, mapIcon, headerLeftMap, headerRightNotification]
);
Expand Down
27 changes: 22 additions & 5 deletions src/app/(app)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ function MapContent() {
const mapRef = useRef<React.ElementRef<typeof Mapbox.MapView>>(null);
const cameraRef = useRef<any>(null); // Using any due to imperative handle
const [isMapReady, setIsMapReady] = useState(false);
// Track screen focus so camera follow/animations can be stopped on blur. A camera
// event delivered while the native map view is tearing down crashes in @rnmapbox/maps
// (onCameraChanged use-after-free), so we quiet the camera when the user navigates
// away to shrink that window.
const [isScreenFocused, setIsScreenFocused] = useState(true);
const [hasUserMovedMap, setHasUserMovedMap] = useState(false);
const [mapPins, setMapPins] = useState<MapMakerInfoData[]>([]);
const [selectedPin, setSelectedPin] = useState<MapMakerInfoData | null>(null);
Expand Down Expand Up @@ -198,6 +203,9 @@ function MapContent() {
// Handle navigation focus - reset map state when user navigates back to map page
useFocusEffect(
useCallback(() => {
// Mark the screen focused again so camera follow/animations are allowed
setIsScreenFocused(true);

// Reset hasUserMovedMap when navigating back to map
setHasUserMovedMap(false);

Expand Down Expand Up @@ -228,6 +236,12 @@ function MapContent() {
},
});
}

// On blur (cleanup), stop the camera following/animating before the native
// map view is detached, so a camera event can't fire into a torn-down view.
return () => {
setIsScreenFocused(false);
};
}, [isMapReady, locationLatitude, locationLongitude, isMapLocked, locationHeading])
);

Expand Down Expand Up @@ -258,7 +272,10 @@ function MapContent() {
}, []);

useEffect(() => {
if (isMapReady && locationLatitude && locationLongitude) {
// Skip camera animations while the screen is unfocused so a location update
// arriving during/after navigation away doesn't drive the (possibly tearing
// down) native map view.
if (isScreenFocused && isMapReady && locationLatitude && locationLongitude) {
// When map is locked, always follow the location
// When map is unlocked, only follow if user hasn't moved the map
if (isMapLocked || !hasUserMovedMap) {
Expand All @@ -278,7 +295,7 @@ function MapContent() {
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMapReady, locationLatitude, locationLongitude, locationHeading, isMapLocked]);
}, [isScreenFocused, isMapReady, locationLatitude, locationLongitude, locationHeading, isMapLocked]);
// NOTE: hasUserMovedMap intentionally excluded from deps to avoid toggle loop
// on web where programmatic easeTo → moveend → setHasUserMovedMap(true) → re-trigger.

Expand Down Expand Up @@ -514,9 +531,9 @@ function MapContent() {
ref={cameraRef}
defaultSettings={initialCameraSettings}
followZoomLevel={isMapLocked ? 16 : 12}
followUserLocation={isMapLocked}
followUserMode={isMapLocked ? Mapbox.UserTrackingMode.FollowWithHeading : undefined}
followPitch={isMapLocked ? 45 : undefined}
followUserLocation={isMapLocked && isScreenFocused}
followUserMode={isMapLocked && isScreenFocused ? Mapbox.UserTrackingMode.FollowWithHeading : undefined}
followPitch={isMapLocked && isScreenFocused ? 45 : undefined}
/>

{locationLatitude != null && locationLongitude != null ? (
Expand Down
45 changes: 45 additions & 0 deletions src/lib/auth/__tests__/token-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from '@jest/globals';
import { AxiosError, type AxiosResponse } from 'axios';

import { isRefreshCredentialRejection } from '../token-refresh';

const axiosErrorWithStatus = (status: number): AxiosError => {
const response = { status, statusText: '', data: {}, headers: {}, config: {} as never } as AxiosResponse;
return new AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, response);
};

describe('isRefreshCredentialRejection', () => {
it('returns true when the token endpoint rejects the refresh token (400 invalid_grant)', () => {
expect(isRefreshCredentialRejection(axiosErrorWithStatus(400))).toBe(true);
});

it('returns true for a 401 from the token endpoint', () => {
expect(isRefreshCredentialRejection(axiosErrorWithStatus(401))).toBe(true);
});

it.each([500, 502, 503, 504, 429])('returns false for transient server status %i (preserve session)', (status) => {
expect(isRefreshCredentialRejection(axiosErrorWithStatus(status))).toBe(false);
});

it('returns false for 403 (ambiguous, not a definitive credential rejection)', () => {
expect(isRefreshCredentialRejection(axiosErrorWithStatus(403))).toBe(false);
Comment on lines +12 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# List relevant files
fd -a "token-refresh.ts" src/lib/auth || true
fd -a "token-refresh.test.ts" src/lib/auth/__tests__ || true
fd -a "client.tsx" src/api/common || true

# Print the test file section
echo "---- token-refresh.test.ts (lines 1-220) ----"
sed -n '1,220p' src/lib/auth/__tests__/token-refresh.test.ts

# Print predicate implementation
echo "---- token-refresh.ts (lines 1-240) ----"
sed -n '1,240p' src/lib/auth/token-refresh.ts

# Print usage in client
echo "---- client.tsx (lines 1-220) ----"
sed -n '1,220p' src/api/common/client.tsx

echo "---- client.tsx (search for isRefreshCredentialRejection) ----"
rg -n "isRefreshCredentialRejection" src/api/common/client.tsx src/lib/auth -S

Repository: Resgrid/Unit

Length of output: 10220


Don’t treat every HTTP 400 from the token endpoint as an invalid refresh credential.

  • isRefreshCredentialRejection returns true for any 400 (status-only), and src/api/common/client.tsx logs the user out whenever it returns true.
  • The JSDoc contract says “400 (invalid_grant) or 401”, but the predicate/tests don’t check for invalid_grant, so the suite currently locks in “logout-on-any-400”.
  • Add a negative test for a 400 response that’s not invalid_grant (and/or update the predicate to inspect the OAuth error payload), or adjust the JSDoc contract.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/auth/__tests__/token-refresh.test.ts` around lines 12 - 25, The
predicate is too broad: isRefreshCredentialRejection currently treats any 400 as
an invalid refresh and causes logout; update isRefreshCredentialRejection to
only return true for status 400 when the OAuth error payload explicitly
indicates "invalid_grant" (inspect error.response?.data?.error or similar) or
for 401 as before, then add a unit test using axiosErrorWithStatus to assert
false for a 400 response whose body does not contain error: "invalid_grant" and
a positive test for a 400 with error: "invalid_grant"; also ensure the logout
callsite in src/api/common/client.tsx still calls isRefreshCredentialRejection
and requires no other changes.

});

it('returns false for a network error (no response — offline/DNS/TLS)', () => {
expect(isRefreshCredentialRejection(new AxiosError('Network Error', 'ERR_NETWORK'))).toBe(false);
});

it('returns false for a timeout (no response)', () => {
expect(isRefreshCredentialRejection(new AxiosError('timeout exceeded', 'ECONNABORTED'))).toBe(false);
});

it('returns false for a non-Axios error (e.g. "no refresh token available")', () => {
expect(isRefreshCredentialRejection(new Error('No refresh token available'))).toBe(false);
});

it('returns false for non-error values', () => {
expect(isRefreshCredentialRejection(null)).toBe(false);
expect(isRefreshCredentialRejection(undefined)).toBe(false);
expect(isRefreshCredentialRejection({ response: { status: 401 } })).toBe(false);
});
});
7 changes: 5 additions & 2 deletions src/lib/auth/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@ export const refreshTokenRequest = async (refreshToken: string): Promise<AuthRes

return response.data;
} catch (error) {
logger.error({
message: 'Token refresh failed',
// The response interceptor (api/common/client.tsx) classifies this failure and
// logs the actionable outcome (transient vs. credential rejection). Keep this at
// warn so a transient refresh failure isn't duplicated to Sentry as an error.
logger.warn({
message: 'Token refresh request failed',
context: { error },
});
throw error;
Expand Down
25 changes: 25 additions & 0 deletions src/lib/auth/token-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { isAxiosError } from 'axios';

/**
* Decides whether a failed token-refresh attempt means the stored credentials are
* known-bad and the user must be logged out.
*
* Returns true ONLY when the OAuth2 token endpoint explicitly rejected the refresh
* token — HTTP 400 (`invalid_grant`) or 401. Every other failure is treated as
* transient and recoverable:
* - no response at all (offline, DNS/TLS failure, request timeout)
* - 5xx server errors (the backend is down/degraded, e.g. 502 Bad Gateway)
* - 429 rate limiting
* - any non-Axios error (e.g. "no refresh token available")
*
* Treating those as recoverable (reject + retry on the next request) instead of
* forcing logout prevents a backend incident from logging out every active user at
* once — critical for an emergency-response app.
*/
export const isRefreshCredentialRejection = (error: unknown): boolean => {
if (!isAxiosError(error)) {
return false;
}
const status = error.response?.status;
return status === 400 || status === 401;
};
Loading
Loading