[object Object]

← back to Dw Signup Fulfillment

harden public mint webhook (best-practices fix): URL-token auth (secret-less-compatible), per-IP rate-limit, created_at freshness gate, daily mint cap + $ logging — blocks mass-minting to the customer base; selftest adds stale-customer test; all verified over HTTP (401/429/503/200), still DRY_RUN

adb478a9165bea8bae89d847c05e5d0be291e02a · 2026-07-28 13:33:52 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit adb478a9165bea8bae89d847c05e5d0be291e02a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 13:33:52 2026 -0700

    harden public mint webhook (best-practices fix): URL-token auth (secret-less-compatible), per-IP rate-limit, created_at freshness gate, daily mint cap + $ logging — blocks mass-minting to the customer base; selftest adds stale-customer test; all verified over HTTP (401/429/503/200), still DRY_RUN
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .env.example          | 12 ++++++++++++
 lib/retail-webhook.js | 34 ++++++++++++++++++++++++++++++----
 scripts/selftest.js   | 11 ++++++++++-
 3 files changed, 52 insertions(+), 5 deletions(-)

diff --git a/.env.example b/.env.example
index 92a0519..943466e 100644
--- a/.env.example
+++ b/.env.example
@@ -67,3 +67,15 @@ ADMIN_PASS=DW2024!
 # Retail: the exact code of the admin-created "DW Free Samples" discount (shared, one-use-per-customer).
 # Must match the code you set in Shopify admin (e.g. DWSAMPLES3). Required for retail emails.
 RETAIL_SHARED_CODE=
+
+# --- Public webhook hardening (mint endpoint is public + secret-less) -------
+# URL-token auth: register the Shopify webhook at
+#   POST $PUBLIC_URL/webhooks/customers/create/<WEBHOOK_URL_TOKEN>
+# Generate: openssl rand -hex 24. If unset, the webhook 503s when live (DRY_RUN dev only runs open).
+WEBHOOK_URL_TOKEN=
+# Only gift customers created within this many minutes (blocks minting to the existing base). Default 1440 (24h).
+WEBHOOK_FRESHNESS_MIN=1440
+# Max webhook POSTs accepted per IP per minute.
+WEBHOOK_RATE_MAX=30
+# Hard cap on gift cards minted per UTC day (money backstop).
+MINT_DAILY_CAP=200
diff --git a/lib/retail-webhook.js b/lib/retail-webhook.js
index 3d614c7..8cfd30a 100644
--- a/lib/retail-webhook.js
+++ b/lib/retail-webhook.js
@@ -13,8 +13,10 @@
 //      attacker gains nothing (they can't point it at themselves).
 //   3) IDEMPOTENCY — a custom.welcome_gift_issued metafield guarantees exactly ONE
 //      gift card per customer, ever. Replays / duplicate deliveries are no-ops.
+const config = require('./config');
 const shopify = require('./shopify');
 const giftcard = require('./giftcard');
+const mintLedger = require('./mint-ledger');
 
 const GIFT_FLAG = { namespace: 'custom', key: 'welcome_gift_issued' };
 
@@ -29,19 +31,43 @@ async function handleCustomerCreate(payload) {
     return { ok: false, reason: 'customer_not_found_or_no_email', id };
   }
 
-  // 2) IDEMPOTENCY — skip if this customer already received their welcome gift.
+  // 2) FRESHNESS — a real customers/create fires within seconds of signup. Refuse to
+  //    gift a customer whose Shopify created_at is older than WEBHOOK_FRESHNESS_MIN, so
+  //    the public endpoint can't be driven to mint gift cards to the EXISTING customer
+  //    base. (Missing created_at ⇒ not real Shopify data ⇒ treated as stale/rejected.)
+  const createdMs = real.created_at ? Date.parse(real.created_at) : NaN;
+  const ageMin = Number.isNaN(createdMs) ? Infinity : (Date.now() - createdMs) / 60000;
+  if (ageMin > config.WEBHOOK_FRESHNESS_MIN) {
+    return { ok: false, reason: 'stale_customer', id, ageMin: Number.isFinite(ageMin) ? Math.round(ageMin) : null };
+  }
+
+  // 3) IDEMPOTENCY — skip if this customer already received their welcome gift.
   const flag = await shopify.getCustomerMetafield(id, GIFT_FLAG.namespace, GIFT_FLAG.key);
   if (flag && String(flag).toLowerCase() === 'true') {
     return { ok: true, skipped: 'already_issued', id };
   }
 
