Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
GOOGLE_CLIENT_ID=replace-with-linku-google-client-id
GOOGLE_CLIENT_SECRET=replace-with-linku-google-client-secret
45 changes: 45 additions & 0 deletions .github/workflows/deploy-cloudflare.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Deploy Cloudflare Worker

on:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: deploy-cloudflare-production
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup pnpm
uses: pnpm/action-setup@v6

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Validate
run: |
pnpm run test:template-share
pnpm run test:worker
pnpm run build:local
pnpm run build:gh-pages
pnpm run build:worker
pnpm run lint

- name: Deploy Worker
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: pnpm exec wrangler deploy
7 changes: 5 additions & 2 deletions .github/workflows/pr-build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ name: PR Build Check

on:
pull_request:
branches:
- main

jobs:
build:
Expand Down Expand Up @@ -37,5 +35,10 @@ jobs:
- name: Build GitHub Pages share viewer
run: pnpm run build:gh-pages

- name: Test and build Cloudflare Worker
run: |
pnpm run test:worker
pnpm run build:worker

- name: Build success
run: echo "✅ Build completed successfully!"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ gh-pages
*.sw?

.claude
.wrangler
.dev.vars*
!.dev.vars.example
24 changes: 16 additions & 8 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,23 @@ IndexedDB로 복사합니다. 이전 값은 한 릴리즈 동안 rollback 원본
`.linku.json` 파일로 내보냅니다. Pages에서 확장 프로그램으로 가져오는 외부
메시지는 manifest와 background 양쪽에서 LinKU share 경로로 제한합니다.

계정 로그인, 여러 기기 동기화, 충돌 처리와 cloud share는 이 로컬 저장소 위에
별도 계층으로 추가하며, 로컬 저장 성공 여부와 분리해야 합니다. 상세 경계는
`docs/LOCAL_FIRST.md`를 참고합니다.
Google 로그인 후에는 IndexedDB v2의 outbox가 템플릿 변경을 Cloudflare Worker와
R2에 동기화합니다. 로컬 저장 성공 여부와 원격 동기화 상태는 분리하며, ETag 충돌은
로컬본을 충돌 복사본으로 보존합니다. 상세 경계와 운영 방법은
`docs/LOCAL_FIRST.md`, `docs/SERVERLESS.md`를 참고합니다.

### Backend와 인증

`src/apis/client.ts`가 `VITE_API_BASE_URL`을 기준으로 backend 요청, bearer token,
response parsing, 만료 감지를 중앙 처리합니다. Google OAuth는
`src/background/handlers/oauth.ts`에서 `chrome.identity.launchWebAuthFlow`를
사용하며 token은 `chrome.storage.local`에 저장합니다.
기존 feature API는 `src/apis/client.ts`가 `VITE_API_BASE_URL`을 기준으로 처리합니다.
계정 동기화는 `src/utils/accountSync.ts`가 `https://linku.turtlehwan.dev/api`와 직접
통신합니다. Google OAuth 시작은 `src/background/handlers/oauth.ts`에서
`chrome.identity.launchWebAuthFlow`를 사용합니다. access token은
`chrome.storage.session`, refresh token과 profile은 `chrome.storage.local`에
저장합니다.

검증된 모든 Google 계정은 같은 계정 기능을 사용합니다. KU 이메일 인증,
guest/member 승격, 이메일 도메인 제한은 없으며 계정 key는 Google `sub`에서 만든
opaque ID입니다.

feature API는 이 client와 background 경계를 재사용해야 합니다. auth code,
token, authorization header를 로그에 남기지 않습니다.
Expand Down Expand Up @@ -101,7 +108,8 @@ popup이 닫힌 동안 background polling은 실행하지 않습니다.

- `chrome.storage.local`: auth, 설정, todo, badge, 공지 캐시, 시간표 metadata와
snapshot/override.
- IndexedDB `linku`: 개인 template, draft, 사용자 icon blob.
- IndexedDB `linku`: 개인 template, draft, 사용자 icon blob, sync outbox와 계정별
ETag metadata.
- `localStorage`: non-extension 시간표 fallback과 이전 template/draft의 1회
마이그레이션 원본. 새 template 데이터는 쓰지 않습니다.
- IndexedDB: 사용자가 직접 올린 시간표 이미지 blob.
Expand Down
5 changes: 5 additions & 0 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ pnpm run build:local
backend 기능에는 유효한 `VITE_API_BASE_URL`이 필요합니다. 실제 secret은 commit하지
마세요.

Cloudflare 계정 동기화는 `docs/SERVERLESS.md`를 따릅니다. 로컬 Worker secret은
`.dev.vars`에만 두고, Google client secret을 Vite 환경 변수로 노출하지 마세요.

## 작업 원칙

- 하나의 branch와 PR은 하나의 목적에 집중합니다.
Expand Down Expand Up @@ -57,6 +60,8 @@ TypeScript, React hook, shared utility를 수정했다면 lint를 실행합니
pnpm run lint
pnpm run test:timetable
pnpm run test:template-share
pnpm run test:worker
pnpm run build:worker
```

변경 유형별 추가 확인:
Expand Down
15 changes: 4 additions & 11 deletions docs/GA4-Data-Taxonomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
| 핵심 가치 행동 파악 | 어떤 행동이 LinKU의 핵심 가치를 보여주는가 | link click rate, template apply rate, alert/todo usage |
| 기능 채택 파악 | 사용자가 어떤 기능을 실제로 쓰는가 | feature adoption by domain |
| 템플릿 기능 성과 파악 | 템플릿 생성/저장/동기화/게시 흐름이 잘 작동하는가 | editor conversion funnel |
| 계정 연동 파악 | 로그인/이메일 인증이 사용성에 어떤 영향을 주는가 | login start -> success, guest -> verified |
| 계정 연동 파악 | Google 로그인이 사용성에 어떤 영향을 주는가 | login start -> success |

## Principles

Expand Down Expand Up @@ -68,7 +68,6 @@
| `error_code` | string | 실패 코드 | `network_error`, `auth_required` |
| `error_message` | string | 사람이 읽는 에러 설명 | `sync_failed` |
| `is_logged_in` | boolean | 로그인 상태 | `true` |
| `is_guest` | boolean | 게스트 회원 여부 | `false` |

> **구현 상태 표기**
> - `구현됨` — `analytics.ts`에 헬퍼 함수가 존재하고 call site에 연결됨
Expand Down Expand Up @@ -103,11 +102,9 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측
| Event Name | 상태 | 목적 | 주요 Params | 우선순위 |
| --- | --- | --- | --- | --- |
| `auth_login_start` | 구현됨 | 로그인 의도 파악 | `provider`, `ui_location` | P1 |
| `auth_login_success` | 구현됨 | 실제 로그인 성공률 측정 | `provider`, `is_guest` | P1 |
| `auth_login_success` | 구현됨 | 실제 로그인 성공률 측정 | `provider` | P1 |
| `auth_login_fail` | 구현됨 | 로그인 장애 파악 | `provider`, `error_code`, `error_message` | P1 |
| `auth_logout` | 구현됨 | 로그아웃 행동 파악 | `ui_location` | P2 |
| `auth_email_verification_start` | 구현됨 | 게스트 → 회원 전환 시작점 | `ui_location` | P1 |
| `auth_email_verification_success` | 구현됨 | 회원 전환 완료 | `domain_type` | P1 |

## Template Events

Expand Down Expand Up @@ -182,7 +179,6 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측
| --- | --- | --- |
| `search_submit` | `MP_search_submit` | MP_ prefix 적용 |
| `auth_login_start/success/fail` | `MP_authLogin_start/success/fail` | prefix + camelCase trio |
| `auth_email_verification_start/success` | `MP_authEmailVerification_start/success` | prefix + camelCase |
| `auth_logout` | `MP_auth_logout` | prefix 적용 |
| `settings_open` | `MP_settings_open` | prefix 적용 |
| `settings_credentials_saved/deleted` | `MP_settingsCredentials_save/delete` | prefix + camelCase, 이벤트명은 동작형 save/delete 사용 |
Expand Down Expand Up @@ -223,7 +219,6 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측
| P0 | `template_apply` | 구현됨 |
| P1 | `auth_login_start` | 구현됨 |
| P1 | `auth_login_success` | 구현됨 |
| P1 | `auth_email_verification_success` | 구현됨 |
| P1 | `template_editor_open` | 구현됨 |
| P1 | `template_item_add` | 구현됨 |
| P1 | `system_error` | 구현됨 |
Expand All @@ -236,7 +231,7 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측
| Session-based return cohort | `extension_session_start` |
| Core action retention | `extension_first_open` cohort + `link_open` return condition |
| Template funnel | `template_editor_open` → `template_item_add` → `template_save_success` → `template_sync_success` → `template_publish_success` |
| Auth funnel | `auth_login_start` → `auth_login_success` → `auth_email_verification_success` |
| Auth funnel | `auth_login_start` → `auth_login_success` |

## Non-Goals

Expand Down Expand Up @@ -275,11 +270,9 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측
| `MP_alerts_view` | 공지 탭 진입 | 공지 탭 사용 여부 | `view_mode`, `category` | `Alerts.tsx · initialize()` | - |
| `MP_alertsItem_open` | 공지 클릭 | 공지 클릭률 측정 | `alert_id`, `category`, `source` | `AlertItem.tsx · handleClick` | - |
| `MP_alertsSubscription_update` | 구독 변경 | 학과 구독 변경 파악 | `category`, `subscription_result`(`subscribe`\|`unsubscribe`) | `SubscriptionManager.tsx · handleSubscribe`, `handleUnsubscribe` | - |
| `MP_authEmailVerification_start` | 이메일 인증 시작 | 게스트→회원 전환 시작점 | `ui_location` | `EmailVerificationDialog.tsx · useEffect([open])` | 다이얼로그 재진입마다 전송 → funnel 시작 수 과집계 가능 |
| `MP_authEmailVerification_success` | 이메일 인증 완료 | 회원 전환 완료 | `domain_type` | `EmailVerificationDialog.tsx · handleVerifyCode` | - |
| `MP_authLogin_fail` | 로그인 실패 | 로그인 장애 파악 | `provider`, `error_code`, `error_message` | `SettingsDialog.tsx · handleGoogleLogin` (결과·예외 분기) | - |
| `MP_authLogin_start` | 로그인 시도 | 로그인 의도 파악 | `provider`, `ui_location` | `SettingsDialog.tsx · handleGoogleLogin` | - |
| `MP_authLogin_success` | 로그인 성공 | 실제 로그인 성공률 측정 | `provider`, `is_guest` | `SettingsDialog.tsx · handleGoogleLogin` | - |
| `MP_authLogin_success` | 로그인 성공 | 실제 로그인 성공률 측정 | `provider` | `SettingsDialog.tsx · handleGoogleLogin` | Google 인증 성공 시 모든 계정 기능 사용 가능 |
| `MP_auth_logout` | 로그아웃 | 로그아웃 행동 파악 | `ui_location` | `SettingsDialog.tsx · handleLogout` | - |
| `MP_banner_open` | 배너 클릭 | 배너 클릭 효율 측정 | `banner_id`, `banner_title`, `banner_position` | `ImageCarousel.tsx · Image onClick` | - |
| `MP_labsFeature_use` | Labs 기능 사용 | Labs 기능 사용 측정 | `feature_name`, `result?` | `QRGeneratorSection.tsx · regenerate()`, `LibrarySeatSection.tsx · handleOpenRoom` | ServerClockSection 미연결 |
Expand Down
8 changes: 4 additions & 4 deletions docs/LOCAL_FIRST.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ LinKU의 개인화 기능은 서버가 없어도 먼저 동작하고, 계정 기
- Pages에서 보낸 가져오기 요청은 service worker가 `chrome.storage.local` queue에
보관하고, popup이 열릴 때 검증 후 IndexedDB에 저장합니다.

## 후속 stateful 계층의 계약
## Stateful 계층의 계약

계정 동기화 PR은 다음 원칙을 지켜 이 기반 위에 추가합니다.
계정 동기화 계층은 다음 원칙을 지켜 이 기반 위에서 동작합니다.

1. IndexedDB 저장은 항상 먼저 완료하고 성공 UI를 반환합니다.
2. 동기화는 durable outbox로 별도 수행하며 네트워크 실패가 로컬 저장을 rollback하지
Expand All @@ -52,5 +52,5 @@ LinKU의 개인화 기능은 서버가 없어도 먼저 동작하고, 계정 기
5. Worker는 인증, 사용자별 object namespace, optimistic concurrency와 공유 수명만
담당합니다. 템플릿 편집·검증·압축·미리보기는 프론트에 둡니다.

stateful 계층이 추가되기 전에는 로그인, 여러 기기 동기화, cloud share, 커뮤니티
게시를 제공한다고 표시하지 않습니다.
로그인과 여러 기기 템플릿 동기화, 큰 payload의 30일 cloud share만 제공합니다.
커뮤니티 게시·검색·좋아요는 아직 제공하지 않습니다.
159 changes: 159 additions & 0 deletions docs/SERVERLESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Cloudflare 계정 동기화 운영

LinKU의 편집과 로컬 저장은 Cloudflare 없이 동작합니다. 이 계층은 Google 계정으로
로그인한 사용자가 템플릿을 여러 Chrome 기기에서 동기화하고, URL fragment에 담기지
않는 큰 템플릿을 제한된 기간 공유할 때만 사용합니다.

## 제품 경계

| 기능 | 실행/저장 위치 | Worker 장애 시 |
| --- | --- | --- |
| 템플릿 편집·검증·압축 | extension | 정상 |
| 개인 템플릿·draft·아이콘 | IndexedDB | 정상 |
| 작은 공유 | GitHub Pages URL fragment | 정상 |
| Google OAuth code 교환 | Worker | 새 로그인 불가 |
| 계정 세션 | R2 private object | 기존 로컬 기능 정상 |
| 템플릿 동기화 | IndexedDB outbox → Worker → R2 | outbox에 보존 |
| 큰 공유 | Worker → R2 public object | `.linku.json` 파일로 대체 |

D1, KV, Durable Objects, Queue, 상시 실행 서버는 사용하지 않습니다. 게시·검색·좋아요와
같은 커뮤니티 기능은 다중 사용자 index와 moderation 정책이 필요하므로 이 PR의
cloud share와 같은 기능으로 취급하지 않습니다.

## 데이터 흐름

```mermaid
flowchart LR
UI["Popup / editor"] --> IDB["IndexedDB v2"]
IDB --> Outbox["template outbox"]
UI --> Fragment["gzip URL fragment"]
UI -->|"로그인 시 명시적 sync"| Worker["Cloudflare Worker"]
Outbox --> Worker
Worker --> R2["R2 Standard"]
Pages["GitHub Pages viewer"] --> Fragment
Pages -->|"큰 공유만"| Worker
```

- 저장은 IndexedDB transaction이 먼저 완료합니다. 원격 실패가 로컬 저장을
rollback하지 않습니다.
- 템플릿마다 UUID와 R2 ETag를 사용합니다. 새 object는 `If-None-Match: *`, 기존
object는 `If-Match`로 저장합니다.
- 충돌하면 로컬 변경을 새 UUID의 `(충돌 복사본)`으로 남기고 원격본을 원래 위치에
반영합니다.
- 삭제 중 원격 변경이 확인되면 최신 원격본을 `(삭제 충돌 복사본)`으로 먼저 남긴
뒤 삭제를 재시도합니다.
- 삭제는 30일 tombstone으로 전달합니다. 30일 넘게 offline인 기기의 복구 정책은
event log가 필요한 단계에서 다시 설계합니다.
- outbox는 항목별로 실패 횟수와 오류를 기록합니다. 한 항목 실패가 다음 항목의
동기화를 막지 않습니다.
- 서로 다른 Google 계정의 원격 metadata는 섞지 않습니다. 같은 Chrome profile에서
계정을 바꾸려면 기존 계정 데이터 삭제를 명시적으로 거쳐야 합니다.

## 인증

Google OAuth의 `openid email profile`만 요청합니다. Worker는 Google JWKS로 ID token
서명과 issuer, audience, expiry, nonce, `email_verified`를 검증합니다. Google
이메일 도메인은 제한하지 않으며 KU 이메일 인증 단계는 없습니다.

Google `sub` 원문은 저장하지 않습니다. issuer와 함께 SHA-256한 opaque account ID만
R2 key에 사용합니다. 자체 JWT 대신 256-bit 임의 access/refresh token을 발급하고,
R2 session object에는 token secret의 SHA-256 hash만 저장합니다.

- access token: 15분, `chrome.storage.session`
- refresh token: 30일, extension context로 제한한 `chrome.storage.local`
- 동시 device session: 계정당 최대 5개
- refresh 시 access/refresh token 모두 회전
- logout/계정 삭제: R2 session object 제거로 즉시 무효화

필요한 Worker secret은 다음 두 개뿐입니다.

```text
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
```

확장 프로그램의 계정 API 주소는 `https://linku.turtlehwan.dev/api`로 고정해 기존
백엔드용 `VITE_API_BASE_URL`과 분리합니다. 따라서 계정 동기화를 위해 추가하는
프론트엔드 환경 변수는 없습니다.

Google OAuth client에는 LinKU 전용 Web application 자격 증명을 만들고 다음 redirect
URI를 정확히 등록합니다.

```text
https://linku.turtlehwan.dev/api/auth/google/callback
```

## R2 구조와 제한

```text
auth/oauth-states/{sha256(state)}.json
auth/exchanges/{sha256(code)}.json
auth/sessions/{account-id}/{device-id}.json
private/{account-id}/templates/{template-uuid}.json
private/{account-id}/share-index/{share-id}.json
public/shares/{share-id}.json
```

application quota는 계정당 템플릿 50개, session 5개, 활성 cloud share 20개입니다.
payload는 256KB 이하이고 cloud share는 30일 후 만료됩니다. R2 lifecycle rule도
다음 prefix에 설정해 요청이 없어도 임시 object가 정리되게 합니다.

- `auth/oauth-states/`: 1일
- `auth/exchanges/`: 1일
- `public/shares/`: 31일

## 최초 설정

R2 Standard subscription을 활성화한 뒤 bucket을 만듭니다. R2는 무료 사용량이
포함된 usage-based subscription이므로, “무료 한도 안에서 운영”은 가능하지만 코드가
초과 과금을 원천 차단해 주지는 않습니다.

```bash
pnpm exec wrangler r2 bucket create linku-data
pnpm exec wrangler r2 bucket create linku-data-preview
pnpm exec wrangler secret put GOOGLE_CLIENT_ID
pnpm exec wrangler secret put GOOGLE_CLIENT_SECRET
```

로컬 Worker 검증에서는 커밋하지 않는 `.dev.vars`를 사용합니다.

```bash
cp .dev.vars.example .dev.vars
pnpm exec wrangler dev
```

`wrangler.jsonc`의 custom domain을 사용하려면 `turtlehwan.dev` zone이 같은 Cloudflare
account에 있어야 합니다. CI 배포에는 repository secret으로 다음 두 값을 둡니다.

- `CLOUDFLARE_API_TOKEN`: Worker 배포와 R2 binding에 필요한 최소 권한
- `CLOUDFLARE_ACCOUNT_ID`: 대상 account ID

## 무료 한도와 운영 경보

2026-08-12 기준 공식 한도는 Workers Free 100,000 requests/day, 10ms CPU/request이며,
R2 Standard 무료 구간은 10GB-month, Class A 1M/month, Class B 10M/month입니다.
현재 Worker dry-run bundle은 gzip 약 9KB이고 CPU 제한을 10ms로 고정했습니다.

Cloudflare Rate Limiting binding은 인증, 공개 공유, 계정 요청을 나눠 적용하지만
location-local이며 정확한 과금 차단 장치가 아닙니다. Cloudflare Billing budget
alert를 낮게 설정하고 Workers/R2 사용량을 확인해야 합니다.

- [Workers limits](https://developers.cloudflare.com/workers/platform/limits/)
- [R2 pricing](https://developers.cloudflare.com/r2/pricing/)
- [R2 get started](https://developers.cloudflare.com/r2/get-started/)
- [R2 conditional operations](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/)

## 검증과 배포

```bash
pnpm run test:worker
pnpm run build:local
pnpm run build:gh-pages
pnpm run build:worker
pnpm run lint
```

`build:worker`는 `wrangler deploy --dry-run`이라 외부 상태를 바꾸지 않습니다. 실제
배포는 `Deploy Cloudflare Worker` workflow를 수동 실행합니다. 배포 후에는 health,
Google 계정 선택, 두 Chrome profile 간 템플릿 왕복, offline 편집 재시도, 충돌
복사본, cloud share 만료, 계정 데이터 삭제를 실제 extension runtime에서 확인합니다.
Loading