@ramonclaudio/create-vexpo 0.1.0 → 0.1.2

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 +10 -10
  2. package/dist/index.js +8 -7
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
  5. package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
  6. package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
  7. package/dist/templates/default/.eas/workflows/release.yml +3 -7
  8. package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
  9. package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
  10. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
  11. package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
  12. package/dist/templates/default/.github/workflows/check.yml +20 -12
  13. package/dist/templates/default/.maestro/launch.yaml +19 -10
  14. package/dist/templates/default/AGENTS.md +25 -8
  15. package/dist/templates/default/DESIGN.md +14 -10
  16. package/dist/templates/default/README.md +83 -78
  17. package/dist/templates/default/SETUP.md +159 -152
  18. package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
  19. package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
  20. package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
  21. package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
  22. package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
  23. package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
  24. package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
  25. package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
  26. package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
  27. package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
  28. package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
  29. package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
  30. package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
  31. package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
  32. package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
  33. package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
  34. package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
  35. package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
  36. package/dist/templates/default/_env.example +7 -7
  37. package/dist/templates/default/_gitattributes +1 -1
  38. package/dist/templates/default/_gitignore +17 -2
  39. package/dist/templates/default/_npmrc +7 -0
  40. package/dist/templates/default/_oxlintrc.json +1 -1
  41. package/dist/templates/default/app-store/accessibility.config.json +20 -0
  42. package/dist/templates/default/app-store/privacy.config.json +27 -0
  43. package/dist/templates/default/app.config.ts +105 -33
  44. package/dist/templates/default/app.json +1 -9
  45. package/dist/templates/default/convex/_generated/api.d.ts +12 -0
  46. package/dist/templates/default/convex/admin.ts +0 -13
  47. package/dist/templates/default/convex/appAttest.ts +467 -0
  48. package/dist/templates/default/convex/appAttestStore.ts +141 -0
  49. package/dist/templates/default/convex/apple.ts +53 -0
  50. package/dist/templates/default/convex/auth.ts +6 -45
  51. package/dist/templates/default/convex/constants.ts +2 -7
  52. package/dist/templates/default/convex/crons.ts +12 -5
  53. package/dist/templates/default/convex/email.ts +4 -24
  54. package/dist/templates/default/convex/env.ts +0 -4
  55. package/dist/templates/default/convex/errors.ts +0 -7
  56. package/dist/templates/default/convex/functions.ts +0 -26
  57. package/dist/templates/default/convex/http.ts +3 -5
  58. package/dist/templates/default/convex/log.ts +2 -25
  59. package/dist/templates/default/convex/pushSender.ts +145 -0
  60. package/dist/templates/default/convex/pushTokens.ts +110 -13
  61. package/dist/templates/default/convex/rateLimit.ts +8 -39
  62. package/dist/templates/default/convex/schema.ts +48 -5
  63. package/dist/templates/default/convex/tsconfig.json +1 -0
  64. package/dist/templates/default/convex/users.ts +143 -61
  65. package/dist/templates/default/convex/validators.ts +1 -38
  66. package/dist/templates/default/convex/webhook.ts +1 -31
  67. package/dist/templates/default/convex.json +1 -2
  68. package/dist/templates/default/metro.config.js +9 -1
  69. package/dist/templates/default/package.json +67 -70
  70. package/dist/templates/default/plugins/README.md +5 -1
  71. package/dist/templates/default/scripts/README.md +9 -9
  72. package/dist/templates/default/scripts/_run.mjs +3 -20
  73. package/dist/templates/default/scripts/clean.ts +81 -69
  74. package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
  75. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
  76. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
  77. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
  78. package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
  79. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
  80. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
  81. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
  82. package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
  83. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
  84. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
  85. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
  86. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
  87. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
  88. package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
  89. package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
  90. package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
  91. package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
  92. package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
  93. package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
  94. package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
  95. package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
  96. package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
  97. package/dist/templates/default/src/app/+native-intent.tsx +25 -0
  98. package/dist/templates/default/src/app/+not-found.tsx +43 -0
  99. package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
  100. package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
  101. package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
  102. package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
  103. package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
  104. package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
  105. package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
  106. package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
  107. package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
  108. package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
  109. package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
  110. package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
  111. package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
  112. package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
  113. package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
  114. package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
  115. package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
  116. package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
  117. package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
  118. package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
  119. package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
  120. package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
  121. package/dist/templates/default/src/hooks/use-network.ts +34 -0
  122. package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
  123. package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
  124. package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
  125. package/dist/templates/default/src/lib/appAttest.ts +78 -0
  126. package/dist/templates/default/src/lib/assets.ts +9 -0
  127. package/dist/templates/default/src/lib/deep-link.ts +82 -0
  128. package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
  129. package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
  130. package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
  131. package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
  132. package/dist/templates/default/src/lib/masks.ts +21 -0
  133. package/dist/templates/default/src/lib/native-state.ts +20 -0
  134. package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
  135. package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
  136. package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
  137. package/dist/templates/default/src/lib/text-style.ts +20 -0
  138. package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
  139. package/dist/templates/default/store.config.json +1 -1
  140. package/dist/templates/default/tsconfig.json +3 -1
  141. package/dist/templates/default/vitest.config.ts +8 -1
  142. package/package.json +5 -5
  143. package/dist/templates/default/app/(app)/_layout.tsx +0 -73
  144. package/dist/templates/default/app/(app)/debug.tsx +0 -389
  145. package/dist/templates/default/app/(app)/sessions.tsx +0 -191
  146. package/dist/templates/default/app/(app)/welcome.tsx +0 -140
  147. package/dist/templates/default/app/+native-intent.tsx +0 -14
  148. package/dist/templates/default/app/+not-found.tsx +0 -51
  149. package/dist/templates/default/bun.lock +0 -1860
  150. package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
  151. package/dist/templates/default/components/ui/convex-error.tsx +0 -32
  152. package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
  153. package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
  154. package/dist/templates/default/components/ui/status-text.tsx +0 -49
  155. package/dist/templates/default/components/ui/update-banner.tsx +0 -82
  156. package/dist/templates/default/fingerprint.config.js +0 -9
  157. package/dist/templates/default/hooks/use-debounce.ts +0 -20
  158. package/dist/templates/default/hooks/use-deep-link.ts +0 -43
  159. package/dist/templates/default/hooks/use-network.ts +0 -11
  160. package/dist/templates/default/lib/assets.ts +0 -17
  161. package/dist/templates/default/lib/deep-link.ts +0 -71
  162. package/dist/templates/default/patches/PR-368.patch +0 -91
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
  165. /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
  166. /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
  167. /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
  168. /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
  169. /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
  170. /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
  171. /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
  172. /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
  173. /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
  174. /package/dist/templates/default/{lib → src/lib}/storage.ts +0 -0
@@ -31,9 +31,6 @@ const FIVE_MINUTES = 5 * ONE_MINUTE;
31
31
 
32
32
  const authFunctions: AuthFunctions = internal.auth;
33
33
 
34
- /**
35
- * Get the app user doc by Better Auth id, using the indexed lookup.
36
- */
37
34
  export async function getUserByAuthId(
38
35
  ctx: QueryCtx | MutationCtx,
39
36
  authId: string,
@@ -44,15 +41,6 @@ export async function getUserByAuthId(
44
41
  .unique();
45
42
  }
46
43
 
47
- /**
48
- * Merged representation of the authenticated user.
49
- *
50
- * Identity fields (email, name, username, image, emailVerified) come from the
51
- * Better Auth user. App-specific fields (_id, bio, avatar, timestamps) come
52
- * from our users table. `avatarUrl` is resolved: user-uploaded storage id
53
- * takes precedence, otherwise falls back to Better Auth's `image` (e.g. OAuth
54
- * provider avatar).
55
- */
56
44
  export type AuthUser = Doc<"users"> & {
57
45
  authUserId: string;
58
46
  email: string;
@@ -65,15 +53,11 @@ export type AuthUser = Doc<"users"> & {
65
53
  hasUploadedAvatar: boolean;
66
54
  };
67
55
 
68
- // The component client has methods needed for integrating Convex with Better
69
- // Auth, plus helper methods for general use.
70
56
  export const authComponent = createClient<DataModel>(components.betterAuth, {
71
57
  authFunctions,
72
58
  triggers: {
73
59
  user: {
74
60
  onCreate: async (ctx, authUser) => {
75
- // Create the app user row with defaults. Identity fields live on the
76
- // Better Auth user record, not here.
77
61
  await ctx.db.insert("users", {
78
62
  authId: authUser._id,
79
63
  createdAt: Date.now(),
@@ -91,10 +75,8 @@ export const authComponent = createClient<DataModel>(components.betterAuth, {
91
75
  },
92
76
  });
93
77
 
94
- // Export trigger handlers - these become available at internal.auth
95
78
  export const { onCreate, onDelete } = authComponent.triggersApi();
96
79
 
97
- // Export client API for AuthBoundary and other client-side auth checks
98
80
  export const { getAuthUser } = authComponent.clientApi();
99
81
 
100
82
  export const createAuth = (ctx: GenericCtx<DataModel>) =>
@@ -114,11 +96,11 @@ export const createAuth = (ctx: GenericCtx<DataModel>) =>
114
96
  emailAndPassword: {
115
97
  enabled: true,
116
98
  // Email verification is gated on the `REQUIRE_EMAIL_VERIFICATION`
117
- // Convex env var. The lite-mode setup (`bunx vexpo lite`) leaves it
99
+ // Convex env var. The lite-mode setup (`npx vexpo lite`) leaves it
118
100
  // unset (default `false`) so sign-up creates verified accounts
119
101
  // immediately and the user can sign in without an OTP. No Resend
120
102
  // configuration needed to get up and running on the iOS Simulator.
121
- // `bunx vexpo full` flips this to `true` when it provisions Resend.
103
+ // `npx vexpo full` flips this to `true` when it provisions Resend.
122
104
  // Production runs with verification on.
123
105
  requireEmailVerification: env.requireEmailVerification,
124
106
  minPasswordLength: 10,
@@ -178,7 +160,6 @@ export const createAuth = (ctx: GenericCtx<DataModel>) =>
178
160
  },
179
161
  plugins: [
180
162
  convex({ authConfig }),
181
- // Email OTP for sign-in, verification, password reset, and change-email.
182
163
  emailOTP({
183
164
  otpLength: 6,
184
165
  expiresIn: FIVE_MINUTES,
@@ -208,11 +189,6 @@ export const createAuth = (ctx: GenericCtx<DataModel>) =>
208
189
  ],
209
190
  } satisfies BetterAuthOptions);
210
191
 
211
- /**
212
- * Safely get the current authenticated user. Returns undefined if not
213
- * authenticated or if the app user row is missing (shouldn't happen in
214
- * practice, but we handle it gracefully).
215
- */
216
192
  export async function safeGetAuthenticatedUser(
217
193
  ctx: QueryCtx | MutationCtx,
218
194
  ): Promise<AuthUser | undefined> {
@@ -222,7 +198,6 @@ export async function safeGetAuthenticatedUser(
222
198
  const user = await getUserByAuthId(ctx, authUser._id);
223
199
  if (!user) return undefined;
224
200
 
225
- // Resolve avatar: user upload takes precedence over Better Auth image.
226
201
  const hasUploadedAvatar = !!user.avatar;
227
202
  const avatarUrl = hasUploadedAvatar
228
203
  ? await ctx.storage.getUrl(user.avatar!)
@@ -242,18 +217,12 @@ export async function safeGetAuthenticatedUser(
242
217
  };
243
218
  }
244
219
 
245
- /**
246
- * Get the current authenticated user, throwing if not authenticated.
247
- */
248
220
  export async function requireAuthenticatedUser(ctx: QueryCtx | MutationCtx): Promise<AuthUser> {
249
221
  const user = await safeGetAuthenticatedUser(ctx);
250
222
  if (!user) throw authenticationRequired();
251
223
  return user;
252
224
  }
253
225
 
254
- /**
255
- * Validator for AuthUser return type.
256
- */
257
226
  export const authUserValidator = v.object({
258
227
  _id: v.id("users"),
259
228
  _creationTime: v.number(),
@@ -262,6 +231,10 @@ export const authUserValidator = v.object({
262
231
  avatar: v.optional(v.id("_storage")),
263
232
  createdAt: v.number(),
264
233
  updatedAt: v.number(),
234
+ // Set when the user has requested account deletion. Within the 30-day
235
+ // grace window the user is still authenticated; the client routes
236
+ // these users to a "restore or continue with deletion" surface.
237
+ deletedAt: v.optional(v.number()),
265
238
  authUserId: v.string(),
266
239
  email: v.string(),
267
240
  name: v.string(),
@@ -273,18 +246,10 @@ export const authUserValidator = v.object({
273
246
  hasUploadedAvatar: v.boolean(),
274
247
  });
275
248
 
276
- // ============================================================================
277
- // Queries
278
- // ============================================================================
279
249
  // These use the raw `query` builder because this file IS the auth primitive
280
250
  // that functions.ts depends on. Importing wrappers from ./functions would
281
251
  // create a circular dependency.
282
252
 
283
- /**
284
- * Check if the current user has a password-based account.
285
- * Useful for detecting social-only accounts that need password setup.
286
- * Returns false if not authenticated.
287
- */
288
253
  export const hasPassword = query({
289
254
  args: {},
290
255
  returns: v.boolean(),
@@ -319,10 +284,6 @@ export const getEnabledProviders = query({
319
284
  },
320
285
  });
321
286
 
322
- /**
323
- * Rotate JWKS keys for JWT signing.
324
- * Run with: bunx convex run auth:rotateKeys
325
- */
326
287
  export const rotateKeys = internalAction({
327
288
  args: {},
328
289
  // Better Auth's `rotateKeys()` returns implementation-specific JWKS metadata
@@ -1,16 +1,11 @@
1
- // Shared constants and pure helpers.
2
1
  // Safe to import from Convex functions AND React routes.
3
2
  // Do not add imports from `convex/server`, `./_generated/*`, or React here.
4
3
 
5
- // ============================================================================
6
- // Username validation (Better Auth `username` plugin)
7
- // ============================================================================
8
-
9
4
  export const USERNAME_MIN_LENGTH = 3;
10
5
  export const USERNAME_MAX_LENGTH = 30;
11
6
 
12
- // Alphanumerics, underscores, dots. Must match the server-side
13
- // `usernameValidator` in convex/auth.ts to avoid client/server drift.
7
+ // Must match the server-side `usernameValidator` in convex/auth.ts to avoid
8
+ // client/server drift.
14
9
  export const USERNAME_FORMAT_REGEX = /^[a-zA-Z0-9_.]+$/;
15
10
 
16
11
  export const RESERVED_USERNAMES = [
@@ -5,14 +5,24 @@ import { internalMutation } from "./_generated/server";
5
5
 
6
6
  const crons = cronJobs();
7
7
 
8
- // Drop push tokens that haven't refreshed in 30 days (stale device or app
9
- // uninstalled). Bounded batches via `internal.pushTokens.cleanupStale`.
10
8
  crons.daily(
11
9
  "cleanup stale push tokens",
12
10
  { hourUTC: 3, minuteUTC: 0 },
13
11
  internal.pushTokens.cleanupStale,
14
12
  );
15
13
 
14
+ crons.daily(
15
+ "hard-delete expired account tombstones",
16
+ { hourUTC: 4, minuteUTC: 0 },
17
+ internal.users.hardDeleteExpired,
18
+ );
19
+
20
+ crons.hourly(
21
+ "cleanup expired app attest challenges",
22
+ { minuteUTC: 17 },
23
+ internal.appAttestStore.cleanupChallenges,
24
+ );
25
+
16
26
  // The Resend component retains finalized (delivered, cancelled, bounced)
17
27
  // emails and it's our job to clear them. Run hourly to keep the emails table
18
28
  // bounded. See @convex-dev/resend README → "Data retention".
@@ -27,12 +37,9 @@ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
27
37
  export const cleanupResend = internalMutation({
28
38
  args: {},
29
39
  handler: async (ctx) => {
30
- // Delivered/cancelled/bounced: 7 day retention.
31
40
  await ctx.scheduler.runAfter(0, components.resend.lib.cleanupOldEmails, {
32
41
  olderThan: ONE_WEEK_MS,
33
42
  });
34
- // Abandoned emails usually indicate a bug, so keep them around longer
35
- // (4 weeks) for debugging before purging.
36
43
  await ctx.scheduler.runAfter(0, components.resend.lib.cleanupAbandonedEmails, {
37
44
  olderThan: 4 * ONE_WEEK_MS,
38
45
  });
@@ -8,12 +8,10 @@ import type { DataModel } from "./_generated/dataModel";
8
8
  import { internalMutation } from "./_generated/server";
9
9
  import { env } from "./env";
10
10
 
11
- // testMode defaults to true so dev can't accidentally email real users.
12
- // Set RESEND_TEST_MODE=false in production to send to real addresses.
13
- // Note: @convex-dev/resend's testMode only permits @resend.dev sandbox
14
- // addresses and throws otherwise. To keep dev sign-up working with real-shaped
15
- // emails, sendAuthOTP below short-circuits when testMode is on and logs the
16
- // OTP to the Convex deployment console instead of calling sendEmail.
11
+ // @convex-dev/resend's testMode only permits @resend.dev sandbox addresses and
12
+ // throws otherwise. To keep dev sign-up working with real-shaped emails,
13
+ // sendAuthOTP below short-circuits when testMode is on and logs the OTP to the
14
+ // Convex deployment console instead of calling sendEmail.
17
15
  // Explicit `: Resend` annotation is required because `onEmailEvent` references
18
16
  // a function in this same module, which would otherwise cause TS inference to
19
17
  // loop on itself.
@@ -24,15 +22,6 @@ export const resend: Resend = new Resend(components.resend, {
24
22
  onEmailEvent: internal.email.handleEmailEvent,
25
23
  });
26
24
 
27
- /**
28
- * Receives delivery events from the Resend webhook (mounted in convex/http.ts).
29
- * The event payload is also automatically persisted to the component's
30
- * `deliveryEvents` table for inspection in the Convex dashboard.
31
- *
32
- * Logs the 4 actionable failure events (bounced, complained, suppressed,
33
- * failed). Extend this handler to flag the user's email as unreachable if
34
- * you want to stop sending auth OTPs to addresses that will never arrive.
35
- */
36
25
  const ACTIONABLE_FAILURE_EVENTS = new Set([
37
26
  "email.bounced",
38
27
  "email.complained",
@@ -76,15 +65,6 @@ const OTP_COPY: Record<OTPType, { subject: string; heading: string; body: string
76
65
  },
77
66
  };
78
67
 
79
- /**
80
- * Send an auth OTP email via Resend. Used by Better Auth's emailOTP plugin
81
- * inside the `sendVerificationOTP` callback in convex/auth.ts.
82
- *
83
- * In test mode (dev default), logs the OTP to the Convex deployment console
84
- * instead of calling Resend. Read it from `bunx convex dev` output or the
85
- * deployment logs in the Convex dashboard. Production sets RESEND_TEST_MODE
86
- * to "false" and sends real emails.
87
- */
88
68
  export async function sendAuthOTP(
89
69
  ctx: GenericCtx<DataModel>,
90
70
  { email, otp, type }: { email: string; otp: string; type: OTPType },
@@ -23,9 +23,5 @@ export const env = {
23
23
  return required("EMAIL_FROM");
24
24
  },
25
25
  },
26
- // Email verification policy. Default `false` (minimal-tier setup, no Resend
27
- // configured). sign-up creates verified-immediately accounts so the user
28
- // can sign in without ever seeing an OTP. `bunx vexpo full` flips
29
- // this to `true` on the Convex env when it provisions Resend.
30
26
  requireEmailVerification: bool("REQUIRE_EMAIL_VERIFICATION", false),
31
27
  } as const;
@@ -1,10 +1,3 @@
1
- /**
2
- * Structured Errors
3
- *
4
- * Error factories emit ConvexError with a stable code so clients can
5
- * branch on `error.data.code` without parsing messages.
6
- */
7
-
8
1
  import { ConvexError } from "convex/values";
9
2
 
10
3
  export const ErrorCode = {
@@ -1,26 +1,11 @@
1
- /**
2
- * Custom Function Wrappers
3
- *
4
- * Authenticated query/mutation wrappers that inject the current user into
5
- * the context. Uses the centralized helpers from auth.ts to avoid duplication.
6
- */
7
-
8
1
  import { customCtx, customMutation, customQuery } from "convex-helpers/server/customFunctions";
9
2
 
10
3
  import { mutation, query } from "./_generated/server";
11
4
  import { requireAuthenticatedUser, safeGetAuthenticatedUser } from "./auth";
12
5
  import type { AuthUser } from "./auth";
13
6
 
14
- // Re-export AuthUser type for convenience
15
7
  export type { AuthUser };
16
8
 
17
- // ============================================================================
18
- // Query Wrappers
19
- // ============================================================================
20
-
21
- /**
22
- * Authenticated query - throws ConvexError if user is not logged in.
23
- */
24
9
  export const authQuery = customQuery(
25
10
  query,
26
11
  customCtx(async (ctx) => ({
@@ -28,10 +13,6 @@ export const authQuery = customQuery(
28
13
  })),
29
14
  );
30
15
 
31
- /**
32
- * Optional auth query - user may be undefined.
33
- * Use for endpoints that work for both authenticated and anonymous users.
34
- */
35
16
  export const optionalAuthQuery = customQuery(
36
17
  query,
37
18
  customCtx(async (ctx) => ({
@@ -39,13 +20,6 @@ export const optionalAuthQuery = customQuery(
39
20
  })),
40
21
  );
41
22
 
42
- // ============================================================================
43
- // Mutation Wrappers
44
- // ============================================================================
45
-
46
- /**
47
- * Authenticated mutation - throws ConvexError if user is not logged in.
48
- */
49
23
  export const authMutation = customMutation(
50
24
  mutation,
51
25
  customCtx(async (ctx) => ({
@@ -51,9 +51,9 @@ http.route({
51
51
  // EAS Build / Submit webhook receiver.
52
52
  //
53
53
  // Wire it up once with:
54
- // bunx eas webhook:create --event BUILD --url https://<your-deployment>.convex.site/eas-webhook --secret <strong-secret>
55
- // bunx eas webhook:create --event SUBMIT --url https://<your-deployment>.convex.site/eas-webhook --secret <strong-secret>
56
- // bunx convex env set EAS_WEBHOOK_SECRET <strong-secret>
54
+ // npx eas webhook:create --event BUILD --url https://<your-deployment>.convex.site/eas-webhook --secret <strong-secret>
55
+ // npx eas webhook:create --event SUBMIT --url https://<your-deployment>.convex.site/eas-webhook --secret <strong-secret>
56
+ // npx convex env set EAS_WEBHOOK_SECRET <strong-secret>
57
57
  //
58
58
  // Per https://docs.expo.dev/eas/webhooks/, EAS signs every POST with
59
59
  // HMAC-SHA1 in `expo-signature: sha1=<hex>`. The factory below handles
@@ -90,8 +90,6 @@ http.route({
90
90
  appName: payload.metadata?.appName,
91
91
  detailsUrl: payload.buildDetailsPageUrl,
92
92
  });
93
- // Extend: dispatch on `payload.status === "errored"` to a Slack
94
- // notifier, persist the build/submit row to a Convex table, etc.
95
93
  return new Response(JSON.stringify({ ok: true, requestId }), {
96
94
  status: 200,
97
95
  headers: { "Content-Type": "application/json", "X-Request-Id": requestId },
@@ -1,24 +1,3 @@
1
- // Structured one-line JSON logger for Convex HTTP handlers.
2
- //
3
- // Convex's web dashboard renders `console.log` output line-by-line. Plain
4
- // strings work for narrative logs but lose context once volume grows. The
5
- // helpers below emit single-line JSON with a stable shape so dashboards
6
- // and log aggregators can filter on fields directly:
7
- //
8
- // { "ts": "...", "level": "info", "event": "webhook.ok", "requestId": "...",
9
- // "durationMs": 12, "platform": "ios", "status": "finished" }
10
- //
11
- // Keep the field set small and predictable:
12
- // - ts ISO timestamp (UTC)
13
- // - level info | warn | error
14
- // - event dot-namespaced verb ("webhook.ok", "aasa.served")
15
- // - requestId set per HTTP request via `newRequestId()`
16
- // - durationMs numeric (preferred over Date arithmetic in queries)
17
- // - err { message, name, stack? } when level === "error"
18
- //
19
- // Anything else is merged in as-is. Field order is alphabetical except `ts`
20
- // and `level` come first for readability when scrolling raw log output.
21
-
22
1
  export type LogLevel = "info" | "warn" | "error";
23
2
 
24
3
  export type LogFields = Record<string, unknown> & {
@@ -44,7 +23,7 @@ function emit(level: LogLevel, fields: LogFields): void {
44
23
  }
45
24
  }
46
25
 
47
- // JSON.stringify replacer: errors don't serialize by default. Pull
26
+ // JSON.stringify replacer: Errors don't serialize by default. Pull
48
27
  // `message`, `name`, and (only in development) `stack`.
49
28
  function replacer(_key: string, value: unknown): unknown {
50
29
  if (value instanceof Error) {
@@ -69,9 +48,7 @@ export const log = {
69
48
  },
70
49
  };
71
50
 
72
- // Cryptographically-random short request ID. Web crypto is available in
73
- // Convex's runtime. 9 bytes → 12-char base64url, plenty for correlation
74
- // without bloating every log line.
51
+ // Web crypto is available in Convex's runtime. 9 bytes 12-char base64url.
75
52
  export function newRequestId(): string {
76
53
  const bytes = new Uint8Array(9);
77
54
  crypto.getRandomValues(bytes);
@@ -0,0 +1,145 @@
1
+ import { v } from "convex/values";
2
+
3
+ import type { Id } from "./_generated/dataModel";
4
+ import { internal } from "./_generated/api";
5
+ import { internalAction } from "./_generated/server";
6
+
7
+ const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
8
+
9
+ // Permanent failure codes from the Expo Push Service. A receipt or ticket
10
+ // with one of these means the device or app install will never accept a
11
+ // push again, so the token is tombstoned. Transient codes (rate limit,
12
+ // message-too-big, server error) are not in this set; those just log.
13
+ //
14
+ // https://docs.expo.dev/push-notifications/sending-notifications/#individual-push-notification-errors
15
+ const PERMANENT_ERROR_CODES = new Set([
16
+ "DeviceNotRegistered",
17
+ "InvalidCredentials",
18
+ "MismatchSenderId",
19
+ ]);
20
+
21
+ type ExpoMessage = {
22
+ to: string;
23
+ title?: string;
24
+ body?: string;
25
+ data?: Record<string, unknown>;
26
+ sound?: "default" | null;
27
+ badge?: number;
28
+ // `content-available: 1`-style silent push. Wakes the background task
29
+ // without showing a banner. Pair with `priority: "high"` so iOS doesn't
30
+ // throttle delivery.
31
+ _contentAvailable?: boolean;
32
+ priority?: "default" | "normal" | "high";
33
+ };
34
+
35
+ type ExpoTicket =
36
+ | { status: "ok"; id: string }
37
+ | { status: "error"; message: string; details?: { error?: string } };
38
+
39
+ type ExpoResponse = { data?: ExpoTicket[]; errors?: Array<{ code?: string; message?: string }> };
40
+
41
+ /**
42
+ * Fan out a push to every active token of `userId`. Tickets returned with
43
+ * a permanent error code mark the originating token revoked so subsequent
44
+ * sends skip it. Transient errors are logged but kept.
45
+ *
46
+ * Best-effort: a single network failure logs and returns; the caller is
47
+ * always a mutation that already committed (account-event hook, scheduled
48
+ * job, etc.), so we never want a push send to surface as a user-facing
49
+ * error.
50
+ */
51
+ export const sendToUser = internalAction({
52
+ args: {
53
+ userId: v.id("users"),
54
+ title: v.optional(v.string()),
55
+ body: v.optional(v.string()),
56
+ data: v.optional(v.record(v.string(), v.any())),
57
+ silent: v.optional(v.boolean()),
58
+ },
59
+ returns: v.object({
60
+ sent: v.number(),
61
+ revoked: v.number(),
62
+ }),
63
+ handler: async (ctx, args) => {
64
+ const tokens = await ctx.runQuery(internal.pushTokens.listActiveByUser, {
65
+ userId: args.userId as Id<"users">,
66
+ });
67
+ if (tokens.length === 0) return { sent: 0, revoked: 0 };
68
+
69
+ const messages: ExpoMessage[] = tokens.map((t) => {
70
+ const m: ExpoMessage = { to: t.token, priority: "high" };
71
+ if (args.silent) {
72
+ m._contentAvailable = true;
73
+ if (args.data) m.data = args.data;
74
+ } else {
75
+ if (args.title) m.title = args.title;
76
+ if (args.body) m.body = args.body;
77
+ if (args.data) m.data = args.data;
78
+ m.sound = "default";
79
+ }
80
+ return m;
81
+ });
82
+
83
+ let payload: ExpoResponse | null = null;
84
+ try {
85
+ const res = await fetch(EXPO_PUSH_URL, {
86
+ method: "POST",
87
+ headers: {
88
+ "Content-Type": "application/json",
89
+ Accept: "application/json",
90
+ "Accept-Encoding": "gzip, deflate",
91
+ },
92
+ body: JSON.stringify(messages),
93
+ });
94
+ payload = (await res.json()) as ExpoResponse;
95
+ if (!res.ok) {
96
+ console.warn(`[push] send non-2xx ${res.status}: ${JSON.stringify(payload?.errors)}`);
97
+ }
98
+ } catch (err) {
99
+ console.warn(`[push] send threw: ${err instanceof Error ? err.message : String(err)}`);
100
+ return { sent: 0, revoked: 0 };
101
+ }
102
+
103
+ const tickets = payload?.data ?? [];
104
+ const revoked = await reconcileTickets(ctx, tokens, tickets);
105
+ return { sent: tickets.length, revoked };
106
+ },
107
+ });
108
+
109
+ /**
110
+ * Match each ticket back to its originating token and tombstone tokens
111
+ * whose ticket reported a permanent error. The Expo API preserves order:
112
+ * `tickets[i]` corresponds to `messages[i]`, which corresponds to
113
+ * `tokens[i]`.
114
+ */
115
+ async function reconcileTickets(
116
+ ctx: {
117
+ runMutation: (
118
+ ref: typeof internal.pushTokens.markRevoked,
119
+ args: { tokenIds: Id<"pushTokens">[]; errorCode: string },
120
+ ) => Promise<number>;
121
+ },
122
+ tokens: Array<{ _id: Id<"pushTokens">; token: string }>,
123
+ tickets: ExpoTicket[],
124
+ ): Promise<number> {
125
+ const buckets = new Map<string, Id<"pushTokens">[]>();
126
+ tickets.forEach((ticket, index) => {
127
+ if (ticket.status !== "error") return;
128
+ const code = ticket.details?.error;
129
+ if (!code || !PERMANENT_ERROR_CODES.has(code)) return;
130
+ const tokenId = tokens[index]?._id;
131
+ if (!tokenId) return;
132
+ const list = buckets.get(code) ?? [];
133
+ list.push(tokenId);
134
+ buckets.set(code, list);
135
+ });
136
+
137
+ let revoked = 0;
138
+ for (const [code, tokenIds] of buckets) {
139
+ revoked += await ctx.runMutation(internal.pushTokens.markRevoked, {
140
+ tokenIds,
141
+ errorCode: code,
142
+ });
143
+ }
144
+ return revoked;
145
+ }