Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
ad826ab
feat: 에러 케이스 세분화 (#153)
gyumong Jan 11, 2026
54e1de0
feat: 복수전공 기능추가 (#155)
gyumong Feb 21, 2026
3313601
chore: dev-main 충돌 해소 (swagger/openapi-dev.json)
gyumong Feb 22, 2026
9c82017
feat: UI 개선 - 배경색, 보더, 패딩 수정 (#157)
gyumong Feb 22, 2026
67f880b
ci: main → dev 백머지 자동 PR 생성 액션 추가 (#159)
gyumong Feb 22, 2026
06a70d4
refactor: migrate portal scraping from SuwonScraping to async PortalL…
Doubtagain Apr 8, 2026
e33fd71
refactor: api 포털 연동 수정에 맞춰 작업
Doubtagain Apr 11, 2026
140384c
chore: cloudflare pages 사전 작업
Doubtagain Apr 11, 2026
a39bdcf
[feat] : /terms를 통해 뒤로가기 버튼 없는 개인정보처리방침 표시되도록 기능 추가
Doubtagain Apr 12, 2026
e08b356
chore: cloudflare pages 배포용 GitHub Actions 워크플로우 추가
Doubtagain Apr 18, 2026
9952df4
fix: /graduation-progress 레이아웃을 /academic-detail 기준 600px 중앙정렬로 통일
Doubtagain Apr 18, 2026
c0ad905
chore: CF Pages 워크플로우를 staged cutover 구조로 확장
Doubtagain Apr 18, 2026
054780e
Merge remote-tracking branch 'origin/dev' into refactor/async-api
Doubtagain Apr 18, 2026
39d8fb5
chore: sync with latest dev backend spec after merge
Doubtagain Apr 18, 2026
e6ed490
refactor: migrate Cloudflare adapter from Pages to Workers + Static A…
Doubtagain Apr 19, 2026
b1bcb76
refactor: address in-scope review findings
Doubtagain Apr 19, 2026
317f515
ci: add workflow_dispatch and temporary refactor/async-api trigger
Doubtagain Apr 19, 2026
a7a9e4c
feat(auth): add iron-session BFF endpoints
Doubtagain Apr 19, 2026
58d8517
refactor(auth): move OAuth token delivery from URL query to HttpOnly …
Doubtagain Apr 19, 2026
6cb298e
feat(auth): add AuthContext with in-memory access token store
Doubtagain Apr 19, 2026
edc3366
feat(auth): auto-refresh access token on 401 via BFF
Doubtagain Apr 19, 2026
1b3279b
refactor(auth): remove sessionStorage and document.cookie token paths
Doubtagain Apr 19, 2026
3d5d596
fix(auth): drop session re-export from auth barrel
Doubtagain Apr 19, 2026
ee904ae
chore(lint): disable exhaustive-deps false positive in useProfileQuery
Doubtagain Apr 19, 2026
1b99823
chore(ci): split deploy secrets by environment with PROD_/STAGING_ pr…
Doubtagain Apr 19, 2026
ba4e604
chore(config): remove unused TEST_TOKEN env vars
Doubtagain Apr 19, 2026
39f1dc2
Merge pull request #164 from gyumong/refactor/async-api
Doubtagain Apr 26, 2026
888e103
fix(dashboard): use lastSyncedAt for SyncUpdateButton
Doubtagain Apr 26, 2026
ebee3fd
feat(portal-link): auto-fail polling after 3-min timeout
Doubtagain Apr 26, 2026
6d9b354
refactor(dashboard): plumb optional onNavigate through cards
Doubtagain Apr 26, 2026
5ebcce8
feat(webview): add JS bridge utility
Doubtagain Apr 26, 2026
141c851
feat(mpa): add /mpa/home and /mpa/me route group
Doubtagain Apr 26, 2026
51c00b2
chore(lint): satisfy curly + no-rest-destructuring in new modules
Doubtagain Apr 26, 2026
4e73737
fix(dashboard): refresh profile on visibilitychange after native resync
Doubtagain May 1, 2026
32702f0
fix: address PR review feedback (env validation, defensive parsing, b…
Doubtagain May 2, 2026
ccfea0b
Merge pull request #165 from gyumong/feat/mpa-webview-home
Doubtagain May 2, 2026
b6b8f03
feat(mpa): add school-link webview flow with native session exchange
Doubtagain May 4, 2026
e6bec97
chore(mpa): address PR #166 CodeRabbit review
Doubtagain May 4, 2026
38b2152
Merge pull request #166 from gyumong/feat/mpa-webview-home
Doubtagain May 4, 2026
f8b561c
refactor(mpa): drop native bridge secret gate from POST /api/session
Doubtagain May 5, 2026
4824b63
fix(mpa): address PR #167 CodeRabbit review
Doubtagain May 5, 2026
c5e823e
Merge pull request #167 from gyumong/refactor/mpa-drop-bridge-secret-…
Doubtagain May 5, 2026
212f7a7
chore: address PR #168 CodeRabbit review
Doubtagain May 11, 2026
fb2b03b
chore: revert overreaching CodeRabbit suggestions
Doubtagain May 11, 2026
afb69cd
Merge pull request #169 from gyumong/chore/pr-168-coderabbit-review
Doubtagain May 12, 2026
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
93 changes: 93 additions & 0 deletions .github/workflows/deploy-cloudflare.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: Deploy to Cloudflare Workers

on:
push:
branches: [main, dev, refactor/async-api]
workflow_dispatch:
inputs:
environment:
description: 'Deploy environment'
type: choice
options: [staging, production]
default: staging

concurrency:
group: cloudflare-workers-${{ github.ref }}
cancel-in-progress: true

jobs:
resolve-env:
runs-on: ubuntu-latest
outputs:
name: ${{ steps.env.outputs.name }}
steps:
- id: env
run: echo "name=${{ github.event.inputs.environment || (github.ref == 'refs/heads/main' && 'production' || 'staging') }}" >> "$GITHUB_OUTPUT"

deploy:
needs: resolve-env
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
deployments: write
env:
# production 이면 PROD_* secret, 그 외(staging)면 STAGING_* secret 을 사용
NEXT_PUBLIC_DEPLOY_ENV: ${{ needs.resolve-env.outputs.name }}

NEXT_PUBLIC_API_BASE_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_API_BASE_URL || secrets.STAGING_NEXT_PUBLIC_API_BASE_URL }}
NEXT_PUBLIC_BASE_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_BASE_URL || secrets.STAGING_NEXT_PUBLIC_BASE_URL }}
NEXT_PUBLIC_SUPABASE_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_SUPABASE_URL || secrets.STAGING_NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_SUPABASE_ANON_KEY || secrets.STAGING_NEXT_PUBLIC_SUPABASE_ANON_KEY }}
NEXT_PUBLIC_JAVASCRIPT_KEY: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_JAVASCRIPT_KEY || secrets.STAGING_NEXT_PUBLIC_JAVASCRIPT_KEY }}
NEXT_PUBLIC_SENTRY_DSN: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_NEXT_PUBLIC_SENTRY_DSN || secrets.STAGING_NEXT_PUBLIC_SENTRY_DSN }}

