diff --git "a/__tests__\\delivery.test.ts" "b/__tests__\\delivery.test.ts" new file mode 100644 index 0000000..fc10428 --- /dev/null +++ "b/__tests__\\delivery.test.ts" @@ -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); + }); +}); diff --git "a/__tests__\\service.test.ts" "b/__tests__\\service.test.ts" new file mode 100644 index 0000000..0e45ec6 --- /dev/null +++ "b/__tests__\\service.test.ts" @@ -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); + }); + }); +}); diff --git "a/__tests__\\signature.test.ts" "b/__tests__\\signature.test.ts" new file mode 100644 index 0000000..5a6e7bb --- /dev/null +++ "b/__tests__\\signature.test.ts" @@ -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); + }); +}); diff --git "a/__tests__\\store.test.ts" "b/__tests__\\store.test.ts" new file mode 100644 index 0000000..5ca71ff --- /dev/null +++ "b/__tests__\\store.test.ts" @@ -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); + }); +}); diff --git a/delivery.ts b/delivery.ts new file mode 100644 index 0000000..26e15a5 --- /dev/null +++ b/delivery.ts @@ -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 + ): Promise { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..3c5da28 --- /dev/null +++ b/index.ts @@ -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 + * \ No newline at end of file diff --git a/package.json b/package.json index d035dd5..781d4a7 100644 --- a/package.json +++ b/package.json @@ -1,35 +1,49 @@ { "name": "@sorosave/sdk", - "version": "0.1.0", - "description": "TypeScript SDK for SoroSave — Decentralized Group Savings Protocol on Soroban", + "version": "1.0.0", + "description": "TypeScript SDK for interacting with the SoroSave smart contracts on Soroban", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./webhook": { + "import": "./webhook/dist/index.js", + "require": "./webhook/dist/index.js", + "types": "./webhook/dist/index.d.ts" }, "./react": { - "types": "./dist/react/index.d.ts", - "default": "./dist/react/index.js" + "import": "./dist/react/index.js", + "require": "./dist/react/index.js", + "types": "./dist/react/index.d.ts" } }, + "files": [ + "dist", + "webhook/dist", + "generated" + ], "scripts": { - "build": "tsc", - "test": "vitest run", - "test:watch": "vitest", - "lint": "eslint src/" - }, - "bin": { - "sorosave": "./dist/cli.js" - }, - "dependencies": { - "@stellar/stellar-sdk": "^13.0.0", - "commander": "^11.0.0" + "build": "tsc && npm run build:webhook", + "build:webhook": "tsc --project webhook/tsconfig.json", + "test": "jest", + "test:webhook": "jest --testPathPattern=webhook", + "lint": "eslint . --ext .ts", + "codegen": "ts-node scripts/codegen.ts" }, + "keywords": [ + "stellar", + "soroban", + "sorosave", + "sdk", + "blockchain" + ], + "license": "MIT", "peerDependencies": { - "react": ">=17.0.0", - "@stellar/stellar-sdk": "^13.0.0" + "react": ">=18.0.0" }, "peerDependenciesMeta": { "react": { @@ -37,27 +51,14 @@ } }, "devDependencies": { + "@types/jest": "^29.0.0", "@types/node": "^20.0.0", - "@types/react": "^18.0.0", - "react": "^18.0.0", - "typescript": "^5.5.0", - "vitest": "^2.0.0" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/big14way/sorosave.git", - "directory": "sdk" - }, - "keywords": [ - "soroban", - "stellar", - "defi", - "savings", - "ajo", - "susu", - "chit-fund", - "react", - "hooks" - ] -} \ No newline at end of file + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.0.0", + "jest": "^29.0.0", + "ts-jest": "^29.0.0", + "ts-node": "^10.0.0", + "typescript": "^5.0.0" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..6b1c537 --- /dev/null +++ b/server.ts @@ -0,0 +1,21 @@ +import { createServer, IncomingMessage, ServerResponse } from "http"; +import { + WebhookService, +} from "./service"; +import { RegisterWebhookInput, EventType } from "./types"; +import { verifySignature } from "./signature"; + +/** + * Lightweight HTTP management API for the WebhookService. + * + * Routes: + * POST /webhooks — Register a webhook + * GET /webhooks — List all webhooks + * GET /webhooks/:id — Get a single webhook + * DELETE /webhooks/:id — Delete a webhook + * POST /webhooks/:id/activate — Activate webhook + * POST /webhooks/:id/deactivate — Deactivate webhook + * POST /webhooks/test — Dispatch a test event + * + * @example + * \ No newline at end of file diff --git a/service.ts b/service.ts new file mode 100644 index 0000000..a47181d --- /dev/null +++ b/service.ts @@ -0,0 +1,20 @@ +import { + Webhook, + RegisterWebhookInput, + DeliveryResult, + EventType, + WebhookServiceOptions, +} from "./types"; +import { WebhookStore } from "./store"; +import { WebhookDelivery } from "./delivery"; + +/** + * WebhookService — the main entry point for the webhook notification system. + * + * Responsibilities: + * - Register, list, and delete webhook endpoints + * - Dispatch events to all subscribed, active webhooks + * - Handle retry logic via WebhookDelivery + * + * @example + * \ No newline at end of file diff --git a/signature.ts b/signature.ts new file mode 100644 index 0000000..0a6d212 --- /dev/null +++ b/signature.ts @@ -0,0 +1,44 @@ +import { createHmac } from "crypto"; + +/** + * Generates an HMAC-SHA256 signature for a webhook payload. + * + * The signature is computed over the raw JSON string of the payload + * and provided as a hex digest, prefixed with `sha256=`. + * + * @param payload - The raw JSON payload string + * @param secret - The webhook's HMAC secret + * @returns Signature string in the form `sha256=` + */ +export function generateSignature(payload: string, secret: string): string { + const hmac = createHmac("sha256", secret); + hmac.update(payload, "utf8"); + return `sha256=${hmac.digest("hex")}`; +} + +/** + * Verifies an HMAC-SHA256 signature against a payload. + * + * Uses a timing-safe comparison to prevent timing attacks. + * + * @param payload - The raw JSON payload string + * @param secret - The webhook's HMAC secret + * @param signature - The signature to verify (in `sha256=` format) + * @returns true if the signature is valid, false otherwise + */ +export function verifySignature( + payload: string, + secret: string, + signature: string +): boolean { + const expected = generateSignature(payload, secret); + // Timing-safe comparison + if (expected.length !== signature.length) return false; + const a = Buffer.from(expected, "utf8"); + const b = Buffer.from(signature, "utf8"); + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff === 0; +} diff --git a/store.ts b/store.ts new file mode 100644 index 0000000..87700f8 --- /dev/null +++ b/store.ts @@ -0,0 +1,82 @@ +import { Webhook, RegisterWebhookInput, EventType } from "./types"; +import { randomUUID } from "crypto"; + +/** + * In-memory store for webhook registrations. + * Can be extended to use a persistent backend. + */ +export class WebhookStore { + private webhooks: Map = new Map(); + + /** + * Register a new webhook. + * @param input - Webhook registration input + * @returns The created webhook + */ + register(input: RegisterWebhookInput): Webhook { + const webhook: Webhook = { + id: randomUUID(), + url: input.url, + events: input.events, + secret: input.secret, + active: true, + createdAt: new Date().toISOString(), + description: input.description, + }; + this.webhooks.set(webhook.id, webhook); + return webhook; + } + + /** + * List all registered webhooks, optionally filtered by event type. + * @param event - Optional event type filter + * @returns Array of matching webhooks + */ + list(event?: EventType): Webhook[] { + const all = Array.from(this.webhooks.values()); + if (!event) return all; + return all.filter((wh) => wh.active && wh.events.includes(event)); + } + + /** + * Get a webhook by ID. + * @param id - Webhook ID + * @returns The webhook or undefined + */ + get(id: string): Webhook | undefined { + return this.webhooks.get(id); + } + + /** + * Delete a webhook by ID. + * @param id - Webhook ID + * @returns true if deleted, false if not found + */ + delete(id: string): boolean { + return this.webhooks.delete(id); + } + + /** + * Deactivate a webhook without deleting it. + * @param id - Webhook ID + * @returns true if deactivated, false if not found + */ + deactivate(id: string): boolean { + const webhook = this.webhooks.get(id); + if (!webhook) return false; + webhook.active = false; + return true; + } + + /** + * Activate a previously deactivated webhook. + * @param id - Webhook ID + * @returns true if activated, false if not found + */ + activate(id: string): boolean { + const webhook = this.webhooks.get(id); + if (!webhook) return false; + webhook.active = true; + return true; + } +} diff --git a/tsconfig.json b/tsconfig.json index 5a12f91..a2974fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,27 @@ { "compilerOptions": { "target": "ES2020", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2020", "DOM"], - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", + "module": "CommonJS", + "lib": ["ES2020"], + "outDir": "dist", + "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node", "resolveJsonModule": true, - "jsx": "react-jsx" + "forceConsistentCasingInFileNames": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} \ No newline at end of file + "include": [ + "*.ts", + "**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "**/__tests__/**" + ] +} diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..1438c5d --- /dev/null +++ b/types.ts @@ -0,0 +1,86 @@ +/** + * Webhook notification service types + */ + +/** Supported contract event types */ +export type EventType = + | "group.created" + | "group.joined" + | "group.contribution" + | "group.payout" + | "group.completed" + | "group.cancelled" + | "member.added" + | "member.removed"; + +/** Registered webhook configuration */ +export interface Webhook { + /** Unique identifier for the webhook */ + id: string; + /** The URL to POST event data to */ + url: string; + /** Event types this webhook is subscribed to */ + events: EventType[]; + /** HMAC secret used to sign payloads */ + secret: string; + /** Whether the webhook is active */ + active: boolean; + /** ISO timestamp of when the webhook was created */ + createdAt: string; + /** Optional description */ + description?: string; +} + +/** Input for registering a new webhook */ +export interface RegisterWebhookInput { + /** The URL to POST event data to */ + url: string; + /** Event types to subscribe to */ + events: EventType[]; + /** HMAC secret for payload signing */ + secret: string; + /** Optional description */ + description?: string; +} + +/** Payload sent to webhook endpoints */ +export interface WebhookPayload { + /** The webhook ID that triggered this delivery */ + webhookId: string; + /** The event type */ + event: EventType; + /** ISO timestamp of when the event occurred */ + timestamp: string; + /** Unique delivery ID for idempotency */ + deliveryId: string; + /** Event-specific data */ + data: Record; +} + +/** Result of a webhook delivery attempt */ +export interface DeliveryResult { + /** Delivery attempt ID */ + deliveryId: string; + /** Webhook ID */ + webhookId: string; + /** Whether the delivery succeeded */ + success: boolean; + /** HTTP status code, if available */ + statusCode?: number; + /** Number of attempts made */ + attempts: number; + /** ISO timestamp of final attempt */ + completedAt: string; + /** Error message, if delivery failed */ + error?: string; +} + +/** Options for the WebhookService */ +export interface WebhookServiceOptions { + /** Maximum number of retry attempts (default: 3) */ + maxRetries?: number; + /** Base delay in ms between retries (default: 1000) */ + retryDelay?: number; + /** Request timeout in ms (default: 5000) */ + timeout?: number; +}