@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.
- package/README.md +10 -10
- package/dist/index.js +8 -7
- package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
- package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
- package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
- package/dist/templates/default/.eas/workflows/release.yml +3 -7
- package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
- package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
- package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
- package/dist/templates/default/.github/workflows/check.yml +20 -12
- package/dist/templates/default/.maestro/launch.yaml +19 -10
- package/dist/templates/default/AGENTS.md +25 -8
- package/dist/templates/default/DESIGN.md +14 -10
- package/dist/templates/default/README.md +83 -78
- package/dist/templates/default/SETUP.md +159 -152
- package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
- package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
- package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
- package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
- package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
- package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
- package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
- package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
- package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
- package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
- package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
- package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
- package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
- package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
- package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
- package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
- package/dist/templates/default/_env.example +7 -7
- package/dist/templates/default/_gitattributes +1 -1
- package/dist/templates/default/_gitignore +17 -2
- package/dist/templates/default/_npmrc +7 -0
- package/dist/templates/default/_oxlintrc.json +1 -1
- package/dist/templates/default/app-store/accessibility.config.json +20 -0
- package/dist/templates/default/app-store/privacy.config.json +27 -0
- package/dist/templates/default/app.config.ts +105 -33
- package/dist/templates/default/app.json +1 -9
- package/dist/templates/default/convex/_generated/api.d.ts +12 -0
- package/dist/templates/default/convex/admin.ts +0 -13
- package/dist/templates/default/convex/appAttest.ts +467 -0
- package/dist/templates/default/convex/appAttestStore.ts +141 -0
- package/dist/templates/default/convex/apple.ts +53 -0
- package/dist/templates/default/convex/auth.ts +6 -45
- package/dist/templates/default/convex/constants.ts +2 -7
- package/dist/templates/default/convex/crons.ts +12 -5
- package/dist/templates/default/convex/email.ts +4 -24
- package/dist/templates/default/convex/env.ts +0 -4
- package/dist/templates/default/convex/errors.ts +0 -7
- package/dist/templates/default/convex/functions.ts +0 -26
- package/dist/templates/default/convex/http.ts +3 -5
- package/dist/templates/default/convex/log.ts +2 -25
- package/dist/templates/default/convex/pushSender.ts +145 -0
- package/dist/templates/default/convex/pushTokens.ts +110 -13
- package/dist/templates/default/convex/rateLimit.ts +8 -39
- package/dist/templates/default/convex/schema.ts +48 -5
- package/dist/templates/default/convex/tsconfig.json +1 -0
- package/dist/templates/default/convex/users.ts +143 -61
- package/dist/templates/default/convex/validators.ts +1 -38
- package/dist/templates/default/convex/webhook.ts +1 -31
- package/dist/templates/default/convex.json +1 -2
- package/dist/templates/default/metro.config.js +9 -1
- package/dist/templates/default/package.json +67 -70
- package/dist/templates/default/plugins/README.md +5 -1
- package/dist/templates/default/scripts/README.md +9 -9
- package/dist/templates/default/scripts/_run.mjs +3 -20
- package/dist/templates/default/scripts/clean.ts +81 -69
- package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
- package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
- package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
- package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
- package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
- package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
- package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
- package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
- package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
- package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
- package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
- package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
- package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
- package/dist/templates/default/src/app/+native-intent.tsx +25 -0
- package/dist/templates/default/src/app/+not-found.tsx +43 -0
- package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
- package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
- package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
- package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
- package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
- package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
- package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
- package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
- package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
- package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
- package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
- package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
- package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
- package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
- package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
- package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
- package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
- package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
- package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
- package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
- package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
- package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
- package/dist/templates/default/src/hooks/use-network.ts +34 -0
- package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
- package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
- package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
- package/dist/templates/default/src/lib/appAttest.ts +78 -0
- package/dist/templates/default/src/lib/assets.ts +9 -0
- package/dist/templates/default/src/lib/deep-link.ts +82 -0
- package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
- package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
- package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
- package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
- package/dist/templates/default/src/lib/masks.ts +21 -0
- package/dist/templates/default/src/lib/native-state.ts +20 -0
- package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
- package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
- package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
- package/dist/templates/default/src/lib/text-style.ts +20 -0
- package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
- package/dist/templates/default/store.config.json +1 -1
- package/dist/templates/default/tsconfig.json +3 -1
- package/dist/templates/default/vitest.config.ts +8 -1
- package/package.json +5 -5
- package/dist/templates/default/app/(app)/_layout.tsx +0 -73
- package/dist/templates/default/app/(app)/debug.tsx +0 -389
- package/dist/templates/default/app/(app)/sessions.tsx +0 -191
- package/dist/templates/default/app/(app)/welcome.tsx +0 -140
- package/dist/templates/default/app/+native-intent.tsx +0 -14
- package/dist/templates/default/app/+not-found.tsx +0 -51
- package/dist/templates/default/bun.lock +0 -1860
- package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
- package/dist/templates/default/components/ui/convex-error.tsx +0 -32
- package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
- package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
- package/dist/templates/default/components/ui/status-text.tsx +0 -49
- package/dist/templates/default/components/ui/update-banner.tsx +0 -82
- package/dist/templates/default/fingerprint.config.js +0 -9
- package/dist/templates/default/hooks/use-debounce.ts +0 -20
- package/dist/templates/default/hooks/use-deep-link.ts +0 -43
- package/dist/templates/default/hooks/use-network.ts +0 -11
- package/dist/templates/default/lib/assets.ts +0 -17
- package/dist/templates/default/lib/deep-link.ts +0 -71
- package/dist/templates/default/patches/PR-368.patch +0 -91
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
- /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
- /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
- /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
- /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 (`
|
|
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
|
-
// `
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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,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
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
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
|
+
}
|