@convex-dev/workos-authkit 0.2.9 → 0.3.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.
Files changed (42) hide show
  1. package/README.md +14 -5
  2. package/dist/client/index.d.ts +5 -0
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +3 -2
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +0 -1
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +0 -2
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/backfill.d.ts +6 -6
  12. package/dist/component/backfill.d.ts.map +1 -1
  13. package/dist/component/backfill.js +12 -5
  14. package/dist/component/backfill.js.map +1 -1
  15. package/dist/component/convex.config.d.ts.map +1 -1
  16. package/dist/component/convex.config.js +0 -2
  17. package/dist/component/convex.config.js.map +1 -1
  18. package/dist/component/lib.d.ts +7 -27
  19. package/dist/component/lib.d.ts.map +1 -1
  20. package/dist/component/lib.js +91 -112
  21. package/dist/component/lib.js.map +1 -1
  22. package/dist/component/schema.d.ts +13 -4
  23. package/dist/component/schema.d.ts.map +1 -1
  24. package/dist/component/schema.js +7 -14
  25. package/dist/component/schema.js.map +1 -1
  26. package/dist/validators.d.ts +30 -0
  27. package/dist/validators.d.ts.map +1 -0
  28. package/dist/validators.js +17 -0
  29. package/dist/validators.js.map +1 -0
  30. package/package.json +7 -7
  31. package/src/client/index.test.ts +18 -0
  32. package/src/client/index.ts +10 -2
  33. package/src/component/_generated/api.ts +0 -1
  34. package/src/component/_generated/component.ts +0 -2
  35. package/src/component/backfill.test.ts +19 -11
  36. package/src/component/backfill.ts +25 -18
  37. package/src/component/convex.config.ts +0 -2
  38. package/src/component/lib.test.ts +248 -11
  39. package/src/component/lib.ts +95 -124
  40. package/src/component/schema.ts +7 -14
  41. package/src/test.ts +4 -3
  42. package/src/validators.ts +17 -0
@@ -3,7 +3,6 @@ import { convexTest } from "convex-test";
3
3
  import { vi, describe, test, expect, beforeEach, afterEach } from "vitest";
4
4
  import { modules } from "./setup.test.js";
5
5
  import schema from "./schema.js";
6
- import workpool from "@convex-dev/workpool/test";
7
6
  import workflow from "@convex-dev/workflow/test";
8
7
  import { api, internal } from "./_generated/api.js";
9
8
 
