Bug
processEvent in src/component/lib.ts (line 172-177) unconditionally forwards all polled events to the user-provided authKitEvent mutation — including the three default event types (user.created, user.updated, user.deleted) that the component handles internally. The dispatch in events() (src/client/index.ts line 147) performs no guard before calling the handler:
handler: async (ctx, args) => {
await opts[args.event as K](ctx, args as never); // line 147
},
If the user only registers handlers for their additionalEventTypes (e.g. organization_membership.*), any default event that arrives causes a runtime TypeError:
TypeError: t[a.event] is not a function
Reproduction
// convex/auth.ts
export const authKit = new AuthKit<DataModel>(components.workOSAuthKit, {
authFunctions,
additionalEventTypes: [
"organization_membership.created",
"organization_membership.updated",
"organization_membership.deleted",
],
});
// Only handlers for additional events — no user.* handlers
export const { authKitEvent } = authKit.events({
"organization_membership.created": async (ctx, event) => { /* ... */ },
"organization_membership.updated": async (ctx, event) => { /* ... */ },
"organization_membership.deleted": async (ctx, event) => { /* ... */ },
});
When a user registers or signs in, updateEvents polls user.created (hardcoded default in lib.ts line 73-75), processEvent handles it internally (line 128-139), then unconditionally dispatches to the user handler (line 172-177). The handler map has no "user.created" key → opts["user.created"] is undefined → TypeError.
Root cause
Two contributing factors:
-
No dispatch guard (src/client/index.ts line 147). The generated mutation calls opts[args.event as K] without checking if the handler exists.
-
Unconditional forwarding (src/component/lib.ts line 172-177). processEvent forwards every event to the user handler regardless of whether the user registered a handler for that type. The switch statement for built-in events (line 127-171) doesn't return after handling — it falls through to the dispatch block.
Event flow
webhook POST → enqueueWebhookEvent() → workpool → updateEvents()
→ workos.events.listEvents({ events: ["user.created", "user.updated", "user.deleted", ...additionalEventTypes] })
→ for each event: processEvent()
→ idempotency check + record
→ switch: handle user.created/updated/deleted internally
→ if (onEventHandle): dispatch to user handler ← crashes here for unregistered types
Why the type system doesn't catch this
The events() method signature:
events<K extends WorkOSEvent["event"]>(opts: {
[Key in K]: (ctx, event) => Promise<void>;
})
K is inferred from the keys the caller provides. If you pass { "organization_membership.created": ... }, then K = "organization_membership.created" | .... TypeScript is satisfied — the mapped type covers all keys in K. But at runtime, args.event can be any event type the component polled, not just K.
Suggested fix
Guard the dispatch in events():
// src/client/index.ts, line 147
handler: async (ctx, args) => {
const handler = opts[args.event as K];
if (handler) {
await handler(ctx, args as never);
}
},
This makes unhandled events a no-op instead of a crash. Users who want to handle default events still can — the behavior is purely additive.
An alternative would be to have processEvent only forward events that aren't in the built-in set (user.created/updated/deleted), but this would prevent users from reacting to user lifecycle events in their own tables (a pattern the README documents at lines 175-236). The guard approach preserves that capability.
Secondary issues observed during investigation
While tracing this bug, two related concerns:
-
Event marked as processed before user handler runs. processEvent inserts into the events table (line 121-125) before dispatching to the user handler (line 172-177). If the user handler throws, the event is permanently marked as processed and will never be retried. Consider moving the insert after successful dispatch, or adding a status field for retry logic.
-
User handler receives partial event data. The dispatch (lib.ts line 173-176) passes { event: string, data: object } but strips the event id, createdAt, and other metadata. This prevents the user handler from implementing its own idempotency checks or time-based logic. Consider forwarding the full event object.
Environment
@convex-dev/workos-authkit: 0.1.7
@workos-inc/node: ^7.75.1
convex: 1.34.x
Current workaround
Register no-op handlers for all default event types:
export const { authKitEvent } = authKit.events({
"user.created": async () => {},
"user.updated": async () => {},
"user.deleted": async () => {},
"organization_membership.created": async (ctx, event) => { /* ... */ },
// ...
});
This is fragile — if the component adds new default event types in a future version, it breaks again silently at runtime.
Bug
processEventinsrc/component/lib.ts(line 172-177) unconditionally forwards all polled events to the user-providedauthKitEventmutation — including the three default event types (user.created,user.updated,user.deleted) that the component handles internally. The dispatch inevents()(src/client/index.tsline 147) performs no guard before calling the handler:If the user only registers handlers for their
additionalEventTypes(e.g.organization_membership.*), any default event that arrives causes a runtime TypeError:Reproduction
When a user registers or signs in,
updateEventspollsuser.created(hardcoded default inlib.tsline 73-75),processEventhandles it internally (line 128-139), then unconditionally dispatches to the user handler (line 172-177). The handler map has no"user.created"key →opts["user.created"]isundefined→ TypeError.Root cause
Two contributing factors:
No dispatch guard (
src/client/index.tsline 147). The generated mutation callsopts[args.event as K]without checking if the handler exists.Unconditional forwarding (
src/component/lib.tsline 172-177).processEventforwards every event to the user handler regardless of whether the user registered a handler for that type. Theswitchstatement for built-in events (line 127-171) doesn'treturnafter handling — it falls through to the dispatch block.Event flow
Why the type system doesn't catch this
The
events()method signature:Kis inferred from the keys the caller provides. If you pass{ "organization_membership.created": ... }, thenK = "organization_membership.created" | .... TypeScript is satisfied — the mapped type covers all keys inK. But at runtime,args.eventcan be any event type the component polled, not justK.Suggested fix
Guard the dispatch in
events():This makes unhandled events a no-op instead of a crash. Users who want to handle default events still can — the behavior is purely additive.
An alternative would be to have
processEventonly forward events that aren't in the built-in set (user.created/updated/deleted), but this would prevent users from reacting to user lifecycle events in their own tables (a pattern the README documents at lines 175-236). The guard approach preserves that capability.Secondary issues observed during investigation
While tracing this bug, two related concerns:
Event marked as processed before user handler runs.
processEventinserts into theeventstable (line 121-125) before dispatching to the user handler (line 172-177). If the user handler throws, the event is permanently marked as processed and will never be retried. Consider moving the insert after successful dispatch, or adding a status field for retry logic.User handler receives partial event data. The dispatch (lib.ts line 173-176) passes
{ event: string, data: object }but strips the eventid,createdAt, and other metadata. This prevents the user handler from implementing its own idempotency checks or time-based logic. Consider forwarding the full event object.Environment
@convex-dev/workos-authkit: 0.1.7@workos-inc/node: ^7.75.1convex: 1.34.xCurrent workaround
Register no-op handlers for all default event types:
This is fragile — if the component adds new default event types in a future version, it breaks again silently at runtime.