@ramonclaudio/create-vexpo 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 (174) hide show
  1. package/README.md +50 -0
  2. package/dist/index.js +183 -0
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
  5. package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
  6. package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
  7. package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
  8. package/dist/templates/default/.eas/workflows/release.yml +44 -0
  9. package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
  10. package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
  11. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
  12. package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
  13. package/dist/templates/default/.github/workflows/check.yml +28 -0
  14. package/dist/templates/default/.maestro/launch.yaml +18 -0
  15. package/dist/templates/default/AGENTS.md +79 -0
  16. package/dist/templates/default/DESIGN.md +331 -0
  17. package/dist/templates/default/LICENSE +21 -0
  18. package/dist/templates/default/README.md +153 -0
  19. package/dist/templates/default/SETUP.md +618 -0
  20. package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
  21. package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
  22. package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
  23. package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
  24. package/dist/templates/default/_easignore +22 -0
  25. package/dist/templates/default/_editorconfig +9 -0
  26. package/dist/templates/default/_env.example +34 -0
  27. package/dist/templates/default/_fingerprintignore +24 -0
  28. package/dist/templates/default/_gitattributes +7 -0
  29. package/dist/templates/default/_gitignore +69 -0
  30. package/dist/templates/default/_oxfmtrc.json +3 -0
  31. package/dist/templates/default/_oxlintrc.json +34 -0
  32. package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
  33. package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
  34. package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
  35. package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
  36. package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
  37. package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
  38. package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
  39. package/dist/templates/default/app/(app)/_layout.tsx +73 -0
  40. package/dist/templates/default/app/(app)/debug.tsx +389 -0
  41. package/dist/templates/default/app/(app)/help.tsx +254 -0
  42. package/dist/templates/default/app/(app)/linked.tsx +116 -0
  43. package/dist/templates/default/app/(app)/privacy.tsx +159 -0
  44. package/dist/templates/default/app/(app)/profile.tsx +915 -0
  45. package/dist/templates/default/app/(app)/sessions.tsx +191 -0
  46. package/dist/templates/default/app/(app)/welcome.tsx +140 -0
  47. package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
  48. package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
  49. package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
  50. package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
  51. package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
  52. package/dist/templates/default/app/+native-intent.tsx +14 -0
  53. package/dist/templates/default/app/+not-found.tsx +51 -0
  54. package/dist/templates/default/app/_layout.tsx +102 -0
  55. package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
  56. package/dist/templates/default/app-store/screenshots/README.md +13 -0
  57. package/dist/templates/default/app.config.ts +201 -0
  58. package/dist/templates/default/app.json +11 -0
  59. package/dist/templates/default/assets/brand-icon-dark.png +0 -0
  60. package/dist/templates/default/assets/brand-icon-light.png +0 -0
  61. package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
  62. package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
  63. package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
  64. package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
  65. package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
  66. package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
  67. package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
  68. package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
  69. package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
  70. package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
  71. package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
  72. package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
  73. package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
  74. package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
  75. package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
  76. package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
  77. package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
  78. package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
  79. package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
  80. package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
  81. package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
  82. package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
  83. package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
  84. package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
  85. package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
  86. package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
  87. package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
  88. package/dist/templates/default/assets/icon.png +0 -0
  89. package/dist/templates/default/assets/sounds/notification.wav +0 -0
  90. package/dist/templates/default/assets/splash-image-dark.png +0 -0
  91. package/dist/templates/default/assets/splash-image-light.png +0 -0
  92. package/dist/templates/default/bun.lock +1860 -0
  93. package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
  94. package/dist/templates/default/components/auth/password-field.tsx +121 -0
  95. package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
  96. package/dist/templates/default/components/ui/convex-error.tsx +32 -0
  97. package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
  98. package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
  99. package/dist/templates/default/components/ui/material.tsx +94 -0
  100. package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
  101. package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
  102. package/dist/templates/default/components/ui/skeleton.tsx +107 -0
  103. package/dist/templates/default/components/ui/status-text.tsx +49 -0
  104. package/dist/templates/default/components/ui/update-banner.tsx +82 -0
  105. package/dist/templates/default/constants/layout.ts +102 -0
  106. package/dist/templates/default/constants/theme.ts +401 -0
  107. package/dist/templates/default/constants/ui.ts +77 -0
  108. package/dist/templates/default/convex/_generated/api.d.ts +77 -0
  109. package/dist/templates/default/convex/_generated/api.js +23 -0
  110. package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
  111. package/dist/templates/default/convex/_generated/server.d.ts +143 -0
  112. package/dist/templates/default/convex/_generated/server.js +93 -0
  113. package/dist/templates/default/convex/admin.ts +102 -0
  114. package/dist/templates/default/convex/auth.config.ts +6 -0
  115. package/dist/templates/default/convex/auth.ts +335 -0
  116. package/dist/templates/default/convex/constants.ts +46 -0
  117. package/dist/templates/default/convex/convex.config.ts +11 -0
  118. package/dist/templates/default/convex/crons.ts +42 -0
  119. package/dist/templates/default/convex/email.ts +109 -0
  120. package/dist/templates/default/convex/env.ts +31 -0
  121. package/dist/templates/default/convex/errors.ts +33 -0
  122. package/dist/templates/default/convex/functions.ts +54 -0
  123. package/dist/templates/default/convex/http.ts +176 -0
  124. package/dist/templates/default/convex/log.ts +81 -0
  125. package/dist/templates/default/convex/pushTokens.ts +114 -0
  126. package/dist/templates/default/convex/rateLimit.ts +92 -0
  127. package/dist/templates/default/convex/schema.ts +28 -0
  128. package/dist/templates/default/convex/tsconfig.json +18 -0
  129. package/dist/templates/default/convex/users.ts +279 -0
  130. package/dist/templates/default/convex/validators.ts +74 -0
  131. package/dist/templates/default/convex/webhook.ts +193 -0
  132. package/dist/templates/default/convex.json +6 -0
  133. package/dist/templates/default/eas.json +56 -0
  134. package/dist/templates/default/fingerprint.config.js +9 -0
  135. package/dist/templates/default/hooks/use-debounce.ts +20 -0
  136. package/dist/templates/default/hooks/use-deep-link.ts +43 -0
  137. package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
  138. package/dist/templates/default/hooks/use-network.ts +11 -0
  139. package/dist/templates/default/hooks/use-notifications.ts +107 -0
  140. package/dist/templates/default/hooks/use-onboarding.ts +15 -0
  141. package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
  142. package/dist/templates/default/hooks/use-theme.ts +53 -0
  143. package/dist/templates/default/hooks/use-updates.ts +86 -0
  144. package/dist/templates/default/lib/a11y.ts +5 -0
  145. package/dist/templates/default/lib/app.ts +14 -0
  146. package/dist/templates/default/lib/assets.ts +17 -0
  147. package/dist/templates/default/lib/auth-client.ts +21 -0
  148. package/dist/templates/default/lib/convex-auth.tsx +79 -0
  149. package/dist/templates/default/lib/deep-link.ts +71 -0
  150. package/dist/templates/default/lib/dev-menu.ts +119 -0
  151. package/dist/templates/default/lib/device.ts +40 -0
  152. package/dist/templates/default/lib/dynamic-font.ts +49 -0
  153. package/dist/templates/default/lib/env.ts +10 -0
  154. package/dist/templates/default/lib/haptics.ts +24 -0
  155. package/dist/templates/default/lib/notifications.ts +276 -0
  156. package/dist/templates/default/lib/preferences.ts +45 -0
  157. package/dist/templates/default/lib/schemas.ts +137 -0
  158. package/dist/templates/default/lib/storage.ts +47 -0
  159. package/dist/templates/default/lib/updates.ts +107 -0
  160. package/dist/templates/default/metro.config.js +14 -0
  161. package/dist/templates/default/package.json +129 -0
  162. package/dist/templates/default/patches/PR-368.patch +91 -0
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. package/dist/templates/default/plugins/README.md +9 -0
  165. package/dist/templates/default/plugins/with-auto-signing.js +45 -0
  166. package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
  167. package/dist/templates/default/scripts/README.md +36 -0
  168. package/dist/templates/default/scripts/_run.mjs +77 -0
  169. package/dist/templates/default/scripts/clean.ts +543 -0
  170. package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
  171. package/dist/templates/default/store.config.json +58 -0
  172. package/dist/templates/default/tsconfig.json +13 -0
  173. package/dist/templates/default/vitest.config.ts +21 -0
  174. package/package.json +69 -0
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import {
4
+ RESERVED_USERNAMES,
5
+ USERNAME_MAX_LENGTH,
6
+ USERNAME_MIN_LENGTH,
7
+ isReservedUsername,
8
+ isValidUsernameFormat,
9
+ } from "@/convex/constants";
10
+
11
+ describe("isValidUsernameFormat", () => {
12
+ test("accepts alphanumerics, dots, underscores", () => {
13
+ expect(isValidUsernameFormat("ray")).toBe(true);
14
+ expect(isValidUsernameFormat("ray_claudio")).toBe(true);
15
+ expect(isValidUsernameFormat("ray.claudio")).toBe(true);
16
+ expect(isValidUsernameFormat("Ray123")).toBe(true);
17
+ });
18
+
19
+ test("rejects strings shorter than the min length", () => {
20
+ expect(USERNAME_MIN_LENGTH).toBe(3);
21
+ expect(isValidUsernameFormat("ab")).toBe(false);
22
+ });
23
+
24
+ test("rejects strings longer than the max length", () => {
25
+ expect(USERNAME_MAX_LENGTH).toBe(30);
26
+ expect(isValidUsernameFormat("a".repeat(31))).toBe(false);
27
+ });
28
+
29
+ test("rejects characters outside the allowed set", () => {
30
+ expect(isValidUsernameFormat("ray-claudio")).toBe(false);
31
+ expect(isValidUsernameFormat("ray claudio")).toBe(false);
32
+ expect(isValidUsernameFormat("ray@claudio")).toBe(false);
33
+ expect(isValidUsernameFormat("rayé")).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("isReservedUsername", () => {
38
+ test("matches every name on the reserved list, case-insensitive", () => {
39
+ for (const name of RESERVED_USERNAMES) {
40
+ expect(isReservedUsername(name)).toBe(true);
41
+ expect(isReservedUsername(name.toUpperCase())).toBe(true);
42
+ }
43
+ });
44
+
45
+ test("does not match a normal username", () => {
46
+ expect(isReservedUsername("ray")).toBe(false);
47
+ expect(isReservedUsername("ramon")).toBe(false);
48
+ });
49
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import { validateBio } from "@/convex/validators";
4
+
5
+ describe("validateBio", () => {
6
+ test("accepts empty string", () => {
7
+ expect(validateBio("")).toEqual({ valid: true });
8
+ });
9
+
10
+ test("accepts a normal bio", () => {
11
+ expect(validateBio("Building things on the internet.")).toEqual({ valid: true });
12
+ });
13
+
14
+ test("accepts exactly 500 characters", () => {
15
+ expect(validateBio("a".repeat(500))).toEqual({ valid: true });
16
+ });
17
+
18
+ test("rejects bios over 500 characters", () => {
19
+ const result = validateBio("a".repeat(501));
20
+ expect(result.valid).toBe(false);
21
+ expect(result.error).toMatch(/500 characters or less/);
22
+ });
23
+ });
@@ -0,0 +1,343 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
2
+
3
+ import { withWebhook } from "@/convex/webhook";
4
+
5
+ // Stub Convex action context. The webhook factory doesn't use it (the inner
6
+ // handler does, if it needs to call queries/mutations), so an empty object
7
+ // satisfies the type for these unit tests. Cast through `unknown` rather than
8
+ // importing the Convex generic action type, which would bloat the test deps.
9
+ const ctx = {} as unknown as Parameters<ReturnType<typeof withWebhook>>[0];
10
+
11
+ async function sign(algorithm: "sha1" | "sha256", secret: string, body: string): Promise<string> {
12
+ const enc = new TextEncoder();
13
+ const key = await crypto.subtle.importKey(
14
+ "raw",
15
+ enc.encode(secret),
16
+ { name: "HMAC", hash: algorithm === "sha1" ? "SHA-1" : "SHA-256" },
17
+ false,
18
+ ["sign"],
19
+ );
20
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(body));
21
+ return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
22
+ }
23
+
24
+ function makeRequest(opts: {
25
+ body: string;
26
+ signatureHeader: string;
27
+ signatureValue?: string;
28
+ timestampHeader?: string;
29
+ timestampValue?: string;
30
+ contentLength?: number;
31
+ }): Request {
32
+ const headers: Record<string, string> = {
33
+ "Content-Type": "application/json",
34
+ };
35
+ if (opts.signatureValue) headers[opts.signatureHeader] = opts.signatureValue;
36
+ if (opts.timestampHeader && opts.timestampValue)
37
+ headers[opts.timestampHeader] = opts.timestampValue;
38
+ if (opts.contentLength !== undefined) headers["Content-Length"] = String(opts.contentLength);
39
+ return new Request("https://example.convex.site/webhook", {
40
+ method: "POST",
41
+ headers,
42
+ body: opts.body,
43
+ });
44
+ }
45
+
46
+ describe("withWebhook (HMAC signature verification)", () => {
47
+ const SECRET = "test-secret-do-not-rotate";
48
+
49
+ beforeEach(() => {
50
+ process.env.TEST_WEBHOOK_SECRET = SECRET;
51
+ });
52
+
53
+ afterEach(() => {
54
+ delete process.env.TEST_WEBHOOK_SECRET;
55
+ });
56
+
57
+ test("503 when secret env var is unset", async () => {
58
+ delete process.env.TEST_WEBHOOK_SECRET;
59
+ const handler = withWebhook(
60
+ {
61
+ source: "test",
62
+ signatureHeader: "x-signature",
63
+ secretEnv: "TEST_WEBHOOK_SECRET",
64
+ algorithm: "sha256",
65
+ },
66
+ () => new Response("ok"),
67
+ );
68
+ const res = await handler(ctx, makeRequest({ body: "{}", signatureHeader: "x-signature" }));
69
+ expect(res.status).toBe(503);
70
+ expect(res.headers.get("X-Request-Id")).toBeTruthy();
71
+ });
72
+
73
+ test("401 when signature header is missing", async () => {
74
+ const handler = withWebhook(
75
+ {
76
+ source: "test",
77
+ signatureHeader: "x-signature",
78
+ secretEnv: "TEST_WEBHOOK_SECRET",
79
+ algorithm: "sha256",
80
+ },
81
+ () => new Response("ok"),
82
+ );
83
+ const res = await handler(ctx, makeRequest({ body: "{}", signatureHeader: "x-signature" }));
84
+ expect(res.status).toBe(401);
85
+ const text = await res.text();
86
+ expect(text).toContain("missing x-signature");
87
+ });
88
+
89
+ test("401 when signature does not match", async () => {
90
+ const handler = withWebhook(
91
+ {
92
+ source: "test",
93
+ signatureHeader: "x-signature",
94
+ secretEnv: "TEST_WEBHOOK_SECRET",
95
+ algorithm: "sha256",
96
+ },
97
+ () => new Response("ok"),
98
+ );
99
+ const res = await handler(
100
+ ctx,
101
+ makeRequest({
102
+ body: "{}",
103
+ signatureHeader: "x-signature",
104
+ signatureValue: "deadbeef",
105
+ }),
106
+ );
107
+ expect(res.status).toBe(401);
108
+ const text = await res.text();
109
+ expect(text).toContain("signature mismatch");
110
+ });
111
+
112
+ test("200 when signature matches (SHA-256)", async () => {
113
+ const body = JSON.stringify({ event: "test.ping" });
114
+ const signature = await sign("sha256", SECRET, body);
115
+ let handlerCalled = false;
116
+ const handler = withWebhook<{ event: string }>(
117
+ {
118
+ source: "test",
119
+ signatureHeader: "x-signature",
120
+ secretEnv: "TEST_WEBHOOK_SECRET",
121
+ algorithm: "sha256",
122
+ },
123
+ (_ctx, payload) => {
124
+ handlerCalled = true;
125
+ return new Response(JSON.stringify({ ok: true, received: payload.event }), { status: 200 });
126
+ },
127
+ );
128
+ const res = await handler(
129
+ ctx,
130
+ makeRequest({
131
+ body,
132
+ signatureHeader: "x-signature",
133
+ signatureValue: signature,
134
+ }),
135
+ );
136
+ expect(res.status).toBe(200);
137
+ expect(handlerCalled).toBe(true);
138
+ const json = (await res.json()) as { ok: boolean; received: string };
139
+ expect(json.received).toBe("test.ping");
140
+ });
141
+
142
+ test("200 when signature matches with prefix (SHA-1, EAS-style)", async () => {
143
+ const body = JSON.stringify({ status: "finished" });
144
+ const signature = `sha1=${await sign("sha1", SECRET, body)}`;
145
+ const handler = withWebhook(
146
+ {
147
+ source: "eas-webhook",
148
+ signatureHeader: "expo-signature",
149
+ signaturePrefix: "sha1=",
150
+ secretEnv: "TEST_WEBHOOK_SECRET",
151
+ algorithm: "sha1",
152
+ },
153
+ () => new Response("ok", { status: 200 }),
154
+ );
155
+ const res = await handler(
156
+ ctx,
157
+ makeRequest({
158
+ body,
159
+ signatureHeader: "expo-signature",
160
+ signatureValue: signature,
161
+ }),
162
+ );
163
+ expect(res.status).toBe(200);
164
+ });
165
+
166
+ test("400 when body is not valid JSON", async () => {
167
+ const body = "not-json{";
168
+ const signature = await sign("sha256", SECRET, body);
169
+ const handler = withWebhook(
170
+ {
171
+ source: "test",
172
+ signatureHeader: "x-signature",
173
+ secretEnv: "TEST_WEBHOOK_SECRET",
174
+ algorithm: "sha256",
175
+ },
176
+ () => new Response("ok"),
177
+ );
178
+ const res = await handler(
179
+ ctx,
180
+ makeRequest({
181
+ body,
182
+ signatureHeader: "x-signature",
183
+ signatureValue: signature,
184
+ }),
185
+ );
186
+ expect(res.status).toBe(400);
187
+ });
188
+
189
+ test("413 when Content-Length exceeds maxBodyBytes", async () => {
190
+ const handler = withWebhook(
191
+ {
192
+ source: "test",
193
+ signatureHeader: "x-signature",
194
+ secretEnv: "TEST_WEBHOOK_SECRET",
195
+ algorithm: "sha256",
196
+ maxBodyBytes: 100,
197
+ },
198
+ () => new Response("ok"),
199
+ );
200
+ const res = await handler(
201
+ ctx,
202
+ makeRequest({
203
+ body: "{}",
204
+ signatureHeader: "x-signature",
205
+ signatureValue: "deadbeef",
206
+ contentLength: 999_999,
207
+ }),
208
+ );
209
+ expect(res.status).toBe(413);
210
+ });
211
+
212
+ test("401 when replay timestamp is missing", async () => {
213
+ const handler = withWebhook(
214
+ {
215
+ source: "test",
216
+ signatureHeader: "x-signature",
217
+ secretEnv: "TEST_WEBHOOK_SECRET",
218
+ algorithm: "sha256",
219
+ replay: { header: "x-timestamp", maxAgeSeconds: 300 },
220
+ },
221
+ () => new Response("ok"),
222
+ );
223
+ const body = "{}";
224
+ const signature = await sign("sha256", SECRET, body);
225
+ const res = await handler(
226
+ ctx,
227
+ makeRequest({
228
+ body,
229
+ signatureHeader: "x-signature",
230
+ signatureValue: signature,
231
+ }),
232
+ );
233
+ expect(res.status).toBe(401);
234
+ const text = await res.text();
235
+ expect(text).toContain("missing x-timestamp");
236
+ });
237
+
238
+ test("401 when replay timestamp is stale", async () => {
239
+ const handler = withWebhook(
240
+ {
241
+ source: "test",
242
+ signatureHeader: "x-signature",
243
+ secretEnv: "TEST_WEBHOOK_SECRET",
244
+ algorithm: "sha256",
245
+ replay: { header: "x-timestamp", maxAgeSeconds: 60 },
246
+ },
247
+ () => new Response("ok"),
248
+ );
249
+ const body = "{}";
250
+ const signature = await sign("sha256", SECRET, body);
251
+ const stale = Date.now() - 120_000; // 2 minutes ago, exceeds 60s window
252
+ const res = await handler(
253
+ ctx,
254
+ makeRequest({
255
+ body,
256
+ signatureHeader: "x-signature",
257
+ signatureValue: signature,
258
+ timestampHeader: "x-timestamp",
259
+ timestampValue: String(stale),
260
+ }),
261
+ );
262
+ expect(res.status).toBe(401);
263
+ const text = await res.text();
264
+ expect(text).toContain("timestamp out of window");
265
+ });
266
+
267
+ test("200 when replay timestamp is fresh", async () => {
268
+ const handler = withWebhook(
269
+ {
270
+ source: "test",
271
+ signatureHeader: "x-signature",
272
+ secretEnv: "TEST_WEBHOOK_SECRET",
273
+ algorithm: "sha256",
274
+ replay: { header: "x-timestamp", maxAgeSeconds: 300 },
275
+ },
276
+ () => new Response("ok", { status: 200 }),
277
+ );
278
+ const body = "{}";
279
+ const signature = await sign("sha256", SECRET, body);
280
+ const res = await handler(
281
+ ctx,
282
+ makeRequest({
283
+ body,
284
+ signatureHeader: "x-signature",
285
+ signatureValue: signature,
286
+ timestampHeader: "x-timestamp",
287
+ timestampValue: String(Date.now()),
288
+ }),
289
+ );
290
+ expect(res.status).toBe(200);
291
+ });
292
+
293
+ test("X-Request-Id header is set on every response", async () => {
294
+ const handler = withWebhook(
295
+ {
296
+ source: "test",
297
+ signatureHeader: "x-signature",
298
+ secretEnv: "TEST_WEBHOOK_SECRET",
299
+ algorithm: "sha256",
300
+ },
301
+ () => new Response("ok", { status: 200 }),
302
+ );
303
+ const body = "{}";
304
+ const signature = await sign("sha256", SECRET, body);
305
+ const res = await handler(
306
+ ctx,
307
+ makeRequest({
308
+ body,
309
+ signatureHeader: "x-signature",
310
+ signatureValue: signature,
311
+ }),
312
+ );
313
+ expect(res.status).toBe(200);
314
+ expect(res.headers.get("X-Request-Id")).toBeTruthy();
315
+ });
316
+
317
+ test("500 when handler throws", async () => {
318
+ const handler = withWebhook(
319
+ {
320
+ source: "test",
321
+ signatureHeader: "x-signature",
322
+ secretEnv: "TEST_WEBHOOK_SECRET",
323
+ algorithm: "sha256",
324
+ },
325
+ () => {
326
+ throw new Error("simulated handler crash");
327
+ },
328
+ );
329
+ const body = "{}";
330
+ const signature = await sign("sha256", SECRET, body);
331
+ const res = await handler(
332
+ ctx,
333
+ makeRequest({
334
+ body,
335
+ signatureHeader: "x-signature",
336
+ signatureValue: signature,
337
+ }),
338
+ );
339
+ expect(res.status).toBe(500);
340
+ const text = await res.text();
341
+ expect(text).toContain("handler error");
342
+ });
343
+ });
@@ -0,0 +1,67 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("expo-linking", () => {
4
+ function parse(url: string) {
5
+ try {
6
+ const u = new URL(url);
7
+ const queryParams: Record<string, string> = {};
8
+ u.searchParams.forEach((v, k) => {
9
+ queryParams[k] = v;
10
+ });
11
+ return {
12
+ scheme: u.protocol.replace(/:$/, ""),
13
+ hostname: u.hostname || null,
14
+ path: u.pathname || null,
15
+ queryParams,
16
+ };
17
+ } catch {
18
+ return { scheme: null, hostname: null, path: url, queryParams: null };
19
+ }
20
+ }
21
+ return { parse, createURL: (p: string) => p };
22
+ });
23
+
24
+ import { resolveDeepLink } from "@/lib/deep-link";
25
+
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ describe("resolveDeepLink", () => {
31
+ it("parses a valid path with query params", () => {
32
+ const result = resolveDeepLink("vexpo://app/linked?foo=bar&n=1");
33
+ expect(result.path).toBe("/linked");
34
+ expect(result.params).toEqual({ foo: "bar", n: "1" });
35
+ });
36
+
37
+ it("returns null path for disallowed routes", () => {
38
+ const result = resolveDeepLink("vexpo://app/admin");
39
+ expect(result.path).toBeNull();
40
+ expect(result.params).toEqual({});
41
+ });
42
+
43
+ it("returns null path for path traversal attempts", () => {
44
+ const result = resolveDeepLink("vexpo://app/../etc/passwd");
45
+ expect(result.path).toBeNull();
46
+ });
47
+
48
+ it("handles empty input", () => {
49
+ expect(resolveDeepLink("")).toEqual({ path: null, params: {} });
50
+ });
51
+
52
+ it("handles garbage input without throwing", () => {
53
+ expect(() => resolveDeepLink("not a url")).not.toThrow();
54
+ const result = resolveDeepLink("not a url");
55
+ expect(result.path).toBeNull();
56
+ });
57
+
58
+ it("normalizes trailing slashes", () => {
59
+ const result = resolveDeepLink("vexpo://app/linked/");
60
+ expect(result.path).toBe("/linked");
61
+ });
62
+
63
+ it("drops nullish query values", () => {
64
+ const result = resolveDeepLink("vexpo://app/linked?x=1");
65
+ expect(result.params).toEqual({ x: "1" });
66
+ });
67
+ });
@@ -0,0 +1,22 @@
1
+ .expo
2
+ .git
3
+ dist
4
+ node_modules
5
+ ios/Pods
6
+ ios/.xcode.env.local
7
+ __tests__
8
+ *.test.ts
9
+ *.test.tsx
10
+ *.spec.ts
11
+ *.spec.tsx
12
+ .env.example
13
+ .env.convex.local
14
+ README.md
15
+ ios/
16
+ plans/
17
+ docs/
18
+ .agents/
19
+ .claude/
20
+ app-store/
21
+ .husky/
22
+ convex/*.test.ts
@@ -0,0 +1,9 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ indent_style = space
7
+ indent_size = 2
8
+ insert_final_newline = true
9
+ trim_trailing_whitespace = true
@@ -0,0 +1,34 @@
1
+ # .env.local template. Copy to .env.local then run `bun run setup`.
2
+ # `bun run setup` writes most of these for you. The two identity vars
3
+ # (EXPO_PUBLIC_APP_BUNDLE_ID and EXPO_PUBLIC_APPLE_TEAM_ID) are prompted by
4
+ # `bun run setup:convex`. Edit by hand if you prefer.
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # Convex deployment (written by `bun run setup:convex`)
8
+ # ---------------------------------------------------------------------------
9
+ CONVEX_DEPLOYMENT=
10
+ EXPO_PUBLIC_CONVEX_URL=
11
+ EXPO_PUBLIC_CONVEX_SITE_URL=
12
+ EXPO_PUBLIC_SITE_URL=
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # iOS identity (prompted by `bun run setup:convex`, also pushed to Convex env)
16
+ # ---------------------------------------------------------------------------
17
+ # Reverse-DNS bundle id, e.g. com.you.vexpo.
18
+ EXPO_PUBLIC_APP_BUNDLE_ID=
19
+ # 10-character Apple Team id from developer.apple.com/account.
20
+ EXPO_PUBLIC_APPLE_TEAM_ID=
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Optional
24
+ # ---------------------------------------------------------------------------
25
+ # Sets `owner` in app.config.ts for EAS. Leave blank if you're not yet linked.
26
+ # EXPO_PUBLIC_EXPO_OWNER=
27
+ # Toggles `name` to "Vexpo (Dev)" so dev and prod can install side-by-side.
28
+ # APP_VARIANT=development
29
+ # Pre-fills the full-access Resend key for `bun run setup:resend` (else prompts).
30
+ # RESEND_FULL_ACCESS_KEY=
31
+ # Skips prompts in `bun run setup:apple` / `setup:asc`.
32
+ # APPLE_P8_PATH=
33
+ # APPLE_AUTH_KEY_PATH=
34
+ # APPLE_ASC_ISSUER_ID=
@@ -0,0 +1,24 @@
1
+ # Environment files (no native impact)
2
+ .env*
3
+
4
+ # EAS workflows (CI config, not native code)
5
+ .eas/**/*
6
+
7
+ # Convex backend (server-side only)
8
+ convex/**/*
9
+
10
+ # Static assets (handled by expo-asset plugin separately)
11
+ assets/**/*
12
+
13
+ # Documentation
14
+ *.md
15
+
16
+ # Meta / ignored by native
17
+ plans/
18
+ docs/
19
+ .agents/
20
+ .claude/
21
+ .github/
22
+ app-store/
23
+ store.config.json
24
+ eas.json
@@ -0,0 +1,7 @@
1
+ .gitattributes merge=ours
2
+ README.md merge=ours
3
+ .env.example merge=ours
4
+ package.json merge=ours
5
+ bun.lock merge=ours
6
+ app.json merge=ours
7
+ eas.json merge=ours
@@ -0,0 +1,69 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Environment variables
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+
9
+ # Expo
10
+ .expo/
11
+ dist/
12
+ expo-env.d.ts
13
+
14
+ # Native project (CNG)
15
+ ios/
16
+ *.p8
17
+ *.p12
18
+ *.mobileprovision
19
+ *.cer
20
+
21
+ # Apple keys with no extension (Apple's default download naming)
22
+ AuthKey_*
23
+ SubscriptionKey_*
24
+
25
+ # EAS (CLI state; keep workflows tracked)
26
+ .eas/*
27
+ !.eas/workflows/
28
+
29
+ # Agents / local AI tooling
30
+ .claude/
31
+ .agents/
32
+ .cursor/
33
+ .aider*
34
+ skills-lock.json
35
+
36
+ # Working notes (not committed)
37
+ plans/
38
+
39
+ # App Store metadata. Ships with placeholder values so `eas submit` works
40
+ # out-of-the-box. `vexpo rebrand` fills in your real review contact + demo creds.
41
+ # Decide whether to commit your filled-in version based on team policy (it
42
+ # contains demo passwords for App Review).
43
+ # store.config.json
44
+
45
+ # Setup state cache (resumable orchestrator; never holds secrets)
46
+ .setup-state.json
47
+ .setup-state.json.*.tmp
48
+
49
+ # Manual-mode instruction files (contain raw secret values when CLIs missing)
50
+ .vexpo-manual-setup/
51
+ .rebrand-backup/
52
+
53
+ # macOS
54
+ .DS_Store
55
+
56
+ # Pack artifacts. Allow patches/ tarballs since those are the source of truth
57
+ # for `file:` deps the template ships with.
58
+ *.tgz
59
+ !patches/*.tgz
60
+
61
+ # Bun
62
+ bun-error.*
63
+
64
+ # Test coverage
65
+ coverage/
66
+ .vitest-cache/
67
+
68
+ # Build / typecheck artifacts
69
+ tsconfig.tsbuildinfo
@@ -0,0 +1,3 @@
1
+ {
2
+ "ignorePatterns": ["convex/_generated/", "ios/", ".expo/", "dist/", "coverage/", "node_modules/"]
3
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3
+ "plugins": ["typescript", "react", "react-hooks", "import", "unicorn", "oxc"],
4
+ "categories": {
5
+ "correctness": "error",
6
+ "suspicious": "warn",
7
+ "perf": "warn",
8
+ "style": "off"
9
+ },
10
+ "rules": {
11
+ "no-console": "off",
12
+ "react/react-in-jsx-scope": "off",
13
+ "react/style-prop-object": "off",
14
+ "react/no-array-index-key": "off",
15
+ "react-hooks/exhaustive-deps": "warn",
16
+ "react-hooks/rules-of-hooks": "error",
17
+ "typescript/no-explicit-any": "warn",
18
+ "typescript/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
19
+ "import/no-unassigned-import": "off",
20
+ "no-await-in-loop": "off",
21
+ "unicorn/consistent-function-scoping": "off",
22
+ "no-underscore-dangle": ["warn", { "allow": ["_id", "_creationTime"] }],
23
+ "no-shadow": ["warn", { "allow": ["resolve", "reject"] }]
24
+ },
25
+ "ignorePatterns": [
26
+ "node_modules",
27
+ "ios",
28
+ "android",
29
+ "dist",
30
+ ".expo",
31
+ "convex/_generated",
32
+ "coverage"
33
+ ]
34
+ }