From b926101e3d9a689c1982598a2b0c13659b15c58e Mon Sep 17 00:00:00 2001 From: Dominic Wild Date: Thu, 22 Oct 2026 01:35:18 +0100 Subject: [PATCH 1/2] Forward user deletion events without a stored user --- README.md | 5 ++++ example/convex/auth.test.ts | 14 ++++++++- src/component/lib.test.ts | 58 ++++++++++++++++++++++++++++++++++++- src/component/lib.ts | 4 +-- 4 files changed, 77 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 06fa7c9..c75bc0e 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,11 @@ By default the component will handle the following events: - `user.updated` - `user.deleted` +The `user.deleted` handler also runs when the component never stored the user, for +example when WorkOS delivers the deletion before the creation. `event.data` is then +the webhook payload as sent, so only `id` is guaranteed, even though it is typed as +a full user. When the component has the user stored, its fields fill any gaps. + ```ts // convex/auth.ts import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit"; diff --git a/example/convex/auth.test.ts b/example/convex/auth.test.ts index 5459ff9..2f1fbfc 100644 --- a/example/convex/auth.test.ts +++ b/example/convex/auth.test.ts @@ -135,17 +135,29 @@ describe("authKitEvent", () => { expect(appUsers).toHaveLength(0); }); - test("events for a deleted user do not reach the callback", async () => { + test("user.deleted removes an app user without a stored component user", async () => { const t = initConvexTest(); const onEventHandle = await t.run(async () => createFunctionHandle(internal.auth.authKitEvent) ); const user = makeUser(); + await t.run(async (ctx) => { + await ctx.db.insert("users", { + authId: user.id, + email: user.email, + name: "Alice Smith", + }); + }); await t.mutation(components.workOSAuthKit.lib.onWebhookEvent, { event: makeEvent("user.deleted", user), onEventHandle, }); + const afterDelete = await t.run(async (ctx) => { + return ctx.db.query("users").collect(); + }); + expect(afterDelete).toHaveLength(0); + await t.mutation(components.workOSAuthKit.lib.onWebhookEvent, { event: makeEvent("user.created", user), onEventHandle, diff --git a/src/component/lib.test.ts b/src/component/lib.test.ts index 22ddee7..4adce1b 100644 --- a/src/component/lib.test.ts +++ b/src/component/lib.test.ts @@ -1,10 +1,14 @@ /// import { convexTest } from "convex-test"; -import { describe, test, expect } from "vitest"; +import { createFunctionHandle, makeFunctionReference } from "convex/server"; +import { describe, test, expect, vi } from "vitest"; import { modules } from "./setup.test.js"; import schema from "./schema.js"; import workflow from "@convex-dev/workflow/test"; import { api } from "./_generated/api.js"; +import { mutation } from "./_generated/server.js"; +import { vEvent } from "./lib.js"; +import { v } from "convex/values"; /** Default test user values. */ const defaultUser = { @@ -285,6 +289,58 @@ describe("onWebhookEvent", () => { expect(dbDeletedUsers[0].id).toBe(user.id); }); + test.each([ + { payload: "a full user", data: { object: "user", ...makeUser() } }, + { payload: "only an id", data: { id: defaultUser.id } }, + ])( + "user.deleted forwards $payload without a stored user", + async ({ data }) => { + const onEvent = vi.fn(); + const t = convexTest(schema, { + ...modules, + "./callbacks.ts": async () => ({ + onEvent: mutation({ + args: { event: vEvent.fields.event, data: vEvent.fields.data }, + returns: v.null(), + handler: async (_ctx, args) => { + onEvent(args); + return null; + }, + }), + }), + }); + const onEventHandle = await t.run(async () => + createFunctionHandle( + makeFunctionReference<"mutation">("callbacks:onEvent") + ) + ); + const event = makeRawEvent("user.deleted", data); + + await t.mutation(api.lib.onWebhookEvent, { event, onEventHandle }); + + expect(onEvent).toHaveBeenCalledExactlyOnceWith({ + event: "user.deleted", + data, + }); + + await t.mutation(api.lib.onWebhookEvent, { event, onEventHandle }); + for (const eventType of ["user.created", "user.updated"]) { + await t.mutation(api.lib.onWebhookEvent, { + event: makeEvent(eventType, makeUser()), + onEventHandle, + }); + } + + expect(onEvent).toHaveBeenCalledTimes(1); + const { dbUsers, dbDeletedUsers } = await t.run(async (ctx) => ({ + dbUsers: await ctx.db.query("users").collect(), + dbDeletedUsers: await ctx.db.query("deletedUsers").collect(), + })); + expect(dbUsers).toHaveLength(0); + expect(dbDeletedUsers).toMatchObject([{ id: data.id }]); + } + ); + test("user.created after user.deleted is skipped", async () => { const t = initConvexTest(); const user = makeUser(); diff --git a/src/component/lib.ts b/src/component/lib.ts index 1741c23..625f6bf 100644 --- a/src/component/lib.ts +++ b/src/component/lib.ts @@ -119,8 +119,8 @@ async function processEventHandler( .withIndex("id", (q) => q.eq("id", id)) .unique(); if (!user) { - console.warn("user not found, skipping deletion", id); - return; + console.warn("user not found, forwarding deletion", id); + break; } await ctx.db.delete("users", user._id); // The callback data is typed as a whole user, so fill anything the From fccde12a3022d0709c83d2b8c30b3aae9bee8ecb Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Wed, 28 Oct 2026 03:04:52 +0000 Subject: [PATCH 2/2] Note that the user.deleted handler should return on a missing app user Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ README.md | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 024e00f..3981f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Call the `user.deleted` handler even when the component never stored the user (#82) + ## 0.2.10 - Fix deleted user handling and drop the unused `additionalEventTypes` option (#77) diff --git a/README.md b/README.md index c75bc0e..882fd29 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,11 @@ example when WorkOS delivers the deletion before the creation. `event.data` is t the webhook payload as sent, so only `id` is guaranteed, even though it is typed as a full user. When the component has the user stored, its fields fill any gaps. +**If your app has no record of the user, be sure to return rather than throw.** +Throwing fails the webhook and rolls back the component's record of the deletion, +so a `user.created` that arrives before WorkOS retries recreates the user in the +component and your app. + ```ts // convex/auth.ts import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit";