@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.
- package/LICENSE +21 -0
- package/README.md +517 -0
- package/package.json +36 -0
- package/src/auth/helpers.ts +99 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/setup.ts +497 -0
- package/src/index.ts +73 -0
- package/src/lib/index.ts +8 -0
- package/src/lib/rateLimit.ts +92 -0
- package/src/lib/scheduling.ts +41 -0
- package/src/lib/validation.ts +63 -0
- package/src/notifications/index.ts +242 -0
- package/src/payments/index.ts +404 -0
- package/src/schema/authTables.ts +38 -0
- package/src/schema/chatTables.ts +58 -0
- package/src/schema/index.ts +8 -0
- package/src/schema/notificationTables.ts +58 -0
- package/src/schema/paymentTables.ts +47 -0
- package/src/schema/tenantScoping.ts +243 -0
- package/src/schema/tenantTables.ts +84 -0
- package/src/webhooks/hmac.ts +125 -0
- package/src/webhooks/index.ts +41 -0
- package/src/webhooks/sharedSecret.ts +48 -0
- package/src/webhooks/stripe.ts +97 -0
- package/src/webhooks/twilio.ts +80 -0
|
@@ -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";
|
package/src/lib/index.ts
ADDED
|
@@ -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
|
+
}
|