@supa-media/convex 1.2.1

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.
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Supa Auth Setup
3
+ *
4
+ * Creates a pre-configured @convex-dev/auth setup with Phone (Twilio) and
5
+ * Email (Resend) OTP providers. Supports dev bypass via DEV_OTP_BYPASS env var.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * // convex/auth.ts
10
+ * import { createSupaAuth } from "@supa-media/convex/auth";
11
+ *
12
+ * export const { auth, signIn, signOut, store, isAuthenticated } = createSupaAuth({
13
+ * appName: "MyApp",
14
+ * resend: {
15
+ * fromAddress: "auth@myapp.com",
16
+ * emailSubject: (code) => `${code} is your MyApp code`,
17
+ * },
18
+ * twilio: {
19
+ * tokenBridgePath: "/api/internal/phone-token",
20
+ * },
21
+ * });
22
+ * ```
23
+ */
24
+
25
+ import { convexAuth } from "@convex-dev/auth/server";
26
+ import { Email } from "@convex-dev/auth/providers/Email";
27
+ import { Phone } from "@convex-dev/auth/providers/Phone";
28
+ import type { GenericId } from "convex/values";
29
+
30
+ export interface SupaAuthResendConfig {
31
+ /** The "from" address for OTP emails. */
32
+ fromAddress: string;
33
+ /** Function to generate the email subject line. */
34
+ emailSubject?: (code: string) => string;
35
+ /** Custom HTML renderer for OTP emails. Receives { code, email }. */
36
+ renderHtml?: (params: { code: string; email: string }) => string;
37
+ }
38
+
39
+ export interface SupaAuthTwilioConfig {
40
+ /** Path on the Convex site URL for the phone token bridge endpoint. */
41
+ tokenBridgePath?: string;
42
+ }
43
+
44
+ /**
45
+ * The provider id a magic-link code is minted under.
46
+ *
47
+ * Exported because minting is the caller's job, not this module's: an app
48
+ * creates the code itself — `ctx.runMutation(internal.auth.store, { args: {
49
+ * type: "createVerificationCode", provider: MAGIC_LINK_PROVIDER_ID, email,
50
+ * code, expirationTime, allowExtraProviders: false } })` — puts it in a URL,
51
+ * and mails it. Redemption needs nothing from the app: `@convex-dev/auth`'s
52
+ * React provider reads a `code` query parameter on mount, calls `signIn` with
53
+ * it, and strips it from the URL.
54
+ *
55
+ * It must not be `"email"`. Sharing the OTP provider's id would mean sharing
56
+ * its `authorize`, which is the whole point of the separation below.
57
+ */
58
+ export const MAGIC_LINK_PROVIDER_ID = "magic-link";
59
+
60
+ export interface SupaAuthMagicLinkConfig {
61
+ /**
62
+ * How long a link stays live, in seconds. Defaults to one hour.
63
+ *
64
+ * Keep it short. A code is typed from a screen somebody is looking at; a
65
+ * link sits in a mailbox, gets forwarded, and is read by anything with
66
+ * access to that mailbox later.
67
+ */
68
+ maxAge?: number;
69
+ /**
70
+ * Send the mail. Receives the same arguments `@convex-dev/auth` passes any
71
+ * email provider, including the `url` the OTP provider throws away.
72
+ *
73
+ * Optional, and omitted is the normal case: an app that mints its own codes
74
+ * (see `MAGIC_LINK_PROVIDER_ID`) has already sent its own mail, and this
75
+ * provider is registered only so the code can be redeemed. It is called
76
+ * only when something signs in *through* this provider by name.
77
+ */
78
+ sendVerificationRequest?: (params: {
79
+ identifier: string;
80
+ url: string;
81
+ token: string;
82
+ }) => Promise<void>;
83
+ }
84
+
85
+ export interface SupaAuthConfig {
86
+ /** App name, used in default email templates. */
87
+ appName?: string;
88
+ /**
89
+ * Which OTP methods to enable. Defaults to both `["email", "phone"]`.
90
+ * Set to `["email"]` for an email-only app so no dormant phone provider
91
+ * is registered.
92
+ */
93
+ methods?: Array<"email" | "phone">;
94
+ /**
95
+ * Register a second, link-only email provider alongside the OTP one.
96
+ *
97
+ * Off by default. Turn it on for an app that emails somebody a URL which
98
+ * signs them in when they click it — an invitation, a "finish setting up"
99
+ * nudge — rather than a code they type. See `SupaAuthMagicLinkConfig` and
100
+ * `MAGIC_LINK_PROVIDER_ID` for what it does and does not change.
101
+ */
102
+ magicLink?: SupaAuthMagicLinkConfig;
103
+ /** Resend email OTP configuration. */
104
+ resend?: SupaAuthResendConfig;
105
+ /** Twilio phone OTP configuration. */
106
+ twilio?: SupaAuthTwilioConfig;
107
+ /**
108
+ * Production deployment identifier substring (e.g. "giddy-donkey-905").
109
+ * When CONVEX_SITE_URL contains this string, DEV_OTP_BYPASS is ignored.
110
+ */
111
+ productionIdentifier?: string;
112
+ }
113
+
114
+ /** Default bypass OTP code for development. */
115
+ const DEV_BYPASS_CODE = "000000";
116
+
117
+ function normalizeConvexSiteUrl(url: string | undefined): string | undefined {
118
+ if (!url) return url;
119
+ if (url.includes(".convex.site")) return url;
120
+ return url.replace(".convex.cloud", ".convex.site");
121
+ }
122
+
123
+ /**
124
+ * Generate a cryptographically secure 6-digit OTP code.
125
+ * In dev mode (DEV_OTP_BYPASS=true), returns the bypass code instead.
126
+ */
127
+ function createOtpGenerator(productionIdentifier?: string) {
128
+ return function generateVerificationToken(): string {
129
+ if (process.env.DEV_OTP_BYPASS === "true") {
130
+ const siteUrl = process.env.CONVEX_SITE_URL ?? "";
131
+ if (productionIdentifier && siteUrl.includes(productionIdentifier)) {
132
+ console.error(
133
+ "DEV_OTP_BYPASS is enabled on production — ignoring. Remove this env var from the production deployment.",
134
+ );
135
+ } else {
136
+ return DEV_BYPASS_CODE;
137
+ }
138
+ }
139
+ const array = new Uint32Array(1);
140
+ crypto.getRandomValues(array);
141
+ return (100000 + (array[0] % 900000)).toString();
142
+ };
143
+ }
144
+
145
+ function createEmailOtp(config: SupaAuthConfig) {
146
+ const resendConfig = config.resend;
147
+
148
+ return Email({
149
+ maxAge: 10 * 60, // 10 minutes
150
+ generateVerificationToken: createOtpGenerator(config.productionIdentifier),
151
+ sendVerificationRequest: async ({ identifier: email, token }) => {
152
+ // Dynamic import of resend — only loaded when RESEND_API_KEY is set
153
+ const apiKey = process.env.RESEND_API_KEY;
154
+
155
+ if (!apiKey) {
156
+ console.log("=== OTP CODE (no RESEND_API_KEY) ===");
157
+ console.log(`To: ${email}`);
158
+ console.log(`Code: ${token}`);
159
+ console.log("=====================================");
160
+ return;
161
+ }
162
+
163
+ const fromAddress = resendConfig?.fromAddress ?? "noreply@example.com";
164
+ const subject = resendConfig?.emailSubject
165
+ ? resendConfig.emailSubject(token)
166
+ : `${token} is your ${config.appName ?? "Supa"} code`;
167
+
168
+ const html = resendConfig?.renderHtml
169
+ ? resendConfig.renderHtml({ code: token, email })
170
+ : `<p>Your verification code is: <strong>${token}</strong></p><p>This code expires in 10 minutes.</p>`;
171
+
172
+ // Use fetch to call Resend API directly to avoid hard dependency
173
+ const response = await fetch("https://api.resend.com/emails", {
174
+ method: "POST",
175
+ headers: {
176
+ Authorization: `Bearer ${apiKey}`,
177
+ "Content-Type": "application/json",
178
+ },
179
+ body: JSON.stringify({
180
+ from: fromAddress,
181
+ to: email,
182
+ subject,
183
+ html,
184
+ }),
185
+ });
186
+
187
+ if (!response.ok) {
188
+ const errorText = await response.text();
189
+ console.error("Resend email send error:", errorText);
190
+ throw new Error("Failed to send verification email. Please try again.");
191
+ }
192
+ },
193
+ });
194
+ }
195
+
196
+ /**
197
+ * The link-only email provider.
198
+ *
199
+ * ## Why this is a second provider and not a flag on the first
200
+ *
201
+ * `Email()` from `@convex-dev/auth` hardcodes an `authorize` that refuses any
202
+ * verification unless `params.email` is supplied and matches the account. That
203
+ * is exactly right for a 6-digit code and exactly wrong for a link, where the
204
+ * whole point is that the URL carries everything.
205
+ *
206
+ * Its own docstring says you can pass `authorize: undefined` to get "magic link
207
+ * behavior". **In 0.0.90 you cannot** — the factory builds its return value
208
+ * field by field and never spreads `config`, so `config.authorize` is dropped
209
+ * on the floor. The only way to clear it is to spread the built provider and
210
+ * override the key afterwards, which is what happens below.
211
+ *
212
+ * Doing that to the OTP provider instead — one line, and the obvious
213
+ * "simplification" of this file — would be a serious regression, because
214
+ * `authorize` is not the only thing keyed on the email:
215
+ *
216
+ * - `verifyCodeAndSignInImpl` derives its rate-limit key from
217
+ * `params.email ?? params.phone`. With no email in params **there is no
218
+ * rate limiting at all.**
219
+ * - The code itself is then the only secret, and the OTP provider's code is
220
+ * six digits — a space of one million, unthrottled, checked against every
221
+ * code in flight for every user at once rather than one account's.
222
+ *
223
+ * So the OTP provider keeps its email check, and links get their own provider
224
+ * whose tokens must carry their own entropy. `@convex-dev/auth` resolves which
225
+ * `authorize` to run from the provider recorded **on the verification code
226
+ * row**, not from what the caller claims, so the two cannot be confused: a
227
+ * six-digit OTP row still demands its email even when redeemed by a client
228
+ * that sent no provider at all.
229
+ *
230
+ * ## What the caller owes
231
+ *
232
+ * A high-entropy token. Nothing here can check that — the app mints the code —
233
+ * so it is stated rather than enforced: 32 random bytes or more. Anything
234
+ * guessable is now guessable without a rate limit and without knowing whose
235
+ * mailbox it was sent to.
236
+ *
237
+ * ## This provider is publicly reachable, and that is survivable
238
+ *
239
+ * `api.auth.signIn` is public, so anybody can call
240
+ * `signIn(MAGIC_LINK_PROVIDER_ID, { email })` for an address they do not own.
241
+ * Two things make that a nuisance rather than a hole, and both are worth
242
+ * knowing before anybody "hardens" it:
243
+ *
244
+ * - **The code that gets minted is strong.** No `generateVerificationToken`
245
+ * is passed, so `@convex-dev/auth` falls back to
246
+ * `generateRandomString(32, <62-char alphabet>)` — around 190 bits. That
247
+ * matters more here than for the OTP provider, because this is the provider
248
+ * with no email check and no rate limit. (Passing a weak generator would not
249
+ * help an attacker either: the factory ignores it, like everything else in
250
+ * its config. It would help a careless *caller*, which is why the paragraph
251
+ * above exists.)
252
+ * - **Nothing is delivered to the attacker.** With no
253
+ * `sendVerificationRequest` configured the warning below fires and no mail
254
+ * goes out; with one configured, the mail goes to the address that was named,
255
+ * not to whoever asked. Either way the code lands somewhere the attacker
256
+ * cannot read.
257
+ *
258
+ * What they can do is mint a code for somebody else's address, which deletes
259
+ * that account's pending code — a denial of service on a link already in
260
+ * flight. That is not new: `signIn("email", { email })` has always been able to
261
+ * invalidate a pending OTP the same way.
262
+ */
263
+ function createMagicLink(config: SupaAuthConfig) {
264
+ const magicLink = config.magicLink ?? {};
265
+
266
+ return {
267
+ ...Email({
268
+ maxAge: magicLink.maxAge ?? 60 * 60,
269
+ sendVerificationRequest: async ({ identifier, url, token }) => {
270
+ if (magicLink.sendVerificationRequest !== undefined) {
271
+ await magicLink.sendVerificationRequest({ identifier, url, token });
272
+ return;
273
+ }
274
+ // Reached only if something signs in through this provider by name.
275
+ // An app that mints its own codes has already sent its own mail; the
276
+ // provider exists so that code can be redeemed. Saying so beats
277
+ // silently succeeding at having sent nothing.
278
+ console.warn(
279
+ `[supa-auth] A sign-in was requested through the "${MAGIC_LINK_PROVIDER_ID}" ` +
280
+ "provider, which has no sendVerificationRequest configured, so no mail was " +
281
+ "sent. Pass magicLink.sendVerificationRequest, or mint the code yourself.",
282
+ );
283
+ },
284
+ }),
285
+ // Two overrides the factory ignores, for the same reason: it builds its
286
+ // result field by field and never spreads `config`, so BOTH `id` and
287
+ // `authorize` are hardcoded and anything passed in is dropped.
288
+ //
289
+ // `id` matters as much as `authorize` here. Left alone, this provider is
290
+ // also called `"email"` — two entries with one id, so
291
+ // `getProviderOrThrow` cannot tell them apart, and whichever it resolves
292
+ // decides whether a six-digit OTP still needs its address. The separation
293
+ // this whole function exists for would silently be no separation at all.
294
+ id: MAGIC_LINK_PROVIDER_ID,
295
+ // Without this the link is inert; with it on the *wrong* provider a
296
+ // six-digit code becomes an unthrottled global secret.
297
+ authorize: undefined,
298
+ };
299
+ }
300
+
301
+ function createPhoneOtp(config: SupaAuthConfig) {
302
+ const bridgePath =
303
+ config.twilio?.tokenBridgePath ?? "/api/internal/phone-token";
304
+
305
+ return Phone({
306
+ maxAge: 10 * 60, // 10 minutes
307
+ sendVerificationRequest: async ({ identifier: phone, token }) => {
308
+ const accountSid = process.env.TWILIO_ACCOUNT_SID;
309
+ const authToken = process.env.TWILIO_AUTH_TOKEN;
310
+ const verifyServiceSid = process.env.TWILIO_VERIFY_SERVICE_SID;
311
+ const siteUrl = normalizeConvexSiteUrl(process.env.CONVEX_SITE_URL);
312
+ const bridgeSecret = process.env.PHONE_TOKEN_BRIDGE_SECRET;
313
+
314
+ if (!siteUrl || !bridgeSecret) {
315
+ console.log("=== SMS OTP (bridge not configured) ===");
316
+ console.log(`To: ${phone}`);
317
+ console.log(`Auth Token: ${token}`);
318
+ console.log("Use this token directly as the code in signIn()");
319
+ console.log("=========================================");
320
+ return;
321
+ }
322
+
323
+ // 1. Store the @convex-dev/auth token via HTTP bridge
324
+ const storeResponse = await fetch(`${siteUrl}${bridgePath}`, {
325
+ method: "POST",
326
+ headers: {
327
+ "Content-Type": "application/json",
328
+ Authorization: `Bearer ${bridgeSecret}`,
329
+ },
330
+ body: JSON.stringify({
331
+ phone,
332
+ token,
333
+ expiresAt: Date.now() + 10 * 60 * 1000,
334
+ }),
335
+ });
336
+
337
+ if (!storeResponse.ok) {
338
+ console.error(
339
+ "Failed to store phone auth token:",
340
+ await storeResponse.text(),
341
+ );
342
+ throw new Error("Unable to initiate verification. Please try again.");
343
+ }
344
+
345
+ // 2. Send SMS via Twilio Verify
346
+ if (!accountSid || !authToken || !verifyServiceSid) {
347
+ console.log("=== SMS OTP (Twilio not configured) ===");
348
+ console.log(`To: ${phone}`);
349
+ console.log("Token stored. Use DEV_BYPASS_CODE to verify.");
350
+ console.log("=========================================");
351
+ return;
352
+ }
353
+
354
+ const response = await fetch(
355
+ `https://verify.twilio.com/v2/Services/${verifyServiceSid}/Verifications`,
356
+ {
357
+ method: "POST",
358
+ headers: {
359
+ Authorization: `Basic ${btoa(`${accountSid}:${authToken}`)}`,
360
+ "Content-Type": "application/x-www-form-urlencoded",
361
+ },
362
+ body: new URLSearchParams({
363
+ To: phone,
364
+ Channel: "sms",
365
+ }),
366
+ },
367
+ );
368
+
369
+ if (!response.ok) {
370
+ const errorText = await response.text();
371
+ let errorData: { code?: number; message?: string };
372
+ try {
373
+ errorData = JSON.parse(errorText);
374
+ } catch {
375
+ errorData = { message: errorText };
376
+ }
377
+
378
+ console.error("Twilio Verify send error:", {
379
+ status: response.status,
380
+ errorCode: errorData?.code,
381
+ errorMessage: errorData?.message,
382
+ phone,
383
+ });
384
+
385
+ throw new Error(
386
+ errorData?.message?.includes("Invalid phone number")
387
+ ? "Invalid phone number. Please check and try again."
388
+ : "Failed to send verification code. Please try again.",
389
+ );
390
+ }
391
+ },
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Create a fully configured Supa auth setup with Phone + Email OTP.
397
+ *
398
+ * Returns the same exports as `convexAuth()`: auth, signIn, signOut, store, isAuthenticated.
399
+ */
400
+ export function createSupaAuth(config: SupaAuthConfig = {}) {
401
+ const methods = config.methods ?? ["email", "phone"];
402
+ const providers = [
403
+ ...(methods.includes("email") ? [createEmailOtp(config)] : []),
404
+ // Additive, and gated on the email method: a link signs somebody into an
405
+ // email account, so registering it for an app that does not do email OTP
406
+ // would be registering a way in that app never asked for.
407
+ ...(methods.includes("email") && config.magicLink !== undefined
408
+ ? [createMagicLink(config)]
409
+ : []),
410
+ ...(methods.includes("phone") ? [createPhoneOtp(config)] : []),
411
+ ];
412
+
413
+ return convexAuth({
414
+ providers,
415
+ callbacks: {
416
+ async createOrUpdateUser(ctx, { existingUserId, type, profile }) {
417
+ // Returning user — auth account already exists
418
+ if (existingUserId !== null) {
419
+ const existingUser = await ctx.db.get(existingUserId);
420
+ if (existingUser) {
421
+ const updateData: Record<string, unknown> = {};
422
+ if (type === "phone" || type === "verification") {
423
+ updateData.phoneVerificationTime = Date.now();
424
+ }
425
+ if (type === "email" || type === "verification") {
426
+ updateData.emailVerificationTime = Date.now();
427
+ }
428
+ if (profile.phone) updateData.phone = profile.phone;
429
+ if (profile.email) updateData.email = profile.email;
430
+ if (profile.name) updateData.name = profile.name;
431
+
432
+ if (Object.keys(updateData).length > 0) {
433
+ await ctx.db.patch(existingUserId, updateData);
434
+ }
435
+ return existingUserId;
436
+ }
437
+ }
438
+
439
+ // New auth account — try to link to existing user by phone
440
+ if (type === "phone" && typeof profile.phone === "string") {
441
+ const phone = profile.phone;
442
+ const existingUser = await ctx.db
443
+ .query("users")
444
+ .filter((q) => q.eq(q.field("phone"), phone))
445
+ .first();
446
+
447
+ if (existingUser) {
448
+ await ctx.db.patch(existingUser._id, {
449
+ phoneVerificationTime: Date.now(),
450
+ });
451
+ return existingUser._id;
452
+ }
453
+ }
454
+
455
+ // New auth account — try to link to existing user by email
456
+ if (type === "email" && typeof profile.email === "string") {
457
+ const email = profile.email;
458
+ const existingUser = await ctx.db
459
+ .query("users")
460
+ .filter((q) => q.eq(q.field("email"), email))
461
+ .first();
462
+
463
+ if (existingUser) {
464
+ await ctx.db.patch(existingUser._id, {
465
+ emailVerificationTime: Date.now(),
466
+ });
467
+ return existingUser._id;
468
+ }
469
+ }
470
+
471
+ // No existing user — create a new one
472
+ const userData: Record<string, unknown> = {};
473
+ if (profile.email) userData.email = profile.email;
474
+ if (profile.phone) userData.phone = profile.phone;
475
+ if (profile.name) userData.name = profile.name;
476
+ if (profile.image) userData.image = profile.image;
477
+ if (profile.emailVerified || type === "email") {
478
+ userData.emailVerificationTime = Date.now();
479
+ }
480
+ if (profile.phoneVerified || type === "phone") {
481
+ userData.phoneVerificationTime = Date.now();
482
+ }
483
+ userData.isActive = true;
484
+ userData.createdAt = Date.now();
485
+
486
+ const userId = await ctx.db.insert(
487
+ "users",
488
+ userData as Record<string, unknown> & {
489
+ email?: string;
490
+ phone?: string;
491
+ },
492
+ );
493
+ return userId as GenericId<"users">;
494
+ },
495
+ },
496
+ });
497
+ }
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @supa-media/convex
3
+ *
4
+ * Backend package for the Supa framework — OTP auth, schema helpers,
5
+ * and backend utilities for Convex.
6
+ */
7
+
8
+ // Auth
9
+ export {
10
+ createSupaAuth,
11
+ MAGIC_LINK_PROVIDER_ID,
12
+ requireAuth,
13
+ requireAuthId,
14
+ getOptionalAuth,
15
+ getCurrentUserId,
16
+ } from "./auth";
17
+ export type {
18
+ SupaAuthConfig,
19
+ SupaAuthMagicLinkConfig,
20
+ SupaAuthResendConfig,
21
+ SupaAuthTwilioConfig,
22
+ } from "./auth";
23
+
24
+ // Schema
25
+ export {
26
+ supaAuthTables,
27
+ supaTenantTables,
28
+ supaTenantScope,
29
+ supaNotificationTables,
30
+ supaChatTables,
31
+ supaPaymentTables,
32
+ } from "./schema";
33
+ export type { TenantTableConfig, SupaTenantScope, TenantScopeConfig } from "./schema";
34
+
35
+ // Lib
36
+ export {
37
+ checkRateLimit,
38
+ supaRateLimitTable,
39
+ isValidPhone,
40
+ isValidEmail,
41
+ normalizePhone,
42
+ normalizeEmail,
43
+ CronSchedules,
44
+ Delay,
45
+ } from "./lib";
46
+
47
+ // Notifications
48
+ export {
49
+ registerPushToken,
50
+ cleanupExpiredTokens,
51
+ enqueueNotification,
52
+ sendPushNotification,
53
+ sendNotificationToUser,
54
+ processNotificationQueue,
55
+ } from "./notifications";
56
+ export type {
57
+ NotificationPayload,
58
+ ExpoPushMessage,
59
+ ExpoPushTicket,
60
+ } from "./notifications";
61
+
62
+ // Payments
63
+ export {
64
+ getOrCreateCustomer,
65
+ createCheckoutSession,
66
+ getSubscriptionStatus,
67
+ handleStripeWebhook,
68
+ verifyStripeSignature,
69
+ } from "./payments";
70
+ export type {
71
+ CheckoutSessionParams,
72
+ SubscriptionStatus,
73
+ } from "./payments";
@@ -0,0 +1,8 @@
1
+ export { checkRateLimit, supaRateLimitTable } from "./rateLimit";
2
+ export {
3
+ isValidPhone,
4
+ isValidEmail,
5
+ normalizePhone,
6
+ normalizeEmail,
7
+ } from "./validation";
8
+ export { CronSchedules, Delay } from "./scheduling";
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Rate Limiting
3
+ *
4
+ * Database-backed rate limiter using a sliding window counter.
5
+ * Designed for brute-force prevention on OTP and auth endpoints.
6
+ *
7
+ * Requires a `rateLimits` table in your schema:
8
+ * ```ts
9
+ * import { supaRateLimitTable } from "@supa-media/convex/lib";
10
+ *
11
+ * export default defineSchema({
12
+ * ...supaRateLimitTable,
13
+ * // other tables...
14
+ * });
15
+ * ```
16
+ */
17
+
18
+ import { defineTable } from "convex/server";
19
+ import { v } from "convex/values";
20
+
21
+ /** Rate limit table definition — spread into your schema. */
22
+ export const supaRateLimitTable = {
23
+ rateLimits: defineTable({
24
+ key: v.string(),
25
+ attempts: v.number(),
26
+ windowStart: v.number(),
27
+ }).index("by_key", ["key"]),
28
+ };
29
+
30
+ /**
31
+ * Minimal mutation context interface for rate limiting.
32
+ * Works with any Convex mutation context without requiring generated types.
33
+ */
34
+ interface RateLimitCtx {
35
+ db: {
36
+ query: (table: string) => any;
37
+ insert: (table: string, doc: any) => Promise<any>;
38
+ patch: (id: any, fields: any) => Promise<void>;
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Check and increment a rate limit counter.
44
+ *
45
+ * Throws a generic "Too many attempts" error if the caller has exceeded
46
+ * `maxAttempts` within `windowMs`. The counter auto-resets when the window expires.
47
+ *
48
+ * @param ctx - Convex mutation context (needs DB read/write)
49
+ * @param key - Rate limit key, e.g. "otp:+12025550123"
50
+ * @param maxAttempts - Max allowed attempts within the window
51
+ * @param windowMs - Window duration in milliseconds
52
+ */
53
+ export async function checkRateLimit(
54
+ ctx: RateLimitCtx,
55
+ key: string,
56
+ maxAttempts: number,
57
+ windowMs: number,
58
+ ): Promise<void> {
59
+ const now = Date.now();
60
+
61
+ const existing = await ctx.db
62
+ .query("rateLimits")
63
+ .withIndex("by_key", (q: any) => q.eq("key", key))
64
+ .first();
65
+
66
+ if (existing) {
67
+ const windowExpired = now - existing.windowStart >= windowMs;
68
+
69
+ if (windowExpired) {
70
+ await ctx.db.patch(existing._id, {
71
+ attempts: 1,
72
+ windowStart: now,
73
+ });
74
+ return;
75
+ }
76
+
77
+ if (existing.attempts >= maxAttempts) {
78
+ throw new Error("Too many attempts. Please try again later.");
79
+ }
80
+
81
+ await ctx.db.patch(existing._id, {
82
+ attempts: existing.attempts + 1,
83
+ });
84
+ return;
85
+ }
86
+
87
+ await ctx.db.insert("rateLimits", {
88
+ key,
89
+ attempts: 1,
90
+ windowStart: now,
91
+ });
92
+ }