@supa-media/convex 1.5.0 → 1.7.0
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 +17 -0
- package/package.json +1 -1
- package/src/auth/index.ts +7 -0
- package/src/auth/setup.ts +18 -33
- package/src/auth/twilioVerify.ts +98 -0
- package/src/auth/users.ts +23 -2
package/README.md
CHANGED
|
@@ -194,6 +194,7 @@ function createSupaAuth(config?: {
|
|
|
194
194
|
methods?: Array<"email" | "phone">; // default ["email", "phone"]
|
|
195
195
|
magicLink?: SupaAuthMagicLinkConfig; // off unless present
|
|
196
196
|
admission?: SupaAuthAdmission; // invite-only; open sign-up unless present
|
|
197
|
+
onUserCreated?: SupaAuthUserCreated; // once per brand-new user, same transaction
|
|
197
198
|
resend?: { fromAddress: string; emailSubject?: (code) => string;
|
|
198
199
|
renderHtml?: (p: { code, email }) => string };
|
|
199
200
|
twilio?: { tokenBridgePath?: string }; // default "/api/internal/phone-token"
|
|
@@ -265,6 +266,22 @@ that throws refuses. Ask your own "is this address let in?" before calling
|
|
|
265
266
|
`signIn` and draw the answer yourself; the error message is redacted in
|
|
266
267
|
production.
|
|
267
268
|
|
|
269
|
+
### New accounts (`onUserCreated`)
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
createSupaAuth({
|
|
273
|
+
onUserCreated: async (ctx, { userId, email }) => {
|
|
274
|
+
await ctx.scheduler.runAfter(0, internal.alerts.newAccount, { userId });
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Runs once for each brand-new user row, after the insert and inside the same
|
|
280
|
+
mutation, so whatever it writes or schedules commits only with the account. A
|
|
281
|
+
returning account, or a new sign-in method linked to an existing user, never
|
|
282
|
+
calls it. A hook that throws fails the sign-in, so keep it to quick writes and
|
|
283
|
+
`ctx.scheduler`.
|
|
284
|
+
|
|
268
285
|
### Magic link (opt-in)
|
|
269
286
|
|
|
270
287
|
Set `magicLink: {}` and a **second** email provider is registered under
|
package/package.json
CHANGED
package/src/auth/index.ts
CHANGED
|
@@ -11,7 +11,14 @@ export type {
|
|
|
11
11
|
SupaAuthResendConfig,
|
|
12
12
|
SupaAuthTestEmailConfig,
|
|
13
13
|
SupaAuthTwilioConfig,
|
|
14
|
+
SupaAuthUserCreated,
|
|
14
15
|
} from "./setup";
|
|
16
|
+
export {
|
|
17
|
+
checkTwilioVerification,
|
|
18
|
+
sendTwilioVerification,
|
|
19
|
+
twilioVerifyKeys,
|
|
20
|
+
} from "./twilioVerify";
|
|
21
|
+
export type { TwilioCheckResult, TwilioSendResult, TwilioVerifyKeys } from "./twilioVerify";
|
|
15
22
|
export {
|
|
16
23
|
requireAuth,
|
|
17
24
|
requireAuthId,
|
package/src/auth/setup.ts
CHANGED
|
@@ -22,14 +22,16 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { sendTwilioVerification } from "./twilioVerify";
|
|
25
26
|
import { convexAuth } from "@convex-dev/auth/server";
|
|
26
27
|
import { Email } from "@convex-dev/auth/providers/Email";
|
|
27
28
|
import { Phone } from "@convex-dev/auth/providers/Phone";
|
|
28
29
|
import { assertMayReceiveEmailCode, type SupaAuthAdmission } from "./admission";
|
|
29
30
|
import { createTestEmailOtps, type SupaAuthTestEmailConfig } from "./testEmail";
|
|
30
|
-
import { userCallback } from "./users";
|
|
31
|
+
import { userCallback, type SupaAuthUserCreated } from "./users";
|
|
31
32
|
|
|
32
33
|
export { NOT_ADMITTED_MESSAGE, type SupaAuthAdmission } from "./admission";
|
|
34
|
+
export type { SupaAuthUserCreated } from "./users";
|
|
33
35
|
|
|
34
36
|
export {
|
|
35
37
|
createTestEmailOtp,
|
|
@@ -124,6 +126,15 @@ export interface SupaAuthConfig {
|
|
|
124
126
|
* get a brand-new account. Omitted, anybody may sign up. See `admission.ts`.
|
|
125
127
|
*/
|
|
126
128
|
admission?: SupaAuthAdmission;
|
|
129
|
+
/**
|
|
130
|
+
* Called once for each brand-new user row, never for a returning account or
|
|
131
|
+
* a new sign-in method linked to an existing one. Runs inside the sign-in
|
|
132
|
+
* mutation, after the insert: what it writes or schedules commits only with
|
|
133
|
+
* the account, and a hook that throws fails the sign-in. Keep it to quick
|
|
134
|
+
* writes and `ctx.scheduler` — a welcome email or a staff alert belongs in a
|
|
135
|
+
* scheduled action, not here.
|
|
136
|
+
*/
|
|
137
|
+
onUserCreated?: SupaAuthUserCreated;
|
|
127
138
|
/** Resend email OTP configuration. */
|
|
128
139
|
resend?: SupaAuthResendConfig;
|
|
129
140
|
/** Twilio phone OTP configuration. */
|
|
@@ -378,39 +389,13 @@ function createPhoneOtp(config: SupaAuthConfig) {
|
|
|
378
389
|
return;
|
|
379
390
|
}
|
|
380
391
|
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
method: "POST",
|
|
385
|
-
headers: {
|
|
386
|
-
Authorization: `Basic ${btoa(`${accountSid}:${authToken}`)}`,
|
|
387
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
388
|
-
},
|
|
389
|
-
body: new URLSearchParams({
|
|
390
|
-
To: phone,
|
|
391
|
-
Channel: "sms",
|
|
392
|
-
}),
|
|
393
|
-
},
|
|
392
|
+
const sent = await sendTwilioVerification(
|
|
393
|
+
{ accountSid, authToken, serviceSid: verifyServiceSid },
|
|
394
|
+
phone,
|
|
394
395
|
);
|
|
395
|
-
|
|
396
|
-
if (!response.ok) {
|
|
397
|
-
const errorText = await response.text();
|
|
398
|
-
let errorData: { code?: number; message?: string };
|
|
399
|
-
try {
|
|
400
|
-
errorData = JSON.parse(errorText);
|
|
401
|
-
} catch {
|
|
402
|
-
errorData = { message: errorText };
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
console.error("Twilio Verify send error:", {
|
|
406
|
-
status: response.status,
|
|
407
|
-
errorCode: errorData?.code,
|
|
408
|
-
errorMessage: errorData?.message,
|
|
409
|
-
phone,
|
|
410
|
-
});
|
|
411
|
-
|
|
396
|
+
if (!sent.ok) {
|
|
412
397
|
throw new Error(
|
|
413
|
-
|
|
398
|
+
sent.reason === "invalid_phone"
|
|
414
399
|
? "Invalid phone number. Please check and try again."
|
|
415
400
|
: "Failed to send verification code. Please try again.",
|
|
416
401
|
);
|
|
@@ -440,6 +425,6 @@ export function createSupaAuth(config: SupaAuthConfig = {}) {
|
|
|
440
425
|
|
|
441
426
|
return convexAuth({
|
|
442
427
|
providers,
|
|
443
|
-
callbacks: { createOrUpdateUser: userCallback(config.admission) },
|
|
428
|
+
callbacks: { createOrUpdateUser: userCallback(config.admission, config.onUserCreated) },
|
|
444
429
|
});
|
|
445
430
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twilio Verify, on its own: send a code to a phone, then check one.
|
|
3
|
+
*
|
|
4
|
+
* `createSupaAuth`'s phone provider uses Twilio Verify to *sign in* with a
|
|
5
|
+
* phone. An app that signs in with email can still want to know a person holds
|
|
6
|
+
* a phone: proof of a real person, or one identity across several sign-in
|
|
7
|
+
* emails. These two calls are that, without a sign-in provider: Twilio keeps
|
|
8
|
+
* the code, so the app stores no secret and only records the result.
|
|
9
|
+
*
|
|
10
|
+
* The keys are the provider's: `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`,
|
|
11
|
+
* `TWILIO_VERIFY_SERVICE_SID`. With any missing, `twilioVerifyKeys` answers
|
|
12
|
+
* `null`, and the app decides what that means. It should never mean "verified".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface TwilioVerifyKeys {
|
|
16
|
+
accountSid: string;
|
|
17
|
+
authToken: string;
|
|
18
|
+
serviceSid: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type TwilioSendResult =
|
|
22
|
+
| { ok: true }
|
|
23
|
+
| { ok: false; reason: "invalid_phone" | "too_many" | "failed" };
|
|
24
|
+
|
|
25
|
+
/** `wrong` covers an expired or unknown code too: Twilio answers both with 404. */
|
|
26
|
+
export type TwilioCheckResult = "approved" | "wrong" | "too_many" | "failed";
|
|
27
|
+
|
|
28
|
+
type Fetch = typeof fetch;
|
|
29
|
+
|
|
30
|
+
/** The keys from the environment, or `null` when any is unset. */
|
|
31
|
+
export function twilioVerifyKeys(
|
|
32
|
+
env: Record<string, string | undefined> = process.env,
|
|
33
|
+
): TwilioVerifyKeys | null {
|
|
34
|
+
const accountSid = env.TWILIO_ACCOUNT_SID?.trim();
|
|
35
|
+
const authToken = env.TWILIO_AUTH_TOKEN?.trim();
|
|
36
|
+
const serviceSid = env.TWILIO_VERIFY_SERVICE_SID?.trim();
|
|
37
|
+
if (!accountSid || !authToken || !serviceSid) return null;
|
|
38
|
+
return { accountSid, authToken, serviceSid };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function request(keys: TwilioVerifyKeys, path: string, body: Record<string, string>, fetchImpl: Fetch) {
|
|
42
|
+
return fetchImpl(`https://verify.twilio.com/v2/Services/${keys.serviceSid}/${path}`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Basic ${btoa(`${keys.accountSid}:${keys.authToken}`)}`,
|
|
46
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
47
|
+
},
|
|
48
|
+
body: new URLSearchParams(body),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function errorOf(response: Response): Promise<{ code?: number; message?: string }> {
|
|
53
|
+
const text = await response.text();
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text) as { code?: number; message?: string };
|
|
56
|
+
} catch {
|
|
57
|
+
return { message: text };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Twilio's "Max send attempts reached" and "Max check attempts reached". */
|
|
62
|
+
const TOO_MANY = new Set([60203, 60202]);
|
|
63
|
+
|
|
64
|
+
/** Text a code to `phone` (E.164). */
|
|
65
|
+
export async function sendTwilioVerification(
|
|
66
|
+
keys: TwilioVerifyKeys,
|
|
67
|
+
phone: string,
|
|
68
|
+
fetchImpl: Fetch = fetch,
|
|
69
|
+
): Promise<TwilioSendResult> {
|
|
70
|
+
const response = await request(keys, "Verifications", { To: phone, Channel: "sms" }, fetchImpl);
|
|
71
|
+
if (response.ok) return { ok: true };
|
|
72
|
+
const error = await errorOf(response);
|
|
73
|
+
// Logged without the number: a phone number is personal data.
|
|
74
|
+
console.error("Twilio Verify send failed", { status: response.status, code: error.code });
|
|
75
|
+
if (error.code !== undefined && TOO_MANY.has(error.code)) return { ok: false, reason: "too_many" };
|
|
76
|
+
if (error.code === 60200 || /invalid.*phone/i.test(error.message ?? "")) {
|
|
77
|
+
return { ok: false, reason: "invalid_phone" };
|
|
78
|
+
}
|
|
79
|
+
return { ok: false, reason: "failed" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Check a code typed for `phone`. Only `approved` means the person holds it. */
|
|
83
|
+
export async function checkTwilioVerification(
|
|
84
|
+
keys: TwilioVerifyKeys,
|
|
85
|
+
phone: string,
|
|
86
|
+
code: string,
|
|
87
|
+
fetchImpl: Fetch = fetch,
|
|
88
|
+
): Promise<TwilioCheckResult> {
|
|
89
|
+
const response = await request(keys, "VerificationCheck", { To: phone, Code: code }, fetchImpl);
|
|
90
|
+
if (response.status === 404) return "wrong";
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const error = await errorOf(response);
|
|
93
|
+
console.error("Twilio Verify check failed", { status: response.status, code: error.code });
|
|
94
|
+
return error.code !== undefined && TOO_MANY.has(error.code) ? "too_many" : "failed";
|
|
95
|
+
}
|
|
96
|
+
const body = (await response.json()) as { status?: string; valid?: boolean };
|
|
97
|
+
return body.status === "approved" && body.valid !== false ? "approved" : "wrong";
|
|
98
|
+
}
|
package/src/auth/users.ts
CHANGED
|
@@ -6,18 +6,29 @@
|
|
|
6
6
|
* who signs in two ways is one user. Only when nobody matches is a user row
|
|
7
7
|
* created — and that is the one place `admission.canCreateUser` is asked, so
|
|
8
8
|
* an invite-only app can refuse a stranger without ever locking out somebody
|
|
9
|
-
* who already has an account.
|
|
9
|
+
* who already has an account. It is also the one place `onUserCreated` runs,
|
|
10
|
+
* so an app hears about each brand-new account exactly once.
|
|
10
11
|
*/
|
|
12
|
+
import type { AnyDataModel, GenericMutationCtx } from "convex/server";
|
|
11
13
|
import type { GenericId } from "convex/values";
|
|
12
14
|
import type { convexAuth } from "@convex-dev/auth/server";
|
|
13
15
|
|
|
14
16
|
import { assertMayCreateUser, type SupaAuthAdmission } from "./admission";
|
|
15
17
|
|
|
18
|
+
/** See `SupaAuthConfig.onUserCreated`. */
|
|
19
|
+
export type SupaAuthUserCreated = (
|
|
20
|
+
ctx: GenericMutationCtx<AnyDataModel>,
|
|
21
|
+
created: { userId: GenericId<"users">; provider: string; email?: string; phone?: string },
|
|
22
|
+
) => Promise<void>;
|
|
23
|
+
|
|
16
24
|
type Handler = NonNullable<
|
|
17
25
|
NonNullable<Parameters<typeof convexAuth>[0]["callbacks"]>["createOrUpdateUser"]
|
|
18
26
|
>;
|
|
19
27
|
|
|
20
|
-
export function userCallback(
|
|
28
|
+
export function userCallback(
|
|
29
|
+
admission: SupaAuthAdmission | undefined,
|
|
30
|
+
onUserCreated?: SupaAuthUserCreated,
|
|
31
|
+
): Handler {
|
|
21
32
|
async function createOrUpdateUser(
|
|
22
33
|
ctx: Parameters<Handler>[0],
|
|
23
34
|
{ existingUserId, type, provider, profile }: Parameters<Handler>[1],
|
|
@@ -106,6 +117,16 @@ export function userCallback(admission: SupaAuthAdmission | undefined): Handler
|
|
|
106
117
|
phone?: string;
|
|
107
118
|
},
|
|
108
119
|
);
|
|
120
|
+
// Same transaction as the insert: a hook that throws undoes the account,
|
|
121
|
+
// and anything it writes or schedules lands only if the account does.
|
|
122
|
+
if (onUserCreated !== undefined) {
|
|
123
|
+
await onUserCreated(ctx, {
|
|
124
|
+
userId: userId as GenericId<"users">,
|
|
125
|
+
provider: provider.id,
|
|
126
|
+
...(typeof profile.email === "string" ? { email: profile.email } : {}),
|
|
127
|
+
...(typeof profile.phone === "string" ? { phone: profile.phone } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
109
130
|
return userId as GenericId<"users">;
|
|
110
131
|
}
|
|
111
132
|
|