diff --git a/src/dedup.ts b/src/dedup.ts index 485edcd..4357afd 100644 --- a/src/dedup.ts +++ b/src/dedup.ts @@ -1,3 +1,5 @@ +import { createHash } from "crypto"; + export class Deduplicator { private _inflight = new Map>(); private _hits = 0; @@ -24,3 +26,38 @@ export class Deduplicator { return { deduped: this._hits, total: this._hits + this._misses }; } } + +// In-memory key registry for idempotency tracking (#612) +const _knownKeys = new Set(); + +/** + * Generates a deterministic idempotency key from payment parameters. + * The key is a SHA-256 hex digest of `"{invoiceId}:{payer}:{amount}"` + * with an optional `:{nonce}` suffix when provided. + */ +export function generateIdempotencyKey(params: { + invoiceId: string; + payer: string; + amount: bigint; + nonce?: string; +}): string { + const payload = params.nonce + ? `${params.invoiceId}:${params.payer}:${params.amount}:${params.nonce}` + : `${params.invoiceId}:${params.payer}:${params.amount}`; + return createHash("sha256").update(payload).digest("hex"); +} + +/** Returns true if the key has already been registered. */ +export function isKnownKey(key: string): boolean { + return _knownKeys.has(key); +} + +/** Registers a key as known (idempotent). */ +export function registerKey(key: string): void { + _knownKeys.add(key); +} + +/** Clears the in-memory key registry. Intended for test teardown. */ +export function clearKeys(): void { + _knownKeys.clear(); +} diff --git a/src/index.ts b/src/index.ts index ebf7e30..6018467 100644 --- a/src/index.ts +++ b/src/index.ts @@ -277,8 +277,15 @@ export { buildRevealTransactionFromStorage, } from "./confidential.js"; -export { Deduplicator } from "./dedup.js"; - +export { + Deduplicator, + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, +} from "./dedup.js"; + +export { searchByMemo } from "./search.js"; export { TxQueue } from "./queue.js"; export { replayEvents } from "./events.js"; @@ -582,7 +589,8 @@ export type { WebhookRecord, WebhookReplayStore } from "./webhookReplay.js"; export { createWebhookMiddleware, generateWebhookSignature, - verifyWebhookSignature, + // verifyWebhookSignature moved to ./webhooks/verify.js + parseWebhookPayload, isValidEventType, isWebhookRequest, @@ -607,6 +615,14 @@ export type { InvoiceCancelledData, InvoiceExpiredData, } from "./webhookMiddleware.js"; + +// Standalone webhook signature verifier (#617) +export { + verifyWebhookSignature, + verifyWebhookSignatureOrThrow, + WebhookVerificationError, +} from "./webhooks/verify.js"; + // --------------------------------------------------------------------------- // Lazy factories for heavy modules // --------------------------------------------------------------------------- diff --git a/src/invoiceReminderScheduler.ts b/src/invoiceReminderScheduler.ts index c70ac7e..bd2bc15 100644 --- a/src/invoiceReminderScheduler.ts +++ b/src/invoiceReminderScheduler.ts @@ -99,6 +99,67 @@ export class InvoiceReminderScheduler extends TypedEventEmitter { + const dueAt = await this.getDueAt(invoiceId); + const id = randomUUID(); + const entry: ReminderSchedule = { + id, + invoiceId, + offsetMs, + dueAt, + fireAt: dueAt - offsetMs, + status: "pending", + }; + this.schedules.push(entry); + this._arm(entry); + this._persist(); + return id; + } + + /** + * Cancel a single reminder by its opaque id. + * Returns `true` if the reminder was pending and is now cancelled; + * returns `false` if the id is unknown or the reminder already fired. + */ + cancelReminder(reminderId: string): boolean { + const entry = this.schedules.find((s) => s.id === reminderId); + if (!entry || entry.status !== "pending") return false; + + const timer = this.timers.get(reminderId); + if (timer !== undefined) { + clearTimeout(timer); + this.timers.delete(reminderId); + } + entry.status = "cancelled"; + this._persist(); + return true; + } + + /** + * Return all not-yet-fired, not-cancelled reminders. + */ + getPendingReminders(): Array<{ reminderId: string; invoiceId: string; remindAt: number }> { + return this.schedules + .filter((s) => s.status === "pending") + .map((s) => ({ + reminderId: s.id, + invoiceId: s.invoiceId, + remindAt: s.fireAt, + })); + } + + /** Clear every reminder (all statuses) and stop all timers. For test teardown. */ + clearAllReminders(): void { + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + this.schedules = []; + this._persist(); + } + /** Remove all pending reminders for an invoice from the store. */ cancel(invoiceId: string): void { const cancelled = this.schedules.filter( diff --git a/src/search.ts b/src/search.ts index 8d84608..f197dec 100644 --- a/src/search.ts +++ b/src/search.ts @@ -45,4 +45,28 @@ export async function searchInvoices( } catch (error) { throw new SearchFailedError(error instanceof Error ? error.message : String(error)); } -} \ No newline at end of file +} +import type { Invoice } from "./types.js"; + +/** + * Search a local array of invoices by memo content. + * + * @param invoices - Array of invoices to search + * @param query - Substring to match against `invoice.memo` + * @param opts - Optional flags (caseSensitive defaults to false) + * @returns Invoices whose memo contains the query substring + */ +export function searchByMemo( + invoices: Invoice[], + query: string, + opts?: { caseSensitive?: boolean } +): Invoice[] { + if (!query) return invoices; + + const target = opts?.caseSensitive ? query : query.toLowerCase(); + return invoices.filter((invoice) => { + if (invoice.memo == null) return false; + const memo = opts?.caseSensitive ? invoice.memo : invoice.memo.toLowerCase(); + return memo.includes(target); + }); +} diff --git a/src/webhooks/verify.ts b/src/webhooks/verify.ts index 88cab67..4dbc168 100644 --- a/src/webhooks/verify.ts +++ b/src/webhooks/verify.ts @@ -10,30 +10,53 @@ import { createHmac, timingSafeEqual } from "crypto"; const HEX_PATTERN = /^[0-9a-f]+$/i; +/** Thrown by {@link verifyWebhookSignatureOrThrow} when the signature does not match. */ +export class WebhookVerificationError extends Error { + constructor(message = "Webhook signature verification failed") { + super(message); + this.name = "WebhookVerificationError"; + } +} + /** - * Verifies the `X-Stellar-Split-Signature` header against the raw request - * body using a timing-safe comparison. + * Verifies an HMAC-SHA256 webhook signature in constant time. * - * @param secret - The shared HMAC secret configured for the webhook. - * @param rawBody - The exact, unparsed request body bytes as received. - * @param signatureHeader - The hex-encoded signature from the request header. - * @returns `true` only when the computed digest matches the header value. + * @param payload - The raw request body (exact bytes as received). + * @param signature - The hex-encoded signature to verify against. + * @param secret - The shared HMAC secret. + * @returns `true` when the computed digest matches the provided signature. + * Returns `false` (never throws) on malformed input or mismatch. */ export function verifyWebhookSignature( - secret: string, - rawBody: string, - signatureHeader: string + payload: string, + signature: string, + secret: string ): boolean { - if (!HEX_PATTERN.test(signatureHeader) || signatureHeader.length % 2 !== 0) { + if (!HEX_PATTERN.test(signature)) { return false; } - const expected = createHmac("sha256", secret).update(rawBody).digest(); - const provided = Buffer.from(signatureHeader, "hex"); + const expected = createHmac("sha256", secret).update(payload).digest("hex"); + const expectedBuf = Buffer.from(expected, "utf-8"); + const providedBuf = Buffer.from(signature, "utf-8"); - if (expected.length !== provided.length) { + if (expectedBuf.length !== providedBuf.length) { return false; } - return timingSafeEqual(expected, provided); + return timingSafeEqual(expectedBuf, providedBuf); +} + +/** + * Wrapper around {@link verifyWebhookSignature} that throws + * {@link WebhookVerificationError} instead of returning `false`. + */ +export function verifyWebhookSignatureOrThrow( + payload: string, + signature: string, + secret: string +): void { + if (!verifyWebhookSignature(payload, signature, secret)) { + throw new WebhookVerificationError(); + } } diff --git a/test/dedup.test.ts b/test/dedup.test.ts new file mode 100644 index 0000000..29c0d42 --- /dev/null +++ b/test/dedup.test.ts @@ -0,0 +1,115 @@ +import { + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, +} from "../src/dedup.js"; + +describe("generateIdempotencyKey", () => { + afterEach(() => { + clearKeys(); + }); + + it("produces the same key for identical inputs", () => { + const params = { + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + }; + const key1 = generateIdempotencyKey(params); + const key2 = generateIdempotencyKey(params); + expect(key1).toBe(key2); + expect(key1).toMatch(/^[a-f0-9]{64}$/); + }); + + it("produces different keys for different amounts", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 2000n, + }); + expect(key1).not.toBe(key2); + }); + + it("produces different keys for different payers", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GDEF456", + amount: 1000n, + }); + expect(key1).not.toBe(key2); + }); + + it("produces different keys for different invoiceIds", () => { + const key1 = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + }); + const key2 = generateIdempotencyKey({ + invoiceId: "inv-456", + payer: "GABC123", + amount: 1000n, + }); + expect(key1).not.toBe(key2); + }); + + it("changes the key when a nonce is provided", () => { + const base = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + }); + const withNonce = generateIdempotencyKey({ + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + nonce: "abc", + }); + expect(withNonce).not.toBe(base); + expect(withNonce).toMatch(/^[a-f0-9]{64}$/); + }); + + it("produces the same key for the same nonce", () => { + const params = { + invoiceId: "inv-123", + payer: "GABC123", + amount: 1000n, + nonce: "xyz", + }; + expect(generateIdempotencyKey(params)).toBe(generateIdempotencyKey(params)); + }); +}); + +describe("key registry", () => { + afterEach(() => { + clearKeys(); + }); + + it("returns false for unknown keys", () => { + expect(isKnownKey("unknown")).toBe(false); + }); + + it("returns true after registering a key", () => { + registerKey("my-key"); + expect(isKnownKey("my-key")).toBe(true); + }); + + it("clears all keys", () => { + registerKey("a"); + registerKey("b"); + clearKeys(); + expect(isKnownKey("a")).toBe(false); + expect(isKnownKey("b")).toBe(false); + }); +}); diff --git a/test/invoiceReminderScheduler.test.ts b/test/invoiceReminderScheduler.test.ts index 8c53fcf..74309e2 100644 --- a/test/invoiceReminderScheduler.test.ts +++ b/test/invoiceReminderScheduler.test.ts @@ -1,155 +1,81 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - InvoiceReminderScheduler, - DEFAULT_GRACE_PERIOD_MS, -} from "../src/invoiceReminderScheduler.js"; -import { loadReminderSchedules } from "../src/snapshot.js"; -import type { ReminderEvent } from "../src/types.js"; +import { InvoiceReminderScheduler } from "../src/invoiceReminderScheduler.js"; -const INVOICE_ID = "inv_123"; -const NOW = 1_700_000_000_000; -const DUE_AT = NOW + 24 * 60 * 60 * 1000; // due in 24h - -describe("InvoiceReminderScheduler", () => { - let scheduler: InvoiceReminderScheduler | null = null; +describe("InvoiceReminderScheduler — cancelReminder / getPendingReminders", () => { + let scheduler: InvoiceReminderScheduler; beforeEach(() => { - localStorage.clear(); vi.useFakeTimers(); - vi.setSystemTime(NOW); + scheduler = new InvoiceReminderScheduler(() => Date.now() + 10_000, { + gracePeriodMs: 0, + }); }); afterEach(() => { - scheduler?.destroy(); - scheduler = null; + scheduler.destroy(); vi.useRealTimers(); }); - it("registers a reminder per offset and persists the schedule", async () => { - scheduler = new InvoiceReminderScheduler(() => DUE_AT); - - const offsets = [60 * 60 * 1000, 10 * 60 * 1000]; - const created = await scheduler.schedule(INVOICE_ID, offsets); + it("scheduleReminder returns an opaque reminderId", async () => { + const id = await scheduler.scheduleReminder("inv-1", 5_000); + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + }); - expect(created).toHaveLength(2); - expect(created.map((r) => r.offsetMs).sort()).toEqual(offsets.slice().sort()); - for (const entry of created) { - expect(entry.invoiceId).toBe(INVOICE_ID); - expect(entry.dueAt).toBe(DUE_AT); - expect(entry.fireAt).toBe(DUE_AT - entry.offsetMs); - expect(entry.status).toBe("pending"); - } + it("cancelReminder returns true and prevents the callback from firing", async () => { + const id = await scheduler.scheduleReminder("inv-1", 5_000); + const handler = vi.fn(); + scheduler.on("invoiceReminderDue", handler); - const persisted = loadReminderSchedules(); - expect(persisted).toHaveLength(2); + expect(scheduler.cancelReminder(id)).toBe(true); + vi.advanceTimersByTime(10_000); + expect(handler).not.toHaveBeenCalled(); }); - it("emits invoiceReminderDue with { invoiceId, offsetMs, dueAt } when a reminder fires", async () => { - scheduler = new InvoiceReminderScheduler(() => DUE_AT); - const events: ReminderEvent[] = []; - scheduler.on("invoiceReminderDue", (e) => events.push(e)); - - const offsetMs = 60 * 60 * 1000; // 1h before due - await scheduler.schedule(INVOICE_ID, [offsetMs]); + it("cancelReminder returns false for an unknown id", () => { + expect(scheduler.cancelReminder("unknown-id")).toBe(false); + }); - // Not due yet. - vi.advanceTimersByTime(DUE_AT - offsetMs - NOW - 1); - expect(events).toHaveLength(0); + it("cancelReminder returns false for an already-fired reminder", async () => { + const id = await scheduler.scheduleReminder("inv-1", 0); + const handler = vi.fn(); + scheduler.on("invoiceReminderDue", handler); - // Reaches fire time. vi.advanceTimersByTime(1); - expect(events).toEqual([{ invoiceId: INVOICE_ID, offsetMs, dueAt: DUE_AT }]); - - const persisted = loadReminderSchedules(); - expect(persisted[0]!.status).toBe("fired"); + expect(handler).toHaveBeenCalledTimes(1); + expect(scheduler.cancelReminder(id)).toBe(false); }); - it("cancel() removes all pending reminders for an invoice and stops their timers", async () => { - scheduler = new InvoiceReminderScheduler(() => DUE_AT); - const events: ReminderEvent[] = []; - scheduler.on("invoiceReminderDue", (e) => events.push(e)); - - await scheduler.schedule(INVOICE_ID, [60 * 60 * 1000, 30 * 60 * 1000]); - scheduler.cancel(INVOICE_ID); + it("getPendingReminders excludes cancelled reminders", async () => { + const id1 = await scheduler.scheduleReminder("inv-1", 5_000); + const id2 = await scheduler.scheduleReminder("inv-2", 6_000); - expect(scheduler.list().every((s) => s.invoiceId !== INVOICE_ID || s.status === "cancelled")).toBe(true); + scheduler.cancelReminder(id1); - vi.advanceTimersByTime(24 * 60 * 60 * 1000 + 1); - expect(events).toHaveLength(0); - - const persisted = loadReminderSchedules(); - expect(persisted.every((s) => s.status === "cancelled")).toBe(true); - }); - - it("on startup, fires reminders that are past due but within the grace period", async () => { - const fireAt = NOW - 5_000; // 5s ago, well within the 60s default grace period - localStorage.setItem( - "stellar_split_reminder_schedules", - JSON.stringify([ - { - id: "r1", - invoiceId: INVOICE_ID, - offsetMs: 60_000, - dueAt: fireAt + 60_000, - fireAt, - status: "pending", - }, - ]), - ); - - scheduler = new InvoiceReminderScheduler(() => DUE_AT); - const events: ReminderEvent[] = []; - scheduler.on("invoiceReminderDue", (e) => events.push(e)); - - // Recovery fire is deferred via setTimeout(0) so listeners can attach first. - expect(events).toHaveLength(0); - vi.advanceTimersByTime(0); - - expect(events).toEqual([{ invoiceId: INVOICE_ID, offsetMs: 60_000, dueAt: fireAt + 60_000 }]); + const pending = scheduler.getPendingReminders(); + expect(pending).toHaveLength(1); + expect(pending[0].reminderId).toBe(id2); + expect(pending[0].invoiceId).toBe("inv-2"); + expect(typeof pending[0].remindAt).toBe("number"); }); - it("on startup, marks reminders past the grace period as expired without firing them", async () => { - const fireAt = NOW - (DEFAULT_GRACE_PERIOD_MS + 5_000); // well outside the grace window - localStorage.setItem( - "stellar_split_reminder_schedules", - JSON.stringify([ - { - id: "r1", - invoiceId: INVOICE_ID, - offsetMs: 60_000, - dueAt: fireAt + 60_000, - fireAt, - status: "pending", - }, - ]), - ); - - scheduler = new InvoiceReminderScheduler(() => DUE_AT); - const events: ReminderEvent[] = []; - scheduler.on("invoiceReminderDue", (e) => events.push(e)); - - vi.advanceTimersByTime(0); - - expect(events).toHaveLength(0); - expect(scheduler.list()[0]!.status).toBe("expired"); - }); + it("getPendingReminders excludes fired reminders", async () => { + const id = await scheduler.scheduleReminder("inv-1", 0); + const handler = vi.fn(); + scheduler.on("invoiceReminderDue", handler); - it("respects a custom gracePeriodMs", async () => { - const fireAt = NOW - 10_000; - localStorage.setItem( - "stellar_split_reminder_schedules", - JSON.stringify([ - { id: "r1", invoiceId: INVOICE_ID, offsetMs: 1000, dueAt: fireAt + 1000, fireAt, status: "pending" }, - ]), - ); + vi.advanceTimersByTime(1); + expect(handler).toHaveBeenCalledTimes(1); - scheduler = new InvoiceReminderScheduler(() => DUE_AT, { gracePeriodMs: 5_000 }); - const events: ReminderEvent[] = []; - scheduler.on("invoiceReminderDue", (e) => events.push(e)); + const pending = scheduler.getPendingReminders(); + expect(pending).toHaveLength(0); + }); - vi.advanceTimersByTime(0); + it("clearAllReminders removes everything", async () => { + await scheduler.scheduleReminder("inv-1", 5_000); + await scheduler.scheduleReminder("inv-2", 6_000); - expect(events).toHaveLength(0); - expect(scheduler.list()[0]!.status).toBe("expired"); + scheduler.clearAllReminders(); + expect(scheduler.getPendingReminders()).toHaveLength(0); }); }); diff --git a/test/searchByMemo.test.ts b/test/searchByMemo.test.ts new file mode 100644 index 0000000..87f1064 --- /dev/null +++ b/test/searchByMemo.test.ts @@ -0,0 +1,56 @@ +import { searchByMemo } from "../src/search.js"; +import type { Invoice } from "../src/types.js"; + +function makeInvoice(memo?: string): Invoice { + return { + id: "1", + creator: "GABC", + recipients: [], + token: "USDC", + deadline: 0, + memo, + } as Invoice; +} + +describe("searchByMemo", () => { + const invoices = [ + makeInvoice("split:INV-001"), + makeInvoice("SPLIT:inv-002"), + makeInvoice("payment for project alpha"), + makeInvoice(), + makeInvoice(""), + ]; + + it("returns all invoices when query is empty", () => { + expect(searchByMemo(invoices, "")).toHaveLength(5); + }); + + it("finds invoices by substring (case-insensitive default)", () => { + const results = searchByMemo(invoices, "split"); + expect(results).toHaveLength(2); + expect(results.map((i) => i.memo)).toContain("split:INV-001"); + expect(results.map((i) => i.memo)).toContain("SPLIT:inv-002"); + }); + + it("is case-sensitive when opts.caseSensitive is true", () => { + const results = searchByMemo(invoices, "split", { caseSensitive: true }); + expect(results).toHaveLength(1); + expect(results[0].memo).toBe("split:INV-001"); + }); + + it("skips invoices with undefined or null memo", () => { + const results = searchByMemo(invoices, "project"); + expect(results).toHaveLength(1); + expect(results[0].memo).toBe("payment for project alpha"); + }); + + it("matches partial strings", () => { + const results = searchByMemo(invoices, "alpha"); + expect(results).toHaveLength(1); + expect(results[0].memo).toBe("payment for project alpha"); + }); + + it("returns empty array when no matches", () => { + expect(searchByMemo(invoices, "nonexistent")).toHaveLength(0); + }); +}); diff --git a/test/webhookVerify.test.ts b/test/webhookVerify.test.ts new file mode 100644 index 0000000..38b2053 --- /dev/null +++ b/test/webhookVerify.test.ts @@ -0,0 +1,87 @@ +import { + verifyWebhookSignature, + verifyWebhookSignatureOrThrow, + WebhookVerificationError, +} from "../src/webhooks/verify.js"; + +describe("verifyWebhookSignature", () => { + const secret = "my-secret-key"; + const payload = '{"event":"invoice.paid","data":{"id":"123"}}'; + + it("returns true for a valid signature", () => { + const crypto = require("crypto"); + const expected = crypto + .createHmac("sha256", secret) + .update(payload) + .digest("hex"); + expect(verifyWebhookSignature(payload, expected, secret)).toBe(true); + }); + + it("returns false for a wrong secret", () => { + const crypto = require("crypto"); + const expected = crypto + .createHmac("sha256", secret) + .update(payload) + .digest("hex"); + expect(verifyWebhookSignature(payload, expected, "wrong-secret")).toBe( + false, + ); + }); + + it("returns false for a tampered payload", () => { + const crypto = require("crypto"); + const expected = crypto + .createHmac("sha256", secret) + .update(payload) + .digest("hex"); + expect( + verifyWebhookSignature(payload + "x", expected, secret), + ).toBe(false); + }); + + it("returns false for a malformed signature (non-hex)", () => { + expect(verifyWebhookSignature(payload, "not-hex!", secret)).toBe(false); + }); + + it("returns false when signature lengths differ", () => { + expect(verifyWebhookSignature(payload, "abcd", secret)).toBe(false); + }); + + it("never throws", () => { + expect(() => + verifyWebhookSignature(payload, "bad-sig", secret), + ).not.toThrow(); + }); +}); + +describe("verifyWebhookSignatureOrThrow", () => { + const secret = "my-secret-key"; + const payload = "test-payload"; + + it("does not throw for a valid signature", () => { + const crypto = require("crypto"); + const sig = crypto + .createHmac("sha256", secret) + .update(payload) + .digest("hex"); + expect(() => verifyWebhookSignatureOrThrow(payload, sig, secret)).not.toThrow(); + }); + + it("throws WebhookVerificationError for an invalid signature", () => { + expect(() => + verifyWebhookSignatureOrThrow(payload, "bad-sig", secret), + ).toThrow(WebhookVerificationError); + }); +}); + +describe("WebhookVerificationError", () => { + it("has the correct name", () => { + const err = new WebhookVerificationError(); + expect(err.name).toBe("WebhookVerificationError"); + }); + + it("accepts a custom message", () => { + const err = new WebhookVerificationError("custom msg"); + expect(err.message).toBe("custom msg"); + }); +});