REST_API_KEY: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_REST_API_KEY || secrets.STAGING_REST_API_KEY }}
CLIENT_SECRET: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_CLIENT_SECRET || secrets.STAGING_CLIENT_SECRET }}
SUPABASE_PROJECT_ID: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_SUPABASE_PROJECT_ID || secrets.STAGING_SUPABASE_PROJECT_ID }}
SESSION_SECRET: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_SESSION_SECRET || secrets.STAGING_SESSION_SECRET }}
SENTRY_AUTH_TOKEN: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_SENTRY_AUTH_TOKEN || secrets.STAGING_SENTRY_AUTH_TOKEN }}
UPSTASH_REDIS_REST_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_UPSTASH_REDIS_REST_URL || secrets.STAGING_UPSTASH_REDIS_REST_URL }}
UPSTASH_REDIS_REST_TOKEN: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_UPSTASH_REDIS_REST_TOKEN || secrets.STAGING_UPSTASH_REDIS_REST_TOKEN }}
LAMBDA_DEV_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_LAMBDA_DEV_URL || secrets.STAGING_LAMBDA_DEV_URL }}
AWS_URL: ${{ needs.resolve-env.outputs.name == 'production' && secrets.PROD_AWS_URL || secrets.STAGING_AWS_URL }}
Comment on lines +35 to +53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

환경 변수 매핑이 반복적이지만 명확함

production/staging 환경 변수를 삼항 연산자로 매핑하는 패턴이 반복됩니다. GitHub Actions의 Environment 기능을 사용하면 더 간결하게 작성할 수 있지만, 현재 방식도 명시적이고 추적하기 쉽다는 장점이 있습니다.

💡 Environment 기능을 사용한 대안 (선택사항)

GitHub Repository Settings → Environments에서 productionstaging 환경을 생성하고 각 환경별로 시크릿을 등록한 후:

deploy:
  needs: resolve-env
  runs-on: ubuntu-latest
  environment: ${{ needs.resolve-env.outputs.name }}
  env:
    NEXT_PUBLIC_DEPLOY_ENV: ${{ needs.resolve-env.outputs.name }}
    NEXT_PUBLIC_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_API_BASE_URL }}
    # ... 각 시크릿을 환경별로 관리

현재 방식이 더 명시적이므로 변경은 선택사항입니다.

🤖 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 @.github/workflows/deploy-cloudflare.yml around lines 35 - 53, The current
secret mapping repeatedly uses ternary-style expressions with
needs.resolve-env.outputs.name; to simplify switch the job to use GitHub
Environments and reference environment-scoped secrets directly by setting the
job's environment to needs.resolve-env.outputs.name (keep NEXT_PUBLIC_DEPLOY_ENV
as is), then replace expressions like NEXT_PUBLIC_API_BASE_URL: ${{
needs.resolve-env.outputs.name == 'production' &&
secrets.PROD_NEXT_PUBLIC_API_BASE_URL ||
secrets.STAGING_NEXT_PUBLIC_API_BASE_URL }} with a single secret reference such
as NEXT_PUBLIC_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_API_BASE_URL }} (do this
for each variable: NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY, NEXT_PUBLIC_JAVASCRIPT_KEY,
NEXT_PUBLIC_SENTRY_DSN, REST_API_KEY, CLIENT_SECRET, SUPABASE_PROJECT_ID,
SESSION_SECRET, SENTRY_AUTH_TOKEN, UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN, LAMBDA_DEV_URL, AWS_URL) after creating matching
secrets in the production and staging Environments.