@@ -44,7 +43,6 @@ function makeUser(overrides: Partial<typeof defaultUser> = {}) {
44
43
  /** Initialize a convex-test instance with sub-component registrations. */
45
44
  function initConvexTest() {
46
45
  const t = convexTest(schema, modules);
47
- workpool.register(t, "eventWorkpool");
48
46
  workflow.register(t, "backfillWorkflow");
49
47
  return t;
50
48
  }
@@ -152,6 +150,22 @@ describe("backfill", () => {
152
150
  expect(dbUsers).toHaveLength(1);
153
151
  });
154
152
 
153
+ test("upsertUsersPage skips users with a tombstone", async () => {
154
+ const t = initConvexTest();
155
+ const user = makeUser({ id: "user_deleted", email: "gone@example.com" });
156
+
157
+ await t.run(async (ctx) => {
158
+ await ctx.db.insert("deletedUsers", { id: user.id });
159
+ });
160
+
161
+ await t.mutation(internal.backfill.upsertUsersPage, { users: [user] });
162
+
163
+ const dbUsers = await t.run(async (ctx) => {
164
+ return ctx.db.query("users").collect();
165
+ });
166
+ expect(dbUsers).toHaveLength(0);
167
+ });
168
+
155
169
  test("full workflow processes single page", async () => {
156
170
  const users = [
157
171
  makeUser({ id: "user_w1", email: "w1@example.com" }),
@@ -224,12 +238,8 @@ describe("backfill", () => {
224
238
  });
225
239
 
226
240
  test("pagination data flows through processUsersPage", async () => {
227
- const page1Users = [
228
- makeUser({ id: "user_p1", email: "p1@example.com" }),
229
- ];
230
- const page2Users = [
231
- makeUser({ id: "user_p2", email: "p2@example.com" }),
232
- ];
241
+ const page1Users = [makeUser({ id: "user_p1", email: "p1@example.com" })];
242
+ const page2Users = [makeUser({ id: "user_p2", email: "p2@example.com" })];
233
243
 
234
244
  const { WorkOS } = await import("@workos-inc/node");
235
245
  const listUsersMock = vi
@@ -300,9 +310,7 @@ describe("backfill", () => {
300
310
  });
301
311
 
302
312
  test("upsert is idempotent after processUsersPage inserts", async () => {
303
- const users = [
304
- makeUser({ id: "user_idem", email: "idem@example.com" }),
305
- ];
313
+ const users = [makeUser({ id: "user_idem", email: "idem@example.com" })];
306
314
 
307
315
  const { WorkOS } = await import("@workos-inc/node");
308
316
  (WorkOS as unknown as ReturnType<typeof vi.fn>).mockImplementation(
@@ -8,16 +8,13 @@ import {
8
8
  import { components, internal } from "./_generated/api.js";
9
9
  import { WorkOS } from "@workos-inc/node";
10
10
  import type { FunctionHandle } from "convex/server";
11
- import { WorkflowManager } from "@convex-dev/workflow";
12
- import { vResultValidator } from "@convex-dev/workpool";
13
- import schema from "./schema.js";
11
+ import { vResultValidator, WorkflowManager } from "@convex-dev/workflow";
12
+ import { vUser } from "../validators.js";
14
13
 
15
14
  const workflow = new WorkflowManager(components.backfillWorkflow, {
16
15
  workpoolOptions: { maxParallelism: 1 },
17
16
  });
18
17
 
19
- const vUser = schema.tables.users.validator;
20
-
21
18
  export const getBackfillApiKey = internalQuery({
22
19
  args: {},
23
20
  returns: v.union(v.string(), v.null()),
@@ -37,10 +34,7 @@ export const processUsersPage = internalAction({
37
34
  returns: v.object({
38
35
  nextCursor: v.optional(v.string()),
39
36
  }),
40
- handler: async (
41
- ctx,
42
- args
43
- ): Promise<{ nextCursor: string | undefined }> => {
37
+ handler: async (ctx, args): Promise<{ nextCursor: string | undefined }> => {
44
38
  const apiKey: string | null = await ctx.runQuery(
45
39
  internal.backfill.getBackfillApiKey,
46
40
  {}
@@ -85,7 +79,23 @@ export const upsertUsersPage = internalMutation({
85
79
  )
86
80
  );
87
81
 
88
- const newUsers = args.users.filter((_, i) => !existingUsers[i]);
82
+ // A user.deleted webhook can arrive between the page fetch and this
83
+ // mutation, so skip anyone recorded in deletedUsers or the backfill
84
+ // would insert them again.
85
+ const deletedUsers = await Promise.all(
86
+ args.users.map((user, i) =>
87
+ existingUsers[i]
88
+ ? null
89
+ : ctx.db
90
+ .query("deletedUsers")
91
+ .withIndex("id", (q) => q.eq("id", user.id))
92
+ .unique()
93
+ )
94
+ );
95
+
96
+ const newUsers = args.users.filter(
97
+ (_, i) => !existingUsers[i] && !deletedUsers[i]
98
+ );
89
99
 
90
100
  if (args.logLevel === "DEBUG") {
91
101
  console.log(
@@ -124,14 +134,11 @@ export const backfillBatch = workflow.define({
124
134
  let pagesProcessed = 0;
125
135
 
126
136
  while (pagesProcessed < MAX_PAGES_PER_BATCH) {
127
- const result = await step.runAction(
128
- internal.backfill.processUsersPage,
129
- {
130
- after: cursor,
131
- onEventHandle: args.onEventHandle,
132
- logLevel: args.logLevel,
133
- }
134
- );
137
+ const result = await step.runAction(internal.backfill.processUsersPage, {
138
+ after: cursor,
139
+ onEventHandle: args.onEventHandle,
140
+ logLevel: args.logLevel,
141
+ });
135
142
 
136
143
  cursor = result.nextCursor;
137
144
  pagesProcessed++;
@@ -1,10 +1,8 @@
1
1
  import { defineComponent } from "convex/server";
2
- import workpool from "@convex-dev/workpool/convex.config";
3
2
  import workflow from "@convex-dev/workflow/convex.config";
4
3
 
5
4
  const component = defineComponent("workOSAuthKit");
6
5
 
7
- component.use(workpool, { name: "eventWorkpool" });
8
6
  component.use(workflow, { name: "backfillWorkflow" });
9
7
 
10
8
  export default component;
@@ -1,11 +1,14 @@
1
1
  /// <reference types="vite/client" />
2
2
  import { convexTest } from "convex-test";
3
- import { describe, test, expect } from "vitest";
3
+ import { createFunctionHandle, makeFunctionReference } from "convex/server";
4
+ import { describe, test, expect, vi } from "vitest";
4
5
  import { modules } from "./setup.test.js";
5
6
  import schema from "./schema.js";
6
- import workpool from "@convex-dev/workpool/test";
7
7
  import workflow from "@convex-dev/workflow/test";
8
8
  import { api } from "./_generated/api.js";
9
+ import { mutation } from "./_generated/server.js";
10
+ import { vEvent } from "./lib.js";
11
+ import { v } from "convex/values";
9
12
 
10
13
  /** Default test user values. */
11
14
  const defaultUser = {
@@ -43,10 +46,19 @@ function makeEvent(
43
46
  };
44
47
  }
45
48
 
49
+ /** Create a webhook event payload for any event type. */
50
+ function makeRawEvent(event: string, data: Record<string, unknown>) {
51
+ return {
52
+ id: `event_${event}`,
53
+ createdAt: "2024-01-01T00:00:00.000Z",
54
+ event,
55
+ data,
56
+ };
57
+ }
58
+
46
59
  /** Initialize a convex-test instance with sub-component registrations. */
47
60
  function initConvexTest() {
48
61
  const t = convexTest(schema, modules);
49
- workpool.register(t, "eventWorkpool");
50
62
  workflow.register(t, "backfillWorkflow");
51
63
  return t;
52
64
  }
@@ -57,7 +69,6 @@ describe("onWebhookEvent", () => {
57
69
  const user = makeUser();
58
70
 
59
71
  await t.mutation(api.lib.onWebhookEvent, {
60
- apiKey: "sk_test_123",
61
72
  event: makeEvent("user.created", user),
62
73
  });
63
74
 
@@ -82,7 +93,6 @@ describe("onWebhookEvent", () => {
82
93
  updatedAt: "2024-01-02T00:00:00.000Z",
83
94
  });
84
95
  await t.mutation(api.lib.onWebhookEvent, {
85
- apiKey: "sk_test_123",
86
96
  event: makeEvent("user.updated", updated),
87
97
  });
88
98
 
@@ -101,7 +111,6 @@ describe("onWebhookEvent", () => {
101
111
  });
102
112
 
103
113
  await t.mutation(api.lib.onWebhookEvent, {
104
- apiKey: "sk_test_123",
105
114
  event: makeEvent("user.deleted", user),
106
115
  });
107
116
 
@@ -117,11 +126,9 @@ describe("onWebhookEvent", () => {
117
126
  const event = makeEvent("user.created", user);
118
127
 
119
128
  await t.mutation(api.lib.onWebhookEvent, {
120
- apiKey: "sk_test_123",
121
129
  event,
122
130
  });
123
131
  await t.mutation(api.lib.onWebhookEvent, {
124
- apiKey: "sk_test_123",
125
132
  event,
126
133
  });
127
134
 
@@ -145,11 +152,9 @@ describe("onWebhookEvent", () => {
145
152
  });
146
153
 
147
154
  await t.mutation(api.lib.onWebhookEvent, {
148
- apiKey: "sk_test_123",
149
155
  event: makeEvent("user.updated", updated),
150
156
  });
151
157
  await t.mutation(api.lib.onWebhookEvent, {
152
- apiKey: "sk_test_123",
153
158
  event: makeEvent("user.created", created),
154
159
  });
155
160
 
@@ -176,7 +181,6 @@ describe("onWebhookEvent", () => {
176
181
  updatedAt: "2024-01-01T00:00:00.000Z",
177
182
  });
178
183
  await t.mutation(api.lib.onWebhookEvent, {
179
- apiKey: "sk_test_123",
180
184
  event: makeEvent("user.updated", stale),
181
185
  });
182
186
 
@@ -186,4 +190,237 @@ describe("onWebhookEvent", () => {
186
190
  expect(dbUser?.name).toBe("Alice Jones");
187
191
  expect(dbUser?.updatedAt).toBe("2024-01-02T00:00:00.000Z");
188
192
  });
193
+
194
+ test("user events record the user id", async () => {
195
+ const t = initConvexTest();
196
+ const user = makeUser();
197
+
198
+ await t.mutation(api.lib.onWebhookEvent, {
199
+ event: makeEvent("user.created", user),
200
+ });
201
+
202
+ const dbEvent = await t.run(async (ctx) => {
203
+ return ctx.db.query("events").unique();
204
+ });
205
+ expect(dbEvent?.userId).toBe(user.id);
206
+ });
207
+
208
+ test("events referencing a user prefer userId over the object id", async () => {
209
+ const t = initConvexTest();
210
+
211
+ await t.mutation(api.lib.onWebhookEvent, {
212
+ event: {
213
+ id: "event_session_created",
214
+ createdAt: "2024-01-01T00:00:00.000Z",
215
+ event: "session.created",
216
+ data: {
217
+ object: "session",
218
+ id: "session_01XYZ",
219
+ userId: "user_01ABC",
220
+ },
221
+ },
222
+ });
223
+
224
+ const dbEvent = await t.run(async (ctx) => {
225
+ return ctx.db.query("events").unique();
226
+ });
227
+ expect(dbEvent?.userId).toBe("user_01ABC");
228
+ });
229
+
230
+ test("events without a usable id record no userId", async () => {
231
+ const t = initConvexTest();
232
+
233
+ await t.mutation(api.lib.onWebhookEvent, {
234
+ event: {
235
+ id: "event_connection_activated",
236
+ createdAt: "2024-01-01T00:00:00.000Z",
237
+ event: "connection.activated",
238
+ data: { object: "connection", id: null },
239
+ },
240
+ });
241
+
242
+ const dbEvent = await t.run(async (ctx) => {
243
+ return ctx.db.query("events").unique();
244
+ });
245
+ expect(dbEvent?.userId).toBeUndefined();
246
+ });
247
+
248
+ test("user.deleted records a tombstone", async () => {
249
+ const t = initConvexTest();
250
+ const user = makeUser();
251
+ await t.run(async (ctx) => {
252
+ await ctx.db.insert("users", user);
253
+ });
254
+
255
+ await t.mutation(api.lib.onWebhookEvent, {
256
+ event: makeEvent("user.deleted", user),
257
+ });
258
+
259
+ const { dbUsers, dbDeletedUsers } = await t.run(async (ctx) => {
260
+ return {
261
+ dbUsers: await ctx.db.query("users").collect(),
262
+ dbDeletedUsers: await ctx.db.query("deletedUsers").collect(),
263
+ };
264
+ });
265
+ expect(dbUsers).toHaveLength(0);
266
+ expect(dbDeletedUsers).toHaveLength(1);
267
+ expect(dbDeletedUsers[0].id).toBe(user.id);
268
+ });
269
+
270
+ test("user.deleted works with only an id in the payload", async () => {
271
+ const t = initConvexTest();
272
+ const user = makeUser();
273
+ await t.run(async (ctx) => {
274
+ await ctx.db.insert("users", user);
275
+ });
276
+
277
+ await t.mutation(api.lib.onWebhookEvent, {
278
+ event: makeRawEvent("user.deleted", { object: "user", id: user.id }),
279
+ });
280
+
281
+ const { dbUsers, dbDeletedUsers } = await t.run(async (ctx) => {
282
+ return {
283
+ dbUsers: await ctx.db.query("users").collect(),
284
+ dbDeletedUsers: await ctx.db.query("deletedUsers").collect(),
285
+ };
286
+ });
287
+ expect(dbUsers).toHaveLength(0);
288
+ expect(dbDeletedUsers).toHaveLength(1);
289
+ expect(dbDeletedUsers[0].id).toBe(user.id);
290
+ });
291
+
292
+ test.each([
293
+ { payload: "a full user", data: { object: "user", ...makeUser() } },
294
+ { payload: "only an id", data: { id: defaultUser.id } },
295
+ ])(
296
+ "user.deleted forwards $payload without a stored user",
297
+ async ({ data }) => {
298
+ const onEvent = vi.fn();
299
+ const t = convexTest(schema, {
300
+ ...modules,
301
+ "./callbacks.ts": async () => ({
302
+ onEvent: mutation({
303
+ args: { event: vEvent.fields.event, data: vEvent.fields.data },
304
+ returns: v.null(),
305
+ handler: async (_ctx, args) => {
306
+ onEvent(args);
307
+ return null;
308
+ },
309
+ }),
310
+ }),
311
+ });
312
+ const onEventHandle = await t.run(async () =>
313
+ createFunctionHandle(
314
+ makeFunctionReference<"mutation">("callbacks:onEvent")
315
+ )
316
+ );
317
+ const event = makeRawEvent("user.deleted", data);
318
+
319
+ await t.mutation(api.lib.onWebhookEvent, { event, onEventHandle });
320
+
321
+ expect(onEvent).toHaveBeenCalledExactlyOnceWith({
322
+ event: "user.deleted",
323
+ data,
324
+ });
325
+
326
+ await t.mutation(api.lib.onWebhookEvent, { event, onEventHandle });
327
+ for (const eventType of ["user.created", "user.updated"]) {
328
+ await t.mutation(api.lib.onWebhookEvent, {
329
+ event: makeEvent(eventType, makeUser()),
330
+ onEventHandle,
331
+ });
332
+ }
333
+
334
+ expect(onEvent).toHaveBeenCalledTimes(1);
335
+ const { dbUsers, dbDeletedUsers } = await t.run(async (ctx) => ({
336
+ dbUsers: await ctx.db.query("users").collect(),
337
+ dbDeletedUsers: await ctx.db.query("deletedUsers").collect(),
338
+ }));
339
+ expect(dbUsers).toHaveLength(0);
340
+ expect(dbDeletedUsers).toMatchObject([{ id: data.id }]);
341
+ }
342
+ );
343
+
344
+ test("user.created after user.deleted is skipped", async () => {
345
+ const t = initConvexTest();
346
+ const user = makeUser();
347
+
348
+ await t.mutation(api.lib.onWebhookEvent, {
349
+ event: makeEvent("user.deleted", user),
350
+ });
351
+ await t.mutation(api.lib.onWebhookEvent, {
352
+ event: makeEvent("user.created", user),
353
+ });
354
+
355
+ const { dbUsers, dbDeletedUsers } = await t.run(async (ctx) => {
356
+ return {
357
+ dbUsers: await ctx.db.query("users").collect(),
358
+ dbDeletedUsers: await ctx.db.query("deletedUsers").collect(),
359
+ };
360
+ });
361
+ expect(dbUsers).toHaveLength(0);
362
+ expect(dbDeletedUsers).toHaveLength(1);
363
+ expect(dbDeletedUsers[0].id).toBe(user.id);
364
+ });
365
+
366
+ test("user.updated after user.deleted is skipped", async () => {
367
+ const t = initConvexTest();
368
+ const user = makeUser();
369
+
370
+ await t.mutation(api.lib.onWebhookEvent, {
371
+ event: makeEvent("user.deleted", user),
372
+ });
373
+ await t.mutation(api.lib.onWebhookEvent, {
374
+ event: makeEvent("user.updated", user),
375
+ });
376
+
377
+ const { dbUsers, dbDeletedUsers } = await t.run(async (ctx) => {
378
+ return {
379
+ dbUsers: await ctx.db.query("users").collect(),
380
+ dbDeletedUsers: await ctx.db.query("deletedUsers").collect(),
381
+ };
382
+ });
383
+ expect(dbUsers).toHaveLength(0);
384
+ expect(dbDeletedUsers).toHaveLength(1);
385
+ expect(dbDeletedUsers[0].id).toBe(user.id);
386
+ });
387
+
388
+ test("organization.created leaves userId unset", async () => {
389
+ const t = initConvexTest();
390
+
391
+ await t.mutation(api.lib.onWebhookEvent, {
392
+ event: makeRawEvent("organization.created", {
393
+ object: "organization",
394
+ id: "org_01ABC",
395
+ name: "Acme",
396
+ }),
397
+ });
398
+
399
+ const dbEvents = await t.run(async (ctx) => {
400
+ return ctx.db.query("events").collect();
401
+ });
402
+ expect(dbEvents).toHaveLength(1);
403
+ expect(dbEvents[0].userId).toBeUndefined();
404
+ });
405
+
406
+ test("authentication events with a null userId are recorded", async () => {
407
+ const t = initConvexTest();
408
+
409
+ await t.mutation(api.lib.onWebhookEvent, {
410
+ event: makeRawEvent("authentication.password_failed", {
411
+ userId: null,
412
+ email: "alice@example.com",
413
+ status: "failed",
414
+ type: "password",
415
+ ipAddress: null,
416
+ userAgent: null,
417
+ }),
418
+ });
419
+
420
+ const dbEvents = await t.run(async (ctx) => {
421
+ return ctx.db.query("events").collect();
422
+ });
423
+ expect(dbEvents).toHaveLength(1);
424
+ expect(dbEvents[0].userId).toBeUndefined();
425
+ });
189
426
  });