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
+93
View File
@@ -0,0 +1,93 @@
// Persistent dedup of settled-payment webhook deliveries — BTCPay
// invoices AND Zaprite orders share this one store. It replaces the
// per-process in-memory Sets the two webhook handlers used to keep:
// those were cleared on restart, so a duplicate delivery that straddled
// a relay restart would double-credit (BTCPay) or double-extend a
// subscription (Zaprite). Persisting the processed-keys to disk closes
// that window.
//
// JSON-file backed (single file at <dataDir>/processed-webhooks.json),
// same rationale as the credit ledger in credits.js: at most one
// mutation per settled payment, so a plain JSON file with serial writes
// is plenty.
//
// Keys are namespaced by their caller so the two rails can't collide in
// the shared store: BTCPay uses `<storeId>|<invoiceId>`, Zaprite uses
// `zaprite:<orderId>`. We store key → ISO timestamp (not a bare set) so
// entries far older than any processor's retry window can be pruned on
// load, keeping the file bounded.
import fs from "fs/promises";
import path from "path";
// Payment processors retry webhook delivery for hours-to-days, never
// months. 180 days is safely beyond any retry window, so pruning past
// it cannot re-open a double-grant gap.
const RETENTION_MS = 180 * 24 * 60 * 60 * 1000;
let storePath = null;
let processed = {}; // key → ISO timestamp
let writing = null; // serializes concurrent writes
export async function initWebhookDedup({ dataDir } = {}) {
const dir = dataDir || "/data";
storePath = path.join(dir, "processed-webhooks.json");
await fs.mkdir(dir, { recursive: true }).catch(() => {});
try {
const raw = await fs.readFile(storePath, "utf8");
const parsed = JSON.parse(raw);
processed =
parsed && typeof parsed === "object" && parsed.keys ? parsed.keys : {};
} catch (err) {
if (err.code !== "ENOENT") {
console.warn(
`[webhook-dedup] failed to read ${storePath}: ${err.message} — starting empty`
);
}
processed = {};
}
// Prune anything older than the retention window so the file stays
// bounded over the relay's lifetime.
const cutoff = Date.now() - RETENTION_MS;
let pruned = 0;
for (const [k, ts] of Object.entries(processed)) {
const t = Date.parse(ts);
if (!Number.isFinite(t) || t < cutoff) {
delete processed[k];
pruned += 1;
}
}
if (pruned > 0) await persist();
console.log(
`[webhook-dedup] loaded ${Object.keys(processed).length} processed key(s)` +
`${pruned ? `, pruned ${pruned} stale` : ""} from ${storePath}`
);
}
async function persist() {
// Coalesce concurrent writes — mirrors credits.js persist().
if (writing) await writing;
writing = (async () => {
const tmp = storePath + ".tmp";
await fs.writeFile(tmp, JSON.stringify({ keys: processed }), { mode: 0o600 });
await fs.rename(tmp, storePath);
})();
try {
await writing;
} finally {
writing = null;
}
}
export function isWebhookProcessed(key) {
return Object.prototype.hasOwnProperty.call(processed, key);
}
// Mark a webhook key processed and persist immediately. Callers invoke
// this AFTER the grant/extend succeeds, so a failed grant leaves the key
// unmarked and a processor retry can complete it. Idempotent.
export async function markWebhookProcessed(key) {
if (isWebhookProcessed(key)) return;
processed[key] = new Date().toISOString();
await persist();
}