feat(webhooks): typed event emitter for the webhook middleware - #947
Merged
Kingsman-99 merged 3 commits intoSep 28, 2026
Merged
Conversation
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.
|
@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! 🚀 |
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aswitchoverreq.webhookPayload.eventin a downstream Express handler, and nothing tied an event name to its payload shape at the type level.Approach
Added a
TypedEventEmitterto the middleware, exposed asmiddleware.emitter:WebhookEventMapbinds eachInvoiceEventTypeto its existingdatainterface, so a handler registered for"invoice.paid"receives aWebhookEventContext<InvoicePaidData>—data.invoiceIdanddata.amountare typed strings with no cast. This reuses theInvoicePaidData/InvoiceCreatedData/etc. types the module already defined but never connected to the event names."*"overload, returning an unsubscribe function.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
datashape 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):RequestHandler.invoice.paiddelivery reaches its handler with the correct typeddata,nonceandevent.invoice.paidhandler does not fire for other events).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."*"fires for every event type; two middleware instances have isolated emitter state.Full suite: 213 passed, 1 skipped, 0 failed.
tsc --noEmitdiffed against the base branch — no new errors (the repo has pre-existing ones unrelated to this change).closes #847