create-saas-starter-workspace 0.1.10 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/workspace/.claude/skills/app-launch/SKILL.md +2 -2
- package/dist/workspace/.claude/skills/app-update/SKILL.md +4 -4
- package/dist/workspace/.claude/skills/bridge-guide/SKILL.md +1 -1
- package/dist/workspace/.claude/skills/domain-connect/SKILL.md +47 -0
- package/dist/workspace/.claude/skills/eas-deploy-guide/SKILL.md +4 -4
- package/dist/workspace/.claude/skills/native-app-guide/SKILL.md +8 -8
- package/dist/workspace/.claude/skills/sketch/SKILL.md +3 -3
- package/dist/workspace/.claude/skills/store-release-guide/SKILL.md +4 -4
- package/dist/workspace/.claude/skills/web-launch/SKILL.md +12 -7
- package/dist/workspace/AGENTS.md +2 -2
- package/dist/workspace/START-HERE.md +1 -1
- package/dist/workspace/mobile/docs/web-adapter/route-app-bridge.ts +3 -2
- package/dist/workspace/web/.env.example +8 -0
- package/dist/workspace/web/.github/workflows/ci.yml +4 -0
- package/dist/workspace/web/AGENTS.md +18 -11
- package/dist/workspace/web/docs/ENVIRONMENTS.md +3 -2
- package/dist/workspace/web/docs/LIMITS.md +9 -0
- package/dist/workspace/web/eslint.config.mjs +22 -1
- package/dist/workspace/web/instrumentation-client.ts +16 -0
- package/dist/workspace/web/lib/analytics.ts +32 -0
- package/dist/workspace/web/lib/env.ts +5 -0
- package/dist/workspace/web/lib/logger.ts +4 -3
- package/dist/workspace/web/package.json +1 -0
- package/dist/workspace/web/pnpm-lock.yaml +67 -0
- package/package.json +1 -1
|
@@ -24,7 +24,7 @@ user-invocable: true
|
|
|
24
24
|
|
|
25
25
|
## 누가 무엇을 하나 (가역성)
|
|
26
26
|
|
|
27
|
-
- 🟢 AI / CLI: 자격증명 파일 작업, EAS 빌드·제출, `eas.json`의 public 값.
|
|
27
|
+
- 🟢 AI / CLI: 자격증명 파일 작업, EAS 빌드·제출, `mobile/eas.json`의 public 값.
|
|
28
28
|
- 🟡 Owner 대시보드 (AI가 정확히 안내): 첫 Apple 로그인과 2FA, 스크린샷, App Privacy·Data Safety 라벨, Play 서비스 계정 JSON.
|
|
29
29
|
- 🔴 Owner 직접 (대리 불가): 카드 결제, 신원 검증, 멤버십 가입.
|
|
30
30
|
|
|
@@ -40,7 +40,7 @@ user-invocable: true
|
|
|
40
40
|
|
|
41
41
|
EAS가 iOS 인증서·프로비저닝·APNs 키와 Android 업로드 키스토어를 자동 생성하므로 `.cer`/`.jks`를 손대지 않는다. iOS ASC API 키도 EAS 자동 생성이 기본이며, Account Holder의 1회 'Request Access' 약관 동의가 선행이다. iOS는 첫 제출이 ASC 앱 레코드를 자동 생성해 TestFlight까지 간다.
|
|
42
42
|
|
|
43
|
-
🔴 트랩: Android 첫 AAB는 Play Console에 **수동으로 1회 업로드**해야 한다(Play API는 첫 릴리스를 만들지 못한다). 첫 자동 제출 실패는 정상이니 선고지한다. 이후부터 `eas submit -p android`가 동작한다. Play 제출용 서비스 계정 JSON은 항상 수동(🟡)이며 EAS 서버에 저장한다(`eas.json`에 시크릿 경로를 넣지 않는다). 절차는 `store-release-guide`.
|
|
43
|
+
🔴 트랩: Android 첫 AAB는 Play Console에 **수동으로 1회 업로드**해야 한다(Play API는 첫 릴리스를 만들지 못한다). 첫 자동 제출 실패는 정상이니 선고지한다. 이후부터 `eas submit -p android`가 동작한다. Play 제출용 서비스 계정 JSON은 항상 수동(🟡)이며 EAS 서버에 저장한다(`mobile/eas.json`에 시크릿 경로를 넣지 않는다). 절차는 `store-release-guide`.
|
|
44
44
|
|
|
45
45
|
### 3. 푸시 자격증명 (앱에 푸시를 켰을 때만)
|
|
46
46
|
|
|
@@ -16,7 +16,7 @@ user-invocable: true
|
|
|
16
16
|
|------|-----|------|
|
|
17
17
|
| 웹/UI — 제품 기능·화면·로직 | 웹 배포 | 웹 repo `git push` → 자동배포. 스토어 액션 0 |
|
|
18
18
|
| RN 셸 순수 JS/TSX/이미지/스타일 | OTA | `npx eas-cli update` — 즉시, 재빌드·재제출 없음 |
|
|
19
|
-
| 네이티브 모듈·`app.config.ts`·plugins·scheme·권한·SDK 업·계약 메시지 추가 | 재빌드 + 재제출 | marketing version 올림 → 빌드 → 테스트 채널 → 공개 승급 |
|
|
19
|
+
| 네이티브 모듈·`mobile/app.config.ts`·plugins·scheme·권한·SDK 업·계약 메시지 추가 | 재빌드 + 재제출 | marketing version 올림 → 빌드 → 테스트 채널 → 공개 승급 |
|
|
20
20
|
|
|
21
21
|
`runtimeVersion`은 `appVersion`을 따른다. OTA는 같은 marketing version으로 나간 빌드에만 도달한다. 네이티브가 한 줄이라도 바뀌면 OTA로 못 내보낸다.
|
|
22
22
|
|
|
@@ -30,12 +30,12 @@ user-invocable: true
|
|
|
30
30
|
|
|
31
31
|
## 4. 재빌드 + 재제출 — 네이티브 변경
|
|
32
32
|
|
|
33
|
-
네이티브 모듈·`app.config.ts`·plugins·scheme·권한·SDK 업·계약 메시지 추가가 끼면 새 바이너리가 필요하다.
|
|
33
|
+
네이티브 모듈·`mobile/app.config.ts`·plugins·scheme·권한·SDK 업·계약 메시지 추가가 끼면 새 바이너리가 필요하다.
|
|
34
34
|
|
|
35
|
-
- marketing version 올림은 **필수**다. `app.config.ts`의 `version`을 올린다. 안 올리면 ITMS-90186으로 거절된다(`autoIncrement`는 buildNumber/versionCode만 올린다).
|
|
35
|
+
- marketing version 올림은 **필수**다. `mobile/app.config.ts`의 `version`을 올린다. 안 올리면 ITMS-90186으로 거절된다(`autoIncrement`는 buildNumber/versionCode만 올린다).
|
|
36
36
|
- 빌드 + 자동 제출 → iOS는 TestFlight, Android은 내부 트랙까지 간다.
|
|
37
37
|
- 같은 production 빌드를 실기기에서 테스트 채널로 확인하고, 그대로 공개로 승급한다.
|
|
38
|
-
- 구체 명령·`eas.json` 프로파일·자격증명·무료 티어는 `eas-deploy-guide`.
|
|
38
|
+
- 구체 명령·`mobile/eas.json` 프로파일·자격증명·무료 티어는 `eas-deploy-guide`.
|
|
39
39
|
|
|
40
40
|
## 5. 공개 승급 (Owner confirm)
|
|
41
41
|
|
|
@@ -75,7 +75,7 @@ user-invocable: false
|
|
|
75
75
|
|
|
76
76
|
## 어댑터 (웹 측)
|
|
77
77
|
|
|
78
|
-
계약(`contract.ts`·`reader.ts`)은 이미 `web/lib/bridge/`에 설치돼 있다. 옵트인은 세션 핸드오프 레이어뿐이다 — 네이티브 소셜 로그인을 켤 때만 `docs/web-adapter/`의 POST 라우트(`route-app-bridge.ts`)·nonce·CSP를 웹 repo에 추가한다. 웹이 더하는 의존성은 `zod` 하나뿐이고, 네이티브·webview 라이브러리는 웹에 들어가지 않는다(웹은 `window.ReactNativeWebView` 전역으로만 앱에 닿는다). gronxb 등 라이브러리는 기각한다 — 네이티브-API-소유·monorepo 전제가 web=SSOT와 충돌한다.
|
|
78
|
+
계약(`contract.ts`·`reader.ts`)은 이미 `web/lib/bridge/`에 설치돼 있다. 옵트인은 세션 핸드오프 레이어뿐이다 — 네이티브 소셜 로그인을 켤 때만 `mobile/docs/web-adapter/`의 POST 라우트(`route-app-bridge.ts`)·nonce·CSP를 웹 repo에 추가한다. 웹이 더하는 의존성은 `zod` 하나뿐이고, 네이티브·webview 라이브러리는 웹에 들어가지 않는다(웹은 `window.ReactNativeWebView` 전역으로만 앱에 닿는다). gronxb 등 라이브러리는 기각한다 — 네이티브-API-소유·monorepo 전제가 web=SSOT와 충돌한다.
|
|
79
79
|
|
|
80
80
|
## 함정 (조용히 깨지는 것)
|
|
81
81
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: domain-connect
|
|
3
|
+
description: Attach a paid custom domain to the already-live web service via Vercel-managed DNS, verify SSL, and propagate the new domain everywhere it matters — Supabase Auth redirect, the webview app's target URL, and the link-preview (OG) base. Use when the Owner wants a custom domain instead of the free *.vercel.app URL, or invokes /domain-connect. Optional and paid.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /domain-connect
|
|
7
|
+
|
|
8
|
+
운영 중인 web에 **유료 커스텀 도메인**을 붙인다. 선택 사항이다 — 기본은 무료 `*.vercel.app` 주소로 충분하고, 직접 도메인을 원할 때만 쓴다. 외부 서비스의 절차·비용·필드명은 자주 바뀌므로 기억에 의존하지 말고 그때그때 공식 문서·`--help`·실제 응답을 확인하며 적응한다.
|
|
9
|
+
|
|
10
|
+
> 전제: web이 이미 실제 주소로 떠 있어야 한다(`/web-launch` 완료). 도메인은 그 위에 얹는 작업이다. 모두 `web/` 안에서 한다(먼저 `cd web/`).
|
|
11
|
+
|
|
12
|
+
## 베스트프랙티스 (2026): Vercel 관리형
|
|
13
|
+
|
|
14
|
+
DNS·SSL·apex(루트 도메인) 평탄화를 Vercel이 자동으로 처리하게 한다. 둘 중 하나:
|
|
15
|
+
|
|
16
|
+
- **Vercel에서 구매** (`vercel domains buy` 또는 대시보드) — 가장 단순. 네임서버가 처음부터 Vercel이다.
|
|
17
|
+
- **외부에서 구매 후 네임서버(NS)를 Vercel로 위임** — 기존 도메인을 쓸 때. 위임하면 그 뒤로는 Vercel이 자동 관리.
|
|
18
|
+
|
|
19
|
+
`vercel domains`·`vercel domains add` CLI라 에이전트가 대부분 수행한다. 구매·NS 변경 같은 과금·계정 행위만 Owner가 직접 확인한다.
|
|
20
|
+
|
|
21
|
+
> **Cloudflare Registrar는 비추천.** NS 위임을 막고 자사 DNS를 강제해 이 자동화와 맞지 않는다. (거기서 이미 산 도메인이라면 A/CNAME 수동 설정으로도 되지만 권장 경로가 아니다.)
|
|
22
|
+
|
|
23
|
+
## 절차 (순서가 중요하다)
|
|
24
|
+
|
|
25
|
+
1. **도메인 확보.** Vercel 구매 또는 외부 구매 후 NS를 Vercel로 위임한다.
|
|
26
|
+
2. **프로젝트에 연결.** `vercel domains add <domain>`(또는 Settings → Domains)으로 **apex와 www 둘 다** 붙이고, 새 도메인을 기본(primary)으로 둔다.
|
|
27
|
+
3. **SSL 자동 발급 확인.** Vercel이 인증서를 발급할 때까지 대기하고, `https://<domain>` 접속이 정상인지 확인한다.
|
|
28
|
+
4. **전파 (빠뜨리면 조용히 깨진다).** 도메인은 web 한 곳만 바꾸는 게 아니다. 세 곳에 같이 반영한다:
|
|
29
|
+
- **Supabase Auth**: prod 프로젝트의 `site_url`과 redirect 허용목록(`uri_allow_list`)에 새 도메인을 추가한다(웹 로그인 리디렉션). `/web-launch` step 7의 redirect 설정 로직을 그대로 재사용한다.
|
|
30
|
+
- **웹뷰 앱**: `mobile/`의 `EXPO_PUBLIC_WEB_URL`을 새 도메인으로 바꾼다. 빌드 시점에 인라인되므로 **재빌드가 필요**하다(→ `eas-deploy-guide`). 앱이 옛 주소를 바라보면 화면이 비거나 로그인이 깨진다.
|
|
31
|
+
- **공유 미리보기(OG)**: 링크 공유 시 보이는 기준 URL(메타데이터 base)을 새 도메인으로 바꾼다.
|
|
32
|
+
5. **옛 주소 유지.** `*.vercel.app`은 살려두되 새 도메인을 기본으로 한다.
|
|
33
|
+
|
|
34
|
+
## 함정 (조용히 깨지는 것)
|
|
35
|
+
|
|
36
|
+
- **apex/www 한쪽만 등록** → 다른 쪽 접속 실패. 둘 다 붙인다.
|
|
37
|
+
- **redirect 미갱신**(4) → 운영 로그인이 무음으로 깨진다(`/web-launch`와 같은 사고). 가장 흔하다.
|
|
38
|
+
- **앱 URL은 빌드 시점 고정** → env만 바꾸고 재빌드 안 하면 앱은 여전히 옛 주소를 본다.
|
|
39
|
+
- **도메인을 또 바꾸면** 4의 전파를 처음부터 다시 해야 한다. 그래서 도메인은 가능한 한 일찍 정한다.
|
|
40
|
+
|
|
41
|
+
## confirm 경계
|
|
42
|
+
|
|
43
|
+
도메인 구매(과금)와 NS 변경은 Owner가 직접 확인한다. SSL·DNS 연결 확인과 전파(redirect·OG) 설정은 자동이다. prod Supabase 변경은 `/web-launch`와 동일하게 Owner confirm 후 적용한다.
|
|
44
|
+
|
|
45
|
+
## 검증
|
|
46
|
+
|
|
47
|
+
성공은 셋이 모두 참일 때다: `https://<domain>`이 LIVE이고(SSL 정상), 그 도메인이 prod Supabase Auth redirect에 반영돼 로그인이 되며, 앱·OG 기준 URL이 새 도메인을 가리킨다. 비용·콘솔 절차는 바뀌므로 Vercel·Supabase 공식 문서를 그 시점 기준으로 확인한다.
|
|
@@ -41,23 +41,23 @@ npx eas-cli build --profile production --platform all --auto-submit --no-wait
|
|
|
41
41
|
|------|------|
|
|
42
42
|
| 웹/UI(제품 기능·화면·로직) | 웹 재배포 — 앱은 그대로, 다음 로드에 새 웹을 띄운다 |
|
|
43
43
|
| 네이티브 셸의 순수 JS/TSX/이미지/스타일 | OTA: `npx eas-cli update` (즉시) |
|
|
44
|
-
| 네이티브 모듈·`app.config.ts`·plugins·scheme·권한·SDK·계약 메시지 추가 | 재빌드 + 재제출 |
|
|
44
|
+
| 네이티브 모듈·`mobile/app.config.ts`·plugins·scheme·권한·SDK·계약 메시지 추가 | 재빌드 + 재제출 |
|
|
45
45
|
|
|
46
46
|
- `runtimeVersion.policy: "appVersion"`이라 같은 marketing version으로 나간 빌드끼리만 OTA가 도달한다. 네이티브가 한 줄이라도 바뀌면 OTA로 못 보낸다.
|
|
47
|
-
- 재빌드·재제출 땐 marketing version(`app.config.ts`의 `version`) 올림이 필수다 — 안 올리면 ITMS-90186으로 거절된다(`autoIncrement`는 buildNumber/versionCode만 올린다).
|
|
47
|
+
- 재빌드·재제출 땐 marketing version(`mobile/app.config.ts`의 `version`) 올림이 필수다 — 안 올리면 ITMS-90186으로 거절된다(`autoIncrement`는 buildNumber/versionCode만 올린다).
|
|
48
48
|
- `eas submit`은 공개 심사까지 가지 않는다 — iOS는 TestFlight, Android은 internal 트랙까지만. 공개 출시(ASC "Submit for Review", Play production 승급)는 Owner가 콘솔에서 수동 + 명시 confirm으로 한다(`app-launch`·`store-release-guide`). Android 첫 AAB는 Play Console에 수동으로 1회 업로드해야 한다(첫 자동 제출 실패는 정상).
|
|
49
49
|
|
|
50
50
|
## 함정 (조용히 깨지는 것)
|
|
51
51
|
|
|
52
52
|
- **실기기 = prod 웹 https.** 실기기는 Owner 컴퓨터의 `localhost`에 닿지 못하고, http LAN은 secure-context가 아니라 카메라·마이크·Web Crypto·클립보드·Secure 쿠키가 실기기에서 경고 없이 깨진다(Android cleartext·iOS ATS). 그래서 빌드의 `EXPO_PUBLIC_WEB_URL`은 항상 prod https다.
|
|
53
|
-
- **URL 가드는 protocol·hostname만 본다**(`src/config/env.ts`). https거나 localhost면 통과하므로 `localhost` URL은 가드를 통과하고도 실기기에선 깨진다 — "실기기=prod https"는 코드가 강제하는 불변식이 아니라 Owner·AI가 지키는 규칙이다. localhost·`10.0.2.2`와 ATS/cleartext 예외는 `development` 프로파일에만 추가한다(`production`엔 절대 넣지 않는다).
|
|
53
|
+
- **URL 가드는 protocol·hostname만 본다**(`mobile/src/config/env.ts`). https거나 localhost면 통과하므로 `localhost` URL은 가드를 통과하고도 실기기에선 깨진다 — "실기기=prod https"는 코드가 강제하는 불변식이 아니라 Owner·AI가 지키는 규칙이다. localhost·`10.0.2.2`와 ATS/cleartext 예외는 `development` 프로파일에만 추가한다(`production`엔 절대 넣지 않는다).
|
|
54
54
|
- **Expo Go로는 안 뜬다**(네이티브 모듈). 비개발자 환경엔 시뮬레이터도 없다고 가정한다 — 포장된 길은 production 빌드를 실폰에 설치하는 것이다.
|
|
55
55
|
- **무료 티어 빌드 수는 제한적이다.** 초기 네이티브 셋업이 iOS 빌드를 여러 개 소모할 수 있으니 ad-hoc preview로 낭비하지 않는다(정확한 한도는 위 Expo 요금 페이지).
|
|
56
56
|
- **SDK 버전을 추측하지 않는다.** 작성 전 `mobile/package.json`·v56 문서를 읽는다. 행동에 영향을 주는 v56 브레이킹: 이벤트 구독 정리는 `subscription.remove()`(`removeEventListener` 제거), `@expo/vector-icons` deprecated → `@react-native-vector-icons/*`, edge-to-edge 강제 기본, `expo-status-bar`의 `backgroundColor`·`translucent` props 제거.
|
|
57
57
|
|
|
58
58
|
## 자격증명 · 플랫폼 (짧게)
|
|
59
59
|
|
|
60
|
-
- 자격증명(iOS 인증서·프로비저닝·APNs, Android 키스토어)은 EAS-managed로 자동 생성된다. `eas.json`엔 public 값만, 시크릿은 EAS 서버에 둔다. `.env`는 커밋하지 않는다(
|
|
60
|
+
- 자격증명(iOS 인증서·프로비저닝·APNs, Android 키스토어)은 EAS-managed로 자동 생성된다. `mobile/eas.json`엔 public 값만, 시크릿은 EAS 서버에 둔다. `.env`는 커밋하지 않는다(`mobile/.env.example`만, 플레이스홀더만).
|
|
61
61
|
- iOS 시뮬은 Mac/Xcode 전용이다. Windows·Linux는 본인 iPhone + EAS 클라우드 빌드로 한다(Mac 불필요). Android 에뮬은 전 OS에서 되되 Google Play Services 포함 이미지여야 한다.
|
|
62
62
|
|
|
63
63
|
## 검증
|
|
@@ -6,7 +6,7 @@ user-invocable: false
|
|
|
6
6
|
|
|
7
7
|
# 셸 레시피 — 앱 껍데기가 메우는 네이티브 공백
|
|
8
8
|
|
|
9
|
-
배포된 prod 웹 https를 로드하는 얇은 WebView 셸이다. 모든 화면·기능·로그인은 웹이 가지고, 셸은 WebView가 막거나 못 하는 네이티브 공백만 메운다. 셸이 진짜 네이티브로 제공하는 건 다음이다. 오프라인 화면(유일하게 분명한 네이티브 전용 화면), 첫/하드 로드와 크래시 재마운트에 한정한 로딩·에러 오버레이, Android 하드웨어 뒤로가기, 외부 링크의 시스템 브라우저 라우팅, safe-area inset 주입, 카메라/마이크/파일 업로드 권한 배선(능력 자체는 웹·라이브러리의 것). 구현은 `src/webview/`에 모이고, 링크 경계는 `src/config/env.ts`의 `ALLOWED_ORIGINS`를 SSOT로 쓴다.
|
|
9
|
+
배포된 prod 웹 https를 로드하는 얇은 WebView 셸이다. 모든 화면·기능·로그인은 웹이 가지고, 셸은 WebView가 막거나 못 하는 네이티브 공백만 메운다. 셸이 진짜 네이티브로 제공하는 건 다음이다. 오프라인 화면(유일하게 분명한 네이티브 전용 화면), 첫/하드 로드와 크래시 재마운트에 한정한 로딩·에러 오버레이, Android 하드웨어 뒤로가기, 외부 링크의 시스템 브라우저 라우팅, safe-area inset 주입, 카메라/마이크/파일 업로드 권한 배선(능력 자체는 웹·라이브러리의 것). 구현은 `mobile/src/webview/`에 모이고, 링크 경계는 `mobile/src/config/env.ts`의 `ALLOWED_ORIGINS`를 SSOT로 쓴다.
|
|
10
10
|
|
|
11
11
|
셸은 4.2(최소기능, [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/))를 통과하도록 설계된 게 아니라 출발점이다. 4.2를 넘으려면 Owner가 진짜 네이티브 차별점을 더해야 한다. 흔한 다음 단계는 네이티브 푸시인데 v1엔 없다(contract.ts에 forward-compat `PUSH_TOKEN_UPDATED` 상수와 `push.v1` capability 자리만 있고, `expo-notifications`·토큰 취득·핸들러는 전무하다). 켜려면 AI가 expo-notifications를 추가하고, 권한·토큰을 등록하고, `PUSH_TOKEN_UPDATED`로 웹에 전달하고, cold-start는 딥링크 섹션의 가드를 배선한다. 4.2·스토어 심사·개발자 계정 가격은 `store-release-guide`(SSOT).
|
|
12
12
|
|
|
@@ -14,9 +14,9 @@ user-invocable: false
|
|
|
14
14
|
|
|
15
15
|
- SDK·RN·React·라이브러리 버전과 브레이킹 변경: `mobile/package.json` + v56 문서(<https://docs.expo.dev/versions/v56.0.0/>). 학습 데이터와 다르니 메모리를 믿지 않는다.
|
|
16
16
|
- 설치된 네이티브 API의 실제 시그니처: `node_modules/react-native-webview/lib/*.d.ts` 등 타입 정의. docs에만 있고 코드엔 없는 이름이 흔하다(아래 함정).
|
|
17
|
-
- 링크 경계·허용 origin: `src/config/env.ts`의 `ALLOWED_ORIGINS`(SSOT). URL 가드 동작도 여기.
|
|
17
|
+
- 링크 경계·허용 origin: `mobile/src/config/env.ts`의 `ALLOWED_ORIGINS`(SSOT). URL 가드 동작도 여기.
|
|
18
18
|
- `EXPO_PUBLIC_WEB_URL`의 정확한 값: `mobile/.env.example`(플레이스홀더만 커밋된다).
|
|
19
|
-
- 셸 구현 본체: `src/webview/`(`Host.tsx`·`linkBoundary.ts`·`linkBoundary.test.ts`).
|
|
19
|
+
- 셸 구현 본체: `mobile/src/webview/`(`Host.tsx`·`linkBoundary.ts`·`linkBoundary.test.ts`).
|
|
20
20
|
|
|
21
21
|
## 먼저: 추측하지 말고 확인
|
|
22
22
|
|
|
@@ -24,7 +24,7 @@ user-invocable: false
|
|
|
24
24
|
|
|
25
25
|
- 컴포넌트는 `React.JSX.Element`를 반환한다. strict TS를 쓰고 `any`를 회피한다.
|
|
26
26
|
- `react-native-url-polyfill/auto`가 `index.ts`에서 먼저 로드돼야 `new URL().origin`이 Hermes에서 동작한다(링크 allow-list의 전제).
|
|
27
|
-
- `EXPO_PUBLIC_WEB_URL`은 빌드 시점에 인라인된다(바꾸려면 재빌드). 인라인 출처는 클라우드 빌드=`eas.json` `build.<profile>.env`, 로컬 `expo start`=Owner가 만든 `.env`다. `.env`는 커밋하지 않고(`.gitignore`), 커밋되는
|
|
27
|
+
- `EXPO_PUBLIC_WEB_URL`은 빌드 시점에 인라인된다(바꾸려면 재빌드). 인라인 출처는 클라우드 빌드=`mobile/eas.json` `build.<profile>.env`, 로컬 `expo start`=Owner가 만든 `.env`다. `.env`는 커밋하지 않고(`.gitignore`), 커밋되는 `mobile/.env.example`은 플레이스홀더만 담는다(정확한 값은 `mobile/.env.example`을 직접 본다). `env.ts` 가드는 protocol/hostname만 본다. 실기기인지 시뮬레이터인지 못 구분하므로 localhost URL도 가드는 통과하나 실기기에선 실패한다. "실기기=prod https"는 코드가 강제하는 불변식이 아니라 Owner/AI 규칙이다.
|
|
28
28
|
|
|
29
29
|
## safe-area (노치 여백)
|
|
30
30
|
|
|
@@ -45,7 +45,7 @@ edge-to-edge가 이 SDK에선 강제 기본이다(Android의 최신 targetSdk가
|
|
|
45
45
|
|
|
46
46
|
## 링크 경계 (외부 링크)
|
|
47
47
|
|
|
48
|
-
부수효과 없는 순수 분류자 `classifyLink(url, allowedOrigins)`(`src/webview/linkBoundary.ts`)가 단일 SSOT다. 셸의 모든 링크 판단이 이 한 곳을 공유한다: 내비게이션(`onShouldStartLoadWithRequest` + `onOpenWindow`)과 브릿지 OPEN_EXTERNAL. 보안 경계(origin 허용목록·스킴 게이트)이므로 네이티브 import 없이 Node(vitest)로 단위 테스트한다(`linkBoundary.test.ts`).
|
|
48
|
+
부수효과 없는 순수 분류자 `classifyLink(url, allowedOrigins)`(`mobile/src/webview/linkBoundary.ts`)가 단일 SSOT다. 셸의 모든 링크 판단이 이 한 곳을 공유한다: 내비게이션(`onShouldStartLoadWithRequest` + `onOpenWindow`)과 브릿지 OPEN_EXTERNAL. 보안 경계(origin 허용목록·스킴 게이트)이므로 네이티브 import 없이 Node(vitest)로 단위 테스트한다(`linkBoundary.test.ts`).
|
|
49
49
|
|
|
50
50
|
세 가지 판정:
|
|
51
51
|
- 파싱 실패 → `"block"`(웹뷰에도 OS에도 안 넘김).
|
|
@@ -90,7 +90,7 @@ WebView 프로세스가 죽으면 `key`를 증가시켜 통째 재마운트한
|
|
|
90
90
|
|
|
91
91
|
- 로딩: `startInLoadingState`/`renderLoading`을 쓰지 않는다(그 이름은 docs에만 있고 코드엔 없다 — 추측하지 말고 확인하라는 이유다). 직접 관리하는 `loading` state를 첫 `onLoadEnd`에서 해제한다(`loading` i18n).
|
|
92
92
|
- 에러: `onError`(네트워크/전송) + `onHttpError`(메인 프레임 에러 상태)만으로 에러 화면을 띄운다(`error.*` i18n + 재시도 버튼). 재시도 = `key` 증가 통째 재마운트(크래시 복구와 동일).
|
|
93
|
-
- 사용자 문자열은 전부 `src/i18n.ts` `t()`를 경유한다.
|
|
93
|
+
- 사용자 문자열은 전부 `mobile/src/i18n.ts` `t()`를 경유한다.
|
|
94
94
|
|
|
95
95
|
## 카메라 · 마이크 · 파일 업로드 (능력은 웹·라이브러리, 셸은 권한만)
|
|
96
96
|
|
|
@@ -98,7 +98,7 @@ WebView 프로세스가 죽으면 `key`를 증가시켜 통째 재마운트한
|
|
|
98
98
|
|
|
99
99
|
- iOS usage string 3종: `NSCameraUsageDescription`·`NSPhotoLibraryUsageDescription`·`NSMicrophoneUsageDescription`(`NSPhotoLibraryAddUsageDescription`은 저장 구현 시만) + WebView `mediaCapturePermissionGrantType="grant"`. iOS는 usage string 누락 시 Apple이 런타임에 앱을 종료시킨다.
|
|
100
100
|
- Android: `CAMERA` 권한과 `<queries>` IMAGE_CAPTURE 인텐트 가시성(app.config.ts `withImageCaptureQuery` 플러그인). Android 11+ 패키지 가시성 때문에 둘은 별개 요구사항이다. 권한=카메라 사용, queries=카메라 앱 조회(`resolveActivity`). queries가 누락되면 갤러리·파일 선택은 되나 `<input type=file capture>`의 '카메라 촬영' 옵션이 안 뜬다(크래시는 아니다).
|
|
101
|
-
- 키보드 focus: `app.config.ts` `android.softwareKeyboardLayoutMode: "resize"` · iOS는 WebView prop `keyboardDisplayRequiresUserAction={false}`(Host.tsx).
|
|
101
|
+
- 키보드 focus: `mobile/app.config.ts` `android.softwareKeyboardLayoutMode: "resize"` · iOS는 WebView prop `keyboardDisplayRequiresUserAction={false}`(Host.tsx).
|
|
102
102
|
|
|
103
103
|
## 아이콘 (벡터 아이콘)
|
|
104
104
|
|
|
@@ -106,7 +106,7 @@ WebView 프로세스가 죽으면 `key`를 증가시켜 통째 재마운트한
|
|
|
106
106
|
|
|
107
107
|
## 로그인 · 소셜(FAST-FOLLOW)
|
|
108
108
|
|
|
109
|
-
로그인은 웹이 담당한다. 기본 상태에서 앱은 로그인에 아무것도 하지 않는다. 웹 로그인 화면이 웹뷰 안에 그대로 뜨고 쿠키·세션도 웹의 `@supabase/ssr`가 처리한다. 네이티브 로그인 모달(`src/auth/LoginScreen.tsx`)은 웹이 `REQUEST_SESSION_INSTALL`을 보낼 때만 뜨는 옵트인 스캐폴드이며, 유일하게 동작하는 버튼('이메일로 계속')은 모달을 닫아 웹 로그인으로 되돌릴 뿐이다(인증·재로드·네비게이션 없음). 소셜 원클릭(Google/Apple)은 disabled 'coming soon' 스텁이다(fast-follow).
|
|
109
|
+
로그인은 웹이 담당한다. 기본 상태에서 앱은 로그인에 아무것도 하지 않는다. 웹 로그인 화면이 웹뷰 안에 그대로 뜨고 쿠키·세션도 웹의 `@supabase/ssr`가 처리한다. 네이티브 로그인 모달(`mobile/src/auth/LoginScreen.tsx`)은 웹이 `REQUEST_SESSION_INSTALL`을 보낼 때만 뜨는 옵트인 스캐폴드이며, 유일하게 동작하는 버튼('이메일로 계속')은 모달을 닫아 웹 로그인으로 되돌릴 뿐이다(인증·재로드·네비게이션 없음). 소셜 원클릭(Google/Apple)은 disabled 'coming soon' 스텁이다(fast-follow).
|
|
110
110
|
|
|
111
111
|
소셜을 켤 땐 웹뷰 안 OAuth로 하지 말고 네이티브로 구현한다: 네이티브 SDK로 idToken을 받아 세션 핸드오프(`requestSessionHandoff`, 상세는 bridge-guide). 이유는 UX가 아니라 동작 자체다. Google은 임베디드 웹뷰 OAuth를 차단하고([`disallowed_useragent`, 2021/2023 시행](https://developers.googleblog.com/upcoming-security-changes-to-googles-oauth-20-authorization-endpoint-in-embedded-webviews/)), [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252)도 임베디드 user-agent를 금지한다. 그래서 시스템 브라우저(ASWebAuthenticationSession/Custom Tabs)나 네이티브 자격증명 선택기로 빠져나가야 한다. Apple 4.8은 조건부다: 구글 등 서드파티 소셜을 제공할 때만 네이티브 Sign in with Apple도 함께 필요하다(이메일/비번만이면 미적용).
|
|
112
112
|
|
|
@@ -5,7 +5,7 @@ description: Make working pages in the dev server so Owner can pick from design
|
|
|
5
5
|
|
|
6
6
|
# /sketch
|
|
7
7
|
|
|
8
|
-
`app/sketch/<feature>/`에 Owner의 의도에 맞춰 동작하는 페이지를 스케치한다.
|
|
8
|
+
`web/app/sketch/<feature>/`에 Owner의 의도에 맞춰 동작하는 페이지를 스케치한다.
|
|
9
9
|
Owner는 dev 서버 브라우저로 결과물을 보고 피드백·확정할 수 있어야 한다.
|
|
10
10
|
|
|
11
11
|
디자인 시안은 서로 다른 안을 나란히 펼쳐, Owner가 어느 룩으로 갈지 고르게 한다.
|
|
@@ -16,10 +16,10 @@ Owner는 dev 서버 브라우저로 결과물을 보고 피드백·확정할 수
|
|
|
16
16
|
|
|
17
17
|
## 정리 (확정 후)
|
|
18
18
|
|
|
19
|
-
`app/sketch/`는 *현재 검토 중인 시안*만 사는 임시 공간이다. 확정되면 채택안만 이 경로에 남기고 나머지 변형은 비워, 다음 스케치가 죽은 안 위에서 시작하지 않게 한다.
|
|
19
|
+
`web/app/sketch/`는 *현재 검토 중인 시안*만 사는 임시 공간이다. 확정되면 채택안만 이 경로에 남기고 나머지 변형은 비워, 다음 스케치가 죽은 안 위에서 시작하지 않게 한다.
|
|
20
20
|
|
|
21
21
|
비운 안은 버리지 말고 디자인 레퍼런스로 옮겨 둔다 — 탈락한 룩·상태 케이스는 다음 디자인 작업에서 다시 꺼내 보는 자산이다. 즉 정리는 *삭제*가 아니라 *라이브 경로에서 빼서 보관*이다. 어디에·어떻게 보관할지 모호하면 Owner에게 묻는다.
|
|
22
22
|
|
|
23
23
|
## 검증
|
|
24
24
|
|
|
25
|
-
`app/sketch/<feature>/`에 채택안 하나만 남아 dev 서버에서 돌고, 탈락 안들은 사라진 게 아니라 레퍼런스로 옮겨져 있다.
|
|
25
|
+
`web/app/sketch/<feature>/`에 채택안 하나만 남아 dev 서버에서 돌고, 탈락 안들은 사라진 게 아니라 레퍼런스로 옮겨져 있다.
|
|
@@ -122,7 +122,7 @@ Google은 플랫폼마다 OAuth 클라이언트가 필요하다. 코드에는 **
|
|
|
122
122
|
|
|
123
123
|
한국 개발자는 Apple Services ID 등록/갱신 시 server-to-server 엔드포인트가 필수가 됐다 ([Apple 공지](https://developer.apple.com/news/?id=j9zukcr6)). 이건 웹 OAuth 방식에만 해당하므로, 기본인 네이티브 idToken 방식을 쓰면 Services ID 자체가 없어 이 요구를 회피한다. 그래서 한국 Owner에게는 네이티브 idToken 방식을 강력 권장한다.
|
|
124
124
|
|
|
125
|
-
핸드오프 함수(`requestSessionHandoff`)는 구현·테스트됐으나 호출자가 없다. 켜려면 입력인 네이티브 idToken 획득도 미배선이고, 출력을 받는 웹 라우트(POST `/auth/app-bridge` + nonce consume)는 이 앱이 아니라 `docs/web-adapter`에서 웹 repo에 설치해야 한다. `secureSession` get/set도 v1 호출자 없음. 기본 로그인은 웹의 것이다. 실제 켜기·콘솔 셋업은 fast-follow이며 이 문서가 단계별 절차를 담는다.
|
|
125
|
+
핸드오프 함수(`requestSessionHandoff`)는 구현·테스트됐으나 호출자가 없다. 켜려면 입력인 네이티브 idToken 획득도 미배선이고, 출력을 받는 웹 라우트(POST `/auth/app-bridge` + nonce consume)는 이 앱이 아니라 `mobile/docs/web-adapter`에서 웹 repo에 설치해야 한다. `secureSession` get/set도 v1 호출자 없음. 기본 로그인은 웹의 것이다. 실제 켜기·콘솔 셋업은 fast-follow이며 이 문서가 단계별 절차를 담는다.
|
|
126
126
|
|
|
127
127
|
---
|
|
128
128
|
|
|
@@ -138,18 +138,18 @@ EAS가 서명을 관리한다. Owner가 fastlane을 직접 구성할 일은 없
|
|
|
138
138
|
|
|
139
139
|
- iOS: 배포 인증서, 프로비저닝 프로파일, APNs 키(푸시용)
|
|
140
140
|
- Android: 업로드 키스토어
|
|
141
|
-
- iOS 제출용 ASC API 키: EAS 자동 생성 (Account Holder가 1회 'Request Access' 약관 동의 선행) — 권장. (`eas.json`엔 대안인 `appleId` 방식 placeholder도 있으나 둘 중 하나만.)
|
|
141
|
+
- iOS 제출용 ASC API 키: EAS 자동 생성 (Account Holder가 1회 'Request Access' 약관 동의 선행) — 권장. (`mobile/eas.json`엔 대안인 `appleId` 방식 placeholder도 있으나 둘 중 하나만.)
|
|
142
142
|
|
|
143
143
|
### 수동인 것 (🟡)
|
|
144
144
|
|
|
145
|
-
- **Play 서비스 계정 JSON** (Android 제출 인증): 한 번만 만든다. `eas.json`엔 경로를 넣지 않는다(시크릿 커밋 위험). 첫 `eas submit -p android`가 JSON 경로를 물으면 그때 주고, EAS가 서버에 저장한다(`$ENV` 보간 미지원). 만드는 순서 ([Expo 절차](https://github.com/expo/fyi/blob/main/creating-google-service-account.md)):
|
|
145
|
+
- **Play 서비스 계정 JSON** (Android 제출 인증): 한 번만 만든다. `mobile/eas.json`엔 경로를 넣지 않는다(시크릿 커밋 위험). 첫 `eas submit -p android`가 JSON 경로를 물으면 그때 주고, EAS가 서버에 저장한다(`$ENV` 보간 미지원). 만드는 순서 ([Expo 절차](https://github.com/expo/fyi/blob/main/creating-google-service-account.md)):
|
|
146
146
|
1. Cloud Console → **Google Play Android Developer API**(`androidpublisher.googleapis.com`) **Enable**
|
|
147
147
|
2. IAM & Admin → **서비스 계정 생성** (역할 지정은 skip)
|
|
148
148
|
3. 그 계정 → 키 → **JSON 키 생성·다운로드** (시크릿, 커밋 금지)
|
|
149
149
|
4. Play Console → **사용자 및 권한** → 그 SA 이메일 **초대** + 앱/계정 권한 부여 (권한 반영 지연 가능)
|
|
150
150
|
- **Android FCM v1 서비스 계정**(푸시용): Firebase Console에서 수동 생성 → EAS 업로드.
|
|
151
151
|
|
|
152
|
-
> `eas.json`엔 public 값만 커밋한다. 시크릿은 EAS 서버에 둔다.
|
|
152
|
+
> `mobile/eas.json`엔 public 값만 커밋한다. 시크릿은 EAS 서버에 둔다.
|
|
153
153
|
> `google-services.json`(public·커밋) ↔ FCM SA JSON(시크릿) ↔ Play 제출 SA JSON. 셋은 별개다. 혼동하지 말라.
|
|
154
154
|
|
|
155
155
|
### EAS 무료 티어 (선고지 — 스냅샷)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: web-launch
|
|
3
|
-
description: Provision a private GitHub repo, Vercel project, Supabase dev/prod, and
|
|
3
|
+
description: Provision a private GitHub repo, Vercel project, Supabase dev/prod, Sentry, and PostHog, then ship the web service's first real deploy and run a security audit. Use when the Owner is ready to take the web live for the first time — stand up its cloud and put it on a real URL — or invokes /web-launch.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# /web-launch
|
|
@@ -11,7 +11,7 @@ description: Provision a private GitHub repo, Vercel project, Supabase dev/prod,
|
|
|
11
11
|
|
|
12
12
|
## 빠른 참조 (값은 여기서 읽는다)
|
|
13
13
|
|
|
14
|
-
- 리소스 이름: 루트 `workspace.json`의 `name`. repo=`<name>-web`, Vercel=`<name>`, Supabase=`<name>-dev`/`-prod`, Sentry=`<name>`.
|
|
14
|
+
- 리소스 이름: 루트 `workspace.json`의 `name`. repo=`<name>-web`, Vercel=`<name>`, Supabase=`<name>-dev`/`-prod`, Sentry=`<name>`, PostHog=`<name>`.
|
|
15
15
|
- env 키 목록과 어느 환경에 들어가는지: `web/.env.example`.
|
|
16
16
|
- dev/prod 분리 매트릭스: `web/docs/ENVIRONMENTS.md`. 무료 티어 한도·플랜별 가용성: `web/docs/LIMITS.md`.
|
|
17
17
|
|
|
@@ -23,20 +23,25 @@ description: Provision a private GitHub repo, Vercel project, Supabase dev/prod,
|
|
|
23
23
|
|
|
24
24
|
3. **Vercel link + env 주입.** `vercel link`는 폴더명 `web`을 기본 프로젝트명으로 제안하니, 그대로 수락하지 말고 `<name>`으로 바꾼다. production 슬롯엔 prod Supabase + `SUPABASE_PROD_REF`, preview·development 슬롯엔 dev + `SUPABASE_DEV_REF`를 채운다. 끝나면 `vercel env pull`로 `.env.local`을 동기화한다.
|
|
25
25
|
|
|
26
|
-
4. **Sentry
|
|
26
|
+
4. **Sentry · PostHog.** 둘 다 prod-only 게이트다(키는 Vercel Production 슬롯에만 → dev·preview 신호가 운영에 안 섞인다).
|
|
27
|
+
- Sentry: config 파일은 이미 있어 env만 채우면 된다(wizard 불필요). org·project는 `sentry-cli`로 조회·생성하고, CLI로 못 만드는 auth token은 Owner가 대시보드(Settings → Auth Tokens)에서 발급한다. **`NEXT_PUBLIC_SENTRY_DSN`은 Vercel Production 슬롯에만 넣는다**(이유는 아래 함정). 빌드용 3종(`SENTRY_ORG`·`SENTRY_PROJECT`·`SENTRY_AUTH_TOKEN`, sourcemap 업로드)은 3환경 + GitHub Secrets에 넣는다.
|
|
28
|
+
- PostHog: 프로젝트 생성 CLI가 없으므로 Owner가 대시보드(posthog.com 가입 → 프로젝트 생성)에서 Project API Key를 복사한다(🟡 Sentry auth token과 같은 수동 단계). **`NEXT_PUBLIC_POSTHOG_KEY`는 Vercel Production 슬롯에만** 넣고(비-prod에 넣으면 dev·preview 이벤트가 분석에 섞인다), `NEXT_PUBLIC_POSTHOG_HOST`(US `https://us.i.posthog.com` / EU `https://eu.i.posthog.com`)는 전 슬롯에 넣는다.
|
|
27
29
|
|
|
28
|
-
5. **`
|
|
30
|
+
5. **`audit_log` 테이블 마이그레이션 (방식 B).** 감사 로그용이다. 마이그레이션에 `enable row level security` + 클라 정책 0개(= `anon`·`authenticated`는 0행) + `anon`·`authenticated` 권한 회수를 동봉한다. insert는 서버 secret key(`admin.ts`)만 하고 RLS를 우회한다(클라는 못 닿음). Data API는 켜둔 채(프로젝트 기본) 클라만 잠근다 — 이게 이 템플릿의 데이터 접근 자세다(인가는 서버 코드). dev는 자동 적용, prod는 Owner confirm 후 적용.
|
|
29
31
|
|
|
30
|
-
6. **GitHub Secrets.** CI 빌드용이다. `.env.local`에서 publishable + URL + Sentry 4
|
|
32
|
+
6. **GitHub Secrets.** CI 빌드용이다. `.env.local`에서 publishable + URL + Sentry 4종 + PostHog 2종(`NEXT_PUBLIC_POSTHOG_KEY`·`NEXT_PUBLIC_POSTHOG_HOST`)을 `gh secret set`으로 넣는다. secret key·DB ref는 CI 컴파일에 불필요하니 넣지 않는다(보안 표면 최소화).
|
|
31
33
|
|
|
32
34
|
7. **첫 배포 → prod redirect 갱신.** main에 push하면 CI와 Vercel 배포가 함께 트리거된다. 배포가 끝나면 **실 production 도메인을 확인해 prod Supabase의 `site_url`·`uri_allow_list`에 반영한다.** 2에서 이걸 미뤘던 이유이고, 빠뜨리면 운영 사이트 로그인이 조용히 깨진다.
|
|
33
35
|
|
|
34
|
-
8. **Owner 대시보드
|
|
36
|
+
8. **Owner 대시보드 설정 (배포 게이트 + preview 보호).**
|
|
37
|
+
- 배포 게이트: main에 직접 push하는 흐름이라 'merge 시 required check'(branch protection)만으로는 못 막는다. Vercel Git 연동에서 **'CI 통과 후에만 배포'**(Deployment Checks / Wait for Checks — Hobby 가용, `web/docs/LIMITS.md`)를 켜, 자동 점검을 통과한 커밋만 운영에 배포되고 빌드·검사 실패 시 직전 정상 배포가 그대로 유지되게 한다('깨진 게 prod를 대체하지 않음').
|
|
38
|
+
- preview 보호: Vercel Authentication을 켜 preview URL 크롤러를 막는다.
|
|
39
|
+
- 플랜별 가용성·한도는 본문에 박지 말고 `web/docs/LIMITS.md`를 따른다.
|
|
35
40
|
|
|
36
41
|
## 함정 (조용히 깨지는 것)
|
|
37
42
|
|
|
38
43
|
- **prod redirect 미갱신**(7) → 운영 로그인이 무음으로 깨진다. 가장 흔한 사고다.
|
|
39
|
-
- **Sentry DSN을 비-prod 슬롯에 주입** → preview 에러가 production으로 찍혀 무료 한도를 깎는다. 클라이언트 SDK는 번들에 `APP_ENV` 신호가 없어 DSN 존재만으로 송출을 켜기 때문이다(서버·엣지는 `APP_ENV`로 가드돼 안전하다). 그래서 4의 DSN은 Production 슬롯 전용이다.
|
|
44
|
+
- **Sentry DSN을 비-prod 슬롯에 주입** → preview 에러가 production으로 찍혀 무료 한도를 깎는다. 클라이언트 SDK는 번들에 `APP_ENV` 신호가 없어 DSN 존재만으로 송출을 켜기 때문이다(서버·엣지는 `APP_ENV`로 가드돼 안전하다). 그래서 4의 DSN은 Production 슬롯 전용이다. **PostHog `NEXT_PUBLIC_POSTHOG_KEY`도 같은 이유로 Production 슬롯 전용**이다(비-prod에 넣으면 dev·preview 이벤트가 분석에 섞인다).
|
|
40
45
|
- **`vercel link` 기본 프로젝트명**이 폴더명 `web`(3) → `<name>`으로 바꾸지 않으면 리소스 이름이 어긋난다.
|
|
41
46
|
- **secret scanning push protection**은 private+free에서 미지원이다(GitHub Advanced Security 유료) — 진단에서 이 항목만 건너뛴다.
|
|
42
47
|
|
package/dist/workspace/AGENTS.md
CHANGED
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
|
|
26
26
|
## 이 템플릿의 성격
|
|
27
27
|
|
|
28
|
-
빈 골격이다. `web/features/`와 `web/supabase/migrations/`는 비어 있고, 로그인·글·피드 같은 제품 기능은 사용자 주제에 맞춰 대화로 만든다. 인프라(데이터 접근 seam,
|
|
28
|
+
빈 골격이다. `web/features/`와 `web/supabase/migrations/`는 비어 있고, 로그인·글·피드 같은 제품 기능은 사용자 주제에 맞춰 대화로 만든다. 인프라(서버측 인가 데이터 접근 seam, 환경 분리, 로깅·테스트, 디자인 시스템, 코드 컨벤션)는 갖춰져 있다.
|
|
29
29
|
|
|
30
|
-
기능 제작은 `web/AGENTS.md`의 규약을 따른다(FSD-light, `features/<f>/repository.ts` 데이터 접근 seam, RLS
|
|
30
|
+
기능 제작은 `web/AGENTS.md`의 규약을 따른다(FSD-light, `features/<f>/repository.ts` 서버측 데이터 접근 seam, 클라 차단 동봉(RLS·grant), schema 3층). 앱 작업은 `mobile/AGENTS.md`와 자동 로드되는 가이드 스킬(`native-app-guide`·`bridge-guide`·`eas-deploy-guide`·`store-release-guide`)을 따른다.
|
|
31
31
|
|
|
32
32
|
## 운영 원칙
|
|
33
33
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
| 명령 | 무엇을 하나 | 당신이 하는 일 |
|
|
20
20
|
| --- | --- | --- |
|
|
21
|
-
| `/web-launch` | GitHub·Vercel·Supabase·Sentry를 만들고 `web/`을 실제 주소로 첫 배포 | 비가역 작업만 확인 |
|
|
21
|
+
| `/web-launch` | GitHub·Vercel·Supabase·Sentry·PostHog를 만들고 `web/`을 실제 주소로 첫 배포 | 비가역 작업만 확인 |
|
|
22
22
|
| `/probe <아이디어>` | 만들 서비스를 인터뷰로 구체화 | 질문에 답하기 |
|
|
23
23
|
| `/sketch` | 화면 디자인 시안 보여주기 | 마음에 드는 안 고르기 |
|
|
24
24
|
| "OO 기능 만들어줘" | 로그인·글쓰기·피드·승인 등 기능을 대화로 제작 | 원하는 바 말하기 |
|
|
@@ -181,8 +181,9 @@ type HandoffSession = { accessToken: string; refreshToken: string };
|
|
|
181
181
|
* OPTION A — Supabase table + RLS, single-use delete (recommended; survives
|
|
182
182
|
* multi-instance deploys, truly single-use because the read deletes the row).
|
|
183
183
|
*
|
|
184
|
-
* Migration to add to your web (ship
|
|
185
|
-
*
|
|
184
|
+
* Migration to add to your web (ship the client-lockout in the SAME file — this
|
|
185
|
+
* template's web denies client table access under 방식 B: enable RLS with zero
|
|
186
|
+
* client policies + revoke anon/authenticated grants; the secret key bypasses it):
|
|
186
187
|
*
|
|
187
188
|
* create table public.app_handoff_nonces (
|
|
188
189
|
* code text primary key,
|
|
@@ -30,6 +30,14 @@ SENTRY_AUTH_TOKEN=
|
|
|
30
30
|
SENTRY_ORG=
|
|
31
31
|
SENTRY_PROJECT=
|
|
32
32
|
|
|
33
|
+
# ─── PostHog (사용자 이벤트 분석) ───────────────────────────────
|
|
34
|
+
# Sentry DSN과 같은 prod-only 게이트: 키는 Vercel "Production" 슬롯에만 넣는다.
|
|
35
|
+
# 브라우저 번들엔 APP_ENV 신호가 없어, 키 존재 여부로 송출을 켠다(키 없으면 no-op).
|
|
36
|
+
# 비-prod 슬롯에 키를 넣으면 dev·preview 이벤트가 분석에 섞인다.
|
|
37
|
+
# 키 = PostHog 대시보드 Project Settings의 Project API Key. 호스트 = US: https://us.i.posthog.com / EU: https://eu.i.posthog.com.
|
|
38
|
+
NEXT_PUBLIC_POSTHOG_KEY=
|
|
39
|
+
NEXT_PUBLIC_POSTHOG_HOST=
|
|
40
|
+
|
|
33
41
|
# ─── 배포 환경 ─────────────────────────────────────────────────
|
|
34
42
|
# 코드 분기는 벤더 중립 APP_ENV로 한다(lib/app-env.ts).
|
|
35
43
|
# Vercel은 아래를 자동 주입하므로 설정 불필요(VERCEL_ENV가 APP_ENV로 매핑됨).
|
|
@@ -59,3 +59,7 @@ jobs:
|
|
|
59
59
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
60
60
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
|
61
61
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
|
62
|
+
# PostHog는 prod-only 게이트라 키가 없으면 init이 no-op이다. CI 빌드는 검증용이므로
|
|
63
|
+
# 비어 있어도 통과한다(실제 송출은 Vercel Production 빌드에서만).
|
|
64
|
+
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
|
|
65
|
+
NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
2. `/probe <아이디어>` — 적응형 인터뷰로 요구사항 도출
|
|
15
15
|
3. `/sketch` — shadcn과 프로젝트 디자인 컨벤션 기반 시안
|
|
16
16
|
4. TDD 개발 — Vitest 테스트 작성, 통과, 커밋을 반복
|
|
17
|
-
5. `git push`
|
|
17
|
+
5. `git push` 전 로컬 전수 점검(typecheck·lint·test·build) → push → CI 통과 시 Vercel 자동 배포
|
|
18
18
|
|
|
19
19
|
`.vercel/`이 없으면 `/web-launch`를 먼저 실행한다. 의존성·CLI 로그인은 설치 스크립트와 `pnpm run doctor`가 챙긴다.
|
|
20
20
|
|
|
@@ -39,18 +39,25 @@ Owner가 직접(CLI 미지원, AI는 안내만): 카드 결제, 도메인 DNS
|
|
|
39
39
|
## 핵심 코드 규칙
|
|
40
40
|
|
|
41
41
|
- TDD 우선: 새 기능은 Vitest 테스트를 작성하고 통과시킨 뒤 커밋한다. 테스트는 `tests/`에 누적한다.
|
|
42
|
-
-
|
|
43
|
-
-
|
|
42
|
+
- push 전 점검: `git push`는 main 직접 push → CI → 운영 배포를 트리거한다. 그러므로 push 전에 `pnpm typecheck && pnpm lint && pnpm test && pnpm build`를 모두 통과시킨다(깨진 코드를 운영에 올리지 않도록 로컬에서 먼저 막는다).
|
|
43
|
+
- 관측은 목적별로 갈라 보낸다(한 곳에 안 몬다):
|
|
44
|
+
- 오류(예외·크래시) → Sentry. 서버는 `lib/logger.ts`의 `error`, 클라이언트는 `Sentry.captureException()`(browser SDK).
|
|
45
|
+
- 감사 기록(누가·무엇·언제 — 가입·삭제·권한변경·관리자 처리 등 상태를 바꾸는 의미 있는 동작) → Supabase `audit_log` 표. 서버에서 `lib/logger.ts`의 `audit`로 남긴다(secret key, server-only). 로그 스트림이 아니라 비즈니스 데이터라 DB에 둔다. 의미 있는·저볼륨만 남기고, 고볼륨·디버그는 보내지 않는다.
|
|
46
|
+
- 사용자 행동 분석 → PostHog. 클라이언트에서 `lib/analytics.ts`의 `captureEvent`로 핵심 이벤트만 남긴다(autocapture off, 과도 추적 금지). 키가 prod에만 주입되므로 운영에서만 송출된다.
|
|
47
|
+
- 임시 디버그 → `console.log`(Vercel 런타임 로그, 1시간 휘발). 남길 가치가 있으면 위 셋으로 승격한다.
|
|
48
|
+
- `lib/logger.ts`는 server-only라 클라이언트에서 직접 import하지 않는다. `lib/analytics.ts`는 client 전용이라 서버에서 import하지 않는다.
|
|
44
49
|
- env: 클라이언트·일반 코드는 `import { env } from "@/lib/env"`(NEXT_PUBLIC_ 만). 서버 전용 키(SUPABASE_SECRET_KEY 등)는 `import { env } from "@/lib/env.server"`(server-only 가드). `process.env.X` 직접 접근은 빌드 시점 설정 파일(`next.config.*`, `sentry.{server,edge}.config.ts`, `instrumentation.ts`, `instrumentation-client.ts`, `vercel.json`)에서만 허용한다.
|
|
45
|
-
- 보안 경계 (
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
50
|
+
- 보안 경계 (서버측 인가 — 방식 B):
|
|
51
|
+
- 테이블 데이터 읽기·쓰기는 전부 서버에서 secret key로 한다. 각 feature의 `repository.ts`가 유일한 데이터 접근 지점이고(server-only), `queries.ts`·`actions.ts`는 `repository.ts`에만 의존한다.
|
|
52
|
+
- secret key는 RLS를 우회하므로 "누가 무엇을 할 수 있는지"(소유권·권한)는 반드시 서버 코드가 확인한다. 모든 읽기·쓰기가 거치는 단일 인가 지점에서 요청자 == 자원 소유자/권한을 검사한다.
|
|
53
|
+
- 세션은 서버에서 `server.ts`(user-scoped)로 재검증한다: `getClaims()`(또는 `getUser()`)를 쓰고 `getSession()`은 신뢰하지 않는다. 클라 로그인은 `client.ts`(browser)로 한다. 이 두 클라이언트는 인증(세션) 전용이고 테이블 데이터를 직접 읽지 않는다.
|
|
54
|
+
- 클라 차단(fail-closed 백스톱): 새 테이블은 같은 마이그레이션에서 `enable row level security` + 클라 정책 0개(= `anon`·`authenticated`는 0행, secret key는 우회) + `anon`·`authenticated` 테이블 권한 회수를 동봉한다(동봉하지 않으면 거부). RLS는 인가 수단이 아니라 클라 차단 백스톱이다 — 인가는 서버 코드가 진다.
|
|
55
|
+
- 파일 업로드는 서버가 발급한 signed-upload 토큰으로 한다(서버가 인가, 바이트는 클라가 직접 전송). 비공개 파일 읽기는 서버가 권한 확인 후 짧은 signed URL을 발급한다.
|
|
56
|
+
- 새 기능에는 공격자 시점 회귀 테스트(IDOR: 다른 사용자로 남의 자원에 접근하면 거부되는지)를 함께 둔다.
|
|
57
|
+
- secret key: `lib/supabase/admin.ts`에서만 만든다(server-only 가드. import는 `features/*/repository.ts`·`lib/`만 — ESLint 강제). `NEXT_PUBLIC_*` 접두사를 붙이지 않는다. auth 흐름에선 `admin.ts`를 import하지 않는다(세션 우회 위험).
|
|
51
58
|
- Supabase Auth: `@supabase/ssr`을 쓴다. `@supabase/auth-helpers-nextjs`(deprecated)는 쓰지 않는다.
|
|
52
59
|
- 환경 분기: 서버 코드는 `import { env } from "@/lib/env.server"` 후 `env.APP_ENV`를 비교한다(값: `production` | `preview` | `development`). `APP_ENV`는 벤더 중립 신호다. Vercel은 `VERCEL_ENV`를 여기 매핑하고, 그 외 호스트는 `APP_ENV`를 직접 설정한다(`lib/app-env.ts`).
|
|
53
|
-
- Vercel Cron route: prod에서만 돈다. 핸들러 시작에서 `env.APP_ENV !== "production"`이면 즉시 no-op으로 반환해, preview가 dev DB·외부 API를 낭비하지 않게 한다. 성공은 `audit`, 실패는 `error`로 `lib/logger.ts`에 남긴다(Sentry·
|
|
60
|
+
- Vercel Cron route: prod에서만 돈다. 핸들러 시작에서 `env.APP_ENV !== "production"`이면 즉시 no-op으로 반환해, preview가 dev DB·외부 API를 낭비하지 않게 한다. 성공은 `audit`, 실패는 `error`로 `lib/logger.ts`에 남긴다(Sentry·audit_log 송출은 자동). 테스트 함정: `env`는 모듈 로드 시 한 번만 parse돼서 `vi.stubEnv`가 듣지 않는다 — cron 테스트는 `vi.mock("@/lib/env.server")`로 모듈을 통째 교체한다. 헤더 검증·idempotent·`CRON_SECRET`·`vercel.json`은 Vercel 문서를 따른다.
|
|
54
61
|
- 의존성 정책: caret range, `pnpm-lock.yaml` 커밋, CI `--frozen-lockfile`. `.npmrc`의 `minimum-release-age=14d`로 갓 나온 npm 패키지는 cooldown 후에만 설치한다. 의존성 변경은 lockfile과 같은 커밋에 포함한다.
|
|
55
62
|
|
|
56
63
|
## 폴더 구조 (FSD-light)
|
|
@@ -67,7 +74,7 @@ features/<feature>/ # 도메인 모듈 (자기완결)
|
|
|
67
74
|
├── queries.ts # 데이터 쿼리 — repository.ts에만 의존
|
|
68
75
|
├── schema.ts # zod (form·경계 검증) — DB schema와 정합 유지
|
|
69
76
|
└── index.ts # public API — 외부 import는 여기만
|
|
70
|
-
supabase/migrations/ # DB schema SSOT (.sql + RLS)
|
|
77
|
+
supabase/migrations/ # DB schema SSOT (.sql + 클라 차단: RLS enable·grant 회수)
|
|
71
78
|
components/ui/ # shadcn primitives (글로벌 재사용)
|
|
72
79
|
lib/ # cross-cutting 인프라
|
|
73
80
|
├── supabase/{server,client,admin}.ts
|
|
@@ -11,6 +11,7 @@ dev와 prod를 서로 다른 Supabase 프로젝트로 완전히 격리한다. Ow
|
|
|
11
11
|
| Supabase Auth | dev project, redirect URL `localhost:3000`·`*.vercel.app` | prod project, prod 도메인 redirect | 사용자 계정도 별개 |
|
|
12
12
|
| Sentry | 1 프로젝트, `environment=development` 태그 | 같은 프로젝트, `environment=production` 태그 | env 태그 + 송출 가드 |
|
|
13
13
|
| Sentry 송출 | off (`APP_ENV !== "production"`) | on | 5k/월 한도 보호. preview에서도 송출하지 않음 |
|
|
14
|
+
| PostHog | off (키 미주입) | on (Production 슬롯 키) | `NEXT_PUBLIC_POSTHOG_KEY`를 Production 슬롯에만 주입 → dev·preview 이벤트 미혼입 |
|
|
14
15
|
| Vercel Cron | preview·dev에서 실행 안 함(`APP_ENV === "production"` 가드) | 정상 실행 | 코드 분기 |
|
|
15
16
|
| env vars 소스 | `vercel env pull --environment=development` → `.env.local` | Vercel "Production" 슬롯(자동 주입) | gitignored |
|
|
16
17
|
| GitHub Actions CI | PR·main 모두 같은 GH Secrets로 컴파일·테스트(앱 런타임 실행은 없음) | 동일 | CI는 dev/prod를 구분하지 않는다. 런타임 분기는 Vercel이 담당 |
|
|
@@ -87,7 +88,7 @@ Sentry.init({
|
|
|
87
88
|
## 마이그레이션 워크플로 (AI 주도)
|
|
88
89
|
|
|
89
90
|
1. `npx supabase migration new <name>`로 파일 생성
|
|
90
|
-
2. SQL 작성 (
|
|
91
|
+
2. SQL 작성 (클라 차단 동봉 필수: `enable row level security` + 클라 정책 0개 + `anon`·`authenticated` 권한 회수. 인가는 서버 코드가 진다 — 방식 B)
|
|
91
92
|
3. `npx supabase db push --project-ref $SUPABASE_DEV_REF`로 dev 적용 (자동)
|
|
92
93
|
4. dev에서 검증한 뒤 Owner에게 보고: "dev 적용·검증 완료. 변경 요약: ___. prod에도 적용할까요?"
|
|
93
94
|
5. Owner confirm
|
|
@@ -97,7 +98,7 @@ Sentry.init({
|
|
|
97
98
|
|
|
98
99
|
## 비용·한도
|
|
99
100
|
|
|
100
|
-
무료 티어 숫자는 [`LIMITS.md`](LIMITS.md)가 SSOT다. 핵심만: dev/prod 분리에 Supabase 무료 슬롯 2개를 모두 쓰고, Sentry는 prod만 송출해 한도를 보호한다. Vercel 로그 보존은 1시간이라, 그 너머는 Sentry와 Supabase `
|
|
101
|
+
무료 티어 숫자는 [`LIMITS.md`](LIMITS.md)가 SSOT다. 핵심만: dev/prod 분리에 Supabase 무료 슬롯 2개를 모두 쓰고, Sentry는 prod만 송출해 한도를 보호한다. Vercel 로그 보존은 1시간이라, 그 너머는 Sentry와 Supabase `audit_log` 테이블에서 조회한다.
|
|
101
102
|
|
|
102
103
|
## 다루지 않는 것
|
|
103
104
|
|
|
@@ -32,6 +32,14 @@
|
|
|
32
32
|
- organization·user: 1
|
|
33
33
|
- 출처: <https://sentry.io/pricing/>
|
|
34
34
|
|
|
35
|
+
## PostHog Free (Product Analytics)
|
|
36
|
+
|
|
37
|
+
- 이벤트: ~1,000,000 / 월 (무료 한도, 초과분은 사용량 과금 또는 송출 중단)
|
|
38
|
+
- 보존: 무료는 짧은 보존창 (정확한 일수는 공식 docs 확인)
|
|
39
|
+
- 출처: <https://posthog.com/pricing>
|
|
40
|
+
|
|
41
|
+
> 과도 추적은 무료 한도를 빠르게 소진한다. autocapture는 끄고(`instrumentation-client.ts`) 핵심 이벤트만 `lib/analytics.ts`로 남긴다.
|
|
42
|
+
|
|
35
43
|
## GitHub Free
|
|
36
44
|
|
|
37
45
|
- private repo: 무제한
|
|
@@ -51,4 +59,5 @@
|
|
|
51
59
|
| Supabase | 프로젝트 비활성화 → Pro $25/mo 또는 데이터 정리 |
|
|
52
60
|
| Vercel | 함수·빌드 정지 → 다음 사이클 대기 또는 Pro $20/mo |
|
|
53
61
|
| Sentry | 새 이벤트 drop → 사이클 대기 또는 Team $26/mo |
|
|
62
|
+
| PostHog | 무료 한도 초과분 drop 또는 사용량 과금 → 이벤트 줄이기 또는 유료 플랜 |
|
|
54
63
|
| GitHub Actions | 차단 → 다음 사이클 대기 또는 분 구입 |
|
|
@@ -13,7 +13,28 @@ const eslintConfig = defineConfig([
|
|
|
13
13
|
},
|
|
14
14
|
rules: {
|
|
15
15
|
"@typescript-eslint/no-deprecated": "warn",
|
|
16
|
-
// feature
|
|
16
|
+
// feature 캡슐화 + 방식 B 데이터 접근 경계.
|
|
17
|
+
"no-restricted-imports": ["error", {
|
|
18
|
+
patterns: [
|
|
19
|
+
{
|
|
20
|
+
// 다른 feature는 public index(@/features/<name>)로만 import — 내부 deep import 금지.
|
|
21
|
+
group: ["@/features/*/*"],
|
|
22
|
+
message: "feature 내부 deep import 금지 — public API(@/features/<name>)로만 import.",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
// 방식 B: secret key(admin)는 데이터 접근 seam에서만. 아래 repository.ts·lib/ override가 재허용.
|
|
26
|
+
group: ["@/lib/supabase/admin"],
|
|
27
|
+
message: "secret key(admin)는 데이터 접근 seam 전용 — features/<f>/repository.ts 또는 lib/에서만 import한다(인가는 서버 코드).",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
}],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
// 데이터 접근 seam: repository.ts는 유일한 데이터 접근 지점이라 admin(secret key)을 import할 수 있다.
|
|
35
|
+
// 위 블록의 admin 제한을 여기서만 푼다(feature deep import 금지는 유지).
|
|
36
|
+
files: ["features/**/repository.ts"],
|
|
37
|
+
rules: {
|
|
17
38
|
"no-restricted-imports": ["error", {
|
|
18
39
|
patterns: [{
|
|
19
40
|
group: ["@/features/*/*"],
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Browser SDK 초기화. Next가 client bundle에 자동 포함.
|
|
2
2
|
import * as Sentry from "@sentry/nextjs";
|
|
3
|
+
import posthog from "posthog-js";
|
|
3
4
|
|
|
4
5
|
// 브라우저 번들엔 배포 환경 신호(APP_ENV/VERCEL_ENV)가 없다 — Next는 NEXT_PUBLIC_* 만 인라인한다.
|
|
5
6
|
// 그래서 송출 게이트를 DSN 존재로 대신한다. 이 코드의 정확성은 "web-launch가 DSN을 Vercel Production
|
|
@@ -14,5 +15,20 @@ Sentry.init({
|
|
|
14
15
|
tracesSampleRate: 0,
|
|
15
16
|
});
|
|
16
17
|
|
|
18
|
+
// PostHog(사용자 이벤트 분석). Sentry DSN과 같은 prod-only 게이트: 키가 있을 때만 init한다.
|
|
19
|
+
// web-launch가 NEXT_PUBLIC_POSTHOG_KEY를 Vercel Production 슬롯에만 넣으므로, dev·preview에선
|
|
20
|
+
// 키가 없어 송출이 꺼진다. 핵심 이벤트는 lib/analytics.ts로 명시 캡처한다(과도 추적 안 함).
|
|
21
|
+
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
|
22
|
+
if (posthogKey) {
|
|
23
|
+
posthog.init(posthogKey, {
|
|
24
|
+
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com",
|
|
25
|
+
defaults: "2026-01-30",
|
|
26
|
+
autocapture: false, // 클릭·폼 자동 캡처 끔. 핵심 이벤트만 명시 캡처.
|
|
27
|
+
capture_pageview: true, // 페이지 이동은 남긴다(어느 화면에서 이탈하는지).
|
|
28
|
+
capture_pageleave: true, // 페이지 이탈도 남긴다.
|
|
29
|
+
person_profiles: "identified_only", // 익명 사용자 프로필 미생성(개인정보 최소화).
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
// Next 16: 내비게이션 계측을 위해 Sentry가 요구하는 hook.
|
|
18
34
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// 사용자 이벤트 분석(PostHog) — 핵심 이벤트만 명시적으로 남기는 단일 진입점.
|
|
2
|
+
//
|
|
3
|
+
// 클라이언트 전용이다(posthog-js는 브라우저 SDK). 서버 코드에서 import하지 않는다.
|
|
4
|
+
// 송출 게이트는 instrumentation-client.ts와 동일하다: NEXT_PUBLIC_POSTHOG_KEY가 있을 때만
|
|
5
|
+
// 동작하고, 없으면(dev·preview) 조용히 no-op이다. web-launch가 키를 Vercel Production 슬롯에만
|
|
6
|
+
// 넣으므로 운영에서만 쌓인다.
|
|
7
|
+
//
|
|
8
|
+
// "과하게 추적하지 않는다": autocapture는 꺼져 있고(instrumentation-client.ts), 여기서 부르는
|
|
9
|
+
// 핵심 이벤트만 쌓인다. 이벤트 이름은 snake_case 동사_명사로 통일한다
|
|
10
|
+
// (예: signup_completed · post_created · post_read).
|
|
11
|
+
import posthog from "posthog-js";
|
|
12
|
+
|
|
13
|
+
const enabled = !!process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
|
14
|
+
|
|
15
|
+
// 핵심 이벤트 1건을 남긴다. 민감한 개인정보는 properties에 담지 않는다.
|
|
16
|
+
export function captureEvent(event: string, properties?: Record<string, unknown>) {
|
|
17
|
+
if (!enabled) return;
|
|
18
|
+
posthog.capture(event, properties);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 로그인 사용자를 식별한다(identified_only 모드에서 프로필 생성·이후 이벤트 연결).
|
|
22
|
+
// id만 넘기고 민감정보는 넣지 않는다.
|
|
23
|
+
export function identifyUser(distinctId: string, properties?: Record<string, unknown>) {
|
|
24
|
+
if (!enabled) return;
|
|
25
|
+
posthog.identify(distinctId, properties);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 로그아웃 시 분석 신원을 초기화한다(다음 사용자와 섞이지 않게).
|
|
29
|
+
export function resetAnalytics() {
|
|
30
|
+
if (!enabled) return;
|
|
31
|
+
posthog.reset();
|
|
32
|
+
}
|
|
@@ -12,10 +12,15 @@ const ClientSchema = z.object({
|
|
|
12
12
|
NEXT_PUBLIC_SUPABASE_URL: z.url(),
|
|
13
13
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: z.string().min(1),
|
|
14
14
|
NEXT_PUBLIC_SENTRY_DSN: emptyAsUndefined(z.url().optional()),
|
|
15
|
+
// PostHog: prod-only 게이트(키 존재 시에만 송출). 미설정이면 조용히 비활성.
|
|
16
|
+
NEXT_PUBLIC_POSTHOG_KEY: emptyAsUndefined(z.string().optional()),
|
|
17
|
+
NEXT_PUBLIC_POSTHOG_HOST: emptyAsUndefined(z.url().optional()),
|
|
15
18
|
});
|
|
16
19
|
|
|
17
20
|
export const env = ClientSchema.parse({
|
|
18
21
|
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
19
22
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
|
20
23
|
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
24
|
+
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
|
25
|
+
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
|
21
26
|
});
|
|
@@ -13,13 +13,14 @@ async function write(level: Level, message: string, context?: Record<string, unk
|
|
|
13
13
|
Sentry.captureException(new Error(message), { extra: context });
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// RLS 정책이
|
|
16
|
+
// audit_log은 RLS만 켜고 클라 정책이 없어(deny-all) 클라이언트가 닿지 못한다.
|
|
17
|
+
// secret key(admin)는 RLS를 우회하므로 감사 기록은 서버에서만 insert한다.
|
|
17
18
|
if (env.SUPABASE_SECRET_KEY) {
|
|
18
19
|
try {
|
|
19
20
|
const admin = createSupabaseAdmin();
|
|
20
|
-
await admin.from("
|
|
21
|
+
await admin.from("audit_log").insert({ level, message, context });
|
|
21
22
|
} catch {
|
|
22
|
-
/*
|
|
23
|
+
/* audit_log 테이블 미존재·연결 실패는 무시 */
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
}
|
|
@@ -35,6 +35,9 @@ importers:
|
|
|
35
35
|
next:
|
|
36
36
|
specifier: ^16.2.6
|
|
37
37
|
version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
|
38
|
+
posthog-js:
|
|
39
|
+
specifier: ^1.381.0
|
|
40
|
+
version: 1.381.0
|
|
38
41
|
react:
|
|
39
42
|
specifier: ^19.2.4
|
|
40
43
|
version: 19.2.4
|
|
@@ -953,6 +956,12 @@ packages:
|
|
|
953
956
|
'@oxc-project/types@0.128.0':
|
|
954
957
|
resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==}
|
|
955
958
|
|
|
959
|
+
'@posthog/core@1.30.9':
|
|
960
|
+
resolution: {integrity: sha512-Cn004VJ7ZWQRaVQG7efh+pMtRMIVZznktngXe5I3E8wClRdMwCZaSa6jo/X04Oc04z8PeMp4GEcWVkdEhmAEXw==}
|
|
961
|
+
|
|
962
|
+
'@posthog/types@1.381.0':
|
|
963
|
+
resolution: {integrity: sha512-AW68BovKFCNbPdq3VjOzfQeSQRYMvQVv+46LDywWFXO/oOTXFKwjY92FaJQSTXWgTNgDpqigCw3yUFDinK3hZA==}
|
|
964
|
+
|
|
956
965
|
'@prisma/instrumentation@7.6.0':
|
|
957
966
|
resolution: {integrity: sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==}
|
|
958
967
|
peerDependencies:
|
|
@@ -1636,6 +1645,9 @@ packages:
|
|
|
1636
1645
|
'@types/tedious@4.0.14':
|
|
1637
1646
|
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
|
|
1638
1647
|
|
|
1648
|
+
'@types/trusted-types@2.0.7':
|
|
1649
|
+
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
|
1650
|
+
|
|
1639
1651
|
'@types/validate-npm-package-name@4.0.2':
|
|
1640
1652
|
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
|
|
1641
1653
|
|
|
@@ -2211,6 +2223,9 @@ packages:
|
|
|
2211
2223
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
|
2212
2224
|
engines: {node: '>=18'}
|
|
2213
2225
|
|
|
2226
|
+
core-js@3.49.0:
|
|
2227
|
+
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
|
2228
|
+
|
|
2214
2229
|
cors@2.8.6:
|
|
2215
2230
|
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
|
2216
2231
|
engines: {node: '>= 0.10'}
|
|
@@ -2347,6 +2362,9 @@ packages:
|
|
|
2347
2362
|
dom-accessibility-api@0.6.3:
|
|
2348
2363
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
|
2349
2364
|
|
|
2365
|
+
dompurify@3.4.8:
|
|
2366
|
+
resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==}
|
|
2367
|
+
|
|
2350
2368
|
dotenv@16.6.1:
|
|
2351
2369
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
|
2352
2370
|
engines: {node: '>=12'}
|
|
@@ -2665,6 +2683,9 @@ packages:
|
|
|
2665
2683
|
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
|
2666
2684
|
engines: {node: ^12.20 || >= 14.13}
|
|
2667
2685
|
|
|
2686
|
+
fflate@0.4.8:
|
|
2687
|
+
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
|
2688
|
+
|
|
2668
2689
|
figures@6.1.0:
|
|
2669
2690
|
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
|
|
2670
2691
|
engines: {node: '>=18'}
|
|
@@ -3681,10 +3702,16 @@ packages:
|
|
|
3681
3702
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
|
3682
3703
|
engines: {node: '>=0.10.0'}
|
|
3683
3704
|
|
|
3705
|
+
posthog-js@1.381.0:
|
|
3706
|
+
resolution: {integrity: sha512-botkF0PUSd19qUTB7lJxKRQAc+9b9v3XcAZnqG/4LMDXJPvMxPeSCj6OVx8e9GCZN38kOg6yUcXMXDTcBqKdlw==}
|
|
3707
|
+
|
|
3684
3708
|
powershell-utils@0.1.0:
|
|
3685
3709
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
|
3686
3710
|
engines: {node: '>=20'}
|
|
3687
3711
|
|
|
3712
|
+
preact@10.29.2:
|
|
3713
|
+
resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==}
|
|
3714
|
+
|
|
3688
3715
|
prelude-ls@1.2.1:
|
|
3689
3716
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
|
3690
3717
|
engines: {node: '>= 0.8.0'}
|
|
@@ -3723,6 +3750,9 @@ packages:
|
|
|
3723
3750
|
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
|
3724
3751
|
engines: {node: '>=0.6'}
|
|
3725
3752
|
|
|
3753
|
+
query-selector-shadow-dom@1.0.1:
|
|
3754
|
+
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
|
|
3755
|
+
|
|
3726
3756
|
queue-microtask@1.2.3:
|
|
3727
3757
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
|
3728
3758
|
|
|
@@ -4389,6 +4419,9 @@ packages:
|
|
|
4389
4419
|
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
|
4390
4420
|
engines: {node: '>= 8'}
|
|
4391
4421
|
|
|
4422
|
+
web-vitals@5.3.0:
|
|
4423
|
+
resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==}
|
|
4424
|
+
|
|
4392
4425
|
webidl-conversions@3.0.1:
|
|
4393
4426
|
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
|
4394
4427
|
|
|
@@ -5403,6 +5436,12 @@ snapshots:
|
|
|
5403
5436
|
|
|
5404
5437
|
'@oxc-project/types@0.128.0': {}
|
|
5405
5438
|
|
|
5439
|
+
'@posthog/core@1.30.9':
|
|
5440
|
+
dependencies:
|
|
5441
|
+
'@posthog/types': 1.381.0
|
|
5442
|
+
|
|
5443
|
+
'@posthog/types@1.381.0': {}
|
|
5444
|
+
|
|
5406
5445
|
'@prisma/instrumentation@7.6.0(@opentelemetry/api@1.9.1)':
|
|
5407
5446
|
dependencies:
|
|
5408
5447
|
'@opentelemetry/api': 1.9.1
|
|
@@ -5999,6 +6038,9 @@ snapshots:
|
|
|
5999
6038
|
dependencies:
|
|
6000
6039
|
'@types/node': 20.19.40
|
|
6001
6040
|
|
|
6041
|
+
'@types/trusted-types@2.0.7':
|
|
6042
|
+
optional: true
|
|
6043
|
+
|
|
6002
6044
|
'@types/validate-npm-package-name@4.0.2': {}
|
|
6003
6045
|
|
|
6004
6046
|
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
|
|
@@ -6594,6 +6636,8 @@ snapshots:
|
|
|
6594
6636
|
|
|
6595
6637
|
cookie@1.1.1: {}
|
|
6596
6638
|
|
|
6639
|
+
core-js@3.49.0: {}
|
|
6640
|
+
|
|
6597
6641
|
cors@2.8.6:
|
|
6598
6642
|
dependencies:
|
|
6599
6643
|
object-assign: 4.1.1
|
|
@@ -6707,6 +6751,10 @@ snapshots:
|
|
|
6707
6751
|
|
|
6708
6752
|
dom-accessibility-api@0.6.3: {}
|
|
6709
6753
|
|
|
6754
|
+
dompurify@3.4.8:
|
|
6755
|
+
optionalDependencies:
|
|
6756
|
+
'@types/trusted-types': 2.0.7
|
|
6757
|
+
|
|
6710
6758
|
dotenv@16.6.1: {}
|
|
6711
6759
|
|
|
6712
6760
|
dotenv@17.4.2: {}
|
|
@@ -7202,6 +7250,8 @@ snapshots:
|
|
|
7202
7250
|
node-domexception: 1.0.0
|
|
7203
7251
|
web-streams-polyfill: 3.3.3
|
|
7204
7252
|
|
|
7253
|
+
fflate@0.4.8: {}
|
|
7254
|
+
|
|
7205
7255
|
figures@6.1.0:
|
|
7206
7256
|
dependencies:
|
|
7207
7257
|
is-unicode-supported: 2.1.0
|
|
@@ -8165,8 +8215,21 @@ snapshots:
|
|
|
8165
8215
|
dependencies:
|
|
8166
8216
|
xtend: 4.0.2
|
|
8167
8217
|
|
|
8218
|
+
posthog-js@1.381.0:
|
|
8219
|
+
dependencies:
|
|
8220
|
+
'@posthog/core': 1.30.9
|
|
8221
|
+
'@posthog/types': 1.381.0
|
|
8222
|
+
core-js: 3.49.0
|
|
8223
|
+
dompurify: 3.4.8
|
|
8224
|
+
fflate: 0.4.8
|
|
8225
|
+
preact: 10.29.2
|
|
8226
|
+
query-selector-shadow-dom: 1.0.1
|
|
8227
|
+
web-vitals: 5.3.0
|
|
8228
|
+
|
|
8168
8229
|
powershell-utils@0.1.0: {}
|
|
8169
8230
|
|
|
8231
|
+
preact@10.29.2: {}
|
|
8232
|
+
|
|
8170
8233
|
prelude-ls@1.2.1: {}
|
|
8171
8234
|
|
|
8172
8235
|
pretty-format@27.5.1:
|
|
@@ -8205,6 +8268,8 @@ snapshots:
|
|
|
8205
8268
|
dependencies:
|
|
8206
8269
|
side-channel: 1.1.0
|
|
8207
8270
|
|
|
8271
|
+
query-selector-shadow-dom@1.0.1: {}
|
|
8272
|
+
|
|
8208
8273
|
queue-microtask@1.2.3: {}
|
|
8209
8274
|
|
|
8210
8275
|
range-parser@1.2.1: {}
|
|
@@ -8982,6 +9047,8 @@ snapshots:
|
|
|
8982
9047
|
|
|
8983
9048
|
web-streams-polyfill@3.3.3: {}
|
|
8984
9049
|
|
|
9050
|
+
web-vitals@5.3.0: {}
|
|
9051
|
+
|
|
8985
9052
|
webidl-conversions@3.0.1: {}
|
|
8986
9053
|
|
|
8987
9054
|
webidl-conversions@8.0.1: {}
|
package/package.json
CHANGED