diff --git a/README.md b/README.md index 762d7fe..5e23b3b 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,34 @@ new StellarSplitClient(config: StellarSplitClientConfig) | `generateWebhookSignature(payload, secret)` | `Promise` | Generate HMAC-SHA256 signature for a webhook payload | | `verifyWebhookSignature(payload, signature, secret)` | `Promise` | Manually verify a webhook signature without middleware | +The middleware also exposes a typed event emitter, so you can subscribe per +event type instead of branching on `req.webhookPayload.event` in a downstream +Express handler. Only deliveries that pass signature, timestamp and nonce +validation are emitted, so handlers can trust what they receive: + +```ts +const middleware = createWebhookMiddleware(secret, { + toleranceSeconds: 300, // reject timestamps outside a 5-minute window + nonceWindowSize: 1000, // in-memory LRU size for replay protection +}); + +// `data` is typed per event — no cast needed +middleware.emitter.on("invoice.paid", ({ data, request }) => { + console.log(data.invoiceId, data.amount); +}); + +middleware.emitter.on("invoice.expired", ({ data }) => { + console.log("expired:", data.invoiceId); +}); + +// Wildcard: fires for every event type +const unsubscribe = middleware.emitter.on("*", ({ event }) => log(event)); + +app.post("/webhooks/stellarsplit", express.raw({ type: "application/json" }), middleware, (req, res) => { + res.status(200).json({ received: true }); +}); +``` + ### Invoice Metadata Enricher | Function | Returns | Description | diff --git a/src/index.ts b/src/index.ts index f9cb56b..4504c63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -627,6 +627,10 @@ export type { WebhookPayload, WebhookRequest, RequestHandler, + WebhookMiddleware, + WebhookEventMap, + WebhookEventContext, + WebhookEventEmitter, InvoiceCreatedData, InvoicePaidData, InvoiceReleasedData, diff --git a/src/webhookMiddleware.ts b/src/webhookMiddleware.ts index 1fba7c9..babd2cc 100644 --- a/src/webhookMiddleware.ts +++ b/src/webhookMiddleware.ts @@ -12,6 +12,7 @@ import type { Request, Response, NextFunction } from "express"; import { ValidationError } from "./errors.js"; +import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; // ============================================================================ // Type Definitions @@ -176,6 +177,65 @@ export type RequestHandler = ( next: NextFunction, ) => void | Promise; +/** + * Payload delivered to {@link WebhookEventEmitter.on} handlers. + * + * It extends the raw delivery with the event type and the request that carried + * it, so handlers can route on `event` without re-inspecting the payload. + */ +export interface WebhookEventContext extends WebhookPayload { + /** The event name the payload was delivered under. */ + event: InvoiceEventType; + /** The validated request the delivery arrived on. */ + request: Request; +} + +/** + * Maps each {@link InvoiceEventType} to the `data` shape it carries. + * + * This is what makes `on()` type-safe: registering a handler for + * `"invoice.paid"` gives that handler a typed `data` with no cast required. + */ +export interface WebhookEventMap extends Record { + "invoice.created": WebhookEventContext; + "invoice.paid": WebhookEventContext; + "invoice.released": WebhookEventContext; + "invoice.failed": WebhookEventContext; + "invoice.refunded": WebhookEventContext; + "invoice.cancelled": WebhookEventContext; + "invoice.expired": WebhookEventContext; +} + +/** + * A typed event emitter for validated webhook deliveries. + * + * A middleware created by {@link createWebhookMiddleware} exposes one of + * these as `.emitter`, so callers can subscribe per event type instead of + * writing a `switch` in a downstream Express handler. + * + * @example + * ```ts + * const middleware = createWebhookMiddleware(secret); + * + * middleware.emitter.on("invoice.paid", ({ data }) => { + * console.log(data.invoiceId, data.amount); // both typed as string + * }); + * ``` + */ +export type WebhookEventEmitter = TypedEventEmitter; + +/** + * The middleware returned by {@link createWebhookMiddleware}. + * + * It is a plain `RequestHandler` (so it drops straight into Express or a + * Next.js route) with a typed {@link WebhookEventEmitter} attached, letting + * consumers subscribe to validated deliveries without a `switch` statement. + */ +export interface WebhookMiddleware extends RequestHandler { + /** Emits every validated delivery, keyed by {@link InvoiceEventType}. */ + emitter: WebhookEventEmitter; +} + // ============================================================================ // LRU Cache Implementation // ============================================================================ @@ -534,7 +594,7 @@ const DEFAULT_OPTIONS: Required = { export function createWebhookMiddleware( secret: string, options?: WebhookOptions, -): RequestHandler { +): WebhookMiddleware { if (!secret || typeof secret !== "string" || secret.length === 0) { throw new ValidationError("Webhook secret must be a non-empty string"); } @@ -547,8 +607,21 @@ export function createWebhookMiddleware( // Initialize LRU cache for nonce tracking const nonceCache = new LRUCache(config.nonceWindowSize); - // Return the middleware function - return async (req: Request, res: Response, next: NextFunction): Promise => { + // Typed emitter that publishes every successfully validated delivery, so + // callers can subscribe per event type instead of branching in a handler. + const emitter = new TypedEventEmitter(); + + /** + * The middleware function, with the typed emitter attached as `.emitter`. + * + * It stays directly callable as an Express handler — Express only ever + * invokes `req, res, next`, so the extra property is inert there. + */ + const handler: WebhookMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { // ==================================================================== // Step 1: Extract and validate headers @@ -670,6 +743,17 @@ export function createWebhookMiddleware( (req as WebhookRequest).webhookPayload = payload; (req as WebhookRequest).rawWebhookBody = rawBody; + // =================================================================== + // Step 9: Publish to the typed emitter + // Only validated deliveries reach listeners, so a subscriber can trust + // that anything it receives passed signature, timestamp and nonce checks. + // `payload` is validated structurally above, so the `data` shape is only + // known per event type; the cast bridges that gap for the typed emitter. + emitter.emit( + payload.event, + { ...payload, event: payload.event, request: req } as WebhookEventMap[typeof payload.event], + ); + // All checks passed - proceed to next middleware/handler next(); } catch (error) { @@ -691,6 +775,11 @@ export function createWebhookMiddleware( }); } }; + + // Attach the emitter so callers can `middleware.emitter.on("invoice.paid", ...)`. + handler.emitter = emitter; + + return handler; } // ============================================================================ diff --git a/test/webhookMiddleware.test.ts b/test/webhookMiddleware.test.ts index abc623c..e28eb09 100644 --- a/test/webhookMiddleware.test.ts +++ b/test/webhookMiddleware.test.ts @@ -745,3 +745,183 @@ describe("LRU Cache (via middleware)", () => { expect(next).toHaveBeenCalled(); // Should succeed (was evicted) }); }); + +// =========================================================================== +// Typed Event Emitter Tests +// =========================================================================== + +describe("createWebhookMiddleware — typed event emitter", () => { + /** Build signed request headers for `payload`. */ + async function signedRequest(payload: WebhookPayload) { + const rawBody = JSON.stringify(payload); + const signature = await generateWebhookSignature(payload, TEST_SECRET); + const ts = String(payload.timestamp); + return createMockRequest(Buffer.from(rawBody), { + "x-stellarsplit-signature": signature, + "x-stellarsplit-timestamp": ts, + "x-stellarsplit-nonce": payload.nonce, + }); + } + + it("exposes an emitter on the returned middleware", () => { + const middleware = createWebhookMiddleware(TEST_SECRET); + expect(middleware.emitter).toBeDefined(); + expect(typeof middleware.emitter.on).toBe("function"); + }); + + it("remains directly callable as a RequestHandler", () => { + const middleware = createWebhookMiddleware(TEST_SECRET); + // Express invokes handlers with exactly three arguments; the attached + // emitter must not interfere with that calling convention. + expect(typeof middleware).toBe("function"); + expect(middleware.length).toBe(3); + }); + + it("emits a validated invoice.paid delivery to its handler", async () => { + const middleware = createWebhookMiddleware(TEST_SECRET); + const handler = vi.fn(); + middleware.emitter.on("invoice.paid", handler); + + const payload = createTestPayload("invoice.paid", { + invoiceId: "42", + payer: "GPARTY", + amount: "1000", + funded: "1000", + remaining: "0", + txHash: "abc", + }); + const next = createMockNext(); + + await middleware((await signedRequest(payload)) as Request, createMockResponse() as Response, next); + + expect(next).toHaveBeenCalled(); + expect(handler).toHaveBeenCalledTimes(1); + const ctx = handler.mock.calls[0]?.[0]; + expect(ctx.event).toBe("invoice.paid"); + expect(ctx.data.invoiceId).toBe("42"); + expect(ctx.nonce).toBe(payload.nonce); + }); + + it("routes deliveries only to the matching event handler", async () => { + const middleware = createWebhookMiddleware(TEST_SECRET); + const paid = vi.fn(); + const released = vi.fn(); + middleware.emitter.on("invoice.paid", paid); + middleware.emitter.on("invoice.released", released); + + const payload = createTestPayload("invoice.paid"); + await middleware( + (await signedRequest(payload)) as Request, + createMockResponse() as Response, + createMockNext(), + ); + + expect(paid).toHaveBeenCalledTimes(1); + expect(released).not.toHaveBeenCalled(); + }); + + it("does not emit when the signature is invalid", async () => { + const middleware = createWebhookMiddleware(TEST_SECRET); + const handler = vi.fn(); + middleware.emitter.on("invoice.paid", handler); + + const payload = createTestPayload("invoice.paid"); + const rawBody = JSON.stringify(payload); + const res = createMockResponse(); + await middleware( + createMockRequest(Buffer.from(rawBody), { + "x-stellarsplit-signature": "a".repeat(64), + "x-stellarsplit-timestamp": String(payload.timestamp), + "x-stellarsplit-nonce": payload.nonce, + }) as Request, + res as Response, + createMockNext(), + ); + + expect(handler).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("does not emit when the timestamp is outside the tolerance window", async () => { + const middleware = createWebhookMiddleware(TEST_SECRET, { toleranceSeconds: 1 }); + const handler = vi.fn(); + middleware.emitter.on("invoice.paid", handler); + + const payload = createTestPayload("invoice.paid"); + payload.timestamp = Math.floor(Date.now() / 1000) - 3600; + const res = createMockResponse(); + await middleware( + (await signedRequest(payload)) as Request, + res as Response, + createMockNext(), + ); + + expect(handler).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("does not emit a replayed nonce twice", async () => { + const middleware = createWebhookMiddleware(TEST_SECRET); + const handler = vi.fn(); + middleware.emitter.on("invoice.paid", handler); + + const payload = createTestPayload("invoice.paid"); + const req = await signedRequest(payload); + + await middleware(req as Request, createMockResponse() as Response, createMockNext()); + const res = createMockResponse(); + await middleware(req as Request, res as Response, createMockNext()); + + expect(handler).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("stops emitting once a handler is unsubscribed", async () => { + const middleware = createWebhookMiddleware(TEST_SECRET); + const handler = vi.fn(); + const unsubscribe = middleware.emitter.on("invoice.paid", handler); + unsubscribe(); + + const payload = createTestPayload("invoice.paid"); + await middleware( + (await signedRequest(payload)) as Request, + createMockResponse() as Response, + createMockNext(), + ); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("supports a wildcard handler for every event type", async () => { + const middleware = createWebhookMiddleware(TEST_SECRET); + const all = vi.fn(); + middleware.emitter.on("*", all); + + for (const event of ["invoice.created", "invoice.paid", "invoice.expired"] as const) { + const payload = createTestPayload(event); + await middleware( + (await signedRequest(payload)) as Request, + createMockResponse() as Response, + createMockNext(), + ); + } + + expect(all).toHaveBeenCalledTimes(3); + }); + + it("keeps emitter state isolated between middleware instances", async () => { + const first = createWebhookMiddleware(TEST_SECRET); + const second = createWebhookMiddleware(TEST_SECRET); + const handler = vi.fn(); + first.emitter.on("invoice.paid", handler); + + const payload = createTestPayload("invoice.paid"); + await second( + (await signedRequest(payload)) as Request, + createMockResponse() as Response, + createMockNext(), + ); + + expect(handler).not.toHaveBeenCalled(); + }); +});