Files
recap-relay/server/routes/zaprite-webhook.js
T
Keysat 238689ddcc 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.
2026-06-15 18:15:00 -05:00

134 lines
5.0 KiB
JavaScript

// POST /relay/zaprite/webhook — card-rail settlement handler.
//
// Zaprite calls this when an order's activity changes. We do NOT trust the
// webhook body to decide whether money landed (Zaprite's webhook-signing
// mechanism isn't publicly documented). Instead we VERIFY by re-fetching
// the order from Zaprite's authenticated API and checking its status —
// the same re-fetch-to-verify pattern the BTCPay handler uses. The body is
// only a nudge that carries the order id.
//
// On a paid order tagged product:"recap_tier_subscription", we extend the
// buyer's prepaid period via extendUserTier — the SAME landing point as
// the BTCPay (Bitcoin) rail, so both rails converge on one tier-grant path.
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";
// 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();
// express.raw so a malformed/empty body can't 400 the webhook into an
// infinite Zaprite retry loop — we parse defensively and always 200
// unless we genuinely want a retry (transient lookup failure → 5xx).
router.post(
"/zaprite/webhook",
express.raw({ type: "*/*", limit: "1mb" }),
async (req, res) => {
let payload = {};
try {
const txt = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
payload = txt ? JSON.parse(txt) : {};
} catch {
return res.status(200).json({ ok: true, ignored: "bad_json" });
}
const orderId = orderIdFromWebhook(payload);
if (!orderId) {
return res.status(200).json({ ok: true, ignored: "no_order_id" });
}
const dedupKey = `zaprite:${orderId}`;
if (isWebhookProcessed(dedupKey)) {
return res
.status(200)
.json({ ok: true, ignored: "already_processed", orderId });
}
const zaprite = await getZapriteConfig();
if (!zaprite.apiKey) {
// Can't verify without the API key. 200 so Zaprite stops retrying.
console.warn("[zaprite/webhook] received but Zaprite not configured");
return res.status(200).json({ ok: true, ignored: "not_configured" });
}
// Re-fetch the order — this is the authoritative status + metadata.
let order;
try {
order = await getOrder({
baseURL: zaprite.baseUrl,
apiKey: zaprite.apiKey,
orderId,
});
} catch (err) {
console.error(
`[zaprite/webhook] getOrder failed for ${orderId}: ${err?.message || err}`,
);
// 5xx → Zaprite retries; likely a transient network/API blip.
return res.status(502).json({ error: "order_lookup_failed" });
}
if (!isOrderPaid(order)) {
// PENDING / PROCESSING / UNDERPAID — ack but don't grant, and DON'T
// mark processed: a later settle webhook for this order should be
// allowed to grant once it's actually paid.
return res.status(200).json({
ok: true,
ignored: `status=${order?.status || "unknown"}`,
orderId,
});
}
const meta = order.metadata || {};
if (meta.product !== "recap_tier_subscription") {
await markWebhookProcessed(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "not_a_tier_order", orderId });
}
const subUserId = typeof meta.user_id === "string" ? meta.user_id : "";
const subTier = meta.tier;
const periodDays = Number(meta.period_days) || 30;
if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
await markWebhookProcessed(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "bad_tier_metadata", orderId });
}
try {
const row = await extendUserTier({
userId: subUserId,
tier: subTier,
periodDays,
});
await markWebhookProcessed(dedupKey);
console.log(
`[zaprite/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (order ${orderId}) → expires ${row.subscription_expires_at}`,
);
return res.status(200).json({
ok: true,
tier: subTier,
user: subUserId,
expires_at: row.subscription_expires_at,
});
} catch (err) {
console.error(
`[zaprite/webhook] extendUserTier failed: ${err?.message || err}`,
);
return res.status(500).json({ error: "tier_grant_failed" });
}
},
);
return router;
}