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,192 @@
1
+ /**
2
+ * Login scaffold (DESIGN decisions 5 & 12).
3
+ *
4
+ * v1 login is intentionally a GENERIC SHELL:
5
+ * - PRIMARY: "Continue with email" dismisses this Modal and hands the user to the WEB's
6
+ * own email/password screen inside the WebView (the web owns auth UI; the shell does
7
+ * not duplicate it).
8
+ * - SOCIAL (Google / Apple): native one-click sign-in is a FAST-FOLLOW (DESIGN 12), so
9
+ * these buttons are present but DISABLED, labelled "coming soon". When you wire them,
10
+ * they will call `requestSessionHandoff` (src/session/sessionHandoff.ts) with a native
11
+ * idToken and load the returned handoffUrl. See the per-button comments + the store-release-guide skill.
12
+ *
13
+ * Apple's 4.8 rule: once a third-party social login (Google) is wired, "Sign in with Apple"
14
+ * must be offered too — that's why both buttons live here from day one (Apple shown on iOS
15
+ * only). All user-facing copy goes through i18n `t()`.
16
+ */
17
+
18
+ import React from "react";
19
+ import { Modal, Platform, Pressable, StyleSheet, Text, View } from "react-native";
20
+ import { t } from "../i18n";
21
+
22
+ interface LoginScreenProps {
23
+ /** Whether the login Modal is shown. */
24
+ visible: boolean;
25
+ /** Dismiss without signing in (close button / Android back / swipe). */
26
+ onClose: () => void;
27
+ /** Proceed with email login inside the WebView (dismiss + let the web show its login). */
28
+ onContinueInWebView: () => void;
29
+ }
30
+
31
+ export function LoginScreen({ visible, onClose, onContinueInWebView }: LoginScreenProps): React.JSX.Element {
32
+ // Apple's "Sign in with Apple" is iOS-only; on Android we simply omit it.
33
+ const showApple = Platform.OS === "ios";
34
+
35
+ return (
36
+ <Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
37
+ {/* Dimmed backdrop; tapping it dismisses, matching the close affordance. */}
38
+ <Pressable style={styles.backdrop} onPress={onClose} accessibilityRole="button">
39
+ {/* Absorb taps inside the sheet so they don't reach the backdrop and dismiss. */}
40
+ <Pressable style={styles.sheet} onPress={() => {}}>
41
+ <Text style={styles.title}>{t("login.title")}</Text>
42
+
43
+ <View style={styles.spacer} />
44
+
45
+ {/* PRIMARY — email login lives in the web. Dismiss and let the WebView show it. */}
46
+ <Pressable
47
+ style={styles.primaryButton}
48
+ onPress={() => {
49
+ onClose();
50
+ onContinueInWebView();
51
+ }}
52
+ accessibilityRole="button"
53
+ accessibilityLabel={t("login.continueWithEmail")}
54
+ >
55
+ <Text style={styles.primaryLabel}>{t("login.continueWithEmail")}</Text>
56
+ </Pressable>
57
+
58
+ <View style={styles.gap} />
59
+
60
+ {/*
61
+ * FAST-FOLLOW — native Google one-click sign-in.
62
+ * To wire: acquire a Google idToken natively, then
63
+ * const r = await requestSessionHandoff({ provider: "google", idToken });
64
+ * if (r.ok) // load r.handoffUrl in the WebView (server sets the cookie via the nonce)
65
+ * Console/credential setup (OAuth client ids, SHA-1, reversed URL scheme): the store-release-guide skill.
66
+ */}
67
+ <Pressable
68
+ style={[styles.socialButton, styles.disabled]}
69
+ disabled
70
+ accessibilityRole="button"
71
+ accessibilityState={{ disabled: true }}
72
+ accessibilityLabel={`${t("login.continueWithGoogle")} — ${t("login.socialComingSoon")}`}
73
+ >
74
+ <Text style={styles.socialLabel}>{t("login.continueWithGoogle")}</Text>
75
+ <Text style={styles.comingSoon}>{t("login.socialComingSoon")}</Text>
76
+ </Pressable>
77
+
78
+ {showApple ? (
79
+ <>
80
+ <View style={styles.gap} />
81
+ {/*
82
+ * FAST-FOLLOW — native "Sign in with Apple" (iOS only; Apple 4.8 companion to Google).
83
+ * To wire: acquire an Apple identity token natively, then
84
+ * const r = await requestSessionHandoff({ provider: "apple", idToken });
85
+ * if (r.ok) // load r.handoffUrl in the WebView.
86
+ * Apple Services ID / nonce / KR server-to-server endpoint: the store-release-guide skill.
87
+ */}
88
+ <Pressable
89
+ style={[styles.socialButton, styles.disabled]}
90
+ disabled
91
+ accessibilityRole="button"
92
+ accessibilityState={{ disabled: true }}
93
+ accessibilityLabel={`${t("login.continueWithApple")} — ${t("login.socialComingSoon")}`}
94
+ >
95
+ <Text style={styles.socialLabel}>{t("login.continueWithApple")}</Text>
96
+ <Text style={styles.comingSoon}>{t("login.socialComingSoon")}</Text>
97
+ </Pressable>
98
+ </>
99
+ ) : null}
100
+
101
+ <View style={styles.spacer} />
102
+
103
+ {/* Close — dismiss the scaffold without signing in. */}
104
+ <Pressable
105
+ style={styles.closeButton}
106
+ onPress={onClose}
107
+ accessibilityRole="button"
108
+ accessibilityLabel={t("login.close")}
109
+ >
110
+ <Text style={styles.closeLabel}>{t("login.close")}</Text>
111
+ </Pressable>
112
+ </Pressable>
113
+ </Pressable>
114
+ </Modal>
115
+ );
116
+ }
117
+
118
+ const styles = StyleSheet.create({
119
+ backdrop: {
120
+ flex: 1,
121
+ backgroundColor: "rgba(0, 0, 0, 0.4)",
122
+ justifyContent: "flex-end",
123
+ },
124
+ sheet: {
125
+ backgroundColor: "#ffffff",
126
+ borderTopLeftRadius: 20,
127
+ borderTopRightRadius: 20,
128
+ paddingHorizontal: 24,
129
+ paddingTop: 24,
130
+ paddingBottom: 36,
131
+ },
132
+ title: {
133
+ fontSize: 22,
134
+ fontWeight: "700",
135
+ color: "#111111",
136
+ textAlign: "center",
137
+ },
138
+ spacer: {
139
+ height: 20,
140
+ },
141
+ gap: {
142
+ height: 12,
143
+ },
144
+ primaryButton: {
145
+ width: "100%",
146
+ height: 52,
147
+ borderRadius: 12,
148
+ backgroundColor: "#111111",
149
+ alignItems: "center",
150
+ justifyContent: "center",
151
+ },
152
+ primaryLabel: {
153
+ color: "#ffffff",
154
+ fontSize: 16,
155
+ fontWeight: "600",
156
+ },
157
+ socialButton: {
158
+ width: "100%",
159
+ minHeight: 52,
160
+ borderRadius: 12,
161
+ borderWidth: 1,
162
+ borderColor: "#dddddd",
163
+ backgroundColor: "#ffffff",
164
+ alignItems: "center",
165
+ justifyContent: "center",
166
+ paddingVertical: 8,
167
+ },
168
+ socialLabel: {
169
+ color: "#111111",
170
+ fontSize: 16,
171
+ fontWeight: "600",
172
+ },
173
+ comingSoon: {
174
+ marginTop: 2,
175
+ color: "#999999",
176
+ fontSize: 12,
177
+ },
178
+ disabled: {
179
+ opacity: 0.55,
180
+ },
181
+ closeButton: {
182
+ width: "100%",
183
+ height: 44,
184
+ alignItems: "center",
185
+ justifyContent: "center",
186
+ },
187
+ closeLabel: {
188
+ color: "#666666",
189
+ fontSize: 15,
190
+ fontWeight: "500",
191
+ },
192
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { APP_CAPABILITIES, buildHello } from "./capabilities";
3
+ import { MSG, OutboundSchema, SSB_NS, SSB_PROTOCOL_VERSION } from "./contract";
4
+
5
+ describe("APP_CAPABILITIES", () => {
6
+ it("advertises exactly the v1 capability set", () => {
7
+ expect(APP_CAPABILITIES).toEqual(["session.v1", "external-links.v1"]);
8
+ });
9
+ });
10
+
11
+ describe("buildHello", () => {
12
+ it("returns a valid HELLO OutboundMessage that parses against OutboundSchema", () => {
13
+ const hello = buildHello({ appVersion: "1.2.3", platform: "ios" });
14
+
15
+ const parsed = OutboundSchema.safeParse(hello);
16
+ expect(parsed.success).toBe(true);
17
+
18
+ expect(hello.type).toBe(MSG.HELLO);
19
+ expect(hello.ns).toBe(SSB_NS);
20
+ expect(hello.v).toBe(SSB_PROTOCOL_VERSION);
21
+ });
22
+
23
+ it("carries the protocol version, capabilities, appVersion and platform in the payload", () => {
24
+ const hello = buildHello({ appVersion: "9.9.9", platform: "android" });
25
+
26
+ // type-narrowed via the parsed result so payload is typed.
27
+ const parsed = OutboundSchema.parse(hello);
28
+ expect(parsed.type).toBe(MSG.HELLO);
29
+ if (parsed.type !== MSG.HELLO) throw new Error("expected HELLO");
30
+
31
+ expect(parsed.payload.protocolVersion).toBe(SSB_PROTOCOL_VERSION);
32
+ expect(parsed.payload.capabilities).toEqual(["session.v1", "external-links.v1"]);
33
+ expect(parsed.payload.appVersion).toBe("9.9.9");
34
+ expect(parsed.payload.platform).toBe("android");
35
+ });
36
+
37
+ it("copies capabilities (does not hand out the readonly source array)", () => {
38
+ const hello = buildHello({ appVersion: "1.0.0", platform: "ios" });
39
+ if (hello.type !== MSG.HELLO) throw new Error("expected HELLO");
40
+
41
+ expect(hello.payload.capabilities).not.toBe(APP_CAPABILITIES);
42
+ expect(hello.payload.capabilities).toEqual([...APP_CAPABILITIES]);
43
+ });
44
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 이 네이티브 셸이 handshake(HELLO)로 광고하는 capability 토큰 목록.
3
+ *
4
+ * 웹은 버전 숫자가 아니라 이 목록으로 기능을 게이트한다(DESIGN 결정 8·15: capabilities
5
+ * 배열로 feature-detect, 버전 분기 금지). 목록은 ADDITIVE-ONLY: 토큰을 추가만 하고
6
+ * 절대 제거·이름변경하지 않는다 → 구버전 앱(적은 토큰)이 최신 웹에서, 신버전 앱이
7
+ * 구버전 웹에서 모두 안전하게 동작한다.
8
+ *
9
+ * - session.v1 : 네이티브 로그인 → 서버 Set-Cookie 세션 핸드오프(REQUEST_SESSION_INSTALL).
10
+ * - external-links.v1 : 외부 URL을 시스템 브라우저로 여는 능력(OPEN_EXTERNAL).
11
+ *
12
+ * 새 네이티브 능력(예: push.v1)을 추가할 때 여기에 토큰을 append 하고, 웹은
13
+ * hasCapability("push.v1") 게이트 뒤에서만 그 기능을 쓴다.
14
+ */
15
+ import { MSG, SSB_NS, SSB_PROTOCOL_VERSION, type OutboundMessage } from "./contract";
16
+
17
+ /** v1 capability 집합. Append-only. */
18
+ export const APP_CAPABILITIES: readonly string[] = ["session.v1", "external-links.v1"] as const;
19
+
20
+ /**
21
+ * HELLO 봉투를 만든다. 앱은 웹뷰가 로드되자마자 이 메시지를 주입해
22
+ * "나 이런 앱이고 이런 능력이 있어요"를 웹에 알린다(DESIGN 결정 8).
23
+ *
24
+ * 순수 함수 — 네이티브 import 없음. appVersion·platform은 호출부(셸)가 넘긴다.
25
+ */
26
+ export function buildHello(args: {
27
+ appVersion: string;
28
+ platform: "ios" | "android";
29
+ }): OutboundMessage {
30
+ return {
31
+ ns: SSB_NS,
32
+ v: SSB_PROTOCOL_VERSION,
33
+ type: MSG.HELLO,
34
+ payload: {
35
+ protocolVersion: SSB_PROTOCOL_VERSION,
36
+ // 광고 시점에 배열 복사를 넘겨 외부에서 readonly 원본을 변형하지 못하게 한다.
37
+ capabilities: [...APP_CAPABILITIES],
38
+ appVersion: args.appVersion,
39
+ platform: args.platform,
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { InboundSchema, OutboundSchema, SSB_NS, SSB_PROTOCOL_VERSION, MSG } from "./contract";
3
+
4
+ describe("bridge contract", () => {
5
+ it("namespace + protocol constants are stable", () => {
6
+ expect(SSB_NS).toBe("ssb");
7
+ expect(SSB_PROTOCOL_VERSION).toBe(1);
8
+ });
9
+
10
+ it("InboundSchema accepts AUTH_STATE_CHANGED with a boolean payload", () => {
11
+ const r = InboundSchema.safeParse({
12
+ ns: "ssb",
13
+ v: 1,
14
+ type: MSG.AUTH_STATE_CHANGED,
15
+ payload: { authenticated: true },
16
+ });
17
+ expect(r.success).toBe(true);
18
+ });
19
+
20
+ it("InboundSchema rejects a foreign namespace", () => {
21
+ const r = InboundSchema.safeParse({ ns: "nope", v: 1, type: MSG.READY });
22
+ expect(r.success).toBe(false);
23
+ });
24
+
25
+ it("InboundSchema rejects AUTH_STATE_CHANGED with a missing payload", () => {
26
+ const r = InboundSchema.safeParse({ ns: "ssb", v: 1, type: MSG.AUTH_STATE_CHANGED });
27
+ expect(r.success).toBe(false);
28
+ });
29
+
30
+ it("OutboundSchema accepts a well-formed HELLO", () => {
31
+ const r = OutboundSchema.safeParse({
32
+ ns: "ssb",
33
+ v: 1,
34
+ type: MSG.HELLO,
35
+ payload: { protocolVersion: 1, capabilities: ["session.v1"], appVersion: "1.0.0", platform: "ios" },
36
+ });
37
+ expect(r.success).toBe(true);
38
+ });
39
+
40
+ it("OutboundSchema rejects an invalid platform", () => {
41
+ const r = OutboundSchema.safeParse({
42
+ ns: "ssb",
43
+ v: 1,
44
+ type: MSG.HELLO,
45
+ payload: { protocolVersion: 1, capabilities: [], appVersion: "1.0.0", platform: "windows" },
46
+ });
47
+ expect(r.success).toBe(false);
48
+ });
49
+ });
@@ -0,0 +1,146 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * App ↔ Web bridge contract — SINGLE SOURCE OF TRUTH.
5
+ *
6
+ * This file is copied byte-for-byte into the web project (docs/web-adapter/contract.ts).
7
+ * The two MUST stay identical; the web adapter ships a checksum check to enforce it.
8
+ *
9
+ * Invariants (never break these):
10
+ * 1. Every message is namespaced (`ns: "ssb"`) so unrelated postMessage traffic on
11
+ * the page is ignored.
12
+ * 2. The contract is ADDITIVE-ONLY. Add new message `type`s or new OPTIONAL payload
13
+ * fields; never remove a type, rename one, or make an existing field required.
14
+ * Old apps must keep working against new webs and vice-versa.
15
+ * 3. Features are gated by HELLO `capabilities` (a string array), NOT by version
16
+ * number. The app advertises what it can do; the web feature-detects.
17
+ * 4. The reader (reader.ts) is tolerant: anything unrecognized becomes UNKNOWN and
18
+ * never throws.
19
+ * 5. Auth tokens NEVER travel over this channel. Session handoff is a server
20
+ * Set-Cookie via a one-time nonce (see src/session + docs/web-adapter).
21
+ */
22
+
23
+ export const SSB_NS = "ssb" as const;
24
+ export const SSB_PROTOCOL_VERSION = 1 as const;
25
+
26
+ /** All message type names. Add here (append-only) when extending the contract. */
27
+ export const MSG = {
28
+ // ── app → web ──
29
+ /** App announces itself + capabilities as soon as the page is reachable. */
30
+ HELLO: "HELLO",
31
+ /** App confirms a web session cookie was installed (after native login handoff). */
32
+ SESSION_INSTALLED: "SESSION_INSTALLED",
33
+ /** App confirms it cleared its local session copy after a logout. */
34
+ LOGOUT_DONE: "LOGOUT_DONE",
35
+ /** Forward-compat only — push is a fast-follow; defined so the web can feature-detect later. */
36
+ PUSH_TOKEN_UPDATED: "PUSH_TOKEN_UPDATED",
37
+
38
+ // ── web → app ──
39
+ /** Web acknowledges the bridge is live (handshake completes). */
40
+ READY: "READY",
41
+ /** Web has no session and asks the app to run native login + install a session. */
42
+ REQUEST_SESSION_INSTALL: "REQUEST_SESSION_INSTALL",
43
+ /** Web tells the app its auth state changed (e.g. user logged in/out inside the webview). */
44
+ AUTH_STATE_CHANGED: "AUTH_STATE_CHANGED",
45
+ /** Web asks the app to forget the session (user logged out). */
46
+ LOGOUT: "LOGOUT",
47
+ /** Web asks the app to open a URL in the system browser instead of the webview. */
48
+ OPEN_EXTERNAL: "OPEN_EXTERNAL",
49
+
50
+ // ── reader fallback (not a wire message) ──
51
+ UNKNOWN: "UNKNOWN",
52
+ } as const;
53
+
54
+ export type MessageType = (typeof MSG)[keyof typeof MSG];
55
+
56
+ /** Fields shared by every envelope. */
57
+ const envelopeBase = {
58
+ ns: z.literal(SSB_NS),
59
+ v: z.number().int(),
60
+ /** Correlation id, present on request/response pairs. */
61
+ id: z.string().optional(),
62
+ };
63
+
64
+ // ───────────────────────── web → app (inbound) ─────────────────────────
65
+ const ReadyMessage = z.object({
66
+ ...envelopeBase,
67
+ type: z.literal(MSG.READY),
68
+ payload: z.object({}).optional(),
69
+ });
70
+
71
+ const RequestSessionInstallMessage = z.object({
72
+ ...envelopeBase,
73
+ type: z.literal(MSG.REQUEST_SESSION_INSTALL),
74
+ payload: z.object({ redirectPath: z.string().optional() }).optional(),
75
+ });
76
+
77
+ const AuthStateChangedMessage = z.object({
78
+ ...envelopeBase,
79
+ type: z.literal(MSG.AUTH_STATE_CHANGED),
80
+ payload: z.object({ authenticated: z.boolean() }),
81
+ });
82
+
83
+ const LogoutMessage = z.object({
84
+ ...envelopeBase,
85
+ type: z.literal(MSG.LOGOUT),
86
+ payload: z.object({}).optional(),
87
+ });
88
+
89
+ const OpenExternalMessage = z.object({
90
+ ...envelopeBase,
91
+ type: z.literal(MSG.OPEN_EXTERNAL),
92
+ payload: z.object({ url: z.string() }),
93
+ });
94
+
95
+ export const InboundSchema = z.discriminatedUnion("type", [
96
+ ReadyMessage,
97
+ RequestSessionInstallMessage,
98
+ AuthStateChangedMessage,
99
+ LogoutMessage,
100
+ OpenExternalMessage,
101
+ ]);
102
+ export type InboundMessage = z.infer<typeof InboundSchema>;
103
+
104
+ // ───────────────────────── app → web (outbound) ─────────────────────────
105
+ const HelloMessage = z.object({
106
+ ...envelopeBase,
107
+ type: z.literal(MSG.HELLO),
108
+ payload: z.object({
109
+ protocolVersion: z.number().int(),
110
+ capabilities: z.array(z.string()),
111
+ appVersion: z.string(),
112
+ platform: z.enum(["ios", "android"]),
113
+ }),
114
+ });
115
+
116
+ const SessionInstalledMessage = z.object({
117
+ ...envelopeBase,
118
+ type: z.literal(MSG.SESSION_INSTALLED),
119
+ payload: z.object({ ok: z.boolean() }),
120
+ });
121
+
122
+ const LogoutDoneMessage = z.object({
123
+ ...envelopeBase,
124
+ type: z.literal(MSG.LOGOUT_DONE),
125
+ payload: z.object({}).optional(),
126
+ });
127
+
128
+ const PushTokenUpdatedMessage = z.object({
129
+ ...envelopeBase,
130
+ type: z.literal(MSG.PUSH_TOKEN_UPDATED),
131
+ payload: z.object({ token: z.string() }),
132
+ });
133
+
134
+ export const OutboundSchema = z.discriminatedUnion("type", [
135
+ HelloMessage,
136
+ SessionInstalledMessage,
137
+ LogoutDoneMessage,
138
+ PushTokenUpdatedMessage,
139
+ ]);
140
+ export type OutboundMessage = z.infer<typeof OutboundSchema>;
141
+
142
+ /**
143
+ * The tolerant reader's fallback shape. Not a valid wire message — produced only
144
+ * when an inbound payload cannot be understood. See reader.ts.
145
+ */
146
+ export type UnknownMessage = { type: typeof MSG.UNKNOWN; raw: unknown };
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildReceiveInjection } from "./messaging";
3
+ import { MSG, SSB_NS, SSB_PROTOCOL_VERSION, type OutboundMessage } from "./contract";
4
+
5
+ const helloMsg: OutboundMessage = {
6
+ ns: SSB_NS,
7
+ v: SSB_PROTOCOL_VERSION,
8
+ type: MSG.HELLO,
9
+ payload: {
10
+ protocolVersion: SSB_PROTOCOL_VERSION,
11
+ capabilities: ["session.v1"],
12
+ appVersion: "1.0.0",
13
+ platform: "ios",
14
+ },
15
+ };
16
+
17
+ describe("buildReceiveInjection", () => {
18
+ it("embeds the envelope as JSON in the injected source", () => {
19
+ const js = buildReceiveInjection(helloMsg);
20
+ expect(js).toContain(JSON.stringify(helloMsg));
21
+ });
22
+
23
+ it("ends with \"true;\" so injectJavaScript has a clean return value", () => {
24
+ const js = buildReceiveInjection(helloMsg);
25
+ expect(js.endsWith("true;")).toBe(true);
26
+ });
27
+
28
+ it("delivers via both __ssbBridge.receive and the ssb:message CustomEvent, wrapped in try/catch", () => {
29
+ const js = buildReceiveInjection(helloMsg);
30
+ expect(js).toContain("window.__ssbBridge");
31
+ expect(js).toContain(".receive(");
32
+ expect(js).toContain('new CustomEvent("ssb:message"');
33
+ expect(js.startsWith("try{")).toBe(true);
34
+ expect(js).toContain("catch");
35
+ });
36
+
37
+ it("escapes embedded quotes so the source stays valid JS", () => {
38
+ const msg: OutboundMessage = {
39
+ ns: SSB_NS,
40
+ v: SSB_PROTOCOL_VERSION,
41
+ type: MSG.PUSH_TOKEN_UPDATED,
42
+ payload: { token: 'tok"with"quotes' },
43
+ };
44
+ const js = buildReceiveInjection(msg);
45
+ // JSON.stringify escapes the inner quotes; the raw unescaped substring must not appear.
46
+ expect(js).toContain(JSON.stringify(msg));
47
+ expect(js).not.toContain('tok"with"quotes');
48
+ });
49
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 앱→웹 전달(delivery) — 순수 JS 소스 빌더.
3
+ *
4
+ * 전달 규약(아키텍처 계약): 앱은 OutboundMessage 봉투를 웹뷰에 주입(injectJavaScript)하고,
5
+ * 그 JS가 두 경로로 웹에 봉투를 흘려보낸다.
6
+ * 1. window.__ssbBridge?.receive(envelope) — 웹 어댑터가 설치한 콜백(있으면).
7
+ * 2. window.dispatchEvent(CustomEvent("ssb:message")) — 어댑터 없이도 듣는 일반 이벤트 경로.
8
+ * 둘 다 try/catch로 감싸 어느 한쪽이 없거나 던져도 무해하게 무시한다.
9
+ *
10
+ * injectJavaScript에 넘기는 소스는 마지막 평가값이 명확해야 iOS WKWebView 경고를 피하므로
11
+ * 반드시 "true;"로 끝낸다(react-native-webview 권장 패턴).
12
+ *
13
+ * 순수 함수 — 네이티브 import 없음. 셸은 결과 문자열을 webRef.injectJavaScript()에 넘긴다.
14
+ */
15
+ import type { OutboundMessage } from "./contract";
16
+
17
+ /**
18
+ * OutboundMessage → injectJavaScript용 JS 소스.
19
+ *
20
+ * 봉투는 JSON.stringify로 직렬화해 JS 리터럴로 박는다(따옴표·역슬래시 자동 이스케이프).
21
+ * 웹의 두 수신 경로(__ssbBridge.receive / "ssb:message" CustomEvent)에 같은 객체를 전달.
22
+ */
23
+ export function buildReceiveInjection(msg: OutboundMessage): string {
24
+ const json = JSON.stringify(msg);
25
+ return (
26
+ "try{" +
27
+ `var m=${json};` +
28
+ "window.__ssbBridge&&window.__ssbBridge.receive(m);" +
29
+ 'window.dispatchEvent(new CustomEvent("ssb:message",{detail:m}));' +
30
+ "}catch(e){}" +
31
+ " true;"
32
+ );
33
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readInbound } from "./reader";
3
+ import { SSB_NS, SSB_PROTOCOL_VERSION, MSG } from "./contract";
4
+
5
+ const wire = (type: string, payload?: unknown, extra?: Record<string, unknown>) =>
6
+ JSON.stringify({ ns: SSB_NS, v: SSB_PROTOCOL_VERSION, type, payload, ...extra });
7
+
8
+ describe("readInbound — tolerant reader", () => {
9
+ it("parses a valid READY message", () => {
10
+ expect(readInbound(wire(MSG.READY)).type).toBe(MSG.READY);
11
+ });
12
+
13
+ it("parses OPEN_EXTERNAL and exposes the url", () => {
14
+ const r = readInbound(wire(MSG.OPEN_EXTERNAL, { url: "https://example.com" }));
15
+ expect(r.type).toBe(MSG.OPEN_EXTERNAL);
16
+ if (r.type === MSG.OPEN_EXTERNAL) expect(r.payload.url).toBe("https://example.com");
17
+ });
18
+
19
+ it("accepts an already-parsed object, not just a JSON string", () => {
20
+ expect(readInbound({ ns: SSB_NS, v: 1, type: MSG.LOGOUT }).type).toBe(MSG.LOGOUT);
21
+ });
22
+
23
+ it("rejects a foreign namespace as UNKNOWN", () => {
24
+ expect(readInbound(JSON.stringify({ ns: "other", v: 1, type: MSG.READY })).type).toBe(MSG.UNKNOWN);
25
+ });
26
+
27
+ it("treats OPEN_EXTERNAL without a url as UNKNOWN (missing required field)", () => {
28
+ expect(readInbound(wire(MSG.OPEN_EXTERNAL, {})).type).toBe(MSG.UNKNOWN);
29
+ });
30
+
31
+ it("treats an unknown future type as UNKNOWN (forward-compat)", () => {
32
+ expect(readInbound(wire("SOME_FUTURE_THING")).type).toBe(MSG.UNKNOWN);
33
+ });
34
+
35
+ it("treats malformed JSON as UNKNOWN and never throws", () => {
36
+ expect(() => readInbound("{not json")).not.toThrow();
37
+ expect(readInbound("{not json").type).toBe(MSG.UNKNOWN);
38
+ });
39
+
40
+ it("handles non-string, non-object input", () => {
41
+ expect(readInbound(42).type).toBe(MSG.UNKNOWN);
42
+ expect(readInbound(null).type).toBe(MSG.UNKNOWN);
43
+ expect(readInbound(undefined).type).toBe(MSG.UNKNOWN);
44
+ expect(readInbound([]).type).toBe(MSG.UNKNOWN);
45
+ });
46
+
47
+ it("preserves the raw input on UNKNOWN", () => {
48
+ const r = readInbound("garbage");
49
+ expect(r.type).toBe(MSG.UNKNOWN);
50
+ if (r.type === MSG.UNKNOWN) expect(r.raw).toBe("garbage");
51
+ });
52
+ });
@@ -0,0 +1,31 @@
1
+ import { InboundSchema, MSG, type InboundMessage, type UnknownMessage } from "./contract";
2
+
3
+ export type ReadResult = InboundMessage | UnknownMessage;
4
+
5
+ /**
6
+ * Tolerant reader for messages arriving FROM the web (WebView `onMessage`).
7
+ *
8
+ * Hard contract: this function NEVER throws. Anything it cannot confidently
9
+ * understand — wrong namespace, non-JSON string, unknown `type`, missing/invalid
10
+ * fields, a number, null — collapses to `{ type: "UNKNOWN", raw }`. The native
11
+ * shell must survive hostile, outdated, or unrelated postMessage traffic.
12
+ *
13
+ * `raw` is whatever `event.nativeEvent.data` gave us (usually a JSON string, but
14
+ * a parsed object is accepted too).
15
+ */
16
+ export function readInbound(raw: unknown): ReadResult {
17
+ let candidate: unknown = raw;
18
+
19
+ if (typeof raw === "string") {
20
+ try {
21
+ candidate = JSON.parse(raw);
22
+ } catch {
23
+ return { type: MSG.UNKNOWN, raw };
24
+ }
25
+ }
26
+
27
+ const parsed = InboundSchema.safeParse(candidate);
28
+ if (parsed.success) return parsed.data;
29
+
30
+ return { type: MSG.UNKNOWN, raw };
31
+ }