Skip to content
Open
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
37 changes: 37 additions & 0 deletions src/dedup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash } from "crypto";

export class Deduplicator<T> {
private _inflight = new Map<string, Promise<T>>();
private _hits = 0;
Expand All @@ -24,3 +26,38 @@ export class Deduplicator<T> {
return { deduped: this._hits, total: this._hits + this._misses };
}
}

// In-memory key registry for idempotency tracking (#612)
const _knownKeys = new Set<string>();

/**
* 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();
}
22 changes: 19 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -582,7 +589,8 @@ export type { WebhookRecord, WebhookReplayStore } from "./webhookReplay.js";
export {
createWebhookMiddleware,
generateWebhookSignature,
verifyWebhookSignature,
// verifyWebhookSignature moved to ./webhooks/verify.js

parseWebhookPayload,
isValidEventType,
isWebhookRequest,
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down
61 changes: 61 additions & 0 deletions src/invoiceReminderScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,67 @@ export class InvoiceReminderScheduler extends TypedEventEmitter<InvoiceReminderS
return created;
}

/**
* Schedule a single reminder for an invoice.
* Returns an opaque reminderId that can be used with {@link cancelReminder}.
*/
async scheduleReminder(invoiceId: string, offsetMs: number): Promise<string> {
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(
Expand Down
26 changes: 25 additions & 1 deletion src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,28 @@ export async function searchInvoices(
} catch (error) {
throw new SearchFailedError(error instanceof Error ? error.message : String(error));
}
}
}
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);
});
}
51 changes: 37 additions & 14 deletions src/webhooks/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
115 changes: 115 additions & 0 deletions test/dedup.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading