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 .github/workflows/ios_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:
- development
- preview
- production
- stage
Copy link
Member

Choose a reason for hiding this comment

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

오 근데 expo 빌드 타입에 stage는 없지 않나요
그래서

development -> stage
production, preview -> production

으로 만들었습니다.

Copy link
Member

Choose a reason for hiding this comment

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

아 추가하셨군요
근데 그냥 preview를 써도 되지 않나요...?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

development: 개발자용. developmentClient가 활성화되어 Expo Dev Client로 실행, 별도 패키지명/구글서비스로 분리됨
preview: QA/테스트 내부 배포용. 스토어 제출 없이 APK로 내부 팀에게 배포
stage: TestFlight(iOS) 용도로 특수하게 사용. 앱 자체는 production 빌드이나 API는 개발 서버(API_ENV: development)를 바라봄 → 앱 이름이 "KONECT S"로 표시
production: 실제 스토어 배포용. Android는 AAB(Play Store 업로드 형식), iOS는 App Store Connect 제출용
이렇게 생각하고 만들었습니다

preview는 internal distribution 이라 testflight 업로드가 안된다고 해서 일단 임의로 stage를 만들었는데 이번 테스트에서만 사용될 것 같아 불필요하다면 푸시 알림 해결되면 제거해도 괜찮을것 같아요

jobs:
build:
runs-on: macos-26
Expand Down
6 changes: 4 additions & 2 deletions app.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { ExpoConfig } from 'expo/config';

const APP_ENV = process.env.EXPO_PUBLIC_APP_ENV || 'production';
const appName = APP_ENV === 'development' ? 'KONECT D' : 'KONECT';
const API_ENV = process.env.EXPO_PUBLIC_API_ENV || APP_ENV;
const appName =
APP_ENV === 'development' ? 'KONECT D' : API_ENV === 'development' ? 'KONECT S' : 'KONECT';
const packageName = APP_ENV === 'development' ? 'com.bcsdlab.konect.dev' : 'com.bcsdlab.konect';
const googleServicesFile =
APP_ENV === 'development' ? './google-services-debug.json' : './google-services.json';
Comment on lines 3 to 9
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent APP_ENV/API_ENV mismatch from producing a stage‑named app with production identifiers.

If API_ENV=development while APP_ENV=production, the app name becomes “KONECT S” but packageName/googleServicesFile stay production, which can overwrite prod installs or bind a stage build to prod Firebase. Consider aligning identifiers with API_ENV or failing fast when envs diverge.

Suggested guard to avoid unsafe env combinations
 const APP_ENV = process.env.EXPO_PUBLIC_APP_ENV || 'production';
 const API_ENV = process.env.EXPO_PUBLIC_API_ENV || APP_ENV;
+if (APP_ENV === 'production' && API_ENV === 'development') {
+  throw new Error(
+    'Invalid env combo: API_ENV=development with APP_ENV=production will use prod bundle IDs/Firebase.'
+  );
+}
 const appName =
   APP_ENV === 'development' ? 'KONECT D' : API_ENV === 'development' ? 'KONECT S' : 'KONECT';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const APP_ENV = process.env.EXPO_PUBLIC_APP_ENV || 'production';
const appName = APP_ENV === 'development' ? 'KONECT D' : 'KONECT';
const API_ENV = process.env.EXPO_PUBLIC_API_ENV || APP_ENV;
const appName = APP_ENV === 'development' ? 'KONECT D' : API_ENV === 'development' ? 'KONECT S' : 'KONECT';
const packageName = APP_ENV === 'development' ? 'com.bcsdlab.konect.dev' : 'com.bcsdlab.konect';
const googleServicesFile =
APP_ENV === 'development' ? './google-services-debug.json' : './google-services.json';
const APP_ENV = process.env.EXPO_PUBLIC_APP_ENV || 'production';
const API_ENV = process.env.EXPO_PUBLIC_API_ENV || APP_ENV;
if (APP_ENV === 'production' && API_ENV === 'development') {
throw new Error(
'Invalid env combo: API_ENV=development with APP_ENV=production will use prod bundle IDs/Firebase.'
);
}
const appName = APP_ENV === 'development' ? 'KONECT D' : API_ENV === 'development' ? 'KONECT S' : 'KONECT';
const packageName = APP_ENV === 'development' ? 'com.bcsdlab.konect.dev' : 'com.bcsdlab.konect';
const googleServicesFile =
APP_ENV === 'development' ? './google-services-debug.json' : './google-services.json';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app.config.ts` around lines 3 - 8, APP_ENV/API_ENV mismatch can produce a
stage-named app with production identifiers; update appName, packageName and
googleServicesFile to be derived from API_ENV (or add a fail-fast check) so
identifiers align with the API target. Concretely, in app.config.ts use API_ENV
to decide appName, packageName and googleServicesFile (or add an explicit guard
that throws/logs an error when APP_ENV !== API_ENV) so a build using
API_ENV=development cannot accidentally use production packageName or
googleServicesFile; adjust the logic around APP_ENV, API_ENV, appName,
packageName and googleServicesFile accordingly.

Expand All @@ -20,7 +22,7 @@ const config: ExpoConfig = {
supportsTablet: true,
usesAppleSignIn: true,
bundleIdentifier: packageName,
buildNumber: '1010500',
buildNumber: '1010604',
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
},
Expand Down
41 changes: 27 additions & 14 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { AppState } from 'react-native';
import { getForceUpdate, appVersion, versionToNumber } from '../services/forceupdate';
import * as Notifications from 'expo-notifications';
import { registerForPushNotificationsAsync, shouldRecheckPermission } from '../services/notifications';
import CookieManager from '@react-native-cookies/cookies';
import { storePushToken } from '../utils/pushTokenStore';
import { storePushToken, initPushTokenStore, getStoredToken } from '../utils/pushTokenStore';
import { getAccessToken } from '../services/nativeAuthStore';
import { registerPushToken } from '../services/pushTokenApi';

Notifications.setNotificationHandler({
handleNotification: async () => ({
Expand All @@ -16,14 +17,6 @@ Notifications.setNotificationHandler({
}),
});

function addTokenToCookie(token: string) {
CookieManager.set('https://agit.gg', {
name: 'EXPO_PUSH_TOKEN',
value: token,
domain: '.agit.gg',
path: '/',
});
}

export default function RootLayout() {
const { replace } = useRouter();
Expand All @@ -34,11 +27,28 @@ export default function RootLayout() {
}, []);

useEffect(() => {
const handleToken = (token?: string) => {
initPushTokenStore();
}, []);

useEffect(() => {
let tokenObtained = false;
let permissionDenied = false;

const handleToken = async (token?: string) => {
if (token) {
addTokenToCookie(token);
storePushToken(token);
tokenObtained = true;
permissionDenied = false;
await storePushToken(token);
console.log('Expo Push Token:', token);

const accessToken = await getAccessToken();
if (accessToken) {
registerPushToken(token).catch((e) =>
console.error('자동 푸시 토큰 등록 실패:', e)
);
}
} else {
permissionDenied = true;
}
};

Expand All @@ -47,7 +57,10 @@ export default function RootLayout() {
.catch((error: any) => console.error(error));

const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active' && shouldRecheckPermission()) {
if (nextAppState !== 'active') return;

const fromSettings = shouldRecheckPermission();
if (fromSettings || (!tokenObtained && !permissionDenied)) {
registerForPushNotificationsAsync()
Comment on lines +62 to 64
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid clearing wentToSettings on non-active transitions.
Line 49 calls shouldRecheckPermission() on every AppState change; that resets the flag (see services/notifications.ts) before the app becomes active, so the retry can be skipped after returning from settings. Move the call inside the nextAppState === 'active' branch.

Suggested fix
-    const fromSettings = shouldRecheckPermission();
-    if (nextAppState === 'active' && (fromSettings || (!tokenObtained && !permissionDenied))) {
-      registerForPushNotificationsAsync()
-        .then(handleToken)
-        .catch((error: any) => console.error(error));
-    }
+    if (nextAppState === 'active') {
+      const fromSettings = shouldRecheckPermission();
+      if (fromSettings || (!tokenObtained && !permissionDenied)) {
+        registerForPushNotificationsAsync()
+          .then(handleToken)
+          .catch((error: any) => console.error(error));
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/_layout.tsx` around lines 49 - 54, The issue is that
shouldRecheckPermission() is being called on every AppState change which clears
the wentToSettings flag too early; move the call into the active-state branch so
the flag is only consumed when nextAppState === 'active'. Concretely, inside the
AppState change handler call shouldRecheckPermission() only when nextAppState
=== 'active' and then use its result to decide whether to call
registerForPushNotificationsAsync(); this keeps wentToSettings in
services/notifications.ts intact during non-active transitions and preserves the
intended retry behavior.

.then(handleToken)
.catch((error: any) => console.error(error));
Expand Down
88 changes: 59 additions & 29 deletions app/webview/[path].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,19 @@ import {
AppStateStatus,
} from 'react-native';
import { Slot, useLocalSearchParams } from 'expo-router';
import { WebView } from 'react-native-webview';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { SafeAreaView } from 'react-native-safe-area-context';
import CookieManager from '@react-native-cookies/cookies';
import { generateUserAgent } from '../../utils/userAgent';
import { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes';
import { webUrl } from '../../constants/constants';
import { onPushToken } from '../../utils/pushTokenStore';
import { getStoredToken } from '../../utils/pushTokenStore';
import { saveAccessToken, clearAccessToken } from '../../services/nativeAuthStore';
import { registerPushToken, unregisterPushToken } from '../../services/pushTokenApi';

const ALLOWED_URL_SCHEMES = ['kakaotalk', 'nidlogin'];
const ALLOWED_ORIGINS = [new URL(webUrl).origin];

const userAgent = generateUserAgent();

const handleOnShouldStartLoadWithRequest = ({ url }: ShouldStartLoadRequest) => {
Expand All @@ -42,37 +46,63 @@ const handleOnShouldStartLoadWithRequest = ({ url }: ShouldStartLoadRequest) =>
export default function Index() {
const webViewRef = useRef<WebView>(null);
const canGoBackRef = useRef(false);
const pageLoadedRef = useRef(false);
const pendingTokenRef = useRef<string | null>(null);
const local = useLocalSearchParams();

const injectPushToken = useCallback((token: string) => {
const safeToken = JSON.stringify(token);
webViewRef.current?.injectJavaScript(`
window.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({ type: 'PUSH_TOKEN', token: ${safeToken} })
}));
true;
`);
}, []);
const handleMessage = useCallback(async (event: WebViewMessageEvent) => {
const origin = event.nativeEvent.url;
if (!origin || !ALLOWED_ORIGINS.some((allowed) => origin.startsWith(allowed))) {
return;
}

useEffect(() => {
return onPushToken((token) => {
if (pageLoadedRef.current) {
injectPushToken(token);
} else {
pendingTokenRef.current = token;
}
});
}, [injectPushToken]);
try {
const data = JSON.parse(event.nativeEvent.data);
const { type } = data;

const handleLoadEnd = useCallback(() => {
pageLoadedRef.current = true;
if (pendingTokenRef.current) {
injectPushToken(pendingTokenRef.current);
pendingTokenRef.current = null;
if (type === 'LOGIN_COMPLETE') {
const { accessToken } = data;
if (!accessToken) return;

await saveAccessToken(accessToken);
console.log('LOGIN_COMPLETE: accessToken 저장 완료');

const pushToken = getStoredToken();
if (pushToken) {
try {
await registerPushToken(pushToken);
console.log('푸시 토큰 백엔드 등록 완료');
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: true } }));true;`
);
} catch (e) {
console.error('푸시 토큰 등록 실패:', e);
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: false } }));true;`
);
}
}
} else if (type === 'TOKEN_REFRESH') {
const { accessToken } = data;
if (accessToken) {
await saveAccessToken(accessToken);
console.log('TOKEN_REFRESH: accessToken 갱신 완료');
}
} else if (type === 'LOGOUT') {
const pushToken = getStoredToken();
if (pushToken) {
try {
await unregisterPushToken(pushToken);
console.log('푸시 토큰 백엔드 삭제 완료');
} catch (e) {
console.error('푸시 토큰 삭제 실패:', e);
}
}
await clearAccessToken();
console.log('LOGOUT: accessToken 삭제 완료');
}
} catch {
// JSON 파싱 실패 등 무시
}
}, [injectPushToken]);
}, []);
Comment on lines +51 to +105
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "[path].tsx" -o -name "*webview*" | grep -i path | head -20

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 53


🏁 Script executed:

fd -t f "path\].tsx"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 93


🏁 Script executed:

fd -t f "\[path\]\.tsx"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 93


🏁 Script executed:

git ls-files | grep -i webview

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 93


🏁 Script executed:

cat -n app/webview/[path].tsx | head -120

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 4852


🏁 Script executed:

rg "webUrl|WebView" app/webview/[path].tsx -A 3 -B 3

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 1514


🏁 Script executed:

rg "onMessage|event.nativeEvent" app/webview/[path].tsx -B 5 -A 5

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 705


🏁 Script executed:

cat -n app/webview/[path].tsx | grep -A 20 "WebView"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 3216


🏁 Script executed:

cat app/constants/constants.ts

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 129


🏁 Script executed:

web_search
react-native-webview WebViewMessageEvent nativeEvent.url property

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 180


🏁 Script executed:

fd -type f "constants" app/

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 241


🏁 Script executed:

rg "webUrl\s*=" --type ts --type tsx -A 2

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 98


🏁 Script executed:

npm list react-native-webview 2>/dev/null || echo "npm unavailable, checking package files"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 168


🏁 Script executed:

cat package.json | rg "react-native-webview"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 109


🏁 Script executed:

fd "constants" -e ts -e tsx

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 93


🏁 Script executed:

find . -name "constants*" -type f

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 13406


🏁 Script executed:

rg "export.*webUrl" --type ts

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 188


🏁 Script executed:

cat package.json | head -50

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 1624


🏁 Script executed:

rg "WebViewMessageEvent\|nativeEvent" --no-filename -A 5 -B 5

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 53


🏁 Script executed:

find . -path ./node_modules -prune -o -name "*.ts" -o -name "*.tsx" -exec grep -l "postMessage\|onMessage" {} \;

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 95


🏁 Script executed:

cat ./node_modules/.pnpm/react-native-webview*/node_modules/react-native-webview/lib/WebViewTypes.d.ts 2>/dev/null | head -100

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 4257


🏁 Script executed:

find ./node_modules -path "*react-native-webview*" -name "*.d.ts" -exec cat {} \; | head -200

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 7357


🏁 Script executed:

cat ./node_modules/.pnpm/react-native-webview@13.15.0/node_modules/react-native-webview/lib/WebViewTypes.d.ts | grep -A 20 "WebViewMessage"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 208


🏁 Script executed:

find ./node_modules -path "*react-native-webview*13.15*" -name "WebViewTypes.d.ts"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 263


🏁 Script executed:

find ./node_modules -path "*react-native-webview*" -name "WebViewTypes.d.ts" 2>/dev/null | head -1 | xargs cat

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 43378


🏁 Script executed:

ls -la ./node_modules/.pnpm/ | grep react-native-webview

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 239


🏁 Script executed:

cat ./node_modules/.pnpm/react-native-webview@13.15.0_react-native@0.81.5_@babel+core@7.29.0_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-webview/lib/WebViewTypes.d.ts | grep -A 10 "interface WebViewMessage"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 338


🏁 Script executed:

cat ./node_modules/.pnpm/react-native-webview@13.15.0_react-native@0.81.5_@babel+core@7.29.0_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-webview/lib/WebViewTypes.d.ts | grep -B 5 "interface WebViewMessage"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 250


Validate WebView message origin before handling auth/token actions.

handleMessage currently trusts all posted messages. Any loaded page can send LOGIN_COMPLETE, TOKEN_REFRESH, or LOGOUT payloads and mutate native token state (save/clear access tokens, register/unregister push tokens).

Suggested change
   const handleMessage = useCallback(async (event: WebViewMessageEvent) => {
     try {
+      const trustedOrigin = new URL(webUrl).origin;
+      const messageUrl = event.nativeEvent.url;
+      if (!messageUrl) return;
+      if (new URL(messageUrl).origin !== trustedOrigin) return;
+
       const data = JSON.parse(event.nativeEvent.data);
       const { type } = data;
+      if (!['LOGIN_COMPLETE', 'TOKEN_REFRESH', 'LOGOUT'].includes(type)) return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleMessage = useCallback(async (event: WebViewMessageEvent) => {
try {
const data = JSON.parse(event.nativeEvent.data);
const { type } = data;
useEffect(() => {
return onPushToken((token) => {
if (pageLoadedRef.current) {
injectPushToken(token);
} else {
pendingTokenRef.current = token;
}
});
}, [injectPushToken]);
if (type === 'LOGIN_COMPLETE') {
const { accessToken } = data;
if (!accessToken) return;
const handleLoadEnd = useCallback(() => {
pageLoadedRef.current = true;
if (pendingTokenRef.current) {
injectPushToken(pendingTokenRef.current);
pendingTokenRef.current = null;
await saveAccessToken(accessToken);
console.log('LOGIN_COMPLETE: accessToken 저장 완료');
const pushToken = getStoredToken();
if (pushToken) {
try {
await registerPushToken(pushToken);
console.log('푸시 토큰 백엔드 등록 완료');
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: true } }));true;`
);
} catch (e) {
console.error('푸시 토큰 등록 실패:', e);
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: false } }));true;`
);
}
}
} else if (type === 'TOKEN_REFRESH') {
const { accessToken } = data;
if (accessToken) {
await saveAccessToken(accessToken);
console.log('TOKEN_REFRESH: accessToken 갱신 완료');
}
} else if (type === 'LOGOUT') {
const pushToken = getStoredToken();
if (pushToken) {
try {
await unregisterPushToken(pushToken);
console.log('푸시 토큰 백엔드 삭제 완료');
} catch (e) {
console.error('푸시 토큰 삭제 실패:', e);
}
}
await clearAccessToken();
console.log('LOGOUT: accessToken 삭제 완료');
}
} catch {
// JSON 파싱 실패 등 무시
}
}, [injectPushToken]);
}, []);
const handleMessage = useCallback(async (event: WebViewMessageEvent) => {
try {
const trustedOrigin = new URL(webUrl).origin;
const messageUrl = event.nativeEvent.url;
if (!messageUrl) return;
if (new URL(messageUrl).origin !== trustedOrigin) return;
const data = JSON.parse(event.nativeEvent.data);
const { type } = data;
if (!['LOGIN_COMPLETE', 'TOKEN_REFRESH', 'LOGOUT'].includes(type)) return;
if (type === 'LOGIN_COMPLETE') {
const { accessToken } = data;
if (!accessToken) return;
await saveAccessToken(accessToken);
console.log('LOGIN_COMPLETE: accessToken 저장 완료');
const pushToken = getStoredToken();
if (pushToken) {
try {
await registerPushToken(pushToken);
console.log('푸시 토큰 백엔드 등록 완료');
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: true } }));true;`
);
} catch (e) {
console.error('푸시 토큰 등록 실패:', e);
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: false } }));true;`
);
}
}
} else if (type === 'TOKEN_REFRESH') {
const { accessToken } = data;
if (accessToken) {
await saveAccessToken(accessToken);
console.log('TOKEN_REFRESH: accessToken 갱신 완료');
}
} else if (type === 'LOGOUT') {
const pushToken = getStoredToken();
if (pushToken) {
try {
await unregisterPushToken(pushToken);
console.log('푸시 토큰 백엔드 삭제 완료');
} catch (e) {
console.error('푸시 토큰 삭제 실패:', e);
}
}
await clearAccessToken();
console.log('LOGOUT: accessToken 삭제 완료');
}
} catch {
// JSON 파싱 실패 등 무시
}
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/webview/`[path].tsx around lines 50 - 99, handleMessage currently
processes any posted message; restrict it by validating the message origin
before acting on auth/token flows. Add an allowlist (e.g., allowedOrigins or
expectedOrigin) and check event.nativeEvent.origin (or event.nativeEvent.url if
origin is not present) against that allowlist at the top of handleMessage; if
the origin is not allowed, return immediately and do not call JSON.parse or any
actions. Keep the rest of the logic (calls to saveAccessToken, clearAccessToken,
registerPushToken, unregisterPushToken, getStoredToken, and
webViewRef.injectJavaScript) unchanged but only execute them after the origin
check passes.


useEffect(() => {
if (Platform.OS === 'android') {
Expand Down Expand Up @@ -121,7 +151,7 @@ export default function Index() {
onShouldStartLoadWithRequest={handleOnShouldStartLoadWithRequest}
originWhitelist={['*']}
startInLoadingState
onLoadEnd={handleLoadEnd}
onMessage={handleMessage}
/>
</SafeAreaView>
);
Expand Down
5 changes: 3 additions & 2 deletions constants/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const APP_ENV = process.env.EXPO_PUBLIC_APP_ENV || 'production';
const API_ENV = process.env.EXPO_PUBLIC_API_ENV || APP_ENV;

export const apiUrl =
APP_ENV === 'development' ? 'https://api.stage.agit.gg' : 'https://api.agit.gg';
export const webUrl = APP_ENV === 'development' ? 'https://stage.agit.gg' : 'https://agit.gg';
API_ENV === 'development' ? 'https://api.stage.agit.gg' : 'https://api.agit.gg';
export const webUrl = API_ENV === 'development' ? 'https://stage.agit.gg' : 'https://agit.gg';
10 changes: 10 additions & 0 deletions eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
"resourceClass": "m-medium"
}
},
"stage": {
"distribution": "store",
"env": {
"EXPO_PUBLIC_APP_ENV": "production",
"EXPO_PUBLIC_API_ENV": "development"
},
"ios": {
"resourceClass": "m-medium"
}
},
Comment on lines +16 to +25
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Confirm EXPO_PUBLIC_APP_ENV: "development" is intentional for a store-distributed build.

distribution: "store" targets TestFlight / App Store channels, but EXPO_PUBLIC_APP_ENV is set to "development" — the same value used by the development profile. If runtime code branches on this variable to select API base URLs, feature flags, or log verbosity, a TestFlight artifact will behave identically to a development build.

If the intent is to have a dedicated staging environment, consider using a distinct value (e.g., "stage") and gating on it appropriately.

🔧 Suggested change if a distinct stage env is intended
     "stage": {
       "distribution": "store",
       "env": {
-        "EXPO_PUBLIC_APP_ENV": "development"
+        "EXPO_PUBLIC_APP_ENV": "stage"
       },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@eas.json` around lines 16 - 24, The eas.json "stage" profile sets
EXPO_PUBLIC_APP_ENV to "development" while using distribution "store"; update
EXPO_PUBLIC_APP_ENV to a distinct value (e.g., "stage") if this profile should
behave differently from the "development" profile, and then ensure runtime
checks that reference EXPO_PUBLIC_APP_ENV (e.g., code paths that select API base
URLs, feature flags, or logging) explicitly handle the new "stage" value; if the
current behavior is intentional, document that EXPO_PUBLIC_APP_ENV="development"
is expected for the "stage" profile.

"preview": {
"distribution": "internal",
"android": {
Expand Down
15 changes: 15 additions & 0 deletions services/nativeAuthStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as SecureStore from 'expo-secure-store';

const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';

export async function saveAccessToken(token: string): Promise<void> {
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, token);
}

export async function getAccessToken(): Promise<string | null> {
return SecureStore.getItemAsync(ACCESS_TOKEN_KEY);
}

export async function clearAccessToken(): Promise<void> {
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
}
55 changes: 55 additions & 0 deletions services/pushTokenApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { apiUrl } from '../constants/constants';
import { getAccessToken } from './nativeAuthStore';

const REQUEST_TIMEOUT_MS = 10_000;

function fetchWithTimeout(url: string, options: RequestInit): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);

return fetch(url, { ...options, signal: controller.signal }).finally(() =>
clearTimeout(timeoutId)
);
}

export async function registerPushToken(pushToken: string): Promise<void> {
const accessToken = await getAccessToken();
if (!accessToken) {
console.warn('registerPushToken: accessToken 없음, 등록 생략');
return;
}

const res = await fetchWithTimeout(`${apiUrl}/notifications/tokens`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ token: pushToken }),
});

if (!res.ok) {
throw new Error(`registerPushToken failed: ${res.status}`);
}
}

export async function unregisterPushToken(pushToken: string): Promise<void> {
const accessToken = await getAccessToken();
if (!accessToken) {
console.warn('unregisterPushToken: accessToken 없음, 삭제 생략');
return;
}

const res = await fetchWithTimeout(`${apiUrl}/notifications/tokens`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ token: pushToken }),
});

if (!res.ok) {
throw new Error(`unregisterPushToken failed: ${res.status}`);
}
}
16 changes: 15 additions & 1 deletion utils/pushTokenStore.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import * as SecureStore from 'expo-secure-store';

const PUSH_TOKEN_KEY = 'PUSH_TOKEN';

let _token: string | null = null;
const _callbacks: ((token: string) => void)[] = [];

export const storePushToken = (token: string) => {
export async function initPushTokenStore(): Promise<void> {
const saved = await SecureStore.getItemAsync(PUSH_TOKEN_KEY);
if (saved) {
_token = saved;
}
}

export const storePushToken = async (token: string) => {
_token = token;
await SecureStore.setItemAsync(PUSH_TOKEN_KEY, token);
_callbacks.forEach((cb) => cb(token));
_callbacks.length = 0;
};

export const getStoredToken = (): string | null => _token;

export const onPushToken = (cb: (token: string) => void): (() => void) => {
if (_token) {
cb(_token);
Expand Down
Loading