# 환경 공통 (Variables 탭 값, Secret 아님)
NEXT_PUBLIC_MAINTENANCE_MODE: ${{ vars.NEXT_PUBLIC_MAINTENANCE_MODE }}
NEXT_PUBLIC_MAINTENANCE_MESSAGE: ${{ vars.NEXT_PUBLIC_MAINTENANCE_MESSAGE }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Enable Corepack
run: corepack enable

- name: Get yarn cache directory
id: yarn-cache
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT

- name: Cache yarn dependencies
uses: actions/cache@v4
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-

- name: Install dependencies
run: yarn install --immutable

- name: Build Cloudflare worker bundle
run: yarn build:worker

- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --env ${{ needs.resolve-env.outputs.name }}
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ yarn-error.log*
# vercel
.vercel

# cloudflare
.open-next/
.wrangler/

# local docs
docs/*
!docs/mpa-school-link-handoff.md

# typescript
*.tsbuildinfo
next-env.d.ts
Expand Down
185 changes: 185 additions & 0 deletions docs/mpa-school-link-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# MPA 학교 인증 웹링크 — 백엔드/모바일 핸드오프

> 관련 문서: `bff-auth-refactor.md` (cchaksa_session 정책), `webview-mpa-request-response.md` (MPA/네이티브 브릿지 정렬)
> 프론트 변경: 본 문서 작성 시점 기준 머지 대기

## 배경

척척학사 모바일 앱(Compose Multiplatform)은 신규 가입 사용자가 학교 인증 없이도 시간표 등 일부 기능을 사용할 수 있도록 설계되어 있다. 사용자가 나중에 학교 인증을 진행할 때, **앱이 학교 인증 화면을 webview 로 띄워** 기존 웹의 `portal-link` 흐름을 그대로 재활용한다.

문제: 모바일 앱은 카카오 SDK 로 직접 로그인하여 백엔드의 `accessToken` / `refreshToken` 만 보유한다. 웹의 모든 인증 API 는 `cchaksa_session` (iron-session 으로 sealData 된 HttpOnly 쿠키) 을 요구하며, 이 쿠키는 `SESSION_SECRET` 을 가진 서버만 만들 수 있다. 즉 **앱이 들고 있는 토큰을 BFF 가 cchaksa_session 으로 익스체인지해주는 경로가 필요**하다.

## 호출 시퀀스 (목표 상태)

```text
앱 (ac/re 보유, cchaksa_session 없음)
├─ ① POST /api/session
│ body: { accessToken, refreshToken }
│ (isPortalLinked 는 서버가 항상 false 로 강제. 요청 바디에서 받지 않음)
│ └─ BFF: sealData → Set-Cookie: cchaksa_session
Comment thread
coderabbitai[bot] marked this conversation as resolved.
├─ ② WebView open /mpa/resync/login
│ └─ (mpa) layout 의 ProtectedRoute → GET /api/session → 200 OK → 폼 렌더
├─ ③ 사용자 학번/비번 입력 → portal-link job 생성
│ └─ router.push(/mpa/resync/scraping)
├─ ④ scraping 페이지 폴링 → succeeded
│ └─ postBridgeMessage('done:portal-link')
└─ ⑤ 네이티브: webview 닫고 dashboard 갱신
```

## 운영 결정 (2026-05-05): 시크릿 게이트 폐기

`x-native-bridge-secret` 헤더 게이트와 `NATIVE_SESSION_EXCHANGE_SECRET` 환경변수는 **코드에서 제거**했다 (`src/app/api/session/route.ts`, `src/config/environment.ts`). 모바일 측은 이 헤더를 부착할 필요가 없고, 인프라 측은 시크릿을 공유·등록할 필요가 없다. 위조 토큰 차단은 백엔드 토큰 진위 검증(B1) 단독 책임으로 통합된다.

**근거**
- 시크릿이 모바일 바이너리에 박혀 디컴파일·정적 분석으로 곧바로 추출 가능 → 보안 효용 제한적
- 위조 토큰의 실질 방어선은 어차피 B1. 게이트가 있어도 B1 이 들어오면 중복 방어가 됨
- 모바일·인프라 양쪽에 시크릿 공유·회전 절차 부담을 강제하기엔 1차 출시 시급도 대비 비용이 큼

**B1 도입 전까지의 노출 면**
- `POST /api/session` 은 임의의 ac/re 토큰을 그대로 봉인 → 위조 호출 시 쓰레기 cchaksa_session 발급 가능 (백엔드 호출은 401 로 차단되지만 쿠키 자체는 30일 유효)
- `isPortalLinked` 는 요청 바디에서 받지 않고 항상 `false` 강제 → 위조 호출이 portal-link 통과 상태로 세션 승격하는 경로 차단. 실제 연동 상태는 재로그인(auth callback) 또는 백엔드 me/profile 응답으로만 갱신
- 발급 시점 CSRF 1차 보호: HTTPS + JSON 본문 요구 + same-origin/CORS 로 외부 페이지의 자동 POST 차단. (SameSite=Lax / HttpOnly 는 *발급된* cchaksa_session 의 후속 보호이지 발급 시점 방어선이 아님 — 서버측 `Origin` 헤더 검증 추가는 후속 강화 트랙)

**재도입 트리거** (필요 시 git history 에서 게이트 코드 복원)
- 익명·봇 트래픽이 `POST /api/session` 을 무차별 호출하는 패턴 관측
- B1 도입이 장기 지연되어 위조 차단 수단이 부재한 상태 지속

## 프론트(웹) 작업 — 본 PR 에 포함

| 변경 | 파일 | 비고 |
|---|---|---|
| `POST /api/session` 익스체인지 추가 | `src/app/api/session/route.ts` | ac/re → cchaksa_session sealing. 토큰 진위 검증은 B1 (백엔드 의존) |
| `ROUTES.MPA.RESYNC_SCRAPING` 추가 | `src/constants/routes.ts` | `/mpa/resync/scraping` |
| `/mpa/resync/login` 페이지 신설 | `src/app/(mpa)/mpa/resync/login/page.tsx` | 기존 `/resync/login` 흐름 mpa 컨텍스트로 복제. 성공 시 `/mpa/resync/scraping` 이동 |
| `/mpa/resync/scraping` 페이지 신설 | `src/app/(mpa)/mpa/resync/scraping/page.tsx` | succeeded 시 `isInWebView()` 분기: webview 면 `postBridgeMessage('done:portal-link')`, 아니면 `/main` fallback. 에러/타임아웃은 throw 대신 `ErrorScreen` 인라인 렌더 |

`(mpa)` route group layout 은 `ProtectedRoute(requirePortalLinked=false)` 이므로, 학교 인증 전(=신규 사용자) 상태에서도 페이지 접근 가능.

## 백엔드 팀 요청

### B1. 토큰 진위 검증 엔드포인트 (필수)

현재 `POST /api/session` 은 앱이 보낸 `accessToken`/`refreshToken` 을 진위 검증 없이 그대로 sealData 한다. 위조 토큰으로 임의의 cchaksa_session 발급이 가능한 상태(쿠키만 30일 유효, 백엔드 호출은 어차피 401 로 막히지만 쿠키 자체가 쓰레기로 남음).

해결안 두 가지 중 택일:

- **(권장) `POST /api/auth/verify`** — body 의 accessToken 이 백엔드가 발급한 유효 토큰인지 확인하고 `200 OK { isPortalLinked }` 또는 `401` 반환. 부작용 없음(토큰 회전 안 됨)
- **(차선) `GET /api/users/me` 같은 가벼운 인증 필수 GET 엔드포인트** — BFF 가 익스체인지 직전 호출해서 200 떨어지면 진위 OK 로 간주

이 엔드포인트가 추가되면 `src/app/api/session/route.ts` 의 `POST` 핸들러에서 sealData 직전 호출하도록 `TODO(backend)` 주석을 풀어 구현한다.

### B2. (옵션) 짧은 TTL 의 session-init token

Auth0 의 *Native to Web SSO* 가 사용하는 패턴. 앱이 ac/re 토큰 자체 대신 짧은 TTL(예: 60초) 1회용 세션 초기화 토큰을 받아서 BFF 에 넘기는 형태. 네트워크 캡처/로그 노출 시 재사용 위험을 낮춘다. **B1 으로도 1차 차단은 충분하므로 시급도는 낮음.**

## 모바일(Compose Multiplatform) 팀 요청

### M1. iOS WKAppBoundDomains 등록 (필수)

iOS 14+ 의 ITP(Intelligent Tracking Prevention) 가 app-bound 로 등록되지 않은 도메인의 쿠키를 강등시킨다. 우리가 발급하는 `cchaksa_session` 의 30일 maxAge 가 사실상 무력화되어 webview 진입마다 재익스체인지가 필요해진다.

`Info.plist`:

```xml
<key>WKAppBoundDomains</key>
<array>
<string>cchaksa.com</string>
<!-- staging 도메인도 별도 등록. 최대 10 개 제한 -->
</array>
```

`WKWebView` 설정에 `limitsNavigationsToAppBoundDomains = true` 권장. 등록은 런타임 변경 불가, 앱 재설치 필요.

### M2. JS Bridge 인터페이스 합의 (필수)

웹 측 핸들러(`src/lib/webview/bridge.ts`) 가 다음 세 가지를 자동 감지한다. 모바일이 어느 형태로 노출하든 한 가지만 충족하면 동작.

| 플랫폼 | 노출 형태 | 비고 |
|---|---|---|
| RN/유사 | `window.ReactNativeWebView.postMessage(string)` | RN 표준 |
| iOS WKWebView | `window.webkit.messageHandlers.bridge.postMessage(string)` | 핸들러 이름이 `bridge` 이어야 함 (필수) |
| Android | `window.Android.postMessage(string)` | `addJavascriptInterface(obj, "Android")` 형태 |

**합의 필요**: iOS 의 message handler 등록 이름. 현재 코드는 `bridge` 가정. 다른 이름이면 `bridge.ts` 수정 필요.

### M3. 메시지 프로토콜 합의 (필수)

현재 웹이 송출하는 메시지 형식 (모두 `string`):

| 메시지 | 송출 위치 | 의미 |
|---|---|---|
| `navigate:<path>` | `src/lib/webview/bridge.ts` `navigateNative()` | 네이티브가 해당 path 로 화면 전환 (예: `navigate:/mpa/graduation-progress`) |
| `done:portal-link` | `/mpa/resync/scraping` succeeded 시 | 학교 인증 잡 완료. 네이티브가 webview 닫고 dashboard 등 갱신 |

**합의 필요**: `done:portal-link` 수신 시 네이티브 동작. 권장 동작:
1. webview 컨테이너 dismiss/pop
2. 직전 화면(예: 마이페이지·홈)에서 프로필/학사 정보 갱신 트리거

### M4. POST /api/session 익스체인지 호출 시점

이전 합의(세션 갱신을 앱이 책임지는 후자 방식)에 따라:

- 학교 인증 webview 진입 직전 한 번 호출 (필수)
- 이후 앱의 ac/re 토큰이 회전될 때마다 다시 호출 (선택, webview 가 살아있는 동안 401 방지)

호출 예시:

```http
POST https://cchaksa.com/api/session
Content-Type: application/json

{
"accessToken": "<백엔드가 발급한 ac>",
"refreshToken": "<백엔드가 발급한 re>"
}
```

응답:
- `200 { ok: true, isPortalLinked: false }` + `Set-Cookie: cchaksa_session=...`
- `isPortalLinked` 는 항상 `false` (위조 방지). 실제 연동 상태는 백엔드 me/profile 응답으로 조회
- `400 { error: "MISSING_ACCESS_TOKEN" | "MISSING_REFRESH_TOKEN" | "INVALID_JSON" }`

### M5. 로그아웃 동기화

앱에서 로그아웃할 때 `DELETE /api/session` 호출 권장. 안 하면 cchaksa_session 쿠키만 30일 살아있는 상태가 됨. 다음 webview 진입 시 잘못된 세션으로 시작될 위험.

## 보안 고려사항

| 항목 | 현재 상태 | 완화 책임 |
|---|---|---|
| 위조 토큰으로 cchaksa_session 발급 | 게이트 폐기로 1차 차단 없음. 쓰레기 쿠키 발급은 가능하나 백엔드 호출은 401, `isPortalLinked` 는 false 강제로 세션 승격 차단 (B1 도입 시 sealData 단계에서 진위 검증) | 백엔드(B1) |
| `/api/session` POST 의 CSRF | JSON 본문 요구 + same-origin/CORS preflight 로 외부 페이지의 자동 POST 차단. SameSite/HttpOnly 는 *발급된* 쿠키의 후속 보호이지 발급 시점 방어선 아님 | 프론트(완료) → 서버측 `Origin` 검사 후속 |
| 앱 ↔ BFF 간 토큰 전송 평문 | HTTPS 필수 | 인프라 |
| WKAppBoundDomains 미등록 | iOS 쿠키 7일~24시간 후 정리 | 모바일(M1) |

## 검증 체크리스트

### 프론트 (본 PR)
- [x] `yarn type-check` 통과
- [x] `yarn lint` 통과
- [ ] 로컬에서 `POST /api/session` 호출 후 `Set-Cookie: cchaksa_session` 발급 확인 (cURL 또는 Postman)
- [ ] `/mpa/resync/login` 직접 진입 시 ProtectedRoute 동작 (세션 없으면 `/`)
- [ ] `/mpa/resync/scraping` succeeded 시 `console` 또는 bridge 모킹으로 `done:portal-link` 송출 확인

### 백엔드 (B1 구현 후)
- [ ] 위조 토큰으로 `POST /api/session` 호출 시 401
- [ ] 유효 토큰으로 호출 시 200 + 쿠키 발급
- [ ] 만료된 ac 토큰으로 호출 시 401 (refresh 자동 시도 X — 이건 별도 endpoint)

### 모바일 (M1~M5 구현 후)
- [ ] iOS: 앱 재시작 후에도 cchaksa_session 살아있음 (WKAppBoundDomains 효과 검증)
- [ ] iOS: WKWebView 에서 `window.webkit.messageHandlers.bridge` 존재
- [ ] Android: WebView 에서 `window.Android.postMessage` 호출 가능
- [ ] 학교 인증 잡 succeeded 시 webview 자동 dismiss + 프로필 갱신
- [ ] 앱 로그아웃 시 `DELETE /api/session` 호출되어 다음 webview 가 미인증 상태로 시작

## 후속 과제

- (B2) session-init token 도입 (보안 강화 트랙)
- (프론트) `done:portal-link` 외 메시지 확장 시 프로토콜 명세를 별도 파일(`docs/webview-bridge-protocol.md`) 로 분리
- (앱+웹 공동) 세션 만료 중 webview 가 살아있을 때의 처리(앞 답변의 옵션 A/B/C 중 결정) — 1차 운영 후 재논의
3 changes: 1 addition & 2 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ const withBundleAnalyzer = withBundleAnalyzerFn({
const nextConfig = {
reactStrictMode: false,
images: {
formats: ['image/avif', 'image/webp'],
minimumCacheTTL: 60 * 60 * 24 * 365, // 1년
unoptimized: true,
},
Comment on lines 13 to 15

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚖️ Poor tradeoff

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check for alternative image optimization solutions

# Check for Cloudflare Images configuration
rg -i 'cloudflare.*image|cf.*image' --type ts --type tsx --type js -C 3

# Check for image loader configuration or external image domains
rg 'loader.*=|images.*domains|images.*remotePatterns' next.config -A 5

# Look for image optimization services in dependencies
cat package.json | jq '.dependencies, .devDependencies' | grep -i 'image\|cloudflare'

Repository: gyumong/chukchuk-haksa

Length of output: 198


🏁 Script executed:

#!/bin/bash
# Find next.config file
fd -e mjs -e js -e ts -e cjs | grep -i 'next\.config'

Repository: gyumong/chukchuk-haksa

Length of output: 102


🏁 Script executed:

#!/bin/bash
# Once we locate next.config.mjs, read its content
git ls-files | grep -i 'next\.config' | head -5

Repository: gyumong/chukchuk-haksa

Length of output: 102


🏁 Script executed:

#!/bin/bash
# Search for image-related configuration in the codebase
rg -i 'images\s*[:{]|loader|imageOptimization' -A 3 | head -50

Repository: gyumong/chukchuk-haksa

Length of output: 2886


🏁 Script executed:

#!/bin/bash
# Check what `@opennextjs/cloudflare` provides regarding image handling
cat package.json | jq '.dependencies, .devDependencies' | head -30

Repository: gyumong/chukchuk-haksa

Length of output: 916


🏁 Script executed:

#!/bin/bash
# Read next.config.mjs to see the image configuration
cat -n next.config.mjs

Repository: gyumong/chukchuk-haksa

Length of output: 4215


🏁 Script executed:

#!/bin/bash
# Check open-next.config.ts for image-related settings
cat -n open-next.config.ts

Repository: gyumong/chukchuk-haksa

Length of output: 194


🏁 Script executed:

#!/bin/bash
# Search for image loader, custom image handler, or Cloudflare Images configuration
rg -i 'loader|cloudflareImages|imageOptimization|customImageLoader' -A 3 -B 1

Repository: gyumong/chukchuk-haksa

Length of output: 50379


🌐 Web query:

@opennextjs/cloudflare image optimization support

💡 Result:

Yes, @opennextjs/cloudflare (OpenNext Cloudflare adapter) supports Next.js image optimization via the <Image /> component. It provides a Next.js-compatible image optimization API powered by Cloudflare Images.[1][2] To enable it: - Define an IMAGES binding in your Cloudflare Worker configuration for Cloudflare Images.[1] - Configure next.config.js as needed (e.g., remotePatterns, formats like PNG/JPEG/WEBP/AVIF/GIF/SVG).[1] - Images are optimized at the edge; unsupported formats return unchanged.[1] Alternatively, use a custom loader for Cloudflare Images: - Enable Cloudflare Images for your zone. - Create image-loader.ts with a cloudflareLoader function. - Set images: { loader: 'custom', loaderFile: './image-loader.ts' } in next.config.js.[1] Limitations: No minimumCacheTTL support (uses immutable caching); local IPs always allowed if in remotePatterns; custom loader bypasses remotePatterns—use Cloudflare dashboard for origin restrictions.[1] Official guide: https://opennext.js.org/cloudflare/howtos/image[1]

Citations:


이미지 최적화 대체 방안이 구성되지 않았습니다.

images.unoptimized = true 설정으로 Next.js 자동 이미지 최적화가 비활성화되어 있습니다. @opennextjs/cloudflare는 Cloudflare Images를 통한 이미지 최적화를 지원하지만, 현재 codebase에서는 다음이 구성되지 않았습니다:

  • Cloudflare Workers 설정의 IMAGES 바인딩
  • next.config.mjs의 커스텀 이미지 로더 (loader: 'custom')
  • open-next.config.ts의 이미지 최적화 설정

성능 최적화를 위해 다음 중 하나를 구성하세요:

  1. Cloudflare Images 바인딩 추가 및 커스텀 이미지 로더 설정
  2. 다른 CDN 기반 이미지 최적화 서비스 구성
🤖 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 `@next.config.mjs` around lines 13 - 15, The images.unoptimized = true setting
disables Next.js image optimization; either configure Cloudflare Images
integration or a CDN image service: add the Cloudflare IMAGES binding in your
Workers config, set next.config.mjs images to use a custom loader (e.g., replace
unoptimized:true with loader:'custom' and appropriate loader configuration), and
enable image optimization options in open-next.config.ts (or alternatively wire
a different CDN/image service and update next.config.mjs accordingly). Ensure
references to IMAGES binding, next.config.mjs images, loader:'custom', and
open-next.config.ts are updated so the `@opennextjs/cloudflare` integration (or
chosen CDN) performs image optimization.

sassOptions: {
// import.meta.dirname는 node v20.11 이상 부터 가능
Expand Down
3 changes: 3 additions & 0 deletions open-next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineCloudflareConfig } from '@opennextjs/cloudflare';

export default defineCloudflareConfig();
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"api:gen": "swagger-typescript-api generate -p ./swagger/openapi-dev.json -o ./src/shared/api --modular --module-name-first-tag --templates ./swagger/templates --name index.ts --extract-response-body --extract-request-params",
"api:move": "node scripts/move-api-domains.mjs",
"api:update": "yarn api:fetch && yarn api:gen && yarn api:move",
"build:worker": "opennextjs-cloudflare build",
"preview:cf": "yarn build && yarn build:worker && opennextjs-cloudflare preview",
"deploy:cf": "yarn build && yarn build:worker && opennextjs-cloudflare deploy",
"env:pull": "vercel env pull",
"env:pull:dev": "vercel env pull .env.development --environment development",
"env:pull:staging": "vercel env pull .env.staging --environment staging",
Expand Down Expand Up @@ -54,6 +57,7 @@
"zod": "^3.24.4"
},
"devDependencies": {
"@opennextjs/cloudflare": "^1",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

@opennextjs/cloudflare latest stable version npm

💡 Result:

Latest stable version on npm (as of 2026-05-10): 1.19.8. [1][2] How to install: - npm i @opennextjs/cloudflare@1.19.8 [1] - npm i @opennextjs/cloudflare (will install the latest stable tag) [1] Notes: - The package’s npm page shows “1.19.8 · Published May 7, 2026”. [1] - The GitHub repository indicates the latest release is 1.19.7, which may lag one patch behind npm’s latest published stable version. [3]

Citations:


@opennextjs/cloudflare 버전 지정 검토

현재 @opennextjs/cloudflare^1로 지정되어 있어 1.0.0부터 1.x까지의 모든 버전이 허용됩니다. 최신 안정 버전은 1.19.8(2026년 5월 7일 기준)입니다.

^1은 npm 생태계의 표준 관행이지만, 더 엄격한 버전 고정이 필요한 경우 ~1.19.8 또는 1.19.8으로 변경하여 마이너 버전 이상의 자동 업데이트를 방지할 수 있습니다.

🤖 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 `@package.json` at line 60, The dependency version for `@opennextjs/cloudflare`
is too loose ("^1"); update package.json to a stricter pin to prevent unexpected
upgrades by replacing "^1" with either a tilde-pinned version "~1.19.8" or an
exact version "1.19.8" (use the exact string "1.19.8" if you want full
immutability), then run your package manager install to update lockfiles; locate
the dependency entry for "@opennextjs/cloudflare" in package.json to make this
change.

"@svgr/webpack": "^8.1.0",
"@tanstack/eslint-plugin-query": "^5.74.7",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
Expand All @@ -75,7 +79,8 @@
"supabase": "2.9.6",
"swagger-typescript-api": "^13.1.1",
"typescript": "5.6.3",
"vitest": "^3.0.5"
"vitest": "^3.0.5",
"wrangler": "^4.59.1"
},
"packageManager": "yarn@4.5.0",
"resolutions": {
Expand Down
2 changes: 1 addition & 1 deletion src/app/(funnel)/agreement/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const Agreement = () => {
}, []);

const handleAgree = useCallback(() => {
router.push(`${ROUTES.FUNNEL.SCRAPING}`);
router.push(`${ROUTES.FUNNEL.COMPLETE}`);
}, []);

return (
Expand Down
4 changes: 2 additions & 2 deletions src/app/(funnel)/complete/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ROUTES } from '@/constants/routes';
import { useInternalRouter } from '@/hooks/useInternalRouter';
import { getSemesterInfo } from '@/lib/utils/semester';
import { FunnelHeadline } from '../components';
import { useStudentInfo } from '../contexts';
import { useFunnelContext } from '../contexts';
import styles from './page.module.scss';

function InfoRow({ label, value }: { label: string; value: string }) {
Expand All @@ -19,7 +19,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
}

export default function Complete() {
const { studentInfo } = useStudentInfo();
const { studentInfo } = useFunnelContext();
const router = useInternalRouter();
const handleNext = () => {
router.push(`${ROUTES.FUNNEL.TARGET_SCORE}`);
Expand Down
Loading
Loading