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
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
47 changes: 44 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,44 @@ 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;
}
// Guard against merging into the previous line when the insertion point
// is not preceded by a newline (e.g. the file's last line is an #import
// with no trailing newline, so endOfLine === -1 and insertAt === length).
const needsLeadingNewline = insertAt > 0 && appDelegate[insertAt - 1] !== '\n';
appDelegate =
appDelegate.slice(0, insertAt) +
(needsLeadingNewline ? '\n' : '') +
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