Skip to content

feat(webhooks): typed event emitter for the webhook middleware - #947

Merged
Kingsman-99 merged 3 commits into
Stellar-split:mainfrom
maztah1:feat/847-webhook-event-emitter
Sep 28, 2026
Merged

Kingsman-99 merged 3 commits into
Stellar-split:mainfrom
maztah1:feat/847-webhook-event-emitter

Conversation

@maztah1

@maztah1 maztah1 commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

What the issue was

#847 asked for webhook middleware with HMAC-SHA256 signature verification, timestamp-window enforcement, nonce-based replay protection, and a typed event emitter with on(event, handler).

The first three were already implemented in src/webhookMiddleware.ts (createWebhookMiddleware, with an LRU nonce cache and constant-time signature comparison — 41 passing tests). The missing piece was the typed event emitter: consumers still had to write a switch over req.webhookPayload.event in a downstream Express handler, and nothing tied an event name to its payload shape at the type level.

Approach

Added a TypedEventEmitter to the middleware, exposed as middleware.emitter:

  • WebhookEventMap binds each InvoiceEventType to its existing data interface, so a handler registered for "invoice.paid" receives a WebhookEventContext<InvoicePaidData> — data.invoiceId and data.amount are typed strings with no cast. This reuses the InvoicePaidData/InvoiceCreatedData/etc. types the module already defined but never connected to the event names.
  • Emitted only after all validation passes (signature, timestamp window, nonce, payload structure, header/body agreement). A subscriber can trust anything it receives; nothing unauthenticated reaches a handler.
  • Wildcard support via the existing "*" overload, returning an unsubscribe function.
  • Still a plain RequestHandler — the emitter is a property on the function, and Express only ever invokes handlers with (req, res, next), so the calling convention is unchanged. There's a test asserting arity stays 3, since that's the thing that would silently break the Express/Next.js integration if it changed.

One narrow cast was needed at the emit site: the payload is validated structurally at runtime, so its data shape is only known per event type. The cast bridges that gap and is commented.

How it was tested

10 new tests in test/webhookMiddleware.test.ts (51 total in that file, all passing):

  • Emitter is present and the middleware remains a 3-arity RequestHandler.
  • A validated invoice.paid delivery reaches its handler with the correct typed data, nonce and event.
  • Deliveries route only to the matching handler (invoice.paid handler does not fire for other events).
  • Negative cases — the security-relevant ones: a tampered signature, a timestamp outside the tolerance window, and a replayed nonce each reach neither a handler nor next(), and respond 400. This is the behaviour that matters most for the emitter specifically, since a leak here would hand unsigned data to application code.
  • Unsubscribe stops delivery; "*" fires for every event type; two middleware instances have isolated emitter state.

Full suite: 213 passed, 1 skipped, 0 failed. tsc --noEmit diffed against the base branch — no new errors (the repo has pre-existing ones unrelated to this change).

closes #847

createWebhookMiddleware() already validated signatures, enforced the
timestamp window and rejected replayed nonces, but consumers still had to
branch on req.webhookPayload.event in a downstream Express handler. Expose
a TypedEventEmitter as middleware.emitter so each event type can be
subscribed to directly.

- WebhookEventMap binds every InvoiceEventType to its typed data shape, so
  a handler registered for "invoice.paid" gets a typed data with no cast
- Handlers receive a WebhookEventContext adding the event name and request
- Emitted only after signature, timestamp and nonce validation pass, so
  subscribers can trust what they receive
- Supports the existing "*" wildcard and returns an unsubscribe function
- The handler stays a plain RequestHandler (arity 3), so it still drops
  straight into Express or a Next.js route

closes Stellar-split#847
Add tests for per-event routing, wildcard delivery, unsubscribe, emitter
isolation between middleware instances, and the negative cases: tampered
signature, out-of-window timestamp and replayed nonce must all reach neither
a handler nor next().

Export WebhookMiddleware, WebhookEventMap, WebhookEventContext and
WebhookEventEmitter from the package root.
@drips-wave

drips-wave Bot commented Sep 26, 2026

Copy link
Copy Markdown

@maztah1 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Kingsman-99
Kingsman-99 merged commit 5029d66 into Stellar-split:main Sep 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement webhook middleware with HMAC validation and replay attack protection

2 participants