@convex-dev/workos-authkit 0.2.8 → 0.2.10

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 (47) hide show
  1. package/README.md +4 -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/_generated/server.d.ts +12 -0
  12. package/dist/component/_generated/server.d.ts.map +1 -1
  13. package/dist/component/_generated/server.js +2 -0
  14. package/dist/component/_generated/server.js.map +1 -1
  15. package/dist/component/backfill.d.ts +6 -6
  16. package/dist/component/backfill.d.ts.map +1 -1
  17. package/dist/component/backfill.js +12 -5
  18. package/dist/component/backfill.js.map +1 -1
  19. package/dist/component/convex.config.d.ts.map +1 -1
  20. package/dist/component/convex.config.js +0 -2
  21. package/dist/component/convex.config.js.map +1 -1
  22. package/dist/component/lib.d.ts +7 -27
  23. package/dist/component/lib.d.ts.map +1 -1
  24. package/dist/component/lib.js +94 -122
  25. package/dist/component/lib.js.map +1 -1
  26. package/dist/component/schema.d.ts +13 -4
  27. package/dist/component/schema.d.ts.map +1 -1
  28. package/dist/component/schema.js +7 -14
  29. package/dist/component/schema.js.map +1 -1
  30. package/dist/validators.d.ts +30 -0
  31. package/dist/validators.d.ts.map +1 -0
  32. package/dist/validators.js +17 -0
  33. package/dist/validators.js.map +1 -0
  34. package/package.json +5 -5
  35. package/src/client/index.test.ts +18 -0
  36. package/src/client/index.ts +10 -2
  37. package/src/component/_generated/api.ts +0 -1
  38. package/src/component/_generated/component.ts +0 -2
  39. package/src/component/_generated/server.ts +13 -0
  40. package/src/component/backfill.test.ts +19 -11
  41. package/src/component/backfill.ts +25 -18
  42. package/src/component/convex.config.ts +0 -2
  43. package/src/component/lib.test.ts +370 -0
  44. package/src/component/lib.ts +97 -133
  45. package/src/component/schema.ts +7 -14
  46. package/src/test.ts +0 -2
  47. package/src/validators.ts +17 -0
@@ -1,23 +1,12 @@
1
+ import { type Event as WorkOSEvent } from "@workos-inc/node";
2
+ import { withoutSystemFields } from "convex-helpers";
3
+ import { parse } from "convex-helpers/validators";
4
+ import type { FunctionHandle } from "convex/server";
1
5
  import { type Infer, v } from "convex/values";
2
- import {
3
- internalAction,
4
- internalMutation,
5
- internalQuery,
6
- mutation,
7
- query,
8
- } from "./_generated/server.js";
6
+ import { vUser } from "../validators.js";
7
+ import type { Doc } from "./_generated/dataModel.js";
9
8
  import type { MutationCtx } from "./_generated/server.js";
10
- import { components, internal } from "./_generated/api.js";
11
- import { omit, withoutSystemFields } from "convex-helpers";
12
- import { WorkOS, type Event as WorkOSEvent } from "@workos-inc/node";
13
- import type { FunctionHandle } from "convex/server";
14
- import { Workpool } from "@convex-dev/workpool";
15
- import schema from "./schema.js";
16
- import { parse } from "convex-helpers/validators";
17
-
18
- const eventWorkpool = new Workpool(components.eventWorkpool, {
19
- maxParallelism: 1,
20
- });
9
+ import { mutation, query } from "./_generated/server.js";
21
10
 
22
11
  export const vEvent = v.object({
23
12
  id: v.string(),
@@ -27,6 +16,25 @@ export const vEvent = v.object({
27
16
  context: v.optional(v.record(v.string(), v.any())),
28
17
  });
29
18
 
19
+ /**
20
+ * Returns the WorkOS user id an event is about, or undefined when the
21
+ * event is about something other than a user.
22
+ */
23
+ function eventUserId(event: Infer<typeof vEvent>): string | undefined {
24
+ const data = event.data as { id?: unknown; userId?: unknown };
25
+ if (typeof data.userId === "string") {
26
+ return data.userId;
27
+ }
28
+ const isUserEvent =
29
+ event.event === "user.created" ||
30
+ event.event === "user.updated" ||
31
+ event.event === "user.deleted";
32
+ if (isUserEvent && typeof data.id === "string") {
33
+ return data.id;
34
+ }
35
+ return undefined;
36
+ }
37
+
30
38
  async function processEventHandler(
31
39
  ctx: MutationCtx,
32
40
  args: {
@@ -38,6 +46,8 @@ async function processEventHandler(
38
46
  if (args.logLevel === "DEBUG") {
39
47
  console.log("processing event", args.event);
40
48
  }
49
+ const event = args.event as WorkOSEvent;
50
+ const userId = eventUserId(args.event);
41
51
  const dbEvent = await ctx.db
42
52
  .query("events")
43
53
  .withIndex("eventId", (q) => q.eq("eventId", args.event.id))
@@ -47,156 +57,102 @@ async function processEventHandler(
47
57
  return;
48
58
  }
49
59
  await ctx.db.insert("events", {
60
+ userId,
50
61
  eventId: args.event.id,
51
62
  event: args.event.event,
52
63
  updatedAt: args.event.data.updatedAt as string | undefined,
53
64
  });
54
- const event = args.event as WorkOSEvent;
65
+ let eventForCallback = event.event;
66
+ let dataForCallback = args.event.data;
55
67
  switch (event.event) {
56
- case "user.created": {
57
- const data = omit(event.data, ["object"]);
68
+ case "user.created":
69
+ case "user.updated": {
70
+ const data = parse(vUser, event.data);
58
71
  const existingUser = await ctx.db
59
72
  .query("users")
60
73
  .withIndex("id", (q) => q.eq("id", data.id))
61
74
  .unique();
62
- if (existingUser) {
63
- console.warn("user already exists", data.id);
64
- return;
75
+ if (!existingUser) {
76
+ const deletedUser = await ctx.db
77
+ .query("deletedUsers")
78
+ .withIndex("id", (q) => q.eq("id", data.id))
79
+ .unique();
80
+ if (deletedUser) {
81
+ console.warn("user already deleted, skipping", event.event, data.id);
82
+ return;
83
+ }
84
+ await ctx.db.insert("users", data);
85
+ if (event.event === "user.updated") {
86
+ // WorkOS can deliver the update before the create. The update
87
+ // payload holds the whole user, so it is safe to insert.
88
+ console.warn("user not found for update, inserting", data.id);
89
+ eventForCallback = "user.created";
90
+ }
91
+ } else {
92
+ if (event.event === "user.created") {
93
+ console.warn("user already exists", data.id);
94
+ // The callback already fired as user.created when this user was
95
+ // first inserted, so skip it here.
96
+ return;
97
+ } else if (existingUser.updatedAt >= data.updatedAt) {
98
+ console.warn(`user already updated for event ${event.id}, skipping`);
99
+ return;
100
+ }
101
+ await ctx.db.patch("users", existingUser._id, data);
65
102
  }
66
- await ctx.db.insert("users", data);
67
103
  break;
68
104
  }
69
- case "user.updated": {
70
- const data = omit(event.data, ["object"]);
71
- const user = await ctx.db
72
- .query("users")
73
- .withIndex("id", (q) => q.eq("id", data.id))
105
+ case "user.deleted": {
106
+ // Only the id is needed, so a trimmed payload should not fail the webhook.
107
+ const { id } = parse(v.object({ id: v.string() }), event.data);
108
+ // Record the deletion even if the user was never inserted locally, so a
109
+ // late user.created or user.updated for this user is skipped.
110
+ const deletedUser = await ctx.db
111
+ .query("deletedUsers")
112
+ .withIndex("id", (q) => q.eq("id", id))
74
113
  .unique();
75
- if (!user) {
76
- console.error("user not found", data.id);
77
- return;
114
+ if (!deletedUser) {
115
+ await ctx.db.insert("deletedUsers", { id });
78
116
  }
79
- if (user.updatedAt >= data.updatedAt) {
80
- console.warn(`user already updated for event ${event.id}, skipping`);
81
- return;
82
- }
83
- await ctx.db.patch("users", user._id, data);
84
- break;
85
- }
86
- case "user.deleted": {
87
- const data = omit(event.data, ["object"]);
88
117
  const user = await ctx.db
89
118
  .query("users")
90
- .withIndex("id", (q) => q.eq("id", data.id))
119
+ .withIndex("id", (q) => q.eq("id", id))
91
120
  .unique();
92
121
  if (!user) {
93
- console.warn("user not found", data.id);
122
+ console.warn("user not found, skipping deletion", id);
94
123
  return;
95
124
  }
96
125
  await ctx.db.delete("users", user._id);
126
+ // The callback data is typed as a whole user, so fill anything the
127
+ // payload left out from the stored user.
128
+ dataForCallback = {
129
+ ...withoutSystemFields(user),
130
+ object: "user",
131
+ ...args.event.data,
132
+ };
97
133
  break;
98
134
  }
99
135
  }
100
136
  if (args.onEventHandle) {
101
137
  await ctx.runMutation(args.onEventHandle as FunctionHandle<"mutation">, {
102
- event: args.event.event,
103
- data: args.event.data,
138
+ event: eventForCallback,
139
+ data: dataForCallback,
104
140
  });
105
141
  }
106
142
  }
107
143
 
108
144
  export const onWebhookEvent = mutation({
109
145
  args: {
110
- apiKey: v.string(),
111
146
  event: vEvent,
112
147
  onEventHandle: v.optional(v.string()),
113
- eventTypes: v.optional(v.array(v.string())),
114
148
  logLevel: v.optional(v.literal("DEBUG")),
115
149
  },
116
150
  returns: v.null(),
117
151
  handler: async (ctx, args) => {
118
- const isCreateEvent = args.event.event.endsWith(".created");
119
-
120
- if (isCreateEvent) {
121
- // Process create events immediately
122
- await processEventHandler(ctx, args);
123
- } else {
124
- // Enqueue update/delete events to workpool
125
- await eventWorkpool.enqueueAction(ctx, internal.lib.updateEvents, {
126
- apiKey: args.apiKey,
127
- onEventHandle: args.onEventHandle,
128
- eventTypes: args.eventTypes,
129
- logLevel: args.logLevel,
130
- });
131
- }
132
- return null;
133
- },
134
- });
135
-
136
- export const getCursor = internalQuery({
137
- args: {},
138
- returns: v.union(v.string(), v.null()),
139
- handler: async (ctx) => {
140
- const lastProcessedEvent = await ctx.db
141
- .query("events")
142
- .order("desc")
143
- .first();
144
- return lastProcessedEvent?.eventId;
145
- },
146
- });
147
-
148
- export const updateEvents = internalAction({
149
- args: {
150
- apiKey: v.string(),
151
- onEventHandle: v.optional(v.string()),
152
- eventTypes: v.optional(v.array(v.string())),
153
- logLevel: v.optional(v.literal("DEBUG")),
154
- },
155
- handler: async (ctx, args) => {
156
- // Cancel other pending workpool jobs since this run will
157
- // process all available events from the WorkOS API.
158
- await eventWorkpool.cancelAll(ctx);
159
- const workos = new WorkOS(args.apiKey);
160
- const cursor = await ctx.runQuery(internal.lib.getCursor);
161
- let nextCursor = cursor ?? undefined;
162
- const eventTypes = [
163
- "user.created" as const,
164
- "user.updated" as const,
165
- "user.deleted" as const,
166
- ...((args.eventTypes as WorkOSEvent["event"][]) ?? []),
167
- ];
168
- // No cursor should mean we haven't handled any events - set
169
- // a start time of 5 minutes ago
170
- let rangeStart = nextCursor
171
- ? undefined
172
- : new Date(Date.now() - 1000 * 60 * 5).toISOString();
173
- do {
174
- const { data, listMetadata } = await workos.events.listEvents({
175
- events: eventTypes,
176
- after: nextCursor,
177
- rangeStart,
178
- });
179
- for (const event of data) {
180
- await ctx.runMutation(internal.lib.processEvent, {
181
- event: parse(vEvent, event),
182
- logLevel: args.logLevel,
183
- onEventHandle: args.onEventHandle,
184
- });
185
- }
186
- nextCursor = listMetadata.after ?? undefined;
187
- rangeStart = undefined;
188
- } while (nextCursor);
189
- },
190
- });
191
-
192
- export const processEvent = internalMutation({
193
- args: {
194
- event: vEvent,
195
- logLevel: v.optional(v.literal("DEBUG")),
196
- onEventHandle: v.optional(v.string()),
197
- },
198
- handler: async (ctx, args) => {
152
+ // The payload is signature-verified and dedupes on eventId, so all
153
+ // event types can be processed inline.
199
154
  await processEventHandler(ctx, args);
155
+ return null;
200
156
  },
201
157
  });
202
158
 
@@ -204,13 +160,13 @@ export const getAuthUser = query({
204
160
  args: {
205
161
  id: v.string(),
206
162
  },
207
- returns: v.union(schema.tables.users.validator, v.null()),
163
+ returns: v.union(vUser, v.null()),
208
164
  handler: async (ctx, args) => {
209
165
  const user = await ctx.db
210
166
  .query("users")
211
167
  .withIndex("id", (q) => q.eq("id", args.id))
212
168
  .unique();
213
- return user ? withoutSystemFields(user) : null;
169
+ return publicUser(user);
214
170
  },
215
171
  });
216
172
 
@@ -218,12 +174,20 @@ export const getAuthUserByExternalId = query({
218
174
  args: {
219
175
  externalId: v.string(),
220
176
  },
221
- returns: v.union(schema.tables.users.validator, v.null()),
177
+ returns: v.union(vUser, v.null()),
222
178
  handler: async (ctx, args) => {
223
179
  const user = await ctx.db
224
180
  .query("users")
225
181
  .withIndex("externalId", (q) => q.eq("externalId", args.externalId))
226
182
  .unique();
227
- return user ? withoutSystemFields(user) : null;
183
+ return publicUser(user);
228
184
  },
229
185
  });
186
+
187
+ /** Drops the Convex system fields so the user matches the public vUser shape. */
188
+ function publicUser(user: Doc<"users"> | null): Infer<typeof vUser> | null {
189
+ if (!user) {
190
+ return null;
191
+ }
192
+ return withoutSystemFields(user);
193
+ }
@@ -1,28 +1,21 @@
1
1
  import { defineSchema, defineTable } from "convex/server";
2
2
  import { v } from "convex/values";
3
+ import { vUser } from "../validators.js";
3
4
 
4
5
  export default defineSchema({
5
6
  events: defineTable({
6
7
  eventId: v.string(),
7
8
  event: v.string(),
8
9
  updatedAt: v.optional(v.string()),
10
+ userId: v.optional(v.string()),
9
11
  }).index("eventId", ["eventId"]),
10
12
  backfillState: defineTable({
11
13
  apiKey: v.string(),
12
14
  }),
13
- users: defineTable({
15
+ users: defineTable(vUser)
16
+ .index("id", ["id"])
17
+ .index("externalId", ["externalId"]),
18
+ deletedUsers: defineTable({
14
19
  id: v.string(),
15
- email: v.string(),
16
- name: v.optional(v.union(v.null(), v.string())),
17
- firstName: v.optional(v.union(v.null(), v.string())),
18
- lastName: v.optional(v.union(v.null(), v.string())),
19
- emailVerified: v.boolean(),
20
- profilePictureUrl: v.optional(v.union(v.null(), v.string())),
21
- lastSignInAt: v.optional(v.union(v.null(), v.string())),
22
- externalId: v.optional(v.union(v.null(), v.string())),
23
- metadata: v.record(v.string(), v.any()),
24
- locale: v.optional(v.union(v.null(), v.string())),
25
- createdAt: v.string(),
26
- updatedAt: v.string(),
27
- }).index("id", ["id"]).index("externalId", ["externalId"]),
20
+ }).index("id", ["id"]),
28
21
  });
package/src/test.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { TestConvex } from "convex-test";
2
2
  import type { GenericSchema, SchemaDefinition } from "convex/server";
3
- import workpool from "@convex-dev/workpool/test";
4
3
  import workflow from "@convex-dev/workflow/test";
5
4
  import schema from "./component/schema.js";
6
5
  const modules = import.meta.glob("./component/**/*.ts");
@@ -15,7 +14,6 @@ function register(
15
14
  name: string = "workOSAuthKit"
16
15
  ) {
17
16
  t.registerComponent(name, schema, modules);
18
- workpool.register(t, `${name}/eventWorkpool`);
19
17
  workflow.register(t, `${name}/backfillWorkflow`);
20
18
  }
21
19
  export default { register, schema, modules };
@@ -0,0 +1,17 @@
1
+ import { v } from "convex/values";
2
+
3
+ export const vUser = v.object({
4
+ id: v.string(),
5
+ email: v.string(),
6
+ name: v.optional(v.union(v.null(), v.string())),
7
+ firstName: v.optional(v.union(v.null(), v.string())),
8
+ lastName: v.optional(v.union(v.null(), v.string())),
9
+ emailVerified: v.boolean(),
10
+ profilePictureUrl: v.optional(v.union(v.null(), v.string())),
11
+ lastSignInAt: v.optional(v.union(v.null(), v.string())),
12
+ externalId: v.optional(v.union(v.null(), v.string())),
13
+ metadata: v.record(v.string(), v.any()),
14
+ locale: v.optional(v.union(v.null(), v.string())),
15
+ createdAt: v.string(),
16
+ updatedAt: v.string(),
17
+ });