Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,34 @@ new StellarSplitClient(config: StellarSplitClientConfig)
| `generateWebhookSignature(payload, secret)` | `Promise<string>` | Generate HMAC-SHA256 signature for a webhook payload |
| `verifyWebhookSignature(payload, signature, secret)` | `Promise<boolean>` | 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 |
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,10 @@ export type {
WebhookPayload,
WebhookRequest,
RequestHandler,
WebhookMiddleware,
WebhookEventMap,
WebhookEventContext,
WebhookEventEmitter,
InvoiceCreatedData,
InvoicePaidData,
InvoiceReleasedData,
Expand Down
95 changes: 92 additions & 3 deletions src/webhookMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import type { Request, Response, NextFunction } from "express";
import { ValidationError } from "./errors.js";
import { TypedEventEmitter } from "./events/TypedEventEmitter.js";

// ============================================================================
// Type Definitions
Expand Down Expand Up @@ -176,6 +177,65 @@ export type RequestHandler = (
next: NextFunction,
) => void | Promise<void>;

/**
* 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<T = unknown> extends WebhookPayload<T> {
/** 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<string, unknown> {
"invoice.created": WebhookEventContext<InvoiceCreatedData>;
"invoice.paid": WebhookEventContext<InvoicePaidData>;
"invoice.released": WebhookEventContext<InvoiceReleasedData>;
"invoice.failed": WebhookEventContext<InvoiceFailedData>;
"invoice.refunded": WebhookEventContext<InvoiceRefundedData>;
"invoice.cancelled": WebhookEventContext<InvoiceCancelledData>;
"invoice.expired": WebhookEventContext<InvoiceExpiredData>;
}

/**
* 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<WebhookEventMap>;

/**
* 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
// ============================================================================
Expand Down Expand Up @@ -534,7 +594,7 @@ const DEFAULT_OPTIONS: Required<WebhookOptions> = {
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");
}
Expand All @@ -547,8 +607,21 @@ export function createWebhookMiddleware(
// Initialize LRU cache for nonce tracking
const nonceCache = new LRUCache<string, number>(config.nonceWindowSize);

// Return the middleware function
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
// 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<WebhookEventMap>();

/**
* 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<void> => {
try {
// ====================================================================
// Step 1: Extract and validate headers
Expand Down Expand Up @@ -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) {
Expand All @@ -691,6 +775,11 @@ export function createWebhookMiddleware(
});
}
};

// Attach the emitter so callers can `middleware.emitter.on("invoice.paid", ...)`.
handler.emitter = emitter;

return handler;
}

// ============================================================================
Expand Down
180 changes: 180 additions & 0 deletions test/webhookMiddleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading