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

126
tests/custom-replay.test.ts Normal file
View file

@ -0,0 +1,126 @@
import { describe, it, expect } from "bun:test";
import { createHmac } from "crypto";
import { customProvider } from "../server/lib/providers/custom";
import type { Env } from "../server/lib/types";
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get: async (key: string) => (store.has(key) ? store.get(key)! : null),
put: async (key: string, value: string) => {
store.set(key, value);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({ keys: [], list_complete: true, cacheStatus: null }),
} as unknown as KVNamespace;
}
function createEnv(secret: string): Env {
return {
GITHUB_WEBHOOK_SECRET: secret,
KV: createMockKV(),
DB: {} as D1Database,
};
}
function replaySignature(secret: string, timestamp: number, nonce: string, body: string): string {
return `sha256=${createHmac("sha256", secret).update(`${timestamp}.${nonce}.${body}`).digest("hex")}`;
}
const now = Math.floor(Date.now() / 1000);
describe("custom provider replay protection", () => {
it("verifies a valid timestamp + nonce + signature", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
const nonce = "nonce-1";
const ok = await customProvider.verify(
body,
{
"x-webhooker-signature": replaySignature(secret, now, nonce, body),
"x-webhooker-timestamp": String(now),
"x-webhooker-nonce": nonce,
},
env,
);
expect(ok).toBe(true);
});
it("rejects a replayed nonce even with a valid signature", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
const nonce = "nonce-reused";
const headers = {
"x-webhooker-signature": replaySignature(secret, now, nonce, body),
"x-webhooker-timestamp": String(now),
"x-webhooker-nonce": nonce,
};
expect(await customProvider.verify(body, headers, env)).toBe(true);
expect(await customProvider.verify(body, headers, env)).toBe(false);
});
it("rejects a timestamp outside the window", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
const stale = now - 3600;
const nonce = "nonce-stale";
const ok = await customProvider.verify(
body,
{
"x-webhooker-signature": replaySignature(secret, stale, nonce, body),
"x-webhooker-timestamp": String(stale),
"x-webhooker-nonce": nonce,
},
env,
);
expect(ok).toBe(false);
});
it("rejects a signature computed over the wrong input", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
const nonce = "nonce-bad-sig";
const wrong = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
const ok = await customProvider.verify(
body,
{
"x-webhooker-signature": wrong,
"x-webhooker-timestamp": String(now),
"x-webhooker-nonce": nonce,
},
env,
);
expect(ok).toBe(false);
});
it("still verifies the legacy body-only signature", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
const ok = await customProvider.verify(body, { "x-webhooker-signature": sig }, env);
expect(ok).toBe(true);
});
it("treats a missing nonce as a legacy (body-only) signature", async () => {
const secret = "s3cret";
const env = createEnv(secret);
const body = JSON.stringify({ hello: "world" });
// Only a timestamp, no nonce → falls back to body-only verification.
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
const ok = await customProvider.verify(
body,
{ "x-webhooker-signature": sig, "x-webhooker-timestamp": String(now) },
env,
);
// Timestamp is present but nonce missing, so replay path is skipped and the
// body-only signature is used → verified.
expect(ok).toBe(true);
});
});