@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
|
@@ -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, {
|
|
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
|
-
*
|
|
99
|
-
*
|
|
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
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
);
|
|
@@ -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.
|
|
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
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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",
|
|
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
|
-
|
|
231
|
-
await
|
|
232
|
-
|
|
233
|
-
await
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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"
|