Persist payment-webhook dedup; declare BTCPay required; scope CORS

Replace the in-memory dedup Sets in the BTCPay and Zaprite webhook
handlers (and the BTCPay rescan path) with a persistent JSON-backed
store (server/webhook-dedup.js). The in-memory sets were cleared on
restart, so a duplicate webhook delivery straddling a relay restart
could double-credit (BTCPay) or double-extend a subscription (Zaprite).
The store atomically writes /data/processed-webhooks.json, namespaces
keys per rail (storeId|invoiceId vs zaprite:orderId), and prunes
entries older than 180 days (safely beyond any retry window).

Also:
- BTCPay is a required running dependency (operator decision). Config
  was already optional:false/kind:'running'; corrected the contradictory
  "optional" comment in the manifest to match.
- Scope cors() to /relay/* only — off /admin/* and the same-origin
  dashboard, which don't need permissive CORS.
- Add money-path unit tests (commitCredit/refundCredit/applyTierPromotion)
  and webhook-dedup tests (incl. the survives-a-restart guarantee).
- Fix two AGENTS.md auth-doc drifts; refresh Current state.

Version 0.2.125 -> 0.2.126.
This commit is contained in:
Keysat
2026-06-15 18:15:00 -05:00
parent 798a698132
commit 238689ddcc
10 changed files with 345 additions and 56 deletions
+70
View File
@@ -0,0 +1,70 @@
// Persistent webhook dedup — the store that stops a settled-payment
// webhook duplicate from double-crediting (BTCPay) or double-extending
// (Zaprite) when the duplicate straddles a relay restart.
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
initWebhookDedup,
isWebhookProcessed,
markWebhookProcessed,
} from "../webhook-dedup.js";
// The module is a singleton; each test inits a fresh dir to reset state.
const freshDir = () => mkdtempSync(path.join(tmpdir(), "relay-dedup-"));
describe("webhook dedup (persistent)", () => {
test("unknown key is not processed; marking makes it processed", async () => {
await initWebhookDedup({ dataDir: freshDir() });
assert.equal(isWebhookProcessed("store1|inv1"), false);
await markWebhookProcessed("store1|inv1");
assert.equal(isWebhookProcessed("store1|inv1"), true);
});
// Headline guarantee for this change.
test("processed keys survive a restart (reload from disk)", async () => {
const dir = freshDir();
await initWebhookDedup({ dataDir: dir });
await markWebhookProcessed("store1|inv-restart");
// Simulate a relay restart: re-init from the SAME data dir.
await initWebhookDedup({ dataDir: dir });
assert.equal(
isWebhookProcessed("store1|inv-restart"),
true,
"a key marked before restart must still be deduped after restart"
);
});
test("BTCPay and Zaprite keys share the store without colliding", async () => {
await initWebhookDedup({ dataDir: freshDir() });
await markWebhookProcessed("store1|inv9");
await markWebhookProcessed("zaprite:order9");
assert.equal(isWebhookProcessed("store1|inv9"), true);
assert.equal(isWebhookProcessed("zaprite:order9"), true);
assert.equal(isWebhookProcessed("zaprite:inv9"), false);
});
test("marking is idempotent", async () => {
await initWebhookDedup({ dataDir: freshDir() });
await markWebhookProcessed("k");
await markWebhookProcessed("k"); // must not throw or duplicate
assert.equal(isWebhookProcessed("k"), true);
});
test("stale entries past the retention window are pruned on load", async () => {
const dir = freshDir();
const ancient = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
const recent = new Date().toISOString();
writeFileSync(
path.join(dir, "processed-webhooks.json"),
JSON.stringify({ keys: { "store1|old": ancient, "store1|new": recent } })
);
await initWebhookDedup({ dataDir: dir });
assert.equal(isWebhookProcessed("store1|old"), false, "1y-old key should be pruned");
assert.equal(isWebhookProcessed("store1|new"), true, "recent key should survive");
});
});