feat(reliability): idempotency store, custom webhook replay protection, correlation ids

Add LICENSE (MIT), an IdempotencyStore abstraction with a KV implementation and provider-scoped delivery keys, optional replay protection for custom webhooks (X-WebHooker-Timestamp + X-WebHooker-Nonce), and per-request correlation ids in webhook responses and logs.
This commit is contained in:
RhenCloud 2026-08-15 14:39:41 +08:00
parent 92ba2203a5
commit 737dd7af98
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
15 changed files with 378 additions and 26 deletions

View file

@ -0,0 +1,7 @@
/** A short, unique correlation id for tracing one webhook request. */
export function newCorrelationId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

View file

@ -0,0 +1,39 @@
/**
* Delivery idempotency: a small, reusable abstraction over "has this key been
* seen / can I claim it once" so webhook dedup, nonce replay protection and
* future delivery retry all share one semantics. `claim` is best-effort atomic
* on KV (get-then-put): under a concurrent double-send both callers may see
* "unclaimed", but that matches the existing dedup behavior and is acceptable
* because dispatch itself is idempotent per (provider, group, delivery).
*/
export interface IdempotencyStore {
has(key: string): Promise<boolean>;
claim(key: string, ttlSeconds: number): Promise<boolean>;
}
export function kvIdempotencyStore(kv: KVNamespace): IdempotencyStore {
return {
async has(key): Promise<boolean> {
const value = await kv.get(key);
return value !== null && value !== undefined;
},
async claim(key, ttlSeconds): Promise<boolean> {
const value = await kv.get(key);
if (value !== null && value !== undefined) return false;
await kv.put(key, "1", { expirationTtl: ttlSeconds });
return true;
},
};
}
/**
* Canonical dedup key for a webhook delivery: provider + tenant + delivery id.
* The legacy global endpoint has no tenant, so `groupId` is "global".
*/
export function deliveryKey(
provider: string,
groupId: string | undefined,
deliveryId: string,
): string {
return `delivery:${provider}:${groupId ?? "global"}:${deliveryId}`;
}