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
+7 -1
View File
@@ -17,6 +17,7 @@ import { initCredits } from "./credits.js";
import { initAuditLog } from "./audit-log.js";
import { initJobCredits } from "./job-credits.js";
import { initOutputStore } from "./output-store.js";
import { initWebhookDedup } from "./webhook-dedup.js";
import {
setupAdminAuthMiddleware,
setupAdminAuthRoutes,
@@ -49,9 +50,14 @@ await initCredits({ dataDir: DATA_DIR });
await initJobCredits({ dataDir: DATA_DIR });
await initAuditLog({ dataDir: DATA_DIR });
await initOutputStore({ dataDir: DATA_DIR });
await initWebhookDedup({ dataDir: DATA_DIR });
const app = express();
app.use(cors());
// CORS only on /relay/* — those are the cross-origin clients (the Recaps
// app + cloud server). /admin/* and the dashboard are served same-origin
// to the operator's browser, so they don't need (and shouldn't get) the
// permissive Access-Control-Allow-Origin a global cors() would apply.
app.use("/relay", cors());
app.use(cookieParser());
// Admin auth must run BEFORE the admin routes register so the cookie
+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}`,
);
+111
View File
@@ -0,0 +1,111 @@
// Core billing primitives: commitCredit (debit), refundCredit (inverse),
// and applyTierPromotion (the Core→paid upgrade bookkeeping). Default
// quotas (no config file → getTierQuotas falls back): Core lifetime=10,
// geminiCapLifetime=5; Pro monthly=50, geminiCapMonthly=25.
import { test, describe, before } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
initCredits,
getOrCreateRow,
getUserCreditRow,
setUserTier,
commitCredit,
refundCredit,
applyTierPromotion,
} from "../credits.js";
before(async () => {
await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-money-")) });
});
describe("commitCredit", () => {
test("Core debits the lifetime bucket; Gemini tracked separately", async () => {
const creditKey = "inst:core-commit";
await commitCredit({ creditKey, tier: "core", backend: "gemini" });
let row = await getOrCreateRow({ creditKey });
assert.equal(row.lifetime_consumed, 1);
assert.equal(row.lifetime_gemini_consumed, 1);
// A hardware call bumps lifetime but NOT the Gemini sub-counter.
await commitCredit({ creditKey, tier: "core", backend: "hardware" });
row = await getOrCreateRow({ creditKey });
assert.equal(row.lifetime_consumed, 2);
assert.equal(row.lifetime_gemini_consumed, 1);
});
test("paid tier debits the monthly bucket", async () => {
await setUserTier({ userId: "p-commit", tier: "pro" });
await commitCredit({ creditKey: "user:p-commit", tier: "pro", backend: "gemini" });
const row = await getUserCreditRow("p-commit");
assert.equal(row.monthly_consumed, 1);
assert.equal(row.monthly_gemini_consumed, 1);
});
test("spend order: once the tier bucket is exhausted, debit purchased", async () => {
const creditKey = "inst:core-exhausted";
const row = await getOrCreateRow({ creditKey });
row.lifetime_consumed = 10; // at the Core lifetime cap
row.purchased_balance = 3;
await commitCredit({ creditKey, tier: "core", backend: "hardware" });
const after = await getOrCreateRow({ creditKey });
assert.equal(after.lifetime_consumed, 10, "tier counter must not exceed the cap");
assert.equal(after.purchased_balance, 2, "overflow comes out of purchased");
});
});
describe("refundCredit", () => {
test("mirrors a Core commit (tier bucket first, Gemini sub-counter too)", async () => {
const creditKey = "inst:core-refund";
await commitCredit({ creditKey, tier: "core", backend: "gemini" });
await refundCredit({ creditKey, tier: "core", backend: "gemini" });
const row = await getOrCreateRow({ creditKey });
assert.equal(row.lifetime_consumed, 0);
assert.equal(row.lifetime_gemini_consumed, 0);
});
test("refund with an empty tier bucket credits the purchased bucket", async () => {
const creditKey = "inst:refund-to-purchased";
const row = await getOrCreateRow({ creditKey });
row.lifetime_consumed = 0;
row.purchased_balance = 0;
await refundCredit({ creditKey, tier: "core", backend: "hardware" });
const after = await getOrCreateRow({ creditKey });
assert.equal(after.lifetime_consumed, 0, "must floor at 0, not go negative");
assert.equal(after.purchased_balance, 1, "refund lands in purchased when tier is already 0");
});
});
describe("applyTierPromotion", () => {
test("Core→paid transfers leftover Core credits to purchased and resets the cycle", async () => {
const row = await getOrCreateRow({ creditKey: "inst:promo" });
row.lifetime_consumed = 4; // 6 of 10 Core credits unused
row.monthly_consumed = 2; // stale paid-counter noise that must be zeroed
const promoted = await applyTierPromotion(row, "pro");
assert.equal(promoted, true);
assert.equal(row.tier_snapshot, "pro");
assert.equal(row.purchased_balance, 6, "leftover Core credits carry forward as durable top-up");
assert.equal(row.purchased_total_ever, 6);
assert.equal(row.monthly_consumed, 0, "promotion starts a fresh monthly cycle");
});
test("idempotent: a second promotion on an already-paid row is a no-op", async () => {
const row = await getOrCreateRow({ creditKey: "inst:promo-again" });
row.lifetime_consumed = 4;
await applyTierPromotion(row, "pro"); // first: fires
const purchasedAfterFirst = row.purchased_balance;
const promoted = await applyTierPromotion(row, "max"); // second: must bail
assert.equal(promoted, false);
assert.equal(row.purchased_balance, purchasedAfterFirst, "no second leftover transfer");
assert.equal(row.tier_snapshot, "pro", "bails before flipping tier again");
});
test("promoting to Core is a no-op", async () => {
const row = await getOrCreateRow({ creditKey: "inst:promo-core" });
assert.equal(await applyTierPromotion(row, "core"), false);
});
});
+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");
});
});
+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();
}