@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
@@ -1,35 +1,48 @@
1
1
  import { v } from "convex/values";
2
2
 
3
3
  import { internal } from "./_generated/api";
4
- import { internalMutation } from "./_generated/server";
4
+ import { internalMutation, internalQuery } from "./_generated/server";
5
5
  import { authMutation, authQuery } from "./functions";
6
+ import { rateLimitWithThrow } from "./rateLimit";
6
7
  import { deviceTypeValidator } from "./validators";
7
8
 
8
9
  const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
10
+ const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
9
11
  const CLEANUP_BATCH = 200;
10
12
 
11
13
  export const upsert = authMutation({
12
14
  args: { token: v.string(), deviceType: deviceTypeValidator },
13
15
  returns: v.id("pushTokens"),
14
16
  handler: async (ctx, { token, deviceType }) => {
17
+ await rateLimitWithThrow(ctx, "userAction", ctx.user._id.toString());
15
18
  const now = Date.now();
16
- // Token may belong to a different user (device transferred), so read it
17
- // by token first and reassign if needed.
18
19
  const existing = await ctx.db
19
20
  .query("pushTokens")
20
21
  .withIndex("by_token", (q) => q.eq("token", token))
21
22
  .unique();
22
23
 
23
24
  if (existing) {
25
+ // Same user: refresh timestamps and clear any prior revocation. The
26
+ // client only re-upserts after `getExpoPushTokenAsync` succeeds, so
27
+ // if we get here the token is alive again.
24
28
  if (existing.userId === ctx.user._id) {
25
- await ctx.db.patch(existing._id, { updatedAt: now });
29
+ await ctx.db.patch(existing._id, {
30
+ updatedAt: now,
31
+ lastSeenAt: now,
32
+ revoked: false,
33
+ revokedAt: undefined,
34
+ lastErrorCode: undefined,
35
+ });
26
36
  return existing._id;
27
37
  }
28
- // Reassign token to current user (device changed owners)
29
38
  await ctx.db.patch(existing._id, {
30
39
  userId: ctx.user._id,
31
40
  deviceType,
32
41
  updatedAt: now,
42
+ lastSeenAt: now,
43
+ revoked: false,
44
+ revokedAt: undefined,
45
+ lastErrorCode: undefined,
33
46
  });
34
47
  return existing._id;
35
48
  }
@@ -40,6 +53,8 @@ export const upsert = authMutation({
40
53
  deviceType,
41
54
  createdAt: now,
42
55
  updatedAt: now,
56
+ lastSeenAt: now,
57
+ revoked: false,
43
58
  });
44
59
  },
45
60
  });
@@ -48,6 +63,7 @@ export const remove = authMutation({
48
63
  args: { token: v.string() },
49
64
  returns: v.null(),
50
65
  handler: async (ctx, { token }) => {
66
+ await rateLimitWithThrow(ctx, "userAction", ctx.user._id.toString());
51
67
  const existing = await ctx.db
52
68
  .query("pushTokens")
53
69
  .withIndex("by_token", (q) => q.eq("token", token))
@@ -71,6 +87,10 @@ export const list = authQuery({
71
87
  deviceType: deviceTypeValidator,
72
88
  createdAt: v.number(),
73
89
  updatedAt: v.number(),
90
+ lastSeenAt: v.optional(v.number()),
91
+ revoked: v.optional(v.boolean()),
92
+ revokedAt: v.optional(v.number()),
93
+ lastErrorCode: v.optional(v.string()),
74
94
  }),
75
95
  ),
76
96
  handler: async (ctx) => {
@@ -85,6 +105,7 @@ export const removeAll = authMutation({
85
105
  args: {},
86
106
  returns: v.null(),
87
107
  handler: async (ctx) => {
108
+ await rateLimitWithThrow(ctx, "userAction", ctx.user._id.toString());
88
109
  const tokens = await ctx.db
89
110
  .query("pushTokens")
90
111
  .withIndex("by_user", (q) => q.eq("userId", ctx.user._id))
@@ -94,21 +115,97 @@ export const removeAll = authMutation({
94
115
  },
95
116
  });
96
117
 
118
+ export const listActiveByUser = internalQuery({
119
+ args: { userId: v.id("users") },
120
+ returns: v.array(
121
+ v.object({
122
+ _id: v.id("pushTokens"),
123
+ token: v.string(),
124
+ }),
125
+ ),
126
+ handler: async (ctx, { userId }) => {
127
+ const rows = await ctx.db
128
+ .query("pushTokens")
129
+ .withIndex("by_user", (q) => q.eq("userId", userId))
130
+ .collect();
131
+ return rows.filter((r) => !r.revoked).map((r) => ({ _id: r._id, token: r.token }));
132
+ },
133
+ });
134
+
135
+ /**
136
+ * Tombstone tokens whose Expo Push receipts came back with a permanent
137
+ * error. The row sticks around for 30 days so a transient client retry
138
+ * doesn't resurrect a dead device, then `cleanupStale` drops it.
139
+ *
140
+ * `errorCode` is one of Expo's documented values: `DeviceNotRegistered`,
141
+ * `InvalidCredentials`, `MismatchSenderId`, etc. Only the permanent codes
142
+ * are passed here; transient errors stay active.
143
+ *
144
+ * https://docs.expo.dev/push-notifications/sending-notifications/#individual-push-notification-errors
145
+ */
146
+ export const markRevoked = internalMutation({
147
+ args: {
148
+ tokenIds: v.array(v.id("pushTokens")),
149
+ errorCode: v.string(),
150
+ },
151
+ returns: v.number(),
152
+ handler: async (ctx, { tokenIds, errorCode }) => {
153
+ const now = Date.now();
154
+ let revoked = 0;
155
+ for (const id of tokenIds) {
156
+ const row = await ctx.db.get(id);
157
+ if (!row) continue;
158
+ await ctx.db.patch(id, {
159
+ revoked: true,
160
+ revokedAt: now,
161
+ updatedAt: now,
162
+ lastErrorCode: errorCode,
163
+ });
164
+ revoked++;
165
+ }
166
+ return revoked;
167
+ },
168
+ });
169
+
97
170
  /**
98
- * Delete push tokens older than 30 days, in bounded batches. Reschedules
99
- * itself when more rows remain so we never load an unbounded set into memory.
171
+ * Daily cleanup. Drops revoked rows older than 30 days and stale rows
172
+ * never re-upserted in 90 days. Bounded batches; reschedules when more
173
+ * rows remain so we never load an unbounded set into memory.
174
+ *
175
+ * The old behavior keyed on `_creationTime`, which deleted long-lived
176
+ * rows even when the device was active. The correct signal is
177
+ * `updatedAt`, which the client touches on every successful re-upsert.
100
178
  */
101
179
  export const cleanupStale = internalMutation({
102
180
  args: {},
103
181
  returns: v.number(),
104
182
  handler: async (ctx) => {
105
- const cutoff = Date.now() - THIRTY_DAYS_MS;
106
- const batch = await ctx.db.query("pushTokens").order("asc").take(CLEANUP_BATCH);
107
- const stale = batch.filter((t) => t._creationTime < cutoff);
108
- await Promise.all(stale.map((t) => ctx.db.delete(t._id)));
109
- if (batch.length === CLEANUP_BATCH) {
183
+ const now = Date.now();
184
+ const revokedCutoff = now - THIRTY_DAYS_MS;
185
+ const staleCutoff = now - NINETY_DAYS_MS;
186
+
187
+ // The index is [revoked, updatedAt] and Convex orders `false < true`, so an
188
+ // unbounded ascending scan returns every active row before any tombstone;
189
+ // at scale the revoked rows would never be reached. Range each partition
190
+ // explicitly. Active rows are always written with `revoked: false`, so the
191
+ // two ranges together cover every row.
192
+ const revoked = await ctx.db
193
+ .query("pushTokens")
194
+ .withIndex("by_revoked_updatedAt", (q) =>
195
+ q.eq("revoked", true).lt("updatedAt", revokedCutoff),
196
+ )
197
+ .take(CLEANUP_BATCH);
198
+ const stale = await ctx.db
199
+ .query("pushTokens")
200
+ .withIndex("by_revoked_updatedAt", (q) => q.eq("revoked", false).lt("updatedAt", staleCutoff))
201
+ .take(CLEANUP_BATCH);
202
+
203
+ const removable = [...revoked, ...stale];
204
+ await Promise.all(removable.map((t) => ctx.db.delete(t._id)));
205
+
206
+ if (revoked.length === CLEANUP_BATCH || stale.length === CLEANUP_BATCH) {
110
207
  await ctx.scheduler.runAfter(0, internal.pushTokens.cleanupStale, {});
111
208
  }
112
- return stale.length;
209
+ return removable.length;
113
210
  },
114
211
  });
@@ -1,26 +1,9 @@
1
- /**
2
- * Rate Limiting Configuration
3
- *
4
- * Uses the @convex-dev/rate-limiter component for application-level rate
5
- * limiting.
6
- *
7
- * Authentication-related rate limiting (sign-in, sign-up, password reset)
8
- * is handled by Better Auth at the HTTP layer. See convex/auth.ts.
9
- *
10
- * @see https://www.convex.dev/components/rate-limiter
11
- */
12
-
13
1
  import { HOUR, MINUTE, RateLimiter } from "@convex-dev/rate-limiter";
14
2
 
15
3
  import { components } from "./_generated/api";
16
4
  import type { MutationCtx } from "./_generated/server";
17
5
 
18
- /**
19
- * Rate limiter instance using the component.
20
- * Defines all application rate limits in one place.
21
- */
22
6
  export const rateLimiter = new RateLimiter(components.rateLimiter, {
23
- // Read operations: permissive for good UX, sharded for throughput
24
7
  apiRead: {
25
8
  kind: "token bucket",
26
9
  rate: 100,
@@ -29,7 +12,6 @@ export const rateLimiter = new RateLimiter(components.rateLimiter, {
29
12
  shards: 2,
30
13
  },
31
14
 
32
- // Write operations: stricter to prevent abuse
33
15
  apiWrite: {
34
16
  kind: "token bucket",
35
17
  rate: 30,
@@ -37,7 +19,6 @@ export const rateLimiter = new RateLimiter(components.rateLimiter, {
37
19
  capacity: 10,
38
20
  },
39
21
 
40
- // General authenticated user actions
41
22
  userAction: {
42
23
  kind: "token bucket",
43
24
  rate: 60,
@@ -45,17 +26,21 @@ export const rateLimiter = new RateLimiter(components.rateLimiter, {
45
26
  capacity: 10,
46
27
  },
47
28
 
48
- // For operations that MUST eventually succeed (use with reserve: true)
29
+ // Sensitive account mutations (delete/restore). A throttle, not a hard block:
30
+ // a legitimate one-shot call never approaches the capacity-5 bucket, and a
31
+ // throttled caller's tokens refill at `rate`, so a retry after the
32
+ // `retryAfter` window succeeds. That, not `reserve`, is how these "must
33
+ // eventually succeed" (reserve returns ok-with-retryAfter, which `throws`
34
+ // would reject anyway, and is meant for deferred/scheduled work, not a
35
+ // synchronous mutation that returns its result inline). Apple 5.1.1(v) in-app
36
+ // deletion still works: a real user deletes once.
49
37
  criticalAction: {
50
38
  kind: "token bucket",
51
39
  rate: 10,
52
40
  period: MINUTE,
53
41
  capacity: 5,
54
- maxReserved: 20,
55
42
  },
56
43
 
57
- // Avatar uploads (product-specific). Generous burst capacity so users
58
- // tweaking their photo a few times in a row don't trip it.
59
44
  avatarUpload: { kind: "token bucket", rate: 30, period: HOUR, capacity: 10 },
60
45
  });
61
46
 
@@ -66,9 +51,6 @@ export type RateLimitName =
66
51
  | "criticalAction"
67
52
  | "avatarUpload";
68
53
 
69
- /**
70
- * Apply a rate limit and throw automatically if exceeded.
71
- */
72
54
  export async function rateLimitWithThrow(
73
55
  ctx: MutationCtx,
74
56
  name: RateLimitName,
@@ -77,16 +59,3 @@ export async function rateLimitWithThrow(
77
59
  ) {
78
60
  return rateLimiter.limit(ctx, name, { key, count, throws: true });
79
61
  }
80
-
81
- /**
82
- * Consume rate limit tokens without throwing.
83
- * Returns { ok, retryAfter } so HTTP callers can build a 429 response.
84
- */
85
- export async function consumeLimit(
86
- ctx: MutationCtx,
87
- name: RateLimitName,
88
- key?: string,
89
- count?: number,
90
- ) {
91
- return rateLimiter.limit(ctx, name, { key, count });
92
- }
@@ -3,26 +3,69 @@ import { v } from "convex/values";
3
3
 
4
4
  export default defineSchema(
5
5
  {
6
- // App-specific user row mirrored from Better Auth via auth triggers.
7
- // Identity fields (name, email, username, image) live on the Better Auth
8
- // user component and are merged in at read time by safeGetAuthenticatedUser.
9
6
  users: defineTable({
10
7
  authId: v.string(),
11
8
  bio: v.optional(v.string()),
12
9
  avatar: v.optional(v.id("_storage")),
13
10
  createdAt: v.number(),
14
11
  updatedAt: v.number(),
15
- }).index("authId", ["authId"]),
12
+ deletedAt: v.optional(v.number()),
13
+ })
14
+ .index("authId", ["authId"])
15
+ .index("by_deletedAt", ["deletedAt"]),
16
+
17
+ accountDeletionAudit: defineTable({
18
+ userId: v.id("users"),
19
+ authId: v.string(),
20
+ event: v.union(v.literal("requested"), v.literal("restored"), v.literal("permanent")),
21
+ at: v.number(),
22
+ })
23
+ .index("by_user", ["userId"])
24
+ .index("by_event_at", ["event", "at"]),
16
25
 
26
+ // On a permanent Expo Push error we tombstone (set `revoked`) instead of
27
+ // deleting, so a race-condition re-upsert doesn't resurrect a dead token.
17
28
  pushTokens: defineTable({
18
29
  userId: v.id("users"),
19
30
  token: v.string(),
20
31
  deviceType: v.literal("ios"),
21
32
  createdAt: v.number(),
22
33
  updatedAt: v.number(),
34
+ lastSeenAt: v.optional(v.number()),
35
+ revoked: v.optional(v.boolean()),
36
+ revokedAt: v.optional(v.number()),
37
+ lastErrorCode: v.optional(v.string()),
23
38
  })
24
39
  .index("by_user", ["userId"])
25
- .index("by_token", ["token"]),
40
+ .index("by_token", ["token"])
41
+ .index("by_revoked_updatedAt", ["revoked", "updatedAt"]),
42
+
43
+ // We expire unused challenges after the TTL so a stolen App Attest
44
+ // challenge can't be replayed later.
45
+ appAttestChallenges: defineTable({
46
+ nonce: v.string(),
47
+ expiresAt: v.number(),
48
+ used: v.optional(v.boolean()),
49
+ })
50
+ .index("by_nonce", ["nonce"])
51
+ .index("by_expiresAt", ["expiresAt"]),
52
+
53
+ appAttestKeys: defineTable({
54
+ // Apple's keyId (base64-encoded SHA256 of the public key per
55
+ // App Attest spec).
56
+ keyId: v.string(),
57
+ publicKey: v.string(),
58
+ // Monotonic counter from the most recent assertion. Reject any
59
+ // assertion with a counter not strictly greater than this.
60
+ counter: v.number(),
61
+ // Dev attestations (`appattestdevelop`) are allowed in non-production
62
+ // Convex env but should never appear in a production app's signed binary.
63
+ environment: v.union(v.literal("development"), v.literal("production")),
64
+ attestedAt: v.number(),
65
+ userId: v.optional(v.id("users")),
66
+ })
67
+ .index("by_keyId", ["keyId"])
68
+ .index("by_user", ["userId"]),
26
69
  },
27
70
  { strictTableNameTypes: true },
28
71
  );
@@ -8,6 +8,7 @@
8
8
  "allowSyntheticDefaultImports": true,
9
9
  "target": "ESNext",
10
10
  "lib": ["ES2021", "dom"],
11
+ "types": ["node"],
11
12
  "forceConsistentCasingInFileNames": true,
12
13
  "module": "ESNext",
13
14
  "isolatedModules": true,
@@ -1,16 +1,9 @@
1
- /**
2
- * User Queries and Mutations
3
- *
4
- * CRUD operations for the app users table.
5
- * Identity fields (name, email, username, image) live on the Better Auth user
6
- * and are merged in at read time by safeGetAuthenticatedUser in auth.ts.
7
- */
8
-
9
1
  import { v } from "convex/values";
10
2
 
11
- import { components } from "./_generated/api";
3
+ import { components, internal } from "./_generated/api";
12
4
  import type { Id } from "./_generated/dataModel";
13
5
  import type { MutationCtx } from "./_generated/server";
6
+ import { internalMutation } from "./_generated/server";
14
7
  import { authComponent, authUserValidator } from "./auth";
15
8
  import { validationError } from "./errors";
16
9
  import { authMutation, optionalAuthQuery } from "./functions";
@@ -22,14 +15,6 @@ import {
22
15
  validateBio,
23
16
  } from "./validators";
24
17
 
25
- // ============================================================================
26
- // Queries
27
- // ============================================================================
28
-
29
- /**
30
- * Get the current authenticated user's profile with resolved avatar URL.
31
- * Returns null when unauthenticated.
32
- */
33
18
  export const getMe = optionalAuthQuery({
34
19
  args: {},
35
20
  returns: v.union(authUserValidator, v.null()),
@@ -39,10 +24,8 @@ export const getMe = optionalAuthQuery({
39
24
  });
40
25
 
41
26
  /**
42
- * Get a user by app user id with Better Auth identity fields merged in.
43
27
  * Accepts an arbitrary string and normalizes it via `ctx.db.normalizeId`,
44
- * so untrusted inputs can be passed straight through. Returns null when the
45
- * id is malformed or either record is missing.
28
+ * so untrusted inputs can be passed straight through.
46
29
  */
47
30
  export const getUser = optionalAuthQuery({
48
31
  args: { userId: v.string() },
@@ -75,10 +58,6 @@ export const getUser = optionalAuthQuery({
75
58
  },
76
59
  });
77
60
 
78
- /**
79
- * List users (paginated) with Better Auth identity fields merged in.
80
- * Entries with a missing Better Auth record are skipped.
81
- */
82
61
  export const listUsers = optionalAuthQuery({
83
62
  args: {
84
63
  cursor: v.optional(v.string()),
@@ -122,14 +101,6 @@ export const listUsers = optionalAuthQuery({
122
101
  },
123
102
  });
124
103
 
125
- // ============================================================================
126
- // Mutations
127
- // ============================================================================
128
-
129
- /**
130
- * Update the current user's bio. Name and username changes go through
131
- * Better Auth directly via authClient.updateUser on the client.
132
- */
133
104
  export const updateProfile = authMutation({
134
105
  args: userProfileUpdateFields,
135
106
  returns: v.id("users"),
@@ -150,10 +121,6 @@ export const updateProfile = authMutation({
150
121
  },
151
122
  });
152
123
 
153
- /**
154
- * Generate an upload URL for avatar images.
155
- * The URL expires in 1 hour.
156
- */
157
124
  export const generateAvatarUploadUrl = authMutation({
158
125
  args: {},
159
126
  returns: v.string(),
@@ -164,8 +131,6 @@ export const generateAvatarUploadUrl = authMutation({
164
131
  });
165
132
 
166
133
  /**
167
- * Update the current user's avatar with a storage id.
168
- * Deletes the previous uploaded avatar from storage if one exists.
169
134
  * Does not touch Better Auth's image field - that's for provider-supplied URLs.
170
135
  */
171
136
  export const updateAvatar = authMutation({
@@ -185,11 +150,6 @@ export const updateAvatar = authMutation({
185
150
  },
186
151
  });
187
152
 
188
- /**
189
- * Delete the current user's uploaded avatar.
190
- * Removes the file from storage and clears the avatar field. After deletion,
191
- * Better Auth's image (e.g. OAuth provider avatar) is used as the fallback.
192
- */
193
153
  export const deleteAvatar = authMutation({
194
154
  args: {},
195
155
  returns: v.object({ success: v.boolean() }),
@@ -207,43 +167,165 @@ export const deleteAvatar = authMutation({
207
167
  },
208
168
  });
209
169
 
170
+ // 30-day grace window between a user requesting deletion and the row
171
+ // being permanently purged. Apple's 5.1.1(v) requires deletability from
172
+ // within the app; the window lets a confused tap be recovered. After it
173
+ // expires, `internal.users.hardDeleteExpired` purges everything irreversibly.
174
+ export const ACCOUNT_DELETION_GRACE_MS = 30 * 24 * 60 * 60 * 1000;
175
+ const HARD_DELETE_BATCH = 50;
176
+
210
177
  /**
211
- * Delete the current user's account.
212
- * Removes app-owned data (push tokens) and all Better Auth records.
213
- * The `users` row is dropped by the auth `onDelete` trigger.
178
+ * Better Auth credentials stay intact until the 30-day window expires so a
179
+ * returning user can call `restoreAccount` to undo the request.
180
+ *
181
+ * Apple `revokeRefreshToken` runs at the hard-delete pass, not here, so
182
+ * a user who restores within the window can still use Sign in with Apple
183
+ * without re-granting authorization in iOS Settings.
214
184
  */
215
185
  export const deleteAccount = authMutation({
216
186
  args: {},
217
- returns: v.object({ success: v.boolean() }),
187
+ returns: v.object({ success: v.boolean(), deletedAt: v.number() }),
218
188
  handler: async (ctx) => {
189
+ await rateLimitWithThrow(ctx, "criticalAction", ctx.user._id.toString());
219
190
  const authUserId = ctx.user.authUserId;
191
+ const userId = ctx.user._id;
192
+ const now = Date.now();
193
+
194
+ if (ctx.user.deletedAt) {
195
+ return { success: true, deletedAt: ctx.user.deletedAt };
196
+ }
220
197
 
221
198
  const pushTokens = await ctx.db
222
199
  .query("pushTokens")
223
- .withIndex("by_user", (q) => q.eq("userId", ctx.user._id))
200
+ .withIndex("by_user", (q) => q.eq("userId", userId))
224
201
  .collect();
225
202
  await Promise.all(pushTokens.map((t) => ctx.db.delete(t._id)));
226
203
 
227
- const authUser = await authComponent.safeGetAuthUser(ctx);
228
-
229
204
  await deleteAllByUserId(ctx, "session", authUserId);
230
- await deleteAllByUserId(ctx, "account", authUserId);
231
- await deleteAllByUserId(ctx, "twoFactor", authUserId);
232
- await deleteAllByUserId(ctx, "oauthAccessToken", authUserId);
233
- await deleteAllByUserId(ctx, "oauthConsent", authUserId);
234
- await deleteAllByUserId(ctx, "oauthApplication", authUserId);
235
- if (authUser?.email) await deleteVerificationByIdentifier(ctx, authUser.email);
236
-
237
- // Deleting the Better Auth user fires the `onDelete` trigger which
238
- // removes the matching app users row and frees the avatar blob.
239
- await ctx.runMutation(components.betterAuth.adapter.deleteOne, {
240
- input: { model: "user", where: [{ field: "_id", value: authUserId }] },
205
+
206
+ await ctx.db.patch(userId, { deletedAt: now, updatedAt: now });
207
+
208
+ await ctx.db.insert("accountDeletionAudit", {
209
+ userId,
210
+ authId: authUserId,
211
+ event: "requested",
212
+ at: now,
213
+ });
214
+
215
+ return { success: true, deletedAt: now };
216
+ },
217
+ });
218
+
219
+ export const restoreAccount = authMutation({
220
+ args: {},
221
+ returns: v.object({ success: v.boolean() }),
222
+ handler: async (ctx) => {
223
+ await rateLimitWithThrow(ctx, "criticalAction", ctx.user._id.toString());
224
+ const now = Date.now();
225
+
226
+ if (!ctx.user.deletedAt) return { success: true };
227
+
228
+ await ctx.db.patch(ctx.user._id, { deletedAt: undefined, updatedAt: now });
229
+
230
+ await ctx.db.insert("accountDeletionAudit", {
231
+ userId: ctx.user._id,
232
+ authId: ctx.user.authUserId,
233
+ event: "restored",
234
+ at: now,
241
235
  });
242
236
 
243
237
  return { success: true };
244
238
  },
245
239
  });
246
240
 
241
+ /**
242
+ * Revokes Apple Sign In refresh tokens per Apple App Store guideline
243
+ * 5.1.1(v): "If people used Sign in with Apple to create an account
244
+ * within your app, you revoke the associated tokens when they delete
245
+ * their account."
246
+ *
247
+ * Deleting the Better Auth user fires the `onDelete` trigger that drops
248
+ * the app users row and frees the avatar blob.
249
+ */
250
+ export const hardDeleteExpired = internalMutation({
251
+ args: {},
252
+ returns: v.number(),
253
+ handler: async (ctx) => {
254
+ const cutoff = Date.now() - ACCOUNT_DELETION_GRACE_MS;
255
+ // Range to rows that have `deletedAt` set. Convex orders
256
+ // `undefined < null < numbers`, so an unbounded scan returns every active
257
+ // user (deletedAt unset) before any tombstone, and the purge would never
258
+ // reach a soft-deleted row once active users exceed the batch size.
259
+ const expired = await ctx.db
260
+ .query("users")
261
+ .withIndex("by_deletedAt", (q) => q.gt("deletedAt", undefined))
262
+ .order("asc")
263
+ .take(HARD_DELETE_BATCH);
264
+
265
+ const purgeable = expired.filter(
266
+ (u) => typeof u.deletedAt === "number" && u.deletedAt < cutoff,
267
+ );
268
+
269
+ for (const user of purgeable) {
270
+ await purgeUser(ctx, user.authId, user._id);
271
+ }
272
+
273
+ if (expired.length === HARD_DELETE_BATCH && purgeable.length > 0) {
274
+ await ctx.scheduler.runAfter(0, internal.users.hardDeleteExpired, {});
275
+ }
276
+
277
+ return purgeable.length;
278
+ },
279
+ });
280
+
281
+ async function purgeUser(ctx: MutationCtx, authUserId: string, userId: Id<"users">): Promise<void> {
282
+ // Snapshot the email before tearing down Better Auth so we can also
283
+ // drop any pending verification rows keyed on it.
284
+ const authUser = (await ctx.runQuery(components.betterAuth.adapter.findOne, {
285
+ model: "user",
286
+ where: [{ field: "_id", value: authUserId }],
287
+ })) as { email?: string } | null;
288
+
289
+ // Revoke Apple Sign In refresh tokens before deleting the account rows.
290
+ // Schedule (not await) so a slow Apple endpoint doesn't hold the
291
+ // mutation transaction open.
292
+ const appleAccounts = (await ctx.runQuery(components.betterAuth.adapter.findMany, {
293
+ model: "account",
294
+ where: [
295
+ { field: "userId", value: authUserId },
296
+ { field: "providerId", value: "apple", connector: "AND" },
297
+ ],
298
+ paginationOpts: { numItems: 100, cursor: null },
299
+ })) as { page: Array<Record<string, unknown>> };
300
+ for (const account of appleAccounts.page) {
301
+ const token = account.refreshToken;
302
+ if (typeof token === "string" && token.length > 0) {
303
+ await ctx.scheduler.runAfter(0, internal.apple.revokeRefreshToken, {
304
+ refreshToken: token,
305
+ });
306
+ }
307
+ }
308
+
309
+ await deleteAllByUserId(ctx, "session", authUserId);
310
+ await deleteAllByUserId(ctx, "account", authUserId);
311
+ await deleteAllByUserId(ctx, "twoFactor", authUserId);
312
+ await deleteAllByUserId(ctx, "oauthAccessToken", authUserId);
313
+ await deleteAllByUserId(ctx, "oauthConsent", authUserId);
314
+ await deleteAllByUserId(ctx, "oauthApplication", authUserId);
315
+ if (authUser?.email) await deleteVerificationByIdentifier(ctx, authUser.email);
316
+
317
+ await ctx.runMutation(components.betterAuth.adapter.deleteOne, {
318
+ input: { model: "user", where: [{ field: "_id", value: authUserId }] },
319
+ });
320
+
321
+ await ctx.db.insert("accountDeletionAudit", {
322
+ userId,
323
+ authId: authUserId,
324
+ event: "permanent",
325
+ at: Date.now(),
326
+ });
327
+ }
328
+
247
329
  type UserIdModel =
248
330
  | "session"
249
331
  | "account"