-  // 3) ISSUE — gift card for the REAL customer (real id + real email; payload ignored).
+  // 4) DAILY MINT CAP — money backstop. Beyond the cap, skip + warn (never mint unbounded).
+  if (mintLedger.todayCount() >= config.MINT_DAILY_CAP) {
+    console.warn(`[retail-webhook] daily mint cap ${config.MINT_DAILY_CAP} reached — skipping id=${id}`);
+    return { ok: false, skipped: 'daily_cap_reached', id, cap: config.MINT_DAILY_CAP };
+  }
+
+  // 5) ISSUE — gift card for the REAL customer (real id + real email; payload ignored).
   const issued = await giftcard.issueRetailGiftCode({ id: real.id, email: real.email, first_name: real.first_name });
 
-  // 4) FLAG in Shopify so it never double-issues (survives restarts; "in Shopify").
+  // 6) FLAG in Shopify so it never double-issues (survives restarts; "in Shopify").
   await shopify.setCustomerMetafield(id, { ...GIFT_FLAG, value: 'true', type: 'boolean' });
 
-  return { ok: true, id, email: real.email, issued };
+  // 7) LEDGER — record the mint value + surface running daily $ total (Steve's cost rule).
+  //    Only count real (non-dry-run) mints so DRY_RUN dev/selftest never inflates the cap.
+  let ledger = null;
+  if (!config.DRY_RUN && !(issued.giftCard && issued.giftCard.dryRun)) {
+    ledger = mintLedger.recordMint(issued.value != null ? issued.value : config.SAMPLE_GIFT_VALUE);
+    console.log(`[retail-webhook] $${issued.value} gift minted — today ${ledger.count}/${config.MINT_DAILY_CAP} ($${ledger.total} liability)`);
+  }
+
+  return { ok: true, id, email: real.email, issued, ledger };
 }
 
 module.exports = { handleCustomerCreate, GIFT_FLAG };
diff --git a/scripts/selftest.js b/scripts/selftest.js
index ac5b5a4..f5a692a 100644
--- a/scripts/selftest.js
+++ b/scripts/selftest.js
@@ -74,7 +74,7 @@ async function main() {
   hr('(a) webhook — secret-less re-fetch auth issues gift card to the REAL on-file email');
   // Monkeypatch the Shopify client so the handler sees a "found" customer whose REAL
   // on-file email DIFFERS from the (attacker-controlled) payload email.
-  const REAL = { id: 8675309, email: 'real-customer@onfile.com', first_name: 'Dana' };
+  const REAL = { id: 8675309, email: 'real-customer@onfile.com', first_name: 'Dana', created_at: new Date().toISOString() };
   const _gc = shopify.getCustomer, _gm = shopify.getCustomerMetafield;
   shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
   shopify.getCustomerMetafield = async () => null; // no gift flag yet
@@ -84,6 +84,15 @@ async function main() {
   if (res1.issued && res1.issued.path === 'gift_card' && res1.issued.value === 12.75) ok('issued a $12.75 gift card (WOULD POST gift_cards)'); else fail('gift card not issued: ' + JSON.stringify(res1.issued));
   if (res1.issued && res1.issued.email && res1.issued.email.dryRun) ok('WOULD email the gift code (dry-run, no real send)'); else fail('gift email not dry-run');
 
+  // ---------------------------------------------------------------------------
+  hr('(a3) freshness gate — an OLD existing customer is NOT gifted (anti mass-mint)');
+  const OLD = { id: 7000001, email: 'old-customer@onfile.com', first_name: 'Pat', created_at: '2024-01-01T00:00:00Z' };
+  shopify.getCustomer = async () => ({ ok: true, json: { customer: OLD } });
+  shopify.getCustomerMetafield = async () => null;
+  const resOld = await retailWebhook.handleCustomerCreate({ id: 7000001, email: 'attacker@evil.com' });
+  if (!resOld.ok && resOld.reason === 'stale_customer') ok('old customer (created 2024) rejected as stale — cannot mass-mint the customer base'); else fail('stale gate did not reject old customer: ' + JSON.stringify(resOld));
+  shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } }); // restore fresh
+
   // ---------------------------------------------------------------------------
   hr('(b) forged / unknown customer id → REJECTED (the re-fetch IS the auth)');
   shopify.getCustomer = async () => ({ ok: true, json: {} }); // customer not found

← 961a1b8 Trade applications email info@ a review card with one-click  ·  back to Dw Signup Fulfillment  ·  Send service outbound from info@ (monitored office inbox) in 6e05436 →