@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
@@ -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,74 +57,94 @@ 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"]);
58
- const existingUser = await ctx.db
59
- .query("users")
60
- .withIndex("id", (q) => q.eq("id", data.id))
61
- .unique();
62
- if (existingUser) {
63
- console.warn("user already exists", data.id);
64
- return;
65
- }
66
- await ctx.db.insert("users", data);
67
- break;
68
- }
68
+ case "user.created":
69
69
  case "user.updated": {
70
- const data = omit(event.data, ["object"]);
71
- const user = await ctx.db
70
+ const data = parse(vUser, event.data);
71
+ const existingUser = await ctx.db
72
72
  .query("users")
73
73
  .withIndex("id", (q) => q.eq("id", data.id))
74
74
  .unique();
75
- if (!user) {
76
- // The update may have been delivered before the create; the
77
- // payload is the full user object, so insert it. The create
78
- // no-ops on arrival via the existing-user guard above.
79
- console.warn("user not found for update, inserting", data.id);
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
+ }
80
84
  await ctx.db.insert("users", data);
81
- break;
82
- }
83
- if (user.updatedAt >= data.updatedAt) {
84
- console.warn(`user already updated for event ${event.id}, skipping`);
85
- return;
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);
86
102
  }
87
- await ctx.db.patch("users", user._id, data);
88
103
  break;
89
104
  }
90
105
  case "user.deleted": {
91
- const data = omit(event.data, ["object"]);
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))
113
+ .unique();
114
+ if (!deletedUser) {
115
+ await ctx.db.insert("deletedUsers", { id });
116
+ }
92
117
  const user = await ctx.db
93
118
  .query("users")
94
- .withIndex("id", (q) => q.eq("id", data.id))
119
+ .withIndex("id", (q) => q.eq("id", id))
95
120
  .unique();
96
121
  if (!user) {
97
- console.warn("user not found", data.id);
98
- return;
122
+ console.warn("user not found, forwarding deletion", id);
123
+ break;
99
124
  }
100
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
+ };
101
133
  break;
102
134
  }
103
135
  }
104
136
  if (args.onEventHandle) {
105
137
  await ctx.runMutation(args.onEventHandle as FunctionHandle<"mutation">, {
106
- event: args.event.event,
107
- data: args.event.data,
138
+ event: eventForCallback,
139
+ data: dataForCallback,
108
140
  });
109
141
  }
110
142
  }
111
143
 
112
144
  export const onWebhookEvent = mutation({
113
145
  args: {
114
- apiKey: v.string(),
115
146
  event: vEvent,
116
147
  onEventHandle: v.optional(v.string()),
117
- eventTypes: v.optional(v.array(v.string())),
118
148
  logLevel: v.optional(v.literal("DEBUG")),
119
149
  },
120
150
  returns: v.null(),
@@ -126,84 +156,17 @@ export const onWebhookEvent = mutation({
126
156
  },
127
157
  });
128
158
 
129
- export const getCursor = internalQuery({
130
- args: {},
131
- returns: v.union(v.string(), v.null()),
132
- handler: async (ctx) => {
133
- const lastProcessedEvent = await ctx.db
134
- .query("events")
135
- .order("desc")
136
- .first();
137
- return lastProcessedEvent?.eventId;
138
- },
139
- });
140
-
141
- export const updateEvents = internalAction({
142
- args: {
143
- apiKey: v.string(),
144
- onEventHandle: v.optional(v.string()),
145
- eventTypes: v.optional(v.array(v.string())),
146
- logLevel: v.optional(v.literal("DEBUG")),
147
- },
148
- handler: async (ctx, args) => {
149
- // Cancel other pending workpool jobs since this run will
150
- // process all available events from the WorkOS API.
151
- await eventWorkpool.cancelAll(ctx);
152
- const workos = new WorkOS(args.apiKey);
153
- const cursor = await ctx.runQuery(internal.lib.getCursor);
154
- let nextCursor = cursor ?? undefined;
155
- const eventTypes = [
156
- "user.created" as const,
157
- "user.updated" as const,
158
- "user.deleted" as const,
159
- ...((args.eventTypes as WorkOSEvent["event"][]) ?? []),
160
- ];
161
- // No cursor should mean we haven't handled any events - set
162
- // a start time of 5 minutes ago
163
- let rangeStart = nextCursor
164
- ? undefined
165
- : new Date(Date.now() - 1000 * 60 * 5).toISOString();
166
- do {
167
- const { data, listMetadata } = await workos.events.listEvents({
168
- events: eventTypes,
169
- after: nextCursor,
170
- rangeStart,
171
- });
172
- for (const event of data) {
173
- await ctx.runMutation(internal.lib.processEvent, {
174
- event: parse(vEvent, event),
175
- logLevel: args.logLevel,
176
- onEventHandle: args.onEventHandle,
177
- });
178
- }
179
- nextCursor = listMetadata.after ?? undefined;
180
- rangeStart = undefined;
181
- } while (nextCursor);
182
- },
183
- });
184
-
185
- export const processEvent = internalMutation({
186
- args: {
187
- event: vEvent,
188
- logLevel: v.optional(v.literal("DEBUG")),
189
- onEventHandle: v.optional(v.string()),
190
- },
191
- handler: async (ctx, args) => {
192
- await processEventHandler(ctx, args);
193
- },
194
- });
195
-
196
159
  export const getAuthUser = query({
197
160
  args: {
198
161
  id: v.string(),
199
162
  },
200
- returns: v.union(schema.tables.users.validator, v.null()),
163
+ returns: v.union(vUser, v.null()),
201
164
  handler: async (ctx, args) => {
202
165
  const user = await ctx.db
203
166
  .query("users")
204
167
  .withIndex("id", (q) => q.eq("id", args.id))
205
168
  .unique();
206
- return user ? withoutSystemFields(user) : null;
169
+ return publicUser(user);
207
170
  },
208
171
  });
209
172
 
@@ -211,12 +174,20 @@ export const getAuthUserByExternalId = query({
211
174
  args: {
212
175
  externalId: v.string(),
213
176
  },
214
- returns: v.union(schema.tables.users.validator, v.null()),
177
+ returns: v.union(vUser, v.null()),
215
178
  handler: async (ctx, args) => {
216
179
  const user = await ctx.db
217
180
  .query("users")
218
181
  .withIndex("externalId", (q) => q.eq("externalId", args.externalId))
219
182
  .unique();
220
- return user ? withoutSystemFields(user) : null;
183
+ return publicUser(user);
221
184
  },
222
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,7 +1,7 @@
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";
4
+ import type { ComponentApi } from "./component/_generated/component.js";
5
5
  import schema from "./component/schema.js";
6
6
  const modules = import.meta.glob("./component/**/*.ts");
7
7
 
@@ -9,13 +9,14 @@ const modules = import.meta.glob("./component/**/*.ts");
9
9
  * Register the component with the test convex instance.
10
10
  * @param t - The test convex instance, e.g. from calling `convexTest`.
11
11
  * @param name - The name of the component, as registered in convex.config.ts.
12
+ * @returns a component api to test via ctx.runMutation or for thick client
13
+ * usage. Also provides types for convex-test's defineTestApp.
12
14
  */
13
15
  function register(
14
16
  t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
15
17
  name: string = "workOSAuthKit"
16
18
  ) {
17
- t.registerComponent(name, schema, modules);
18
- workpool.register(t, `${name}/eventWorkpool`);
19
19
  workflow.register(t, `${name}/backfillWorkflow`);
20
+ return t.registerComponent(name, schema, modules) as unknown as ComponentApi;
20
21
  }
21
22
  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
+ });