Skip to content
Open
32 changes: 32 additions & 0 deletions __tests__\delivery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { WebhookDelivery } from "../delivery";
import { Webhook } from "../types";

const mockWebhook: Webhook = {
id: "test-webhook-id",
url: "http://localhost:19998/hook",
events: ["group.created"],
secret: "supersecretkey",
active: true,
createdAt: new Date().toISOString(),
};

describe("WebhookDelivery", () => {
it("returns a failed DeliveryResult when endpoint is unreachable", async () => {
const delivery = new WebhookDelivery({ maxRetries: 2, retryDelay: 10, timeout: 200 });
const result = await delivery.deliver(mockWebhook, "group.created", { groupId: 1 });

expect(result.success).toBe(false);
expect(result.webhookId).toBe(mockWebhook.id);
expect(result.attempts).toBe(2);
expect(result.error).toBeDefined();
expect(result.deliveryId).toBeDefined();
expect(result.completedAt).toBeDefined();
});

it("includes a deliveryId in results", async () => {
const delivery = new WebhookDelivery({ maxRetries: 1, retryDelay: 10, timeout: 200 });
const result = await delivery.deliver(mockWebhook, "group.created", {});
expect(typeof result.deliveryId).toBe("string");
expect(result.deliveryId.length).toBeGreaterThan(0);
});
});
110 changes: 110 additions & 0 deletions __tests__\service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { WebhookService } from "../service";

describe("WebhookService", () => {
let service: WebhookService;

beforeEach(() => {
service = new WebhookService({ maxRetries: 1, retryDelay: 10 });
});

describe("register", () => {
it("registers a valid webhook", () => {
const wh = service.register({
url: "https://example.com/hook",
events: ["group.created"],
secret: "supersecretkey",
});
expect(wh.id).toBeDefined();
expect(wh.active).toBe(true);
});

it("throws for an invalid URL", () => {
expect(() =>
service.register({
url: "not-a-url",
events: ["group.created"],
secret: "supersecretkey",
})
).toThrow("Invalid webhook URL");
});

it("throws for empty events array", () => {
expect(() =>
service.register({
url: "https://example.com/hook",
events: [],
secret: "supersecretkey",
})
).toThrow("At least one event type");
});

it("throws for a secret shorter than 8 characters", () => {
expect(() =>
service.register({
url: "https://example.com/hook",
events: ["group.created"],
secret: "short",
})
).toThrow("Secret must be at least 8 characters");
});
});

describe("list / get / delete", () => {
it("lists registered webhooks", () => {
service.register({
url: "https://example.com/hook",
events: ["group.created"],
secret: "supersecretkey",
});
expect(service.list()).toHaveLength(1);
});

it("gets a webhook by ID", () => {
const wh = service.register({
url: "https://example.com/hook",
events: ["group.created"],
secret: "supersecretkey",
});
expect(service.get(wh.id)).toEqual(wh);
});

it("deletes a webhook", () => {
const wh = service.register({
url: "https://example.com/hook",
events: ["group.created"],
secret: "supersecretkey",
});
expect(service.delete(wh.id)).toBe(true);
expect(service.get(wh.id)).toBeUndefined();
});
});

describe("dispatch", () => {
it("returns empty array when no webhooks are registered", async () => {
const results = await service.dispatch("group.created", { groupId: 1 });
expect(results).toHaveLength(0);
});

it("delivers to matching webhooks and records failure for unreachable URLs", async () => {
service.register({
url: "http://localhost:19999/unreachable",
events: ["group.created"],
secret: "supersecretkey",
});
const results = await service.dispatch("group.created", { groupId: 1 });
expect(results).toHaveLength(1);
expect(results[0].success).toBe(false);
expect(results[0].attempts).toBeGreaterThanOrEqual(1);
});

it("does not deliver to webhooks not subscribed to the event", async () => {
service.register({
url: "https://example.com/hook",
events: ["group.payout"],
secret: "supersecretkey",
});
const results = await service.dispatch("group.created", { groupId: 1 });
expect(results).toHaveLength(0);
});
});
});
44 changes: 44 additions & 0 deletions __tests__\signature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { generateSignature, verifySignature } from "../signature";

describe("generateSignature", () => {
it("returns a sha256= prefixed hex string", () => {
const sig = generateSignature('{"hello":"world"}', "mysecret");
expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/);
});

it("produces consistent output for the same input", () => {
const payload = JSON.stringify({ event: "group.created" });
const sig1 = generateSignature(payload, "secret123");
const sig2 = generateSignature(payload, "secret123");
expect(sig1).toBe(sig2);
});

it("produces different output for different secrets", () => {
const payload = JSON.stringify({ event: "group.created" });
const sig1 = generateSignature(payload, "secret-a");
const sig2 = generateSignature(payload, "secret-b");
expect(sig1).not.toBe(sig2);
});
});

describe("verifySignature", () => {
it("returns true for a valid signature", () => {
const payload = JSON.stringify({ event: "group.created" });
const secret = "test-secret-key";
const sig = generateSignature(payload, secret);
expect(verifySignature(payload, secret, sig)).toBe(true);
});

it("returns false for an invalid signature", () => {
const payload = JSON.stringify({ event: "group.created" });
expect(verifySignature(payload, "test-secret", "sha256=invalidsig")).toBe(false);
});

it("returns false when payload is tampered with", () => {
const secret = "test-secret-key";
const original = JSON.stringify({ event: "group.created", groupId: 1 });
const sig = generateSignature(original, secret);
const tampered = JSON.stringify({ event: "group.created", groupId: 999 });
expect(verifySignature(tampered, secret, sig)).toBe(false);
});
});
60 changes: 60 additions & 0 deletions __tests__\store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { WebhookStore } from "../store";

const sampleInput = {
url: "https://example.com/hook",
events: ["group.created" as const],
secret: "supersecretkey",
};

describe("WebhookStore", () => {
let store: WebhookStore;

beforeEach(() => {
store = new WebhookStore();
});

it("registers a webhook and returns it with a generated ID", () => {
const wh = store.register(sampleInput);
expect(wh.id).toBeDefined();
expect(wh.url).toBe(sampleInput.url);
expect(wh.active).toBe(true);
expect(wh.createdAt).toBeDefined();
});

it("lists all webhooks", () => {
store.register(sampleInput);
store.register({ ...sampleInput, url: "https://example.com/hook2" });
expect(store.list()).toHaveLength(2);
});

it("filters by event type", () => {
store.register(sampleInput);
store.register({
...sampleInput,
url: "https://other.com/hook",
events: ["group.payout"],
});
const filtered = store.list("group.created");
expect(filtered).toHaveLength(1);
expect(filtered[0].url).toBe(sampleInput.url);
});

it("deletes a webhook by ID", () => {
const wh = store.register(sampleInput);
expect(store.delete(wh.id)).toBe(true);
expect(store.get(wh.id)).toBeUndefined();
});

it("returns false when deleting non-existent webhook", () => {
expect(store.delete("nonexistent")).toBe(false);
});

it("deactivates and reactivates a webhook", () => {
const wh = store.register(sampleInput);
expect(store.deactivate(wh.id)).toBe(true);
expect(store.list("group.created")).toHaveLength(0); // filtered out

expect(store.activate(wh.id)).toBe(true);
expect(store.list("group.created")).toHaveLength(1);
});
});
128 changes: 128 additions & 0 deletions delivery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { randomUUID } from "crypto";
import { Webhook, WebhookPayload, DeliveryResult, EventType, WebhookServiceOptions } from "./types";
import { generateSignature } from "./signature";

const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_RETRY_DELAY_MS = 1000;
const DEFAULT_TIMEOUT_MS = 5000;

/**
* Handles the delivery of webhook payloads to registered URLs,
* including retry logic and HMAC signing.
*/
export class WebhookDelivery {
private readonly maxRetries: number;
private readonly retryDelay: number;
private readonly timeout: number;

constructor(options: WebhookServiceOptions = {}) {
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
this.retryDelay = options.retryDelay ?? DEFAULT_RETRY_DELAY_MS;
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
}

/**
* Deliver an event payload to a single webhook endpoint.
* Retries up to `maxRetries` times on failure with exponential back-off.
*
* @param webhook - The target webhook
* @param event - The event type being delivered
* @param data - The event-specific data
* @returns A DeliveryResult describing the outcome
*/
async deliver(
webhook: Webhook,
event: EventType,
data: Record<string, unknown>
): Promise<DeliveryResult> {
const deliveryId = randomUUID();
const payload: WebhookPayload = {
webhookId: webhook.id,
event,
timestamp: new Date().toISOString(),
deliveryId,
data,
};

const body = JSON.stringify(payload);
const signature = generateSignature(body, webhook.secret);

let lastError: string | undefined;
let lastStatusCode: number | undefined;

for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const result = await this.attemptDelivery(webhook.url, body, signature, deliveryId);
lastStatusCode = result.statusCode;

if (result.ok) {
return {
deliveryId,
webhookId: webhook.id,
success: true,
statusCode: result.statusCode,
attempts: attempt,
completedAt: new Date().toISOString(),
};
}

lastError = `HTTP ${result.statusCode}`;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : String(err);
}

// Wait before retrying (exponential back-off, skip wait on last attempt)
if (attempt < this.maxRetries) {
await this.sleep(this.retryDelay * Math.pow(2, attempt - 1));
}
}

return {
deliveryId,
webhookId: webhook.id,
success: false,
statusCode: lastStatusCode,
attempts: this.maxRetries,
completedAt: new Date().toISOString(),
error: lastError,
};
}

/**
* Perform a single HTTP POST attempt.
*/
private async attemptDelivery(
url: string,
body: string,
signature: string,
deliveryId: string
): Promise<{ ok: boolean; statusCode: number }> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeout);

try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-SoroSave-Signature": signature,
"X-SoroSave-Delivery": deliveryId,
"User-Agent": "SoroSave-Webhook/1.0",
},
body,
signal: controller.signal,
});

return { ok: response.ok, statusCode: response.status };
} finally {
clearTimeout(timer);
}
}

/**
* Sleep for the specified number of milliseconds.
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
10 changes: 10 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* @module webhook
*
* SoroSave Webhook Notification Service
*
* Provides webhook registration, management, and event dispatching
* with HMAC-SHA256 signature verification and retry logic.
*
* @example
*
Loading