From 068027a827bd0b65c1ba9ce731805098bb6056da Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Tue, 25 Aug 2026 01:31:39 +0900 Subject: [PATCH 1/5] fix(mobile): defer manager transfer entry until startup settles --- apps/mobile/App.tsx | 172 ++++++++++++++---- apps/mobile/src/config/linking.test.ts | 39 +++- apps/mobile/src/config/linking.ts | 31 ++++ .../components/ManagerTransferSection.tsx | 3 +- .../ManagerTransferAcceptanceScreen/index.tsx | 22 ++- .../club/utils/managerTransfer.test.ts | 16 ++ .../features/club/utils/managerTransfer.ts | 9 + .../src/shared/components/AppModalManager.tsx | 26 +-- .../src/shared/components/ForceUpdateGate.tsx | 19 +- .../shared/contexts/appModalFlowContext.tsx | 92 ++++++++++ .../contexts/loginBottomSheetContext.tsx | 7 +- .../src/shared/hooks/useAnnouncementModals.ts | 38 +++- .../src/shared/hooks/usePendingTerms.ts | 22 ++- .../src/shared/utils/appModalFlow.test.ts | 128 +++++++++++++ apps/mobile/src/shared/utils/appModalFlow.ts | 56 ++++++ .../src/shared/utils/entryIntent.test.ts | 97 ++++++++++ apps/mobile/src/shared/utils/entryIntent.ts | 61 +++++++ .../src/shared/utils/entrySplash.test.ts | 20 +- apps/mobile/src/shared/utils/entrySplash.ts | 15 +- 19 files changed, 787 insertions(+), 86 deletions(-) create mode 100644 apps/mobile/src/shared/contexts/appModalFlowContext.tsx create mode 100644 apps/mobile/src/shared/utils/appModalFlow.test.ts create mode 100644 apps/mobile/src/shared/utils/appModalFlow.ts create mode 100644 apps/mobile/src/shared/utils/entryIntent.test.ts create mode 100644 apps/mobile/src/shared/utils/entryIntent.ts diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index c1dcc9b7..faecdb3f 100644 --- a/apps/mobile/App.tsx +++ b/apps/mobile/App.tsx @@ -7,15 +7,15 @@ import { QueryClientProvider, } from "@tanstack/react-query"; import type React from "react"; -import { useCallback, useEffect, useState } from "react"; -import { AppState, StyleSheet, Text, View } from "react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AppState, Linking, StyleSheet, Text, View } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { initialWindowMetrics, SafeAreaProvider, } from "react-native-safe-area-context"; import Toast, { type ToastConfig } from "react-native-toast-message"; -import { linking } from "@/config/linking"; +import { createAppLinking } from "@/config/linking"; import AnnouncementEditScreen from "@/features/club/screens/AnnouncementEditScreen"; import AnnouncementRegistrationScreen from "@/features/club/screens/AnnouncementRegistrationScreen"; import ManagerTransferAcceptanceScreen from "@/features/club/screens/ManagerTransferAcceptanceScreen"; @@ -37,15 +37,26 @@ import { SCREEN_TYPE, } from "@/shared/constants/screen"; import { typography } from "@/shared/constants/typography"; +import { + AppModalFlowProvider, + useAppModalFlow, +} from "@/shared/contexts/appModalFlowContext"; import { LoginBottomSheetProvider } from "@/shared/contexts/loginBottomSheetContext"; import { ManageClubBottomSheetProvider } from "@/shared/contexts/manageClubBottomSheet"; import { ProfileProvider } from "@/shared/contexts/profileContext"; import { serviceContext } from "@/shared/contexts/serviceContext"; import { UserVoiceBottomSheetProvider } from "@/shared/contexts/userVoiceBottomSheetContext"; import { initToken } from "@/shared/utils/api"; -import { shouldShowGlobalAppModals } from "@/shared/utils/entrySplash"; +import { + getNavigationInitialUrl, + type PendingEntryIntent, + parsePendingEntryIntent, + replacePendingEntryIntent, + resolveExecutableEntryIntent, +} from "@/shared/utils/entryIntent"; import { _navigationRef, + navigation, setIsNavigationReady, } from "@/shared/utils/navigation"; import { ms, s, vs } from "@/shared/utils/scale"; @@ -73,6 +84,36 @@ const queryClient = new QueryClient({ }, }); +type PendingEntryIntentHandlerProps = { + pendingEntryIntent: PendingEntryIntent; + isNavigationReady: boolean; + onConsume: () => void; +}; + +const PendingEntryIntentHandler = ({ + pendingEntryIntent, + isNavigationReady, + onConsume, +}: PendingEntryIntentHandlerProps) => { + const { state: appModalFlowState } = useAppModalFlow(); + + useEffect(() => { + const executableIntent = resolveExecutableEntryIntent({ + pendingIntent: pendingEntryIntent, + isNavigationReady, + appModalFlowState, + }); + if (!executableIntent) return; + + onConsume(); + navigation.navigate(SCREEN_TYPE.MANAGER_TRANSFER_ACCEPTANCE, { + token: executableIntent.token, + }); + }, [appModalFlowState, isNavigationReady, onConsume, pendingEntryIntent]); + + return null; +}; + function App(): React.JSX.Element { const { Provider: ServiceProvider } = serviceContext; @@ -127,17 +168,66 @@ function App(): React.JSX.Element { // 그렇지 않으면 _token이 채워지기 전에 /v2/users/me가 게스트로 나가 자동로그인이 깨진다. const [isBootstrapped, setIsBootstrapped] = useState(false); const [entryFlowComplete, setEntryFlowComplete] = useState(false); + const [initialNavigationUrl, setInitialNavigationUrl] = useState< + string | null + >(null); + const [pendingEntryIntent, setPendingEntryIntent] = + useState(null); + const [isNavigationReady, setNavigationReady] = useState(false); const handleEntryComplete = useCallback(() => setEntryFlowComplete(true), []); + const handlePendingEntryIntent = useCallback( + (intent: NonNullable) => { + setPendingEntryIntent((currentIntent) => + replacePendingEntryIntent(currentIntent, intent), + ); + }, + [], + ); + const handleConsumePendingEntryIntent = useCallback( + () => setPendingEntryIntent(null), + [], + ); + const handleNavigationReady = useCallback(() => { + setIsNavigationReady(true); + setNavigationReady(true); + }, []); + const appLinking = useMemo( + () => + createAppLinking({ + initialUrl: initialNavigationUrl, + onPendingEntryIntent: handlePendingEntryIntent, + }), + [handlePendingEntryIntent, initialNavigationUrl], + ); useEffect(() => { + let isCancelled = false; const bootstrap = async () => { - await initToken(); - setIsNavigationReady(true); + const [, initialUrl] = await Promise.all([ + initToken(), + Linking.getInitialURL(), + ]); + if (isCancelled) return; + + const initialEntryIntent = parsePendingEntryIntent(initialUrl); + setPendingEntryIntent(initialEntryIntent); + setInitialNavigationUrl(getNavigationInitialUrl(initialUrl)); setIsBootstrapped(true); }; bootstrap(); + + return () => { + isCancelled = true; + }; }, []); + useEffect( + () => () => { + setIsNavigationReady(false); + }, + [], + ); + useEffect(() => { const subscription = AppState.addEventListener("change", (nextAppState) => { focusManager.setFocused(nextAppState === "active"); @@ -159,40 +249,46 @@ function App(): React.JSX.Element { - - - - - + + + + - - - - - - - {shouldShowGlobalAppModals({ entryFlowComplete }) && ( + + + + + + + + - )} - - - + + + + diff --git a/apps/mobile/src/config/linking.test.ts b/apps/mobile/src/config/linking.test.ts index 7de75edd..cd1f473c 100644 --- a/apps/mobile/src/config/linking.test.ts +++ b/apps/mobile/src/config/linking.test.ts @@ -1,5 +1,6 @@ import { getStateFromPath } from "@react-navigation/native"; -import { linking } from "@/config/linking"; +import { Linking } from "react-native"; +import { createAppLinking, linking } from "@/config/linking"; import { SCREEN_TYPE } from "@/shared/constants/screen"; jest.mock("react-native-config", () => ({ @@ -23,4 +24,40 @@ describe("deep linking", () => { ], }); }); + + it("실행 중 관리자 이전 링크는 보류하고 일반 링크만 네비게이션에 전달한다", () => { + let urlHandler: ((event: { url: string }) => void) | undefined; + const remove = jest.fn(); + jest + .spyOn(Linking, "addEventListener") + .mockImplementation((_eventType, handler) => { + urlHandler = handler; + return { remove } as unknown as ReturnType< + typeof Linking.addEventListener + >; + }); + const onPendingEntryIntent = jest.fn(); + const navigationListener = jest.fn(); + const token = "b".repeat(43); + const appLinking = createAppLinking({ + initialUrl: null, + onPendingEntryIntent, + }); + + const unsubscribe = appLinking.subscribe?.(navigationListener); + urlHandler?.({ + url: `https://dev.all-clear.cc/manager-transfer/${token}`, + }); + urlHandler?.({ url: "https://dev.all-clear.cc/club/example" }); + unsubscribe?.(); + + expect(onPendingEntryIntent).toHaveBeenCalledWith({ + type: "MANAGER_TRANSFER", + token, + }); + expect(navigationListener).toHaveBeenCalledWith( + "https://dev.all-clear.cc/club/example", + ); + expect(remove).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/mobile/src/config/linking.ts b/apps/mobile/src/config/linking.ts index 7442be28..9082d9a8 100644 --- a/apps/mobile/src/config/linking.ts +++ b/apps/mobile/src/config/linking.ts @@ -1,9 +1,14 @@ import type { LinkingOptions } from "@react-navigation/native"; +import { Linking } from "react-native"; import { ENV } from "@/config/ENV"; import { type RootStackParamList, SCREEN_TYPE, } from "@/shared/constants/screen"; +import { + type PendingEntryIntent, + parsePendingEntryIntent, +} from "@/shared/utils/entryIntent"; export const linking: LinkingOptions = { prefixes: [ @@ -27,3 +32,29 @@ export const linking: LinkingOptions = { }, }, }; + +type CreateAppLinkingParams = { + initialUrl: string | null; + onPendingEntryIntent: (intent: NonNullable) => void; +}; + +export const createAppLinking = ({ + initialUrl, + onPendingEntryIntent, +}: CreateAppLinkingParams): LinkingOptions => ({ + ...linking, + getInitialURL: () => initialUrl, + subscribe: (listener) => { + const subscription = Linking.addEventListener("url", ({ url }) => { + const pendingIntent = parsePendingEntryIntent(url); + if (pendingIntent) { + onPendingEntryIntent(pendingIntent); + return; + } + + listener(url); + }); + + return () => subscription.remove(); + }, +}); diff --git a/apps/mobile/src/features/club/components/ManagerTransferSection.tsx b/apps/mobile/src/features/club/components/ManagerTransferSection.tsx index a75441a1..da48c52d 100644 --- a/apps/mobile/src/features/club/components/ManagerTransferSection.tsx +++ b/apps/mobile/src/features/club/components/ManagerTransferSection.tsx @@ -5,6 +5,7 @@ import Toast from "react-native-toast-message"; import { copyManagerTransferLink, getManagerTransferErrorContent, + MANAGER_TRANSFER_LINK_COPIED_DESCRIPTION, } from "@/features/club/utils/managerTransfer"; import type { ClubManager } from "@/repositories/club"; import AlertModal from "@/shared/components/AlertModal"; @@ -99,7 +100,7 @@ const ManagerTransferSection = ({ clubId, clubName, managers }: Props) => { visible={phase === "copied"} onClose={() => setPhase("idle")} title="링크 복사 완료!" - description="관리자 권한을 이전할 상대에게\n링크를 보내주세요" + description={MANAGER_TRANSFER_LINK_COPIED_DESCRIPTION} buttonLabel="확인" onButtonPress={() => setPhase("idle")} /> diff --git a/apps/mobile/src/features/club/screens/ManagerTransferAcceptanceScreen/index.tsx b/apps/mobile/src/features/club/screens/ManagerTransferAcceptanceScreen/index.tsx index 865296f3..90c2c81b 100644 --- a/apps/mobile/src/features/club/screens/ManagerTransferAcceptanceScreen/index.tsx +++ b/apps/mobile/src/features/club/screens/ManagerTransferAcceptanceScreen/index.tsx @@ -1,9 +1,10 @@ import type { NativeStackScreenProps } from "@react-navigation/native-stack"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useContext, useEffect, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { + canLoadManagerTransferInvitation, getManagerTransferErrorAction, getManagerTransferErrorContent, } from "@/features/club/utils/managerTransfer"; @@ -16,6 +17,7 @@ import type { SCREEN_TYPE, } from "@/shared/constants/screen"; import { typography } from "@/shared/constants/typography"; +import { useAppModalFlow } from "@/shared/contexts/appModalFlowContext"; import { useLoginBottomSheet } from "@/shared/contexts/loginBottomSheetContext"; import { useProfile } from "@/shared/contexts/profileContext"; import { serviceContext } from "@/shared/contexts/serviceContext"; @@ -34,21 +36,27 @@ const ManagerTransferAcceptanceScreen = ({ const { clubService } = useContext(serviceContext); const { user, isLoading: isProfileLoading } = useProfile(); const { openBottomSheet } = useLoginBottomSheet(); + const { state: appModalFlowState } = useAppModalFlow(); const queryClient = useQueryClient(); const hasPromptedLogin = useRef(false); const [acceptedClubName, setAcceptedClubName] = useState(null); const [acceptanceError, setAcceptanceError] = useState(null); + const openLoginForTransfer = useCallback(() => { + openBottomSheet(() => { + queryClient.invalidateQueries({ queryKey: ["terms"] }); + }); + }, [openBottomSheet, queryClient]); useEffect(() => { if (isProfileLoading || user || hasPromptedLogin.current) return; hasPromptedLogin.current = true; - openBottomSheet(); - }, [isProfileLoading, openBottomSheet, user]); + openLoginForTransfer(); + }, [isProfileLoading, openLoginForTransfer, user]); const invitationQuery = useQuery({ queryKey: ["managerTransferInvitation", token], queryFn: () => clubService.getManagerTransferInvitation({ token }), - enabled: !!user, + enabled: canLoadManagerTransferInvitation(Boolean(user), appModalFlowState), retry: false, }); @@ -89,7 +97,9 @@ const ManagerTransferAcceptanceScreen = ({ invitationQuery.refetch(); }; - const isLoading = isProfileLoading || (!!user && invitationQuery.isLoading); + const isLoading = + isProfileLoading || + (!!user && (appModalFlowState !== "settled" || invitationQuery.isLoading)); const invitation = invitationQuery.data; return ( @@ -107,7 +117,7 @@ const ManagerTransferAcceptanceScreen = ({ + +

+ {hasAttemptedAppOpen + ? '앱이 열리지 않으면 아래 스토어에서 설치 여부를 확인해주세요.' + : '앱을 여는 중이에요.'} +

+
+ +
+ + +) + +export default ManagerTransferFallbackView diff --git a/apps/web/src/managerTransfer/managerTransferFallback.test.tsx b/apps/web/src/managerTransfer/managerTransferFallback.test.tsx new file mode 100644 index 00000000..16b02cd0 --- /dev/null +++ b/apps/web/src/managerTransfer/managerTransferFallback.test.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { getServerSideProps } from '../../pages/manager-transfer/[token]' +import AppStoreButtons from '../common/components/AppStoreButtons' +import ManagerTransferFallbackView from './ManagerTransferFallbackView' +import { + buildManagerTransferAppUrl, + buildManagerTransferWebUrl, + parseManagerTransferToken, +} from './managerTransferFallback' + +describe('manager transfer web fallback', () => { + const token = 'a'.repeat(43) + + it('원래 토큰을 앱 URL과 Smart App Banner URL에 보존한다', () => { + expect(parseManagerTransferToken(token)).toBe(token) + expect(buildManagerTransferAppUrl(token)).toBe(`allclear://manager-transfer/${token}`) + expect(buildManagerTransferWebUrl(token, 'dev.all-clear.cc')).toBe( + `https://dev.all-clear.cc/manager-transfer/${token}`, + ) + }) + + it('유효한 동적 경로를 redirect 없이 서버 렌더링한다', async () => { + const result = await getServerSideProps({ + params: { token }, + req: { headers: { host: 'dev.all-clear.cc' } }, + } as never) + + expect(result).toEqual({ + props: { + token, + managerTransferUrl: `https://dev.all-clear.cc/manager-transfer/${token}`, + }, + }) + }) + + it.each(['', 'short', 'contains/slash', 'a'.repeat(257)])( + '유효하지 않은 토큰 %s를 거부한다', + (invalidToken) => { + expect(parseManagerTransferToken(invalidToken)).toBeNull() + }, + ) + + it('앱 열기 버튼과 두 스토어 선택지를 함께 표시한다', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('올클 앱에서 열기') + expect(markup).toContain('앱이 열리지 않으면') + expect(markup).toContain('App Store') + expect(markup).toContain('Google Play') + }) + + it('Android에서도 App Store와 Google Play를 모두 직접 선택할 수 있다', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain('App Store') + expect(markup).toContain('Google Play') + }) +}) diff --git a/apps/web/src/managerTransfer/managerTransferFallback.ts b/apps/web/src/managerTransfer/managerTransferFallback.ts new file mode 100644 index 00000000..4e2644c9 --- /dev/null +++ b/apps/web/src/managerTransfer/managerTransferFallback.ts @@ -0,0 +1,19 @@ +import { buildAppDeepLinkUrl } from '../club/openInApp' + +const MANAGER_TRANSFER_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/ +const ALLOWED_WEB_HOSTS = new Set(['all-clear.cc', 'www.all-clear.cc', 'dev.all-clear.cc']) + +export const parseManagerTransferToken = (value: unknown): string | null => + typeof value === 'string' && MANAGER_TRANSFER_TOKEN_PATTERN.test(value) ? value : null + +export const buildManagerTransferDeepPath = (token: string): string => + `manager-transfer/${encodeURIComponent(token)}` + +export const buildManagerTransferAppUrl = (token: string): string => + buildAppDeepLinkUrl(buildManagerTransferDeepPath(token)) + +export const buildManagerTransferWebUrl = (token: string, requestHost: string): string => { + const normalizedHost = requestHost.split(':')[0].toLowerCase() + const host = ALLOWED_WEB_HOSTS.has(normalizedHost) ? normalizedHost : 'all-clear.cc' + return `https://${host}/manager-transfer/${encodeURIComponent(token)}` +} From 83f17aeae92e51bbffed1323d72ade9eeca39f61 Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Tue, 25 Aug 2026 01:37:23 +0900 Subject: [PATCH 3/5] test(web): cover concurrent manager transfer acceptance --- .../club-manager-transfer.service.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/apps/web/server/service/club-manager-transfer.service.test.ts b/apps/web/server/service/club-manager-transfer.service.test.ts index 4d98b2f6..30ea1630 100644 --- a/apps/web/server/service/club-manager-transfer.service.test.ts +++ b/apps/web/server/service/club-manager-transfer.service.test.ts @@ -228,6 +228,9 @@ describe('ClubManagerTransferService', () => { sourceId: '10', metadata: { clubName: '와플스튜디오' }, }) + expect(clubManagerRepository.softDelete).toHaveBeenCalledTimes(1) + expect(clubManagerRepository.insert).toHaveBeenCalledTimes(1) + expect(notificationRepository.insert).toHaveBeenCalledTimes(1) }) it('rejects a self-transfer before mutating manager state', async () => { @@ -307,6 +310,77 @@ describe('ClubManagerTransferService', () => { expect(clubManagerRepository.insert).not.toHaveBeenCalled() }) + it('hides a concurrently accepted invitation when another recipient removed the manager', async () => { + const { service, invitationRepository, clubManagerRepository, notificationRepository } = + createTestContext() + invitationRepository.findOne + .mockResolvedValueOnce({ + id: '10', + clubId: clubUuid, + senderServiceUserId, + expiresAt: '2026-08-27T00:00:00.000Z', + revokedAt: null, + acceptedByServiceUserId: null, + acceptedAt: null, + }) + .mockResolvedValueOnce({ + id: '10', + clubId: clubUuid, + senderServiceUserId, + expiresAt: '2026-08-27T00:00:00.000Z', + revokedAt: null, + acceptedByServiceUserId: '04cc77e4-b65d-4df5-b4d6-3a52bdd49974', + acceptedAt: '2026-08-24T00:00:00.000Z', + }) + clubManagerRepository.findOne.mockResolvedValue(null) + + await expect(service.acceptInvitation(rawToken, recipient)).rejects.toBeInstanceOf( + NotFoundError, + ) + expect(invitationRepository.findOne).toHaveBeenNthCalledWith(2, { + where: { id: '10' }, + }) + expect(clubManagerRepository.softDelete).not.toHaveBeenCalled() + expect(clubManagerRepository.insert).not.toHaveBeenCalled() + expect(notificationRepository.insert).not.toHaveBeenCalled() + }) + + it('hides a concurrently accepted invitation after acquiring the invitation lock', async () => { + const { service, invitationRepository, clubManagerRepository, notificationRepository } = + createTestContext() + invitationRepository.findOne + .mockResolvedValueOnce({ + id: '10', + clubId: clubUuid, + senderServiceUserId, + expiresAt: '2026-08-27T00:00:00.000Z', + revokedAt: null, + acceptedByServiceUserId: null, + acceptedAt: null, + }) + .mockResolvedValueOnce({ + id: '10', + clubId: clubUuid, + senderServiceUserId, + expiresAt: '2026-08-27T00:00:00.000Z', + revokedAt: null, + acceptedByServiceUserId: '04cc77e4-b65d-4df5-b4d6-3a52bdd49974', + acceptedAt: '2026-08-24T00:00:00.000Z', + }) + clubManagerRepository.findOne.mockResolvedValue({ id: 7 }) + + await expect(service.acceptInvitation(rawToken, recipient)).rejects.toBeInstanceOf( + NotFoundError, + ) + expect(invitationRepository.findOne).toHaveBeenNthCalledWith(2, { + where: { id: '10' }, + lock: { mode: 'pessimistic_write' }, + }) + expect(clubManagerRepository.softDelete).not.toHaveBeenCalled() + expect(clubManagerRepository.insert).not.toHaveBeenCalled() + expect(notificationRepository.insert).not.toHaveBeenCalled() + }) + it('hides an accepted invitation from a different recipient', async () => { const { service, invitationRepository } = createTestContext() invitationRepository.findOne.mockResolvedValue({ From 49a85142b72cc57812ff74b13978007e8931c40c Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Tue, 25 Aug 2026 01:38:49 +0900 Subject: [PATCH 4/5] chore: verify mobile app link associations --- .github/workflows/android-dev-internal.yml | 36 ++++++++ .../ios/clubhouse/clubhouseDebug.entitlements | 5 ++ .../ios/clubhouse/clubhouseLocal.entitlements | 5 ++ apps/web/next.config.js | 4 + apps/web/src/lib/app-links.test.ts | 82 +++++++++++++++++++ 5 files changed, 132 insertions(+) diff --git a/.github/workflows/android-dev-internal.yml b/.github/workflows/android-dev-internal.yml index c00624ad..9fc414e9 100644 --- a/.github/workflows/android-dev-internal.yml +++ b/.github/workflows/android-dev-internal.yml @@ -51,6 +51,42 @@ jobs: printf '%s' "$ANDROID_DEBUG_KEYSTORE_BASE64" | base64 --decode > android/app/debug.keystore chmod 600 android/app/debug.keystore + - name: Verify Android App Link certificate + shell: bash + working-directory: apps/mobile + run: | + actual_fingerprint="$( + keytool -exportcert \ + -alias androiddebugkey \ + -keystore android/app/debug.keystore \ + -storepass android \ + -rfc \ + | openssl x509 -noout -fingerprint -sha256 \ + | cut -d= -f2 + )" + expected_fingerprints="$( + node -e ' + const { readFileSync } = require("node:fs"); + const associations = JSON.parse( + readFileSync("../web/public/.well-known/assetlinks.json", "utf8"), + ); + const developmentApp = associations.find( + ({ target }) => + target.package_name === + "com.padocorp.clubhouse.applicationId.debug", + ); + if (!developmentApp) process.exit(1); + process.stdout.write( + developmentApp.target.sha256_cert_fingerprints.join("\n"), + ); + ' + )" + + if ! printf '%s\n' "$expected_fingerprints" | grep -Fxq "$actual_fingerprint"; then + echo "Restored Android certificate is not registered in assetlinks.json" + exit 1 + fi + - name: Build standalone dev APK working-directory: apps/mobile run: | diff --git a/apps/mobile/ios/clubhouse/clubhouseDebug.entitlements b/apps/mobile/ios/clubhouse/clubhouseDebug.entitlements index 703de465..f188636d 100644 --- a/apps/mobile/ios/clubhouse/clubhouseDebug.entitlements +++ b/apps/mobile/ios/clubhouse/clubhouseDebug.entitlements @@ -6,6 +6,11 @@ Default + com.apple.developer.associated-domains + + applinks:all-clear.cc + applinks:dev.all-clear.cc + com.apple.security.application-groups diff --git a/apps/mobile/ios/clubhouse/clubhouseLocal.entitlements b/apps/mobile/ios/clubhouse/clubhouseLocal.entitlements index 703de465..f188636d 100644 --- a/apps/mobile/ios/clubhouse/clubhouseLocal.entitlements +++ b/apps/mobile/ios/clubhouse/clubhouseLocal.entitlements @@ -6,6 +6,11 @@ Default + com.apple.developer.associated-domains + + applinks:all-clear.cc + applinks:dev.all-clear.cc + com.apple.security.application-groups diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 6782efaa..0f365aef 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -14,6 +14,10 @@ const nextConfig = { source: '/.well-known/apple-app-site-association', headers: [{ key: 'Content-Type', value: 'application/json' }], }, + { + source: '/.well-known/assetlinks.json', + headers: [{ key: 'Content-Type', value: 'application/json' }], + }, ] }, async redirects() { diff --git a/apps/web/src/lib/app-links.test.ts b/apps/web/src/lib/app-links.test.ts index dbdc9d7b..0d461646 100644 --- a/apps/web/src/lib/app-links.test.ts +++ b/apps/web/src/lib/app-links.test.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -17,4 +18,85 @@ describe('apple universal links', () => { expect(app.paths).toContain('/manager-transfer/*') } }) + + it('declares associated domains in every iOS build entitlement', () => { + const entitlementPaths = [ + 'clubhouse.entitlements', + 'clubhouseDebug.entitlements', + 'clubhouseLocal.entitlements', + 'clubhouseRelease.entitlements', + ] + + for (const entitlementPath of entitlementPaths) { + const entitlement = readFileSync( + join(process.cwd(), '../mobile/ios/clubhouse', entitlementPath), + 'utf8', + ) + expect(entitlement).toContain('com.apple.developer.associated-domains') + expect(entitlement).toContain('applinks:all-clear.cc') + expect(entitlement).toContain('applinks:dev.all-clear.cc') + } + }) +}) + +describe('android app links', () => { + it('keeps verified HTTPS domains and the custom scheme in the Android manifest', () => { + const manifest = readFileSync( + join(process.cwd(), '../mobile/android/app/src/main/AndroidManifest.xml'), + 'utf8', + ) + + expect(manifest).toContain('android:scheme="allclear"') + expect(manifest).toContain('android:autoVerify="true"') + expect(manifest).toContain('android:host="all-clear.cc"') + expect(manifest).toContain('android:host="dev.all-clear.cc"') + }) + + it('registers the CI devRelease package certificate', () => { + const association = JSON.parse( + readFileSync(join(process.cwd(), 'public/.well-known/assetlinks.json'), 'utf8'), + ) as Array<{ + target: { package_name: string; sha256_cert_fingerprints: string[] } + }> + const developmentApp = association.find( + ({ target }) => target.package_name === 'com.padocorp.clubhouse.applicationId.debug', + ) + + expect(developmentApp?.target.sha256_cert_fingerprints).toContain( + '18:DE:12:2E:AE:A1:E4:69:C9:32:16:7C:09:FF:F9:E1:85:A0:51:F9:0E:26:39:1A:E6:C6:2D:CF:9B:54:4F:C2', + ) + }) + + it('fails the Android dev build when the restored keystore is not registered', () => { + const workflow = readFileSync( + join(process.cwd(), '../../.github/workflows/android-dev-internal.yml'), + 'utf8', + ) + + expect(workflow).toContain('Verify Android App Link certificate') + expect(workflow).toContain('sha256_cert_fingerprints') + expect(workflow).toContain('keytool -exportcert') + }) +}) + +describe('app association response headers', () => { + it('serves AASA and assetlinks files as JSON', async () => { + const require = createRequire(import.meta.url) + const nextConfig = require('../../next.config.js') as { + headers: () => Promise< + Array<{ source: string; headers: Array<{ key: string; value: string }> }> + > + } + + const headers = await nextConfig.headers() + for (const source of [ + '/.well-known/apple-app-site-association', + '/.well-known/assetlinks.json', + ]) { + expect(headers).toContainEqual({ + source, + headers: [{ key: 'Content-Type', value: 'application/json' }], + }) + } + }) }) From e4612f5ba4de802a8f08ac3e7e4d09c50fd86580 Mon Sep 17 00:00:00 2001 From: dennis0405 Date: Tue, 25 Aug 2026 01:54:02 +0900 Subject: [PATCH 5/5] fix: close manager transfer deep link review gaps --- .github/workflows/android-dev-internal.yml | 20 ++-- apps/mobile/App.tsx | 108 +++++++++--------- .../android/app/src/main/AndroidManifest.xml | 2 + apps/mobile/src/config/linking.test.ts | 53 ++++++--- apps/mobile/src/config/linking.ts | 52 +++++++-- .../src/shared/utils/entryIntent.test.ts | 3 + apps/mobile/src/shared/utils/entryIntent.ts | 5 +- apps/web/next.config.js | 4 + apps/web/pages/manager-transfer/[token].tsx | 10 +- apps/web/src/lib/app-links.test.ts | 40 ++++++- .../managerTransferFallback.test.tsx | 13 +++ .../managerTransferFallback.ts | 7 +- 12 files changed, 220 insertions(+), 97 deletions(-) diff --git a/.github/workflows/android-dev-internal.yml b/.github/workflows/android-dev-internal.yml index 9fc414e9..cfbd57f4 100644 --- a/.github/workflows/android-dev-internal.yml +++ b/.github/workflows/android-dev-internal.yml @@ -70,14 +70,20 @@ jobs: const associations = JSON.parse( readFileSync("../web/public/.well-known/assetlinks.json", "utf8"), ); - const developmentApp = associations.find( - ({ target }) => - target.package_name === - "com.padocorp.clubhouse.applicationId.debug", - ); - if (!developmentApp) process.exit(1); + const developmentFingerprints = associations + .filter( + ({ relation, target }) => + relation.includes( + "delegate_permission/common.handle_all_urls", + ) && + target.namespace === "android_app" && + target.package_name === + "com.padocorp.clubhouse.applicationId.debug", + ) + .flatMap(({ target }) => target.sha256_cert_fingerprints); + if (developmentFingerprints.length === 0) process.exit(1); process.stdout.write( - developmentApp.target.sha256_cert_fingerprints.join("\n"), + [...new Set(developmentFingerprints)].join("\n"), ); ' )" diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index faecdb3f..aa821ee0 100644 --- a/apps/mobile/App.tsx +++ b/apps/mobile/App.tsx @@ -15,7 +15,10 @@ import { SafeAreaProvider, } from "react-native-safe-area-context"; import Toast, { type ToastConfig } from "react-native-toast-message"; -import { createAppLinking } from "@/config/linking"; +import { + createAppLinking, + createBufferedUrlSubscription, +} from "@/config/linking"; import AnnouncementEditScreen from "@/features/club/screens/AnnouncementEditScreen"; import AnnouncementRegistrationScreen from "@/features/club/screens/AnnouncementRegistrationScreen"; import ManagerTransferAcceptanceScreen from "@/features/club/screens/ManagerTransferAcceptanceScreen"; @@ -106,7 +109,7 @@ const PendingEntryIntentHandler = ({ if (!executableIntent) return; onConsume(); - navigation.navigate(SCREEN_TYPE.MANAGER_TRANSFER_ACCEPTANCE, { + navigation.replace(SCREEN_TYPE.MANAGER_TRANSFER_ACCEPTANCE, { token: executableIntent.token, }); }, [appModalFlowState, isNavigationReady, onConsume, pendingEntryIntent]); @@ -116,53 +119,43 @@ const PendingEntryIntentHandler = ({ function App(): React.JSX.Element { const { Provider: ServiceProvider } = serviceContext; + const services = useMemo(() => { + const announcementRepository = getAnnouncementRepository(); + const appVersionRepository = getAppVersionRepository(); + const authRepository = getAuthRepository(); + const categoryRepository = getCategoryRepository(); + const clubRepository = getClubRepository(); + const recentSearchRepository = getRecentSearchRepository(); + const recruitmentRepository = getRecruitmentRepository(); + const reviewRepository = getReviewRepository(); + const termRepository = getTermRepository(); + const userRepository = getUserRepository(); - const announcementRepository = getAnnouncementRepository(); - const appVersionRepository = getAppVersionRepository(); - const authRepository = getAuthRepository(); - const categoryRepository = getCategoryRepository(); - const clubRepository = getClubRepository(); - const recentSearchRepository = getRecentSearchRepository(); - const recruitmentRepository = getRecruitmentRepository(); - const reviewRepository = getReviewRepository(); - const termRepository = getTermRepository(); - const userRepository = getUserRepository(); - - const announcementService = getAnnouncementService({ - repositories: [announcementRepository], - }); - const appVersionService = getAppVersionService({ - repositories: [appVersionRepository], - }); - const authService = getAuthService({ repositories: [authRepository] }); - const categoryService = getCategoryService({ - repositories: [categoryRepository], - }); - const clubService = getClubService({ repositories: [clubRepository] }); - const eventLogService = getEventLogService(); - const recentSearchService = getRecentSearchService({ - repositories: [recentSearchRepository], - }); - const recruitmentService = getRecruitmentService({ - repositories: [recruitmentRepository], - }); - const reviewService = getReviewService({ repositories: [reviewRepository] }); - const termService = getTermService({ repositories: [termRepository] }); - const userService = getUserService({ repositories: [userRepository] }); - - const services = { - announcementService, - appVersionService, - authService, - categoryService, - clubService, - eventLogService, - recentSearchService, - recruitmentService, - reviewService, - termService, - userService, - }; + return { + announcementService: getAnnouncementService({ + repositories: [announcementRepository], + }), + appVersionService: getAppVersionService({ + repositories: [appVersionRepository], + }), + authService: getAuthService({ repositories: [authRepository] }), + categoryService: getCategoryService({ + repositories: [categoryRepository], + }), + clubService: getClubService({ repositories: [clubRepository] }), + eventLogService: getEventLogService(), + recentSearchService: getRecentSearchService({ + repositories: [recentSearchRepository], + }), + recruitmentService: getRecruitmentService({ + repositories: [recruitmentRepository], + }), + reviewService: getReviewService({ repositories: [reviewRepository] }), + termService: getTermService({ repositories: [termRepository] }), + userService: getUserService({ repositories: [userRepository] }), + }; + }, []); + const urlEvents = useMemo(() => createBufferedUrlSubscription(), []); // 토큰을 메모리로 복원한 뒤에야 인증 요청을 하는 트리(ProfileProvider 등)를 마운트한다. // 그렇지 않으면 _token이 채워지기 전에 /v2/users/me가 게스트로 나가 자동로그인이 깨진다. @@ -196,30 +189,37 @@ function App(): React.JSX.Element { createAppLinking({ initialUrl: initialNavigationUrl, onPendingEntryIntent: handlePendingEntryIntent, + subscribeToUrls: urlEvents.subscribe, }), - [handlePendingEntryIntent, initialNavigationUrl], + [handlePendingEntryIntent, initialNavigationUrl, urlEvents.subscribe], ); useEffect(() => { let isCancelled = false; + const subscription = Linking.addEventListener("url", ({ url }) => { + urlEvents.publish(url); + }); const bootstrap = async () => { const [, initialUrl] = await Promise.all([ initToken(), - Linking.getInitialURL(), + Linking.getInitialURL().catch(() => null), ]); if (isCancelled) return; - const initialEntryIntent = parsePendingEntryIntent(initialUrl); - setPendingEntryIntent(initialEntryIntent); - setInitialNavigationUrl(getNavigationInitialUrl(initialUrl)); + if (!urlEvents.hasPublishedUrl()) { + const initialEntryIntent = parsePendingEntryIntent(initialUrl); + setPendingEntryIntent(initialEntryIntent); + setInitialNavigationUrl(getNavigationInitialUrl(initialUrl)); + } setIsBootstrapped(true); }; bootstrap(); return () => { isCancelled = true; + subscription.remove(); }; - }, []); + }, [urlEvents]); useEffect( () => () => { diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index d662f1d1..3dc050e5 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,7 @@ + @@ -45,6 +46,7 @@ + diff --git a/apps/mobile/src/config/linking.test.ts b/apps/mobile/src/config/linking.test.ts index cd1f473c..165e4bfb 100644 --- a/apps/mobile/src/config/linking.test.ts +++ b/apps/mobile/src/config/linking.test.ts @@ -1,6 +1,9 @@ import { getStateFromPath } from "@react-navigation/native"; -import { Linking } from "react-native"; -import { createAppLinking, linking } from "@/config/linking"; +import { + createAppLinking, + createBufferedUrlSubscription, + linking, +} from "@/config/linking"; import { SCREEN_TYPE } from "@/shared/constants/screen"; jest.mock("react-native-config", () => ({ @@ -26,29 +29,19 @@ describe("deep linking", () => { }); it("실행 중 관리자 이전 링크는 보류하고 일반 링크만 네비게이션에 전달한다", () => { - let urlHandler: ((event: { url: string }) => void) | undefined; - const remove = jest.fn(); - jest - .spyOn(Linking, "addEventListener") - .mockImplementation((_eventType, handler) => { - urlHandler = handler; - return { remove } as unknown as ReturnType< - typeof Linking.addEventListener - >; - }); + const urlEvents = createBufferedUrlSubscription(); const onPendingEntryIntent = jest.fn(); const navigationListener = jest.fn(); const token = "b".repeat(43); const appLinking = createAppLinking({ initialUrl: null, onPendingEntryIntent, + subscribeToUrls: urlEvents.subscribe, }); const unsubscribe = appLinking.subscribe?.(navigationListener); - urlHandler?.({ - url: `https://dev.all-clear.cc/manager-transfer/${token}`, - }); - urlHandler?.({ url: "https://dev.all-clear.cc/club/example" }); + urlEvents.publish(`https://dev.all-clear.cc/manager-transfer/${token}`); + urlEvents.publish("https://dev.all-clear.cc/club/example"); unsubscribe?.(); expect(onPendingEntryIntent).toHaveBeenCalledWith({ @@ -58,6 +51,32 @@ describe("deep linking", () => { expect(navigationListener).toHaveBeenCalledWith( "https://dev.all-clear.cc/club/example", ); - expect(remove).toHaveBeenCalledTimes(1); + }); + + it("부트스트랩 중 관리자 이전과 일반 URL을 종류별 최신 1개씩 보존한다", () => { + const urlEvents = createBufferedUrlSubscription(); + const listener = jest.fn(); + const firstToken = "c".repeat(43); + const latestToken = "d".repeat(43); + + urlEvents.publish( + `https://dev.all-clear.cc/manager-transfer/${firstToken}`, + ); + urlEvents.publish("https://dev.all-clear.cc/club/first"); + urlEvents.publish( + `https://dev.all-clear.cc/manager-transfer/${latestToken}`, + ); + urlEvents.publish("https://dev.all-clear.cc/club/latest"); + expect(urlEvents.hasPublishedUrl()).toBe(true); + + const unsubscribe = urlEvents.subscribe(listener); + expect(listener.mock.calls).toEqual([ + ["https://dev.all-clear.cc/club/latest"], + [`https://dev.all-clear.cc/manager-transfer/${latestToken}`], + ]); + + unsubscribe(); + urlEvents.publish("https://dev.all-clear.cc/club/after-unsubscribe"); + expect(listener).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/mobile/src/config/linking.ts b/apps/mobile/src/config/linking.ts index 9082d9a8..f5ac752e 100644 --- a/apps/mobile/src/config/linking.ts +++ b/apps/mobile/src/config/linking.ts @@ -1,5 +1,4 @@ import type { LinkingOptions } from "@react-navigation/native"; -import { Linking } from "react-native"; import { ENV } from "@/config/ENV"; import { type RootStackParamList, @@ -36,16 +35,58 @@ export const linking: LinkingOptions = { type CreateAppLinkingParams = { initialUrl: string | null; onPendingEntryIntent: (intent: NonNullable) => void; + subscribeToUrls: SubscribeToUrls; +}; + +type UrlListener = (url: string) => void; +export type SubscribeToUrls = (listener: UrlListener) => () => void; + +export const createBufferedUrlSubscription = () => { + let listener: UrlListener | null = null; + let latestNavigationUrl: string | null = null; + let latestManagerTransferUrl: string | null = null; + let hasPublishedUrl = false; + + return { + publish: (url: string) => { + hasPublishedUrl = true; + if (listener) { + listener(url); + return; + } + + if (parsePendingEntryIntent(url)) { + latestManagerTransferUrl = url; + } else { + latestNavigationUrl = url; + } + }, + subscribe: (nextListener: UrlListener) => { + listener = nextListener; + const pendingUrls = [latestNavigationUrl, latestManagerTransferUrl]; + latestNavigationUrl = null; + latestManagerTransferUrl = null; + for (const pendingUrl of pendingUrls) { + if (pendingUrl) nextListener(pendingUrl); + } + + return () => { + if (listener === nextListener) listener = null; + }; + }, + hasPublishedUrl: () => hasPublishedUrl, + }; }; export const createAppLinking = ({ initialUrl, onPendingEntryIntent, + subscribeToUrls, }: CreateAppLinkingParams): LinkingOptions => ({ ...linking, getInitialURL: () => initialUrl, - subscribe: (listener) => { - const subscription = Linking.addEventListener("url", ({ url }) => { + subscribe: (listener) => + subscribeToUrls((url) => { const pendingIntent = parsePendingEntryIntent(url); if (pendingIntent) { onPendingEntryIntent(pendingIntent); @@ -53,8 +94,5 @@ export const createAppLinking = ({ } listener(url); - }); - - return () => subscription.remove(); - }, + }), }); diff --git a/apps/mobile/src/shared/utils/entryIntent.test.ts b/apps/mobile/src/shared/utils/entryIntent.test.ts index 64684e6a..de76a896 100644 --- a/apps/mobile/src/shared/utils/entryIntent.test.ts +++ b/apps/mobile/src/shared/utils/entryIntent.test.ts @@ -13,6 +13,8 @@ describe("manager transfer entry intent", () => { it.each([ `https://all-clear.cc/manager-transfer/${firstToken}`, `https://dev.all-clear.cc/manager-transfer/${firstToken}`, + `http://all-clear.cc/manager-transfer/${firstToken}`, + `http://dev.all-clear.cc/manager-transfer/${firstToken}`, `allclear://manager-transfer/${firstToken}`, ])("관리자 권한 이전 URL %s를 보류 진입 의도로 파싱한다", (url) => { expect(parsePendingEntryIntent(url)).toEqual({ @@ -33,6 +35,7 @@ describe("manager transfer entry intent", () => { "https://example.com/manager-transfer/token", "allclear://manager-transfer/contains%2Fslash", "allclear://manager-transfer/", + "allclear://manager-transfer/short-token", ])("허용되지 않은 URL %s를 관리자 이전 의도로 취급하지 않는다", (url) => { expect(parsePendingEntryIntent(url)).toBeNull(); }); diff --git a/apps/mobile/src/shared/utils/entryIntent.ts b/apps/mobile/src/shared/utils/entryIntent.ts index ae97582a..e0d68029 100644 --- a/apps/mobile/src/shared/utils/entryIntent.ts +++ b/apps/mobile/src/shared/utils/entryIntent.ts @@ -1,6 +1,6 @@ import type { AppModalFlowState } from "@/shared/utils/appModalFlow"; -const MANAGER_TRANSFER_TOKEN_PATTERN = /^[A-Za-z0-9_-]{20,256}$/; +const MANAGER_TRANSFER_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; const WEB_LINK_HOSTS = new Set(["all-clear.cc", "dev.all-clear.cc"]); export type PendingEntryIntent = { @@ -18,7 +18,8 @@ export const parsePendingEntryIntent = ( const isCustomScheme = url.protocol === "allclear:" && url.hostname === "manager-transfer"; const isWebLink = - url.protocol === "https:" && WEB_LINK_HOSTS.has(url.hostname); + (url.protocol === "https:" || url.protocol === "http:") && + WEB_LINK_HOSTS.has(url.hostname); const encodedToken = isCustomScheme ? url.pathname.match(/^\/([^/]+)\/?$/)?.[1] : isWebLink diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 0f365aef..17430709 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -18,6 +18,10 @@ const nextConfig = { source: '/.well-known/assetlinks.json', headers: [{ key: 'Content-Type', value: 'application/json' }], }, + { + source: '/manager-transfer/:token', + headers: [{ key: 'Referrer-Policy', value: 'no-referrer' }], + }, ] }, async redirects() { diff --git a/apps/web/pages/manager-transfer/[token].tsx b/apps/web/pages/manager-transfer/[token].tsx index f3adc006..de8291f9 100644 --- a/apps/web/pages/manager-transfer/[token].tsx +++ b/apps/web/pages/manager-transfer/[token].tsx @@ -7,6 +7,7 @@ import { buildManagerTransferDeepPath, buildManagerTransferWebUrl, parseManagerTransferToken, + shouldAutomaticallyOpenManagerTransfer, } from '../../src/managerTransfer/managerTransferFallback' type Props = { @@ -19,7 +20,7 @@ const ManagerTransferFallbackPage = ({ managerTransferUrl, }: InferGetServerSidePropsType) => { const [hasAttemptedAppOpen, setHasAttemptedAppOpen] = useState(false) - const hasAutomaticallyAttemptedAppOpen = useRef(false) + const automaticallyAttemptedToken = useRef(null) const openManagerTransferInApp = useCallback(() => { setHasAttemptedAppOpen(true) return openAppDeepLink(buildManagerTransferDeepPath(token), { @@ -28,16 +29,17 @@ const ManagerTransferFallbackPage = ({ }, [token]) useEffect(() => { - if (hasAutomaticallyAttemptedAppOpen.current) return - hasAutomaticallyAttemptedAppOpen.current = true + if (!shouldAutomaticallyOpenManagerTransfer(token, automaticallyAttemptedToken.current)) return + automaticallyAttemptedToken.current = token return openManagerTransferInApp() - }, [openManagerTransferInApp]) + }, [openManagerTransferInApp, token]) return ( <> 관리자 권한 이전 | 올클 + { ) expect(manifest).toContain('android:scheme="allclear"') + expect(manifest).toContain('android:scheme="http"') expect(manifest).toContain('android:autoVerify="true"') expect(manifest).toContain('android:host="all-clear.cc"') expect(manifest).toContain('android:host="dev.all-clear.cc"') @@ -56,13 +57,23 @@ describe('android app links', () => { const association = JSON.parse( readFileSync(join(process.cwd(), 'public/.well-known/assetlinks.json'), 'utf8'), ) as Array<{ - target: { package_name: string; sha256_cert_fingerprints: string[] } + relation: string[] + target: { + namespace: string + package_name: string + sha256_cert_fingerprints: string[] + } }> - const developmentApp = association.find( - ({ target }) => target.package_name === 'com.padocorp.clubhouse.applicationId.debug', - ) + const developmentFingerprints = association + .filter( + ({ relation, target }) => + relation.includes('delegate_permission/common.handle_all_urls') && + target.namespace === 'android_app' && + target.package_name === 'com.padocorp.clubhouse.applicationId.debug', + ) + .flatMap(({ target }) => target.sha256_cert_fingerprints) - expect(developmentApp?.target.sha256_cert_fingerprints).toContain( + expect(developmentFingerprints).toContain( '18:DE:12:2E:AE:A1:E4:69:C9:32:16:7C:09:FF:F9:E1:85:A0:51:F9:0E:26:39:1A:E6:C6:2D:CF:9B:54:4F:C2', ) }) @@ -76,6 +87,10 @@ describe('android app links', () => { expect(workflow).toContain('Verify Android App Link certificate') expect(workflow).toContain('sha256_cert_fingerprints') expect(workflow).toContain('keytool -exportcert') + expect(workflow).toContain('.filter(') + expect(workflow).toContain('.flatMap(') + expect(workflow).toContain('delegate_permission/common.handle_all_urls') + expect(workflow).toContain('target.namespace === "android_app"') }) }) @@ -99,4 +114,19 @@ describe('app association response headers', () => { }) } }) + + it('prevents manager transfer tokens from leaking through referrer headers', async () => { + const require = createRequire(import.meta.url) + const nextConfig = require('../../next.config.js') as { + headers: () => Promise< + Array<{ source: string; headers: Array<{ key: string; value: string }> }> + > + } + + const headers = await nextConfig.headers() + expect(headers).toContainEqual({ + source: '/manager-transfer/:token', + headers: [{ key: 'Referrer-Policy', value: 'no-referrer' }], + }) + }) }) diff --git a/apps/web/src/managerTransfer/managerTransferFallback.test.tsx b/apps/web/src/managerTransfer/managerTransferFallback.test.tsx index 16b02cd0..f8086de9 100644 --- a/apps/web/src/managerTransfer/managerTransferFallback.test.tsx +++ b/apps/web/src/managerTransfer/managerTransferFallback.test.tsx @@ -8,6 +8,7 @@ import { buildManagerTransferAppUrl, buildManagerTransferWebUrl, parseManagerTransferToken, + shouldAutomaticallyOpenManagerTransfer, } from './managerTransferFallback' describe('manager transfer web fallback', () => { @@ -21,6 +22,18 @@ describe('manager transfer web fallback', () => { ) }) + it('지원하지 않는 www 호스트는 entitlement에 등록된 운영 호스트로 정규화한다', () => { + expect(buildManagerTransferWebUrl(token, 'www.all-clear.cc')).toBe( + `https://all-clear.cc/manager-transfer/${token}`, + ) + }) + + it('같은 토큰은 한 번만 자동 실행하고 새 토큰은 다시 자동 실행한다', () => { + expect(shouldAutomaticallyOpenManagerTransfer(token, null)).toBe(true) + expect(shouldAutomaticallyOpenManagerTransfer(token, token)).toBe(false) + expect(shouldAutomaticallyOpenManagerTransfer('b'.repeat(43), token)).toBe(true) + }) + it('유효한 동적 경로를 redirect 없이 서버 렌더링한다', async () => { const result = await getServerSideProps({ params: { token }, diff --git a/apps/web/src/managerTransfer/managerTransferFallback.ts b/apps/web/src/managerTransfer/managerTransferFallback.ts index 4e2644c9..b4ef96b1 100644 --- a/apps/web/src/managerTransfer/managerTransferFallback.ts +++ b/apps/web/src/managerTransfer/managerTransferFallback.ts @@ -1,7 +1,7 @@ import { buildAppDeepLinkUrl } from '../club/openInApp' const MANAGER_TRANSFER_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/ -const ALLOWED_WEB_HOSTS = new Set(['all-clear.cc', 'www.all-clear.cc', 'dev.all-clear.cc']) +const ALLOWED_WEB_HOSTS = new Set(['all-clear.cc', 'dev.all-clear.cc']) export const parseManagerTransferToken = (value: unknown): string | null => typeof value === 'string' && MANAGER_TRANSFER_TOKEN_PATTERN.test(value) ? value : null @@ -17,3 +17,8 @@ export const buildManagerTransferWebUrl = (token: string, requestHost: string): const host = ALLOWED_WEB_HOSTS.has(normalizedHost) ? normalizedHost : 'all-clear.cc' return `https://${host}/manager-transfer/${encodeURIComponent(token)}` } + +export const shouldAutomaticallyOpenManagerTransfer = ( + token: string, + automaticallyAttemptedToken: string | null, +): boolean => token !== automaticallyAttemptedToken