Skip to content

events() crashes on unhandled event types forwarded by processEvent #35

@kloudysky

Description

@kloudysky

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:

  1. No dispatch guard (src/client/index.ts line 147). The generated mutation calls opts[args.event as K] without checking if the handler exists.

  2. 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:

  1. 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.

  2. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions