create-saas-starter-workspace 0.1.0

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.
Files changed (123) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +17 -0
  3. package/README.md +19 -0
  4. package/bin/index.mjs +201 -0
  5. package/package.json +25 -0
  6. package/src/scaffold.mjs +109 -0
  7. package/src/validate.mjs +22 -0
  8. package/templates/workspace/.claude/settings.json +44 -0
  9. package/templates/workspace/.claude/skills/bridge-guide/SKILL.md +71 -0
  10. package/templates/workspace/.claude/skills/eas-deploy-guide/SKILL.md +107 -0
  11. package/templates/workspace/.claude/skills/go-live/SKILL.md +66 -0
  12. package/templates/workspace/.claude/skills/kickoff/SKILL.md +72 -0
  13. package/templates/workspace/.claude/skills/launch/SKILL.md +69 -0
  14. package/templates/workspace/.claude/skills/native-app-guide/SKILL.md +102 -0
  15. package/templates/workspace/.claude/skills/preview/SKILL.md +43 -0
  16. package/templates/workspace/.claude/skills/probe/SKILL.md +17 -0
  17. package/templates/workspace/.claude/skills/release/SKILL.md +51 -0
  18. package/templates/workspace/.claude/skills/sketch/SKILL.md +19 -0
  19. package/templates/workspace/.claude/skills/store-release-guide/SKILL.md +239 -0
  20. package/templates/workspace/.claude/skills/vercel-cron/SKILL.md +17 -0
  21. package/templates/workspace/.claude/skills/warmup/SKILL.md +33 -0
  22. package/templates/workspace/AGENTS.md +39 -0
  23. package/templates/workspace/CLAUDE.md +2 -0
  24. package/templates/workspace/LICENSE +17 -0
  25. package/templates/workspace/README.md +20 -0
  26. package/templates/workspace/START-HERE.md +52 -0
  27. package/templates/workspace/gitignore +18 -0
  28. package/templates/workspace/mobile/.env.example +25 -0
  29. package/templates/workspace/mobile/AGENTS.md +69 -0
  30. package/templates/workspace/mobile/App.tsx +47 -0
  31. package/templates/workspace/mobile/CLAUDE.md +2 -0
  32. package/templates/workspace/mobile/LICENSE +17 -0
  33. package/templates/workspace/mobile/README.md +73 -0
  34. package/templates/workspace/mobile/app.config.ts +153 -0
  35. package/templates/workspace/mobile/assets/android-icon-background.png +0 -0
  36. package/templates/workspace/mobile/assets/android-icon-foreground.png +0 -0
  37. package/templates/workspace/mobile/assets/android-icon-monochrome.png +0 -0
  38. package/templates/workspace/mobile/assets/favicon.png +0 -0
  39. package/templates/workspace/mobile/assets/icon.png +0 -0
  40. package/templates/workspace/mobile/assets/splash-icon.png +0 -0
  41. package/templates/workspace/mobile/docs/web-adapter/README.md +130 -0
  42. package/templates/workspace/mobile/docs/web-adapter/route-app-bridge.ts +235 -0
  43. package/templates/workspace/mobile/eas.json +31 -0
  44. package/templates/workspace/mobile/gitignore +45 -0
  45. package/templates/workspace/mobile/index.ts +12 -0
  46. package/templates/workspace/mobile/package.json +38 -0
  47. package/templates/workspace/mobile/pnpm-lock.yaml +5201 -0
  48. package/templates/workspace/mobile/src/auth/LoginScreen.tsx +192 -0
  49. package/templates/workspace/mobile/src/bridge/capabilities.test.ts +44 -0
  50. package/templates/workspace/mobile/src/bridge/capabilities.ts +42 -0
  51. package/templates/workspace/mobile/src/bridge/contract.test.ts +49 -0
  52. package/templates/workspace/mobile/src/bridge/contract.ts +146 -0
  53. package/templates/workspace/mobile/src/bridge/messaging.test.ts +49 -0
  54. package/templates/workspace/mobile/src/bridge/messaging.ts +33 -0
  55. package/templates/workspace/mobile/src/bridge/reader.test.ts +52 -0
  56. package/templates/workspace/mobile/src/bridge/reader.ts +31 -0
  57. package/templates/workspace/mobile/src/bridge/router.test.ts +124 -0
  58. package/templates/workspace/mobile/src/bridge/router.ts +89 -0
  59. package/templates/workspace/mobile/src/config/env.ts +51 -0
  60. package/templates/workspace/mobile/src/i18n.ts +71 -0
  61. package/templates/workspace/mobile/src/session/secureSession.ts +63 -0
  62. package/templates/workspace/mobile/src/session/sessionHandoff.ts +151 -0
  63. package/templates/workspace/mobile/src/ui/ErrorView.tsx +75 -0
  64. package/templates/workspace/mobile/src/ui/LoadingView.tsx +38 -0
  65. package/templates/workspace/mobile/src/ui/OfflineView.tsx +73 -0
  66. package/templates/workspace/mobile/src/webview/Host.tsx +353 -0
  67. package/templates/workspace/mobile/src/webview/linkBoundary.test.ts +57 -0
  68. package/templates/workspace/mobile/src/webview/linkBoundary.ts +58 -0
  69. package/templates/workspace/mobile/tsconfig.json +8 -0
  70. package/templates/workspace/mobile/vitest.config.ts +14 -0
  71. package/templates/workspace/package.json +9 -0
  72. package/templates/workspace/scripts/doctor.mjs +291 -0
  73. package/templates/workspace/ssb/README.md +10 -0
  74. package/templates/workspace/ssb/contract.ts +146 -0
  75. package/templates/workspace/ssb/reader.ts +31 -0
  76. package/templates/workspace/web/.env.example +39 -0
  77. package/templates/workspace/web/.gitattributes +1 -0
  78. package/templates/workspace/web/.github/workflows/ci.yml +61 -0
  79. package/templates/workspace/web/.vscode/settings.json +8 -0
  80. package/templates/workspace/web/AGENTS.md +103 -0
  81. package/templates/workspace/web/CLAUDE.md +2 -0
  82. package/templates/workspace/web/DESIGN.md +18 -0
  83. package/templates/workspace/web/LICENSE +17 -0
  84. package/templates/workspace/web/README.md +48 -0
  85. package/templates/workspace/web/app/error.tsx +28 -0
  86. package/templates/workspace/web/app/favicon.ico +0 -0
  87. package/templates/workspace/web/app/global-error.tsx +19 -0
  88. package/templates/workspace/web/app/globals.css +130 -0
  89. package/templates/workspace/web/app/layout.tsx +33 -0
  90. package/templates/workspace/web/app/not-found.tsx +12 -0
  91. package/templates/workspace/web/app/page.tsx +11 -0
  92. package/templates/workspace/web/components/ui/button.tsx +58 -0
  93. package/templates/workspace/web/components.json +25 -0
  94. package/templates/workspace/web/docs/ENVIRONMENTS.md +102 -0
  95. package/templates/workspace/web/docs/LIMITS.md +54 -0
  96. package/templates/workspace/web/eslint.config.mjs +46 -0
  97. package/templates/workspace/web/features/.gitkeep +0 -0
  98. package/templates/workspace/web/gitignore +51 -0
  99. package/templates/workspace/web/instrumentation-client.ts +16 -0
  100. package/templates/workspace/web/instrumentation.ts +12 -0
  101. package/templates/workspace/web/lib/app-env.ts +12 -0
  102. package/templates/workspace/web/lib/bridge/contract.ts +146 -0
  103. package/templates/workspace/web/lib/bridge/reader.ts +31 -0
  104. package/templates/workspace/web/lib/env.server.ts +33 -0
  105. package/templates/workspace/web/lib/env.ts +21 -0
  106. package/templates/workspace/web/lib/logger.ts +32 -0
  107. package/templates/workspace/web/lib/supabase/admin.ts +14 -0
  108. package/templates/workspace/web/lib/supabase/client.ts +9 -0
  109. package/templates/workspace/web/lib/supabase/server.ts +24 -0
  110. package/templates/workspace/web/lib/utils.ts +6 -0
  111. package/templates/workspace/web/next.config.ts +16 -0
  112. package/templates/workspace/web/npmrc +14 -0
  113. package/templates/workspace/web/package.json +60 -0
  114. package/templates/workspace/web/pnpm-lock.yaml +9155 -0
  115. package/templates/workspace/web/postcss.config.mjs +7 -0
  116. package/templates/workspace/web/sentry.edge.config.ts +9 -0
  117. package/templates/workspace/web/sentry.server.config.ts +9 -0
  118. package/templates/workspace/web/supabase/migrations/.gitkeep +0 -0
  119. package/templates/workspace/web/tests/setup.ts +1 -0
  120. package/templates/workspace/web/tests/utils.test.ts +12 -0
  121. package/templates/workspace/web/tsconfig.json +35 -0
  122. package/templates/workspace/web/vercel.json +6 -0
  123. package/templates/workspace/web/vitest.config.ts +15 -0
@@ -0,0 +1,73 @@
1
+ # my-space-app
2
+
3
+ 이미 배포된 웹 서비스를 iOS·Android 스토어 앱으로 감싸는 스타터입니다. 결정은 Owner가 하고, 코드·빌드·배포는 AI 코딩 에이전트(권장: Claude Code)가 맡습니다.
4
+
5
+ ## 이게 뭔가요
6
+
7
+ Owner가 만든 웹을 감싸는 앱(웹뷰 래퍼)입니다. 화면과 기능은 전부 Owner의 웹(이 워크스페이스의 `web/`으로 배포한 Next.js)이 담당하고, 이 앱은 그 웹 주소를 스토어에 올릴 수 있는 앱 껍데기(셸) 안에 띄웁니다.
8
+
9
+ - 웹을 고치면 앱도 같이 바뀝니다. 대부분의 변경은 웹만 다시 배포하면 끝이고, 앱을 다시 만들거나 제출할 필요가 없습니다.
10
+ - 셸이 채우는 것: 외부 링크를 모바일 브라우저로 열기, Android 뒤로가기, 카메라·마이크·파일 올리기 권한, 화면 안전 여백, 오프라인 화면, 첫 로딩·크래시 복구 화면. 웹뷰만으로 막히는 네이티브 빈틈만 메웁니다.
11
+ - 소셜 로그인(구글·애플): 웹뷰 안에서도 동작하지만, 앱의 원클릭 경험을 원하면 네이티브 로그인 화면을 따로 구현합니다.
12
+
13
+ ## 시작 전에 (한 번만)
14
+
15
+ 이 README에서 "AI에게 부탁한다"는 건 [Claude Code](https://claude.com/claude-code)(터미널·에디터에서 도는 AI 코딩 도구)에게 말하는 것입니다. 설치·로그인을 마친 뒤, 워크스페이스 루트(이 `mobile/`이 아니라 한 단계 위)에서 Claude Code를 엽니다. 명령(스킬)은 거기 있습니다. 그다음부터는 평소 말로 부탁하면 됩니다.
16
+
17
+ 준비물:
18
+
19
+ - 이미 배포된 웹 서비스 주소(https). 이 앱이 띄울 대상입니다. 앱은 항상 배포된 웹을 감쌀 뿐, Owner의 컴퓨터에서 도는 웹(localhost)은 띄우지 못합니다.
20
+ - 스토어 개발자 계정. 일정상 가장 먼저 시작하세요. iOS는 Apple Developer(연 $99, 환불 불가, 결제 후 신원검증에 수시간~2일), Android는 Google Play Developer(1회 $25, 환불 불가). 올릴 플랫폼 것만 만들면 됩니다. 신규 Google 계정은 정식 공개 전에 "비공개 테스트 12명·14일 연속"을 통과해야 해서(구글 규칙) 출시까지 2주 이상 막힐 수 있습니다.
21
+ - 무료 Expo 계정. 빌드를 클라우드(Expo의 빌드 서비스 EAS)에서 만드는 데 필요합니다. eas-cli 로그인은 첫 빌드 때 하며, 이메일·2FA는 Owner가 직접 합니다.
22
+ - Node.js와 pnpm. AI가 설치·빌드에 씁니다. 있는지 모르면 AI에게 확인·설치를 부탁하세요.
23
+
24
+ 직접 명령어를 칠 일은 거의 없습니다. 단 결제, 로그인 2FA, 스토어 콘솔 입력처럼 돈·신원이 걸린 일은 AI가 대신 못 하니 Owner가 직접 하고, 그 순간을 AI가 짚어 줍니다. 아래 명령어 블록은 AI가 안에서 무엇을 하는지 보여주는 참고용입니다.
25
+
26
+ ## 1. 설치
27
+
28
+ ```bash
29
+ pnpm install
30
+ ```
31
+
32
+ ## 2. 설정
33
+
34
+ 앱 이름, 스토어에서 앱을 구분하는 고유 식별자(iOS·Android 각각, 예 `com.이름.앱`), 앱이 띄울 웹 주소 등을 정합니다. "현재 상태를 파악한 뒤 앱 설정을 같이 정하자"라고 부탁하면 됩니다.
35
+
36
+ > 고유 식별자(bundle ID·패키지명)는 영구입니다. 한 번 스토어에 올리면 못 바꾸고, 스토어 콘솔에 등록한 값과 정확히 같아야 합니다(앱 이름·아이콘은 나중에 바꿔도 됩니다).
37
+ > 웹 주소는 빌드할 때 앱 안에 고정됩니다(`eas.json`). 기본값은 자리표시 값이라 그대로 빌드하면 앱 시작 시 멈춥니다(흰 화면 대신 명확한 에러로). `/kickoff`이 실제 https 주소로 바꿔 줍니다. 주소를 바꾸면 재빌드해야 반영됩니다.
38
+
39
+ ## 3. 미리보기 (폰에서 보기)
40
+
41
+ 출시 전 Owner 폰에서 확인하려면, 출시용(production) 빌드를 EAS에서 만들어 스토어 테스트 채널로 보냅니다. iOS는 TestFlight, Android는 Play 내부 테스트입니다. 이 빌드가 그대로 출시까지 이어지므로 미리보기용 빌드를 따로 만들지 않습니다(EAS 무료 티어 작업량 최소화).
42
+
43
+ "production 빌드로 미리보기할 수 있게 올려줘"라고 부탁하면 됩니다.
44
+
45
+ ```bash
46
+ # 출시용 빌드를 만들어 테스트 채널로 보낸다 (iOS=TestFlight, Android=Play 내부 테스트)
47
+ eas build --profile production --platform ios --auto-submit # 또는 android
48
+ ```
49
+
50
+ > 첫 빌드 전 Expo 로그인이 필요합니다(이메일·2FA는 Owner가 직접).
51
+ > Android 첫 출시만 수동 준비가 필요합니다. Play는 앱의 첫 릴리스를 자동으로 못 만들기 때문에, 처음 한 번은 Play Console에서 앱을 만들고 첫 빌드를 직접 업로드한 뒤(이후 자동), 자동 제출용 구글 서비스계정 키를 한 번 설정합니다. AI가 그 시점에 안내합니다. iOS는 이 수동 단계 없이 바로 TestFlight까지 갑니다.
52
+
53
+ 설치는 iOS는 TestFlight 앱으로, Android는 테스트 앱 초대 링크로 합니다. 폰은 Owner의 컴퓨터(localhost)에 닿지 못하고 http 주소로는 카메라나 소셜 로그인이 동작을 멈추므로, 미리보기든 출시든 배포된 https 웹과 연동된 production 빌드를 씁니다. 첫 빌드와 스토어 처리는 대기 포함 10~20분이고, Android 최초는 위 수동 단계 때문에 더 걸립니다.
54
+
55
+ ## 4. 출시 (정식 공개)
56
+
57
+ 3단계에서 만든 빌드가 이미 테스트 채널에 올라가 있습니다. 출시는 그 같은 빌드를 정식 공개로 올리는 것이고, 이 단계는 스토어 콘솔에서 Owner가 직접 합니다. 리스팅·스크린샷·개인정보 항목을 채우고 공개 버튼을 누르는 일은 자동화되지 않습니다(이 템플릿은 fastlane을 쓰지 않습니다). AI는 콘솔에서 무엇을 어디서 눌러야 하는지 단계별로 안내합니다.
58
+
59
+ "정식 출시 준비하자, 콘솔에서 뭘 해야 하는지 알려줘"라고 부탁하세요.
60
+
61
+ > 마지막 공개 버튼은 Owner가 직접 누릅니다(App Store는 "Submit for Review", Play는 production 승급). 이후 스토어 심사를 거쳐 공개됩니다.
62
+ > Android는 관문이 하나 더 있습니다. 새 개발자 계정은 production 전에 비공개 테스트(테스터 12명·14일 연속)를 통과해야 합니다.
63
+
64
+ ## 가이드 (작업하면 자동으로 뜨는 도움말)
65
+
66
+ Owner가 작업을 부탁하면 AI가 관련 도움말을 불러옵니다.
67
+
68
+ | Owner가 부탁하는 것 | 자동으로 뜨는 도움말 |
69
+ |------|----------------------|
70
+ | 앱 껍데기 손보기: 오프라인 화면, 로딩·크래시 복구 화면, 뒤로가기, 외부 링크, 안전 여백, 카메라·파일 권한 | `native-app-guide` |
71
+ | 앱과 웹이 주고받는 메시지 손보기 | `bridge-guide` |
72
+ | 빌드·미리보기·배포·업데이트 | `eas-deploy-guide` |
73
+ | 스토어 출시 준비: 개발자 계정·심사(4.2)·제출·비공개 테스트·컴플라이언스 | `store-release-guide` |
@@ -0,0 +1,153 @@
1
+ // app.config.ts — 동적 Expo 앱 설정 (app.json 대체)
2
+ //
3
+ // 이 파일은 `expo config` / EAS 빌드 / 메트로 번들 시점에 Node로 실행됩니다.
4
+ // 따라서 여기서는 `process.env.*`를 직접 읽는 것이 허용됩니다(빌드 시점 설정의 정식 예외).
5
+ // 런타임 앱 코드(src/**)는 절대 process.env를 직접 쓰지 말고 src/config/env.ts를 통하세요.
6
+ //
7
+ // 학생이 채워야 하는 곳은 모두 `// ⬅️ 학생: ...` 주석으로 표시했습니다.
8
+ // 값은 두 경로로 들어옵니다:
9
+ // 1) 로컬: .env (예시는 .env.example 참고) — `expo config`/`expo start`에서 인라인
10
+ // 2) EAS 빌드: eas.json의 build.<profile>.env — 빌드 채널별로 주입
11
+ // 어느 쪽도 없을 때를 대비해 모든 값에 안전한 기본값을 두어, env가 비어 있어도
12
+ // `expo config`가 크래시 나지 않게 했습니다(기본값은 명백한 placeholder).
13
+ import { ConfigContext, ExpoConfig } from "expo/config";
14
+ import { withAndroidManifest, type ConfigPlugin } from "expo/config-plugins";
15
+
16
+ // 웹뷰 <input type="file" capture>의 '카메라 촬영' 옵션이 Android 11+(패키지 가시성)에서 뜨도록
17
+ // IMAGE_CAPTURE 인텐트 가시성을 매니페스트 <queries>에 선제 주입한다. react-native-webview 번들
18
+ // 매니페스트는 web-payment(PAY) queries만 더하므로 이건 별도로 필요하다. ExpoConfig 타입엔 'queries'
19
+ // 키가 없어 config plugin(withAndroidManifest)으로 더한다. iOS는 패키지 가시성 개념이 없어 무관.
20
+ const IMAGE_CAPTURE_ACTION = "android.media.action.IMAGE_CAPTURE";
21
+ const withImageCaptureQuery: ConfigPlugin = (config) =>
22
+ withAndroidManifest(config, (cfg) => {
23
+ const { manifest } = cfg.modResults;
24
+ manifest.queries ??= []; // 파싱된 매니페스트에 <queries>가 없을 수 있어 방어적으로 초기화.
25
+ const already = manifest.queries.some((q) =>
26
+ q.intent?.some((i) => i.action?.some((a) => a.$?.["android:name"] === IMAGE_CAPTURE_ACTION)),
27
+ );
28
+ if (!already) {
29
+ manifest.queries.push({
30
+ intent: [{ action: [{ $: { "android:name": IMAGE_CAPTURE_ACTION } }] }],
31
+ });
32
+ }
33
+ return cfg;
34
+ });
35
+
36
+ export default ({ config }: ConfigContext): ExpoConfig => {
37
+ // ⬅️ 학생: 스토어/홈 화면에 보일 앱 이름. .env의 EXPO_PUBLIC_APP_NAME 로 바꾸세요.
38
+ const appName = process.env.EXPO_PUBLIC_APP_NAME ?? "My App";
39
+
40
+ // ⬅️ 학생: iOS 번들 ID (Apple Developer에 등록한 식별자와 동일해야 함). 역도메인 형식.
41
+ const iosBundleIdentifier =
42
+ process.env.EXPO_PUBLIC_IOS_BUNDLE_ID ?? "com.example.myapp";
43
+
44
+ // ⬅️ 학생: Android 패키지명 (Play Console에 등록한 것과 동일). 역도메인 형식.
45
+ const androidPackage =
46
+ process.env.EXPO_PUBLIC_ANDROID_PACKAGE ?? "com.example.myapp";
47
+
48
+ // ⬅️ 학생: 딥링크 scheme (소문자, 공백 없이). 예: 로그인 리다이렉트 myapp:// 에 사용.
49
+ const scheme = process.env.EXPO_PUBLIC_SCHEME ?? "myapp";
50
+
51
+ // ⬅️ 학생: EAS 프로젝트 ID. `eas init` 후 자동 생성되며 .env / eas.json env로 주입.
52
+ // 비어 있으면 빌드 시 EAS가 채워주므로 로컬 `expo config`에서는 undefined로 둬도 됩니다.
53
+ const easProjectId = process.env.EXPO_PUBLIC_EAS_PROJECT_ID;
54
+
55
+ // iOS 권한 사용 설명 문자열 (웹뷰 안의 <input type="file"> 카메라/사진 업로드에 필수).
56
+ // 누락 시 Apple이 런타임에 앱을 종료시킵니다(DESIGN 결정 21 — 파일 업로드 레시피).
57
+ // ⬅️ 학생: 실제 앱이 카메라/사진/마이크를 왜 쓰는지 사용자가 이해할 문장으로 다듬으세요.
58
+ const iosUsageDescriptions = {
59
+ NSCameraUsageDescription:
60
+ "사진 촬영 및 업로드를 위해 카메라 접근이 필요합니다.",
61
+ NSPhotoLibraryUsageDescription:
62
+ "이미지 첨부를 위해 사진 보관함 접근이 필요합니다.",
63
+ NSMicrophoneUsageDescription:
64
+ "동영상 촬영 시 마이크 접근이 필요할 수 있습니다.",
65
+ };
66
+
67
+ const expoConfig: ExpoConfig = {
68
+ // Expo 기본값 위에 우리 설정을 덮어쓴다. app.config.ts가 유일한 설정 소스다(app.json 없음).
69
+ ...config,
70
+ name: appName,
71
+ // slug: Expo 프로젝트 식별 슬러그. 보통 레포명과 맞춥니다(영소문자+하이픈).
72
+ slug: "my-space-app",
73
+ version: "1.0.0", // 마케팅 버전(CFBundleShortVersionString / versionName).
74
+ // 재제출할 때 네이티브가 바뀌면 이 값을 올려야 합니다(DESIGN 결정 9: autoIncrement는
75
+ // buildNumber만 올림 — marketing version은 수동, 안 올리면 ITMS-90186).
76
+ orientation: "portrait",
77
+ icon: "./assets/icon.png",
78
+ userInterfaceStyle: "light",
79
+ scheme, // 딥링크/리다이렉트용 URL scheme.
80
+
81
+ // 참고: newArchEnabled / edgeToEdgeEnabled 는 더 이상 설정 키가 아닙니다.
82
+ // New Arch는 SDK 55(RN 0.82+)부터 강제·비활성화 불가이고 SDK 56이 이를 물려받습니다.
83
+ // Android edge-to-edge도 기본 ON이라, 둘 다 명시하지 않습니다.
84
+
85
+ // OTA vs 재빌드 경계 (DESIGN 결정 9).
86
+ // runtimeVersion=appVersion: JS 변경은 `eas update`로 OTA, 네이티브/설정 변경은 재빌드+버전 올림.
87
+ // version("1.0.0")이 곧 runtimeVersion이 되어, 호환되는 빌드끼리만 OTA가 적용됩니다.
88
+ runtimeVersion: {
89
+ policy: "appVersion",
90
+ },
91
+
92
+ ios: {
93
+ bundleIdentifier: iosBundleIdentifier,
94
+ supportsTablet: true,
95
+ // iOS 최소 지원 버전. SDK 56 권장 하한.
96
+ deploymentTarget: "16.4",
97
+ infoPlist: {
98
+ // 수출 규정(암호화) 면제 선언 — 표준 HTTPS만 쓰는 웹뷰 앱은 false.
99
+ ITSAppUsesNonExemptEncryption: false,
100
+ // 웹뷰 파일 업로드용 권한 문자열(위에서 정의).
101
+ ...iosUsageDescriptions,
102
+ },
103
+ },
104
+
105
+ android: {
106
+ package: androidPackage,
107
+ // 스캐폴드의 adaptiveIcon 자산을 그대로 보존(아이콘 4종).
108
+ adaptiveIcon: {
109
+ backgroundColor: "#E6F4FE",
110
+ foregroundImage: "./assets/android-icon-foreground.png",
111
+ backgroundImage: "./assets/android-icon-background.png",
112
+ monochromeImage: "./assets/android-icon-monochrome.png",
113
+ },
114
+ // 예측형 뒤로가기 제스처: 스캐폴드 기본값 유지(웹뷰 자체 back 처리와 충돌 방지).
115
+ predictiveBackGestureEnabled: false,
116
+ // 키보드가 뜰 때 화면을 밀어 올려 입력칸이 가려지지 않게 함(android:windowSoftInputMode=adjustResize).
117
+ // 웹 폼 입력 경험에 중요 (DESIGN 결정 21).
118
+ softwareKeyboardLayoutMode: "resize",
119
+ // 웹뷰 <input type="file"> 카메라 촬영을 위한 권한.
120
+ // ⬅️ 학생: 앱이 카메라 업로드를 안 쓰면 이 배열을 비우거나 CAMERA를 빼도 됩니다.
121
+ permissions: ["CAMERA"],
122
+ // 카메라 업로드 가시성(Android 11+): 위 withImageCaptureQuery 플러그인이 <queries>에 IMAGE_CAPTURE
123
+ // 인텐트 가시성을 선제 주입한다(미주입 시 갤러리는 되나 '카메라 촬영' 옵션이 안 뜸). CAMERA 권한과는
124
+ // 별개 요구사항이다 — 권한=카메라 사용, queries=카메라 앱 조회(resolveActivity).
125
+ },
126
+
127
+ plugins: [
128
+ // expo install이 추가한 플러그인(secure-store: 토큰 보관소). 보존 필수.
129
+ "expo-secure-store",
130
+ // 스플래시(첫 실행 빈 화면 방지). 자산은 스캐폴드의 splash-icon.png.
131
+ // ⬅️ 학생: 브랜드 색/로고가 정해지면 backgroundColor·image를 바꾸세요.
132
+ [
133
+ "expo-splash-screen",
134
+ {
135
+ image: "./assets/splash-icon.png",
136
+ imageWidth: 200,
137
+ resizeMode: "contain",
138
+ backgroundColor: "#ffffff",
139
+ },
140
+ ],
141
+ ],
142
+
143
+ extra: {
144
+ eas: {
145
+ // EAS 빌드/런타임/푸시토큰에서 단일 소스로 쓰이는 프로젝트 ID.
146
+ projectId: easProjectId,
147
+ },
148
+ },
149
+ };
150
+
151
+ // config plugin을 적용해 반환(ExpoConfig.plugins 타입은 함수 플러그인을 안 받으므로 직접 적용).
152
+ return withImageCaptureQuery(expoConfig);
153
+ };
@@ -0,0 +1,130 @@
1
+ # 웹 어댑터 — 브릿지를 웹에 설치하기
2
+
3
+ 이 폴더는 앱↔웹 브릿지의 웹 쪽 조각이다. 네이티브 앱(이 repo)은 완성돼 있고, 배포된 웹이 앱과 통신하게 하려면 이 얇은 opt-in 레이어를 웹(`web/`)에 설치한다. 앱은 웹의 git을 건드리지 않으니, 아래 조각들을 직접 복사해 넣는다.
4
+
5
+ 기본 로그인은 웹 자체 로그인이 WebView 안에서 렌더되고, email/password·magic-link가 동작한다. 예외는 소셜 로그인(구글·애플)이다. WebView 안에서 도는 OAuth는 provider가 차단하므로(`disallowed_useragent` / RFC 8252), 네이티브 소셜 원클릭 + 핸드오프 경로가 존재한다. 이 경로는 v1에선 동작하지 않는다(앱의 네이티브 소셜 버튼은 비활성 stub). 켤 때만 핸드오프 라우트를 설치한다.
6
+
7
+ > 웹은 앱 없이도 항상 동작한다. 여기 있는 모든 것은 additive하고 gated다. `window.ReactNativeWebView`·`window.__ssbBridge`가 없는 일반 브라우저는 아무것도 보지 못하고, 앱-대상 send를 모두 HELLO 이후 `hasCapability(...)` 뒤에서 보내면 capability 없는 앱엔 아무것도 가지 않는다. 그렇게 설치하면 어댑터가 웹을 깨뜨릴 수 없다.
8
+
9
+ 어댑터가 웹에 더하는 의존성은 `zod` 하나뿐이다(이 템플릿엔 이미 있다). 네이티브 라이브러리는 웹으로 새지 않으며, 웹은 주입된 `window.ReactNativeWebView` 전역으로만 앱에 닿는다.
10
+
11
+ ---
12
+
13
+ ## 설치 (4단계)
14
+
15
+ ### 1. 공유 계약 복사 — 바이트 단위로 동일하게
16
+
17
+ 브릿지 계약은 앱과 웹이 공유하는 SSOT다. 계약 정의와 불변식은 `ssb/README.md`와 `contract.ts` 헤더에 있다. 아래 두 파일을 앱 repo에서 웹으로 그대로 복사한다.
18
+
19
+ | 원본 (앱 repo) | 대상 (웹) |
20
+ |---|---|
21
+ | `src/bridge/contract.ts` | `lib/bridge/contract.ts` |
22
+ | `src/bridge/reader.ts` | `lib/bridge/reader.ts` |
23
+
24
+ 두 사본은 바이트 단위로 동일해야 한다. 어긋나면 양쪽이 메시지 형태를 다르게 해석해 브릿지가 오작동하므로, CI checksum으로 강제한다.
25
+
26
+ ```bash
27
+ cmp -s mobile/src/bridge/contract.ts web/lib/bridge/contract.ts || exit 1
28
+ cmp -s mobile/src/bridge/reader.ts web/lib/bridge/reader.ts || exit 1
29
+ ```
30
+
31
+ ### 2. 세션 핸드오프 라우트 추가
32
+
33
+ `route-app-bridge.ts`(이 README 옆)를 웹에 복사하고 두 라우트로 나눈다.
34
+
35
+ ```
36
+ app/auth/app-bridge/route.ts # POST — 네이티브 로그인 검증, 1회용 nonce 발급
37
+ app/auth/app-bridge/consume/route.ts # GET — nonce 1회 검증, 쿠키 설정, 303
38
+ ```
39
+
40
+ 이건 템플릿이다. 인증은 각자의 것이므로 nonce 저장(`mintNonce`/`consumeNonce`)을 직접 구현한다. 구현 전에는 둘 다 throw하므로, 저장을 배선하지 않고 라우트만 복사하면 핸드오프가 fail-closed로 막힌다. 파일 헤더에 두 저장 옵션이 있다(Supabase 테이블 + RLS의 single-use delete, 또는 signed HMAC 토큰).
41
+
42
+ 보안 규칙:
43
+
44
+ - 세션 토큰을 URL에 넣지 않는다. `?token=`은 금지이고, URL에는 1회용 nonce(`?code=`)만 실린다.
45
+ - nonce는 single-use이고 short TTL(≤60s)이다. 재사용·만료는 거부한다.
46
+ - POST에 origin allowlist(`ALLOWED_POST_ORIGINS`)를 둔다. 세션을 발급하므로 cross-origin 호출을 막아야 한다.
47
+ - `@supabase/ssr`은 의도적으로 non-httpOnly 쿠키를 쓴다. 브라우저 클라이언트가 refresh를 위해 토큰을 읽어야 하므로 httpOnly로 바꾸지 않는다. 네이티브는 `autoRefreshToken:false`를 쓴다. refresher가 둘이면 Supabase refresh-token reuse-detection이 세션 전체를 폐기하기 때문이다.
48
+
49
+ ### 3. CSP / origin allowlist
50
+
51
+ - 웹은 `window.ReactNativeWebView.postMessage(...)`로 앱에 닿는다. 이는 CSP `connect-src` 대상이 아니지만, CSP가 자체 스크립트와 추가한 브릿지 클라이언트의 실행을 막지 않아야 한다.
52
+ - origin allowlist를 두 곳에서 일치시킨다: 웹 라우트의 `ALLOWED_POST_ORIGINS`와 앱 `src/config/env.ts`의 `ALLOWED_ORIGINS`(SSOT). 같은 신뢰 경계의 양면이며, 상세는 `native-app-guide`에 있다.
53
+ - consume 라우트의 `next`는 path-only만 받는다(절대 URL 금지, open-redirect 방지).
54
+
55
+ ### 4. feature-detect 후 앱과 통신
56
+
57
+ 웹은 앱 안에서 돌 때만, 그리고 앱이 capability를 알릴 때만 동작한다. 양방향 모두 같은 `ssb` envelope를 쓴다.
58
+
59
+ ```ts
60
+ // 웹 측 — 감지, HELLO에서 capabilities 캡처, gate, send. 일반 브라우저에선 no-op.
61
+ import { SSB_NS, SSB_PROTOCOL_VERSION, MSG } from "@/lib/bridge/contract";
62
+
63
+ /** 네이티브 앱의 WebView 안인가? */
64
+ function isInApp(): boolean {
65
+ return typeof window !== "undefined" && !!window.ReactNativeWebView;
66
+ }
67
+
68
+ /** 앱으로 ssb envelope 전송(web → app). 일반 브라우저에선 no-op. */
69
+ function postToApp(type: string, payload?: unknown): void {
70
+ if (!isInApp()) return;
71
+ const envelope = { ns: SSB_NS, v: SSB_PROTOCOL_VERSION, type, payload };
72
+ window.ReactNativeWebView!.postMessage(JSON.stringify(envelope));
73
+ }
74
+
75
+ // 앱은 capabilities를 HELLO envelope의 payload.capabilities로 전달한다.
76
+ // window.__ssbBridge.capabilities를 세팅하지 않으므로, 웹이 HELLO 도착 시 캡처한다.
77
+ let appCapabilities: string[] = [];
78
+ function hasCapability(token: string): boolean {
79
+ return appCapabilities.includes(token);
80
+ }
81
+
82
+ // 앱→웹 전달은 window.__ssbBridge.receive(envelope) 호출과 CustomEvent("ssb:message") 발화 양쪽이다.
83
+ // 둘 다 onAppMessage로 라우팅한다.
84
+ function onAppMessage(envelope: { type?: string; payload?: { capabilities?: string[] } }): void {
85
+ if (envelope?.type === MSG.HELLO) {
86
+ appCapabilities = envelope.payload?.capabilities ?? [];
87
+ onAppReady(); // capabilities 확정 → 이제 gated 메시지 send가 안전
88
+ }
89
+ // 다른 inbound 타입은 여기서 라우팅한다. v1에서 앱은 HELLO와 LOGOUT_DONE만 보낸다.
90
+ // MSG.SESSION_INSTALLED는 forward-compat이다(계약에 선언, 아직 미발화).
91
+ }
92
+ window.__ssbBridge = { receive: onAppMessage };
93
+ window.addEventListener("ssb:message", (e) => onAppMessage((e as CustomEvent).detail));
94
+
95
+ // gated send는 HELLO 이후(capabilities 확정 후) 실행한다. module load 시 동기 실행하지 않는다.
96
+ function onAppReady(): void {
97
+ // OPT-IN: 앱의 네이티브 LoginScreen 모달을 띄우도록 요청. v1에선 그 모달의 활성 버튼이
98
+ // 웹 로그인으로 되돌아갈 뿐이다(네이티브 소셜은 비활성 stub). 네이티브 소셜을 켜면 실제 핸드오프가 된다.
99
+ if (hasCapability("session.v1")) {
100
+ postToApp(MSG.REQUEST_SESSION_INSTALL, { redirectPath: window.location.pathname });
101
+ }
102
+ // 외부 링크를 webview 대신 시스템 브라우저에서 연다.
103
+ if (hasCapability("external-links.v1")) {
104
+ postToApp(MSG.OPEN_EXTERNAL, { url: "https://example.com" });
105
+ }
106
+ }
107
+ ```
108
+
109
+ > 웹의 global 타입에 `window.ReactNativeWebView`·`window.__ssbBridge` 선언을 작게 추가하고, envelope 구성은 공유 `contract.ts` 상수를 거쳐 앱과 어긋나지 않게 한다.
110
+
111
+ ---
112
+
113
+ ## 세션 핸드오프 흐름 (end to end)
114
+
115
+ 아래 핸드오프는 네이티브 소셜 로그인을 켤 때만 동작한다. v1은 `requestSessionHandoff` 호출자가 없어 아무것도 발화하지 않는다.
116
+
117
+ ```
118
+ [네이티브 앱] 사용자가 "구글/애플로 계속"을 탭 → idToken 획득
119
+ │ POST /auth/app-bridge { provider, idToken } (토큰은 body, URL 아님)
120
+
121
+ [웹 POST] @supabase/ssr로 idToken 검증 → 서버측 세션 수립
122
+ 1회용 nonce 발급 → 200 { handoffUrl: "/auth/app-bridge/consume?code=<nonce>" }
123
+ │ 앱이 WebView를 handoffUrl로 이동 (URL엔 nonce만)
124
+
125
+ [웹 GET] nonce 1회 검증(재사용·만료 거부) → setSession → Set-Cookie → 303 → target
126
+
127
+ [웹] 이후 세션과 refresh를 웹이 단독으로 소유한다.
128
+ ```
129
+
130
+ 로그아웃은 대칭이다. 웹이 `LOGOUT`을 보내면 앱이 로컬 SecureStore 사본을 지우고 `LOGOUT_DONE`으로 응답한다. 토큰은 브릿지를 건너지 않으며, URL과 consume에 실리는 건 1회용 nonce뿐이다.
@@ -0,0 +1,235 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * TEMPLATE — App ↔ Web session handoff route handler (Next.js App Router)
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * ⚠️ THIS IS A TEMPLATE. Copy it INTO YOUR WEB PROJECT (solo-saas-starter web),
7
+ * then adapt it to YOUR auth. Your auth is your own — this file does NOT live
8
+ * in the app repo at runtime; it is documentation the student installs.
9
+ *
10
+ * Target layout in the web (split the two handlers into two routes):
11
+ * app/auth/app-bridge/route.ts ← the POST handler below (mint nonce)
12
+ * app/auth/app-bridge/consume/route.ts ← the GET handler below (set cookie + 303)
13
+ * (Both are shown in this one file for review; split them when you install.)
14
+ *
15
+ * ───────────────────────────── WHY THIS SHAPE ─────────────────────────────
16
+ * The native app owns social login (one-click Google/Apple). After a native
17
+ * login it has Supabase tokens, but TOKENS MUST NEVER CROSS THE BRIDGE and
18
+ * MUST NEVER APPEAR IN A URL. So instead of the (forbidden) `?token=...` query:
19
+ *
20
+ * 1. App POSTs {provider, idToken} here. The server verifies it with Supabase
21
+ * and establishes a real session SERVER-SIDE.
22
+ * 2. The server mints a ONE-TIME, short-TTL nonce and returns { handoffUrl }
23
+ * pointing at the consume route with `?code=<nonce>` (the nonce — NOT the
24
+ * session token — is the only thing that ever touches a URL).
25
+ * 3. The app navigates the WebView to handoffUrl. The consume route validates
26
+ * the nonce ONCE, installs the Supabase session cookie via @supabase/ssr,
27
+ * and 303-redirects to the target path. From then on THE WEB owns refresh.
28
+ *
29
+ * ───────────────────────────── SECURITY RULES ─────────────────────────────
30
+ * • NEVER put the session/access token in a URL. `?token=` is FORBIDDEN — query
31
+ * strings leak via history, logs, Referer. Only a one-time nonce may appear.
32
+ * • Nonce = SINGLE-USE + SHORT TTL (≤60s). Reject on reuse or expiry. The window
33
+ * between mint and consume is milliseconds (the app navigates immediately).
34
+ * • Origin allowlist: the POST must come from your own app/web. Verify Origin and
35
+ * keep redirect targets PATH-ONLY (never an absolute URL from the request).
36
+ * • @supabase/ssr uses NON-httpOnly cookies on purpose, so the web's JS Supabase
37
+ * client can READ and REFRESH the session. The NATIVE side sets
38
+ * autoRefreshToken:false so only ONE refresher exists (no refresh-token reuse
39
+ * race that would force-log-out the user).
40
+ * • The native app keeps at most a READ-ONLY copy in SecureStore. The web is the
41
+ * single source of truth for the live session.
42
+ */
43
+
44
+ import { NextResponse } from "next/server";
45
+ import { cookies } from "next/headers";
46
+ import { createServerClient } from "@supabase/ssr";
47
+ import { env } from "@/lib/env"; // your web's client-safe env (NEXT_PUBLIC_ only)
48
+
49
+ export const dynamic = "force-dynamic"; // never cache a session handoff
50
+
51
+ // ── Config you adapt ──────────────────────────────────────────────────────
52
+ const NONCE_TTL_SECONDS = 60; // keep tiny; the app navigates immediately
53
+ const CONSUME_PATH = "/auth/app-bridge/consume";
54
+ const DEFAULT_TARGET = "/"; // where the web lands after the session is installed
55
+
56
+ /**
57
+ * Origins allowed to POST here. Mirror your web's own origin(s). Reject anything
58
+ * else — this endpoint mints a session, so it must not be callable cross-origin.
59
+ * Keep this in sync with the app's ALLOWED_ORIGINS (app's src/config/env.ts SSOT).
60
+ *
61
+ * TODO(student): set this to your deployed web origin(s). The base template's
62
+ * lib/env.ts has no site-URL key, so add a NEXT_PUBLIC_SITE_URL (recommended) or
63
+ * hard-code your origin here. Do NOT leave it empty in production.
64
+ */
65
+ const ALLOWED_POST_ORIGINS: readonly string[] = [
66
+ // e.g. "https://your-prod-web", "https://your-app.vercel.app"
67
+ ];
68
+
69
+ /** Shared @supabase/ssr server client with the standard cookies adapter. */
70
+ function makeSupabaseServer(cookieStore: Awaited<ReturnType<typeof cookies>>) {
71
+ // Mirrors the web's lib/supabase/server.ts. NON-httpOnly cookies (ssr default)
72
+ // so the browser Supabase client can refresh — do NOT force httpOnly here.
73
+ return createServerClient(
74
+ env.NEXT_PUBLIC_SUPABASE_URL,
75
+ env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
76
+ {
77
+ cookies: {
78
+ getAll: () => cookieStore.getAll(),
79
+ setAll: (toSet) => {
80
+ for (const { name, value, options } of toSet) {
81
+ cookieStore.set(name, value, options);
82
+ }
83
+ },
84
+ },
85
+ },
86
+ );
87
+ }
88
+
89
+ // ═══════════════════════════════════════════════════════════════════════════
90
+ // POST app/auth/app-bridge/route.ts — verify native login, mint a nonce
91
+ // ═══════════════════════════════════════════════════════════════════════════
92
+ // Request body: { provider: "google" | "apple", idToken: string }
93
+ // Response: { handoffUrl: string } (200) | { error } (4xx)
94
+ export async function POST(req: Request): Promise<Response> {
95
+ // 1) Origin allowlist — this endpoint mints a session; reject foreign callers.
96
+ // FAIL-CLOSED: a missing Origin header is rejected too (don't let origin-less callers
97
+ // — curl / server-to-server — skip the check on a session-minting route).
98
+ const origin = req.headers.get("origin");
99
+ if (!origin || !ALLOWED_POST_ORIGINS.includes(origin)) {
100
+ return NextResponse.json({ error: "forbidden_origin" }, { status: 403 });
101
+ }
102
+
103
+ // 2) Parse + validate the body. (Add zod in your web for a real boundary check.)
104
+ let body: { provider?: string; idToken?: string };
105
+ try {
106
+ body = await req.json();
107
+ } catch {
108
+ return NextResponse.json({ error: "bad_json" }, { status: 400 });
109
+ }
110
+ const provider = body.provider;
111
+ const idToken = body.idToken;
112
+ if ((provider !== "google" && provider !== "apple") || typeof idToken !== "string") {
113
+ return NextResponse.json({ error: "bad_request" }, { status: 400 });
114
+ }
115
+
116
+ const cookieStore = await cookies();
117
+ const supabase = makeSupabaseServer(cookieStore);
118
+
119
+ // 3) Verify the native idToken with Supabase. This establishes a real session
120
+ // server-side (cookies are written by the ssr adapter above).
121
+ const { data, error } = await supabase.auth.signInWithIdToken({ provider, token: idToken });
122
+ if (error || !data.session) {
123
+ return NextResponse.json({ error: "auth_failed" }, { status: 401 });
124
+ }
125
+
126
+ // 4) Mint a ONE-TIME nonce that maps to this freshly-created session.
127
+ // Pick ONE of the two options below (see helpers at the bottom of this file).
128
+ const nonce = await mintNonce(cookieStore, supabase, {
129
+ accessToken: data.session.access_token,
130
+ refreshToken: data.session.refresh_token,
131
+ });
132
+
133
+ // 5) Return the handoff URL. ONLY the nonce travels in the URL — never the token.
134
+ const handoffUrl = `${CONSUME_PATH}?code=${encodeURIComponent(nonce)}`;
135
+ return NextResponse.json({ handoffUrl });
136
+ }
137
+
138
+ // ═══════════════════════════════════════════════════════════════════════════
139
+ // GET app/auth/app-bridge/consume/route.ts — validate nonce ONCE, set cookie, 303
140
+ // ═══════════════════════════════════════════════════════════════════════════
141
+ // The app navigates the WebView here: /auth/app-bridge/consume?code=<nonce>&next=<path>
142
+ export async function GET(req: Request): Promise<Response> {
143
+ const url = new URL(req.url);
144
+ const code = url.searchParams.get("code") ?? "";
145
+ // `next` is a PATH ONLY. Never accept an absolute URL (open-redirect). Strip to a
146
+ // leading-slash path; fall back to the default target.
147
+ const rawNext = url.searchParams.get("next") ?? DEFAULT_TARGET;
148
+ const next = rawNext.startsWith("/") && !rawNext.startsWith("//") ? rawNext : DEFAULT_TARGET;
149
+
150
+ const cookieStore = await cookies();
151
+ const supabase = makeSupabaseServer(cookieStore);
152
+
153
+ // 1) Validate + consume the nonce ONCE (rejects if used/expired/forged).
154
+ const session = await consumeNonce(cookieStore, code);
155
+ if (!session) {
156
+ // Don't leak why. Send the user to login; the app retries native login.
157
+ return NextResponse.redirect(new URL("/login?error=handoff", req.url), 303);
158
+ }
159
+
160
+ // 2) Install the Supabase session as cookies via the ssr client. setSession writes
161
+ // the auth cookies through the setAll adapter above (non-httpOnly → web refreshes).
162
+ const { error } = await supabase.auth.setSession({
163
+ access_token: session.accessToken,
164
+ refresh_token: session.refreshToken,
165
+ });
166
+ if (error) {
167
+ return NextResponse.redirect(new URL("/login?error=handoff", req.url), 303);
168
+ }
169
+
170
+ // 3) 303 to the in-app target. The web now owns the session + refresh.
171
+ return NextResponse.redirect(new URL(next, req.url), 303);
172
+ }
173
+
174
+ // ─────────────────────────────────────────────────────────────────────────────
175
+ // NONCE STORAGE — choose ONE option. Both must be SINGLE-USE + SHORT-TTL.
176
+ // ─────────────────────────────────────────────────────────────────────────────
177
+
178
+ type HandoffSession = { accessToken: string; refreshToken: string };
179
+
180
+ /**
181
+ * OPTION A — Supabase table + RLS, single-use delete (recommended; survives
182
+ * multi-instance deploys, truly single-use because the read deletes the row).
183
+ *
184
+ * Migration to add to your web (ship RLS in the SAME file — this template's web
185
+ * forbids tables without RLS):
186
+ *
187
+ * create table public.app_handoff_nonces (
188
+ * code text primary key,
189
+ * user_id uuid not null references auth.users(id) on delete cascade,
190
+ * access_token text not null,
191
+ * refresh_token text not null,
192
+ * expires_at timestamptz not null,
193
+ * created_at timestamptz not null default now()
194
+ * );
195
+ * alter table public.app_handoff_nonces enable row level security;
196
+ * -- No client policies: only the SERVICE-ROLE (admin.ts) touches this table.
197
+ * -- RLS enabled with zero policies = clients get nothing; the secret key bypasses RLS.
198
+ *
199
+ * mintNonce: generate a random code, insert the row with expires_at = now()+TTL
200
+ * using the SERVICE-ROLE client (your web's lib/supabase/admin.ts — server-only).
201
+ * consumeNonce: DELETE ... WHERE code=$1 AND expires_at > now() RETURNING ...; the
202
+ * atomic delete-returning makes it single-use even under concurrent hits.
203
+ *
204
+ * OPTION B — Signed HMAC token, no DB. Sign {sub, accessToken, refreshToken, exp}
205
+ * with a server-only secret; the `code` IS the signed token. Verify signature +
206
+ * exp on consume. SIMPLER, but harder to make truly single-use (you'd need a
207
+ * used-jti set). Prefer Option A when single-use matters (it does, for sessions).
208
+ *
209
+ * The stubs below are INTENTIONALLY minimal — wire them to your chosen option.
210
+ */
211
+ async function mintNonce(
212
+ _cookieStore: Awaited<ReturnType<typeof cookies>>,
213
+ _supabase: ReturnType<typeof makeSupabaseServer>,
214
+ _session: HandoffSession,
215
+ ): Promise<string> {
216
+ // TODO(student): implement Option A (table insert via admin.ts) or B (sign).
217
+ // Generate a cryptographically random code:
218
+ // const code = crypto.randomUUID() + crypto.randomUUID();
219
+ // Store it (Option A) or sign the session into it (Option B), TTL = NONCE_TTL_SECONDS.
220
+ throw new Error(
221
+ "mintNonce is a TEMPLATE stub — implement nonce storage (Option A table or B HMAC). " +
222
+ `TTL must be ≤ ${NONCE_TTL_SECONDS}s and single-use.`,
223
+ );
224
+ }
225
+
226
+ async function consumeNonce(
227
+ _cookieStore: Awaited<ReturnType<typeof cookies>>,
228
+ _code: string,
229
+ ): Promise<HandoffSession | null> {
230
+ // TODO(student): atomically validate + invalidate the nonce ONCE.
231
+ // Option A: DELETE ... WHERE code=$1 AND expires_at > now() RETURNING tokens;
232
+ // return null if no row (already used / expired / forged).
233
+ // Option B: verify HMAC signature + exp; reject if a used-jti set already has it.
234
+ throw new Error("consumeNonce is a TEMPLATE stub — implement single-use validation.");
235
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "cli": {
3
+ "version": ">= 12.0.0",
4
+ "appVersionSource": "remote"
5
+ },
6
+ "build": {
7
+ "development": {
8
+ "developmentClient": true,
9
+ "distribution": "internal",
10
+ "env": {
11
+ "EXPO_PUBLIC_WEB_URL": "http://REPLACE-WITH-YOUR-DEV-WEB-URL"
12
+ }
13
+ },
14
+ "production": {
15
+ "autoIncrement": true,
16
+ "env": {
17
+ "EXPO_PUBLIC_WEB_URL": "http://REPLACE-WITH-YOUR-PROD-WEB-URL"
18
+ }
19
+ }
20
+ },
21
+ "submit": {
22
+ "production": {
23
+ "ios": {
24
+ "ascAppId": "REPLACE_WITH_ASC_APP_ID"
25
+ },
26
+ "android": {
27
+ "track": "internal"
28
+ }
29
+ }
30
+ }
31
+ }