@supa-media/convex 1.4.0 → 1.6.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 CHANGED
@@ -193,6 +193,8 @@ function createSupaAuth(config?: {
193
193
  appName?: string;
194
194
  methods?: Array<"email" | "phone">; // default ["email", "phone"]
195
195
  magicLink?: SupaAuthMagicLinkConfig; // off unless present
196
+ admission?: SupaAuthAdmission; // invite-only; open sign-up unless present
197
+ onUserCreated?: SupaAuthUserCreated; // once per brand-new user, same transaction
196
198
  resend?: { fromAddress: string; emailSubject?: (code) => string;
197
199
  renderHtml?: (p: { code, email }) => string };
198
200
  twilio?: { tokenBridgePath?: string }; // default "/api/internal/phone-token"
@@ -244,6 +246,42 @@ production login code.
244
246
  > `PHONE_TOKEN_BRIDGE_SECRET` is unset, the provider logs the raw token to the
245
247
  > console and returns, so local dev degrades rather than breaks.
246
248
 
249
+
250
+ ### Invite-only sign-in (`admission`)
251
+
252
+ ```ts
253
+ createSupaAuth({
254
+ admission: {
255
+ canReceiveEmailCode: (ctx, email) => ctx.runQuery(internal.waitlist.isAdmitted, { email }),
256
+ canCreateUser: (ctx, { email }) => isAdmitted(ctx.db, email),
257
+ },
258
+ });
259
+ ```
260
+
261
+ `canReceiveEmailCode` runs before a sign-in code is mailed; refusing means no
262
+ mail goes out and `signIn` throws. `canCreateUser` runs before a user row is
263
+ created for somebody with no account, whichever provider they came through, and
264
+ is never asked for an account that already exists. Both fail closed: a hook
265
+ that throws refuses. Ask your own "is this address let in?" before calling
266
+ `signIn` and draw the answer yourself; the error message is redacted in
267
+ production.
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
+
247
285
  ### Magic link (opt-in)
248
286
 
249
287
  Set `magicLink: {}` and a **second** email provider is registered under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supa-media/convex",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Backend package for the Supa framework — OTP auth, schema helpers, and backend utilities for Convex",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Who may sign in at all: the hooks an invite-only or waitlisted app uses.
3
+ *
4
+ * Without `admission`, anybody who can read their inbox can make an account —
5
+ * the default, and unchanged. With it, an app answers two questions:
6
+ *
7
+ * - **May this address be mailed a code?** Asked before the email OTP
8
+ * provider sends anything. Refusing means no mail goes out and `signIn`
9
+ * throws, so a person who has not been let in never holds a code.
10
+ * - **May this person get a brand-new account?** Asked in
11
+ * `createOrUpdateUser`, only when no user exists yet. This is the backstop,
12
+ * and it is the one that covers every provider — the magic link, the
13
+ * fixed-code test accounts, a phone number — not only the email OTP.
14
+ * Returning users never reach it, so turning an app invite-only can never
15
+ * lock out an account that already exists.
16
+ *
17
+ * Both hooks fail closed: one that throws, or a send that arrives with no ctx
18
+ * to ask with, refuses.
19
+ *
20
+ * The refusal is one fixed message, `NOT_ADMITTED_MESSAGE`. A client should
21
+ * not rely on reading it — Convex redacts action errors in production — and
22
+ * should instead ask the app whether an address is let in *before* calling
23
+ * `signIn`, drawing its own "you're on the list" for the answer. These hooks
24
+ * are what makes that answer binding rather than advisory.
25
+ */
26
+ import type {
27
+ AnyDataModel,
28
+ GenericActionCtx,
29
+ GenericMutationCtx,
30
+ } from "convex/server";
31
+
32
+ export interface SupaAuthAdmission {
33
+ /**
34
+ * Before a sign-in code is mailed. `email` is the address as the provider
35
+ * received it; normalize it the same way the app stores addresses.
36
+ */
37
+ canReceiveEmailCode?: (
38
+ ctx: GenericActionCtx<AnyDataModel>,
39
+ email: string,
40
+ ) => Promise<boolean>;
41
+ /**
42
+ * Before a user row is created for somebody with no account. `provider` is
43
+ * the auth provider's id (`"email"`, `"magic-link"`, `"test-email"`, …).
44
+ */
45
+ canCreateUser?: (
46
+ ctx: GenericMutationCtx<AnyDataModel>,
47
+ who: { provider: string; email?: string; phone?: string },
48
+ ) => Promise<boolean>;
49
+ }
50
+
51
+ export const NOT_ADMITTED_MESSAGE = "This address has not been let in yet.";
52
+
53
+ async function allowed(check: () => Promise<boolean>): Promise<boolean> {
54
+ try {
55
+ return (await check()) === true;
56
+ } catch (error) {
57
+ console.error("[supa-auth] admission check failed, refusing:", error);
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /** Throws unless `admission` lets `email` be mailed a code. */
63
+ export async function assertMayReceiveEmailCode(
64
+ admission: SupaAuthAdmission | undefined,
65
+ ctx: unknown,
66
+ email: string,
67
+ ): Promise<void> {
68
+ const check = admission?.canReceiveEmailCode;
69
+ if (check === undefined) return;
70
+ const ok =
71
+ ctx !== undefined &&
72
+ ctx !== null &&
73
+ (await allowed(() =>
74
+ check(ctx as GenericActionCtx<AnyDataModel>, email),
75
+ ));
76
+ if (!ok) throw new Error(NOT_ADMITTED_MESSAGE);
77
+ }
78
+
79
+ /** Throws unless `admission` lets this person have a new account. */
80
+ export async function assertMayCreateUser(
81
+ admission: SupaAuthAdmission | undefined,
82
+ ctx: GenericMutationCtx<AnyDataModel>,
83
+ who: { provider: string; email?: string; phone?: string },
84
+ ): Promise<void> {
85
+ const check = admission?.canCreateUser;
86
+ if (check === undefined) return;
87
+ if (!(await allowed(() => check(ctx, who)))) {
88
+ throw new Error(NOT_ADMITTED_MESSAGE);
89
+ }
90
+ }
package/src/auth/index.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  export {
2
2
  createSupaAuth,
3
3
  MAGIC_LINK_PROVIDER_ID,
4
+ NOT_ADMITTED_MESSAGE,
4
5
  TEST_EMAIL_PROVIDER_ID,
5
6
  } from "./setup";
6
7
  export type {
8
+ SupaAuthAdmission,
7
9
  SupaAuthConfig,
8
10
  SupaAuthMagicLinkConfig,
9
11
  SupaAuthResendConfig,
10
12
  SupaAuthTestEmailConfig,
11
13
  SupaAuthTwilioConfig,
14
+ SupaAuthUserCreated,
12
15
  } from "./setup";
13
16
  export {
14
17
  requireAuth,
package/src/auth/setup.ts CHANGED
@@ -25,9 +25,12 @@
25
25
  import { convexAuth } from "@convex-dev/auth/server";
26
26
  import { Email } from "@convex-dev/auth/providers/Email";
27
27
  import { Phone } from "@convex-dev/auth/providers/Phone";
28
- import type { GenericId } from "convex/values";
29
-
28
+ import { assertMayReceiveEmailCode, type SupaAuthAdmission } from "./admission";
30
29
  import { createTestEmailOtps, type SupaAuthTestEmailConfig } from "./testEmail";
30
+ import { userCallback, type SupaAuthUserCreated } from "./users";
31
+
32
+ export { NOT_ADMITTED_MESSAGE, type SupaAuthAdmission } from "./admission";
33
+ export type { SupaAuthUserCreated } from "./users";
31
34
 
32
35
  export {
33
36
  createTestEmailOtp,
@@ -117,6 +120,20 @@ export interface SupaAuthConfig {
117
120
  * other email address is refused by a distinct provider.
118
121
  */
119
122
  testEmail?: SupaAuthTestEmailConfig | SupaAuthTestEmailConfig[];
123
+ /**
124
+ * Make the app invite-only: who may be mailed a sign-in code, and who may
125
+ * get a brand-new account. Omitted, anybody may sign up. See `admission.ts`.
126
+ */
127
+ admission?: SupaAuthAdmission;
128
+ /**
129
+ * Called once for each brand-new user row, never for a returning account or
130
+ * a new sign-in method linked to an existing one. Runs inside the sign-in
131
+ * mutation, after the insert: what it writes or schedules commits only with
132
+ * the account, and a hook that throws fails the sign-in. Keep it to quick
133
+ * writes and `ctx.scheduler` — a welcome email or a staff alert belongs in a
134
+ * scheduled action, not here.
135
+ */
136
+ onUserCreated?: SupaAuthUserCreated;
120
137
  /** Resend email OTP configuration. */
121
138
  resend?: SupaAuthResendConfig;
122
139
  /** Twilio phone OTP configuration. */
@@ -165,7 +182,10 @@ function createEmailOtp(config: SupaAuthConfig) {
165
182
  return Email({
166
183
  maxAge: 10 * 60, // 10 minutes
167
184
  generateVerificationToken: createOtpGenerator(config.productionIdentifier),
168
- sendVerificationRequest: async ({ identifier: email, token }) => {
185
+ // `@convex-dev/auth` passes its action ctx as a second argument that the
186
+ // Auth.js type does not declare; the admission check needs it to ask.
187
+ sendVerificationRequest: async ({ identifier: email, token }, ctx?: unknown) => {
188
+ await assertMayReceiveEmailCode(config.admission, ctx, email);
169
189
  // Dynamic import of resend — only loaded when RESEND_API_KEY is set
170
190
  const apiKey = process.env.RESEND_API_KEY;
171
191
 
@@ -430,86 +450,6 @@ export function createSupaAuth(config: SupaAuthConfig = {}) {
430
450
 
431
451
  return convexAuth({
432
452
  providers,
433
- callbacks: {
434
- async createOrUpdateUser(ctx, { existingUserId, type, profile }) {
435
- // Returning user — auth account already exists
436
- if (existingUserId !== null) {
437
- const existingUser = await ctx.db.get(existingUserId);
438
- if (existingUser) {
439
- const updateData: Record<string, unknown> = {};
440
- if (type === "phone" || type === "verification") {
441
- updateData.phoneVerificationTime = Date.now();
442
- }
443
- if (type === "email" || type === "verification") {
444
- updateData.emailVerificationTime = Date.now();
445
- }
446
- if (profile.phone) updateData.phone = profile.phone;
447
- if (profile.email) updateData.email = profile.email;
448
- if (profile.name) updateData.name = profile.name;
449
-
450
- if (Object.keys(updateData).length > 0) {
451
- await ctx.db.patch(existingUserId, updateData);
452
- }
453
- return existingUserId;
454
- }
455
- }
456
-
457
- // New auth account — try to link to existing user by phone
458
- if (type === "phone" && typeof profile.phone === "string") {
459
- const phone = profile.phone;
460
- const existingUser = await ctx.db
461
- .query("users")
462
- .filter((q) => q.eq(q.field("phone"), phone))
463
- .first();
464
-
465
- if (existingUser) {
466
- await ctx.db.patch(existingUser._id, {
467
- phoneVerificationTime: Date.now(),
468
- });
469
- return existingUser._id;
470
- }
471
- }
472
-
473
- // New auth account — try to link to existing user by email
474
- if (type === "email" && typeof profile.email === "string") {
475
- const email = profile.email;
476
- const existingUser = await ctx.db
477
- .query("users")
478
- .filter((q) => q.eq(q.field("email"), email))
479
- .first();
480
-
481
- if (existingUser) {
482
- await ctx.db.patch(existingUser._id, {
483
- emailVerificationTime: Date.now(),
484
- });
485
- return existingUser._id;
486
- }
487
- }
488
-
489
- // No existing user — create a new one
490
- const userData: Record<string, unknown> = {};
491
- if (profile.email) userData.email = profile.email;
492
- if (profile.phone) userData.phone = profile.phone;
493
- if (profile.name) userData.name = profile.name;
494
- if (profile.image) userData.image = profile.image;
495
- if (profile.emailVerified || type === "email") {
496
- userData.emailVerificationTime = Date.now();
497
- }
498
- if (profile.phoneVerified || type === "phone") {
499
- userData.phoneVerificationTime = Date.now();
500
- }
501
- userData.isActive = true;
502
- userData.createdAt = Date.now();
503
-
504
- const userId = await ctx.db.insert(
505
- "users",
506
- userData as Record<string, unknown> & {
507
- email?: string;
508
- phone?: string;
509
- },
510
- );
511
- return userId as GenericId<"users">;
512
- },
513
- },
453
+ callbacks: { createOrUpdateUser: userCallback(config.admission, config.onUserCreated) },
514
454
  });
515
455
  }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The `createOrUpdateUser` callback `createSupaAuth` hands `convexAuth`.
3
+ *
4
+ * A returning account keeps its user. A new account is linked to an existing
5
+ * user with the same phone or email before anything is created, so one person
6
+ * who signs in two ways is one user. Only when nobody matches is a user row
7
+ * created — and that is the one place `admission.canCreateUser` is asked, so
8
+ * an invite-only app can refuse a stranger without ever locking out somebody
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.
11
+ */
12
+ import type { AnyDataModel, GenericMutationCtx } from "convex/server";
13
+ import type { GenericId } from "convex/values";
14
+ import type { convexAuth } from "@convex-dev/auth/server";
15
+
16
+ import { assertMayCreateUser, type SupaAuthAdmission } from "./admission";
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
+
24
+ type Handler = NonNullable<
25
+ NonNullable<Parameters<typeof convexAuth>[0]["callbacks"]>["createOrUpdateUser"]
26
+ >;
27
+
28
+ export function userCallback(
29
+ admission: SupaAuthAdmission | undefined,
30
+ onUserCreated?: SupaAuthUserCreated,
31
+ ): Handler {
32
+ async function createOrUpdateUser(
33
+ ctx: Parameters<Handler>[0],
34
+ { existingUserId, type, provider, profile }: Parameters<Handler>[1],
35
+ ): Promise<GenericId<"users">> {
36
+ // Returning user — auth account already exists
37
+ if (existingUserId !== null) {
38
+ const existingUser = await ctx.db.get(existingUserId);
39
+ if (existingUser) {
40
+ const updateData: Record<string, unknown> = {};
41
+ if (type === "phone" || type === "verification") {
42
+ updateData.phoneVerificationTime = Date.now();
43
+ }
44
+ if (type === "email" || type === "verification") {
45
+ updateData.emailVerificationTime = Date.now();
46
+ }
47
+ if (profile.phone) updateData.phone = profile.phone;
48
+ if (profile.email) updateData.email = profile.email;
49
+ if (profile.name) updateData.name = profile.name;
50
+
51
+ if (Object.keys(updateData).length > 0) {
52
+ await ctx.db.patch(existingUserId, updateData);
53
+ }
54
+ return existingUserId;
55
+ }
56
+ }
57
+
58
+ // New auth account — try to link to existing user by phone
59
+ if (type === "phone" && typeof profile.phone === "string") {
60
+ const phone = profile.phone;
61
+ const existingUser = await ctx.db
62
+ .query("users")
63
+ .filter((q) => q.eq(q.field("phone"), phone))
64
+ .first();
65
+
66
+ if (existingUser) {
67
+ await ctx.db.patch(existingUser._id, {
68
+ phoneVerificationTime: Date.now(),
69
+ });
70
+ return existingUser._id;
71
+ }
72
+ }
73
+
74
+ // New auth account — try to link to existing user by email
75
+ if (type === "email" && typeof profile.email === "string") {
76
+ const email = profile.email;
77
+ const existingUser = await ctx.db
78
+ .query("users")
79
+ .filter((q) => q.eq(q.field("email"), email))
80
+ .first();
81
+
82
+ if (existingUser) {
83
+ await ctx.db.patch(existingUser._id, {
84
+ emailVerificationTime: Date.now(),
85
+ });
86
+ return existingUser._id;
87
+ }
88
+ }
89
+
90
+ // No existing user. Ask the app first: this is the backstop that holds
91
+ // for every provider, not only the email OTP. See `admission.ts`.
92
+ await assertMayCreateUser(admission, ctx, {
93
+ provider: provider.id,
94
+ ...(typeof profile.email === "string" ? { email: profile.email } : {}),
95
+ ...(typeof profile.phone === "string" ? { phone: profile.phone } : {}),
96
+ });
97
+
98
+ // No existing user — create a new one
99
+ const userData: Record<string, unknown> = {};
100
+ if (profile.email) userData.email = profile.email;
101
+ if (profile.phone) userData.phone = profile.phone;
102
+ if (profile.name) userData.name = profile.name;
103
+ if (profile.image) userData.image = profile.image;
104
+ if (profile.emailVerified || type === "email") {
105
+ userData.emailVerificationTime = Date.now();
106
+ }
107
+ if (profile.phoneVerified || type === "phone") {
108
+ userData.phoneVerificationTime = Date.now();
109
+ }
110
+ userData.isActive = true;
111
+ userData.createdAt = Date.now();
112
+
113
+ const userId = await ctx.db.insert(
114
+ "users",
115
+ userData as Record<string, unknown> & {
116
+ email?: string;
117
+ phone?: string;
118
+ },
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
+ }
130
+ return userId as GenericId<"users">;
131
+ }
132
+
133
+ return createOrUpdateUser;
134
+ }