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
+17 -19
View File
@@ -49,18 +49,16 @@ import {
BtcPayError,
} from "../btcpay-client.js";
import { recordCall } from "../audit-log.js";
import { isWebhookProcessed, markWebhookProcessed } from "../webhook-dedup.js";
import { envelope, errorEnvelope } from "./envelope.js";
// In-memory dedup of processed BTCPay invoice ids. BTCPay retries
// webhook deliveries on non-2xx responses or network errors, so the
// same InvoiceSettled event can land more than once. We don't want
// to grant credits twice for one paid invoice.
//
// Cleared on relay restart, which means an unlucky webhook duplicate
// straddling a restart would double-credit. Acceptable tradeoff for
// v1 (operator can manually adjust the ledger if it happens), but
// worth swapping for a persistent set once the relay sees real load.
const processedInvoices = new Set();
// Dedup of processed BTCPay invoice ids. BTCPay retries webhook
// deliveries on non-2xx responses or network errors, so the same
// InvoiceSettled event can land more than once — we don't want to grant
// credits twice for one paid invoice. Backed by the persistent
// webhook-dedup store (../webhook-dedup.js) so a duplicate straddling a
// relay restart can't double-credit. Keys are namespaced
// `<storeId>|<invoiceId>` to share the store with the Zaprite rail.
export function creditsRouter() {
const router = express.Router();
@@ -452,7 +450,7 @@ export function creditsRouter() {
}
const dedupKey = `${cfg.relay_btcpay_store_id}|${invoiceId}`;
if (processedInvoices.has(dedupKey)) {
if (isWebhookProcessed(dedupKey)) {
return res
.status(200)
.json({ ok: true, ignored: "already_processed", invoiceId });
@@ -487,7 +485,7 @@ export function creditsRouter() {
const subTier = meta.tier;
const periodDays = Number(meta.period_days) || 30;
if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
processedInvoices.add(dedupKey);
await markWebhookProcessed(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "bad_tier_metadata", invoiceId });
@@ -498,7 +496,7 @@ export function creditsRouter() {
tier: subTier,
periodDays,
});
processedInvoices.add(dedupKey);
await markWebhookProcessed(dedupKey);
console.log(
`[btcpay/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (invoice ${invoiceId}) → expires ${row.subscription_expires_at}`,
);
@@ -529,7 +527,7 @@ export function creditsRouter() {
// Not ours — could be an invoice from another product on the
// same store. Mark processed so we don't keep retrying, and
// 200 so BTCPay stops calling.
processedInvoices.add(dedupKey);
await markWebhookProcessed(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "no_recap_metadata" });
@@ -541,7 +539,7 @@ export function creditsRouter() {
creditKey,
amount: credits,
});
processedInvoices.add(dedupKey);
await markWebhookProcessed(dedupKey);
await recordCall({
install_id: installId,
license_fingerprint: buyerFp,
@@ -614,8 +612,8 @@ function rewriteCheckoutUrl(url, browserBase) {
// Recovery: when the BTCPay webhook URL was broken and paid
// invoices never got credited, this scans BTCPay's recent settled
// invoices and grants credits for ones the relay hasn't processed.
// Idempotent via the same processedInvoices dedup the webhook uses,
// so re-running is safe.
// Idempotent via the same persistent webhook-dedup store the webhook
// uses, so re-running is safe.
//
// Exported so the admin route in btcpay-setup.js can call it. Not
// exposed via /relay/* because it's operator-initiated, not buyer.
@@ -660,7 +658,7 @@ export async function rescanSettledInvoices() {
const details = [];
for (const invoice of invoices || []) {
const dedupKey = `${cfg.relay_btcpay_store_id}|${invoice.id}`;
if (processedInvoices.has(dedupKey)) {
if (isWebhookProcessed(dedupKey)) {
alreadyProcessed++;
continue;
}
@@ -684,7 +682,7 @@ export async function rescanSettledInvoices() {
creditKey,
amount: credits,
});
processedInvoices.add(dedupKey);
await markWebhookProcessed(dedupKey);
await recordCall({
install_id: installId,
license_fingerprint: buyerFp,
+11 -13
View File
@@ -15,16 +15,14 @@ import express from "express";
import { extendUserTier } from "../credits.js";
import { getZapriteConfig } from "../config.js";
import { getOrder, isOrderPaid, orderIdFromWebhook } from "../zaprite-client.js";
import { isWebhookProcessed, markWebhookProcessed } from "../webhook-dedup.js";
// In-memory dedup of fully-processed orders (mirrors the BTCPay handler's
// processedInvoices). Zaprite retries on non-200, and may fire multiple
// events per order, so we guard the grant. Cleared on restart — a
// re-delivered webhook after restart re-fetches + re-grants, but
// extendUserTier is keyed per order via this set within a process; a
// duplicate grant across a restart is the same harmless "extend by one
// period" the operator-comp path already tolerates. (Acceptable: card
// double-fires across a restart are vanishingly rare.)
const processedZaprite = new Set();
// Dedup of fully-processed orders (mirrors the BTCPay handler). Zaprite
// retries on non-200, and may fire multiple events per order, so we
// guard the grant. Backed by the persistent webhook-dedup store
// (../webhook-dedup.js) so a re-delivered webhook straddling a relay
// restart can't double-extend a subscription. Keys are namespaced
// `zaprite:<orderId>` to share the store with the BTCPay rail.
export function zapriteWebhookRouter() {
const router = express.Router();
@@ -49,7 +47,7 @@ export function zapriteWebhookRouter() {
return res.status(200).json({ ok: true, ignored: "no_order_id" });
}
const dedupKey = `zaprite:${orderId}`;
if (processedZaprite.has(dedupKey)) {
if (isWebhookProcessed(dedupKey)) {
return res
.status(200)
.json({ ok: true, ignored: "already_processed", orderId });
@@ -91,7 +89,7 @@ export function zapriteWebhookRouter() {
const meta = order.metadata || {};
if (meta.product !== "recap_tier_subscription") {
processedZaprite.add(dedupKey);
await markWebhookProcessed(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "not_a_tier_order", orderId });
@@ -100,7 +98,7 @@ export function zapriteWebhookRouter() {
const subTier = meta.tier;
const periodDays = Number(meta.period_days) || 30;
if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
processedZaprite.add(dedupKey);
await markWebhookProcessed(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "bad_tier_metadata", orderId });
@@ -112,7 +110,7 @@ export function zapriteWebhookRouter() {
tier: subTier,
periodDays,
});
processedZaprite.add(dedupKey);
await markWebhookProcessed(dedupKey);
console.log(
`[zaprite/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (order ${orderId}) → expires ${row.subscription_expires_at}`,
);