[object Object]

← back to Dw Signup Fulfillment

webhook: secret-less re-fetch auth (Steve's choice) — re-fetch customer, use real on-file email, idempotent one-gift-per-customer metafield; selftest green

d3d9b16b36e6a79865a698e2def805509282e09b · 2026-07-28 11:33:06 -0700 · Steve

Files touched

Diff

commit d3d9b16b36e6a79865a698e2def805509282e09b
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 28 11:33:06 2026 -0700

    webhook: secret-less re-fetch auth (Steve's choice) — re-fetch customer, use real on-file email, idempotent one-gift-per-customer metafield; selftest green
---
 lib/retail-webhook.js | 47 ++++++++++++++++++++++++++++++++++++++++++
 scripts/selftest.js   | 57 ++++++++++++++++++++++++++++-----------------------
 server.js             | 34 +++++++++++-------------------
 3 files changed, 90 insertions(+), 48 deletions(-)

diff --git a/lib/retail-webhook.js b/lib/retail-webhook.js
new file mode 100644
index 0000000..3d614c7
--- /dev/null
+++ b/lib/retail-webhook.js
@@ -0,0 +1,47 @@
+'use strict';
+// Secret-less webhook authentication (Steve's decision, TK-10006 — "verify without
+// the secret"). Instead of HMAC (which needs the Full Access app's API secret), each
+// customers/create event is authenticated by RE-FETCHING the customer from Shopify by
+// id, and everything downstream uses Shopify's REAL on-file data — never the webhook
+// payload's fields.
+//
+// Why this is safe against a forged/replayed POST to the public endpoint:
+//   1) AUTH — a forged/unknown customer id is not found in Shopify → rejected.
+//   2) NO REDIRECT — the gift card is created for the real customer id and emailed to
+//      the customer's REAL on-file email (from the re-fetch), so a forged event can
+//      only ever deliver the code to the legitimate customer's own account. An
+//      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 shopify = require('./shopify');
+const giftcard = require('./giftcard');
+
+const GIFT_FLAG = { namespace: 'custom', key: 'welcome_gift_issued' };
+
+async function handleCustomerCreate(payload) {
+  const id = payload && payload.id;
+  if (!id) return { ok: false, reason: 'no_customer_id' };
+
+  // 1) AUTH — re-fetch the customer from Shopify. Forged/unknown id ⇒ reject.
+  const r = await shopify.getCustomer(id);
+  const real = r && r.json && r.json.customer ? r.json.customer : null;
+  if (!real || !real.id || !real.email) {
+    return { ok: false, reason: 'customer_not_found_or_no_email', id };
+  }
+
+  // 2) 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).
+  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").
+  await shopify.setCustomerMetafield(id, { ...GIFT_FLAG, value: 'true', type: 'boolean' });
+
+  return { ok: true, id, email: real.email, issued };
+}
+
+module.exports = { handleCustomerCreate, GIFT_FLAG };
diff --git a/scripts/selftest.js b/scripts/selftest.js
index d4a714b..ac5b5a4 100644
--- a/scripts/selftest.js
+++ b/scripts/selftest.js
@@ -52,7 +52,8 @@ function restore() {
 }
 
 const config = require('../lib/config');
-const webhook = require('../lib/webhook');
+const retailWebhook = require('../lib/retail-webhook'); // WIRED webhook handler (secret-less re-fetch auth)
+const shopify = require('../lib/shopify');
 const giftcard = require('../lib/giftcard');      // WIRED retail path (FINAL — gift card, memo §2)
 const retailCode = require('../lib/retail-code'); // alternate (shared function code — needs admin discount)
 const trade = require('../lib/trade');
@@ -70,36 +71,40 @@ async function main() {
   if (!config.DRY_RUN) { fail('DRY_RUN is OFF — refusing to run selftest that would make live writes'); return; }
 
   // ---------------------------------------------------------------------------
-  hr('(a) customers/create webhook — VALID HMAC → WIRED gift-card path');
-  const fakeCustomer = { id: 8675309, email: 'newshopper@example.com', first_name: 'Dana', created_at: new Date().toISOString() };
-  const raw = Buffer.from(JSON.stringify(fakeCustomer), 'utf8');
-  const goodHmac = webhook.sign(raw, TEST_SECRET);
-  console.log('  computed X-Shopify-Hmac-Sha256 = ' + goodHmac);
-  if (webhook.verify(raw, goodHmac)) ok('HMAC verify ACCEPTED the valid signature'); else fail('valid HMAC was rejected');
-
-  console.log('  --- WIRED retail issuance (gift card, what it WOULD do) ---');
-  const gcResult = await giftcard.issueRetailGiftCode(fakeCustomer);
-  console.log('  result: ' + JSON.stringify(gcResult, null, 2));
-  if (gcResult.path === 'gift_card') ok('retail path is gift_card (unique code emailed per signup)'); else fail('retail path is not gift_card: ' + gcResult.path);
-  if (gcResult.value === 12.75) ok('gift value = 3 × $4.25 = $12.75'); else fail('unexpected gift value: ' + gcResult.value);
-  const would = gcResult.shopifyCall && gcResult.shopifyCall.WOULD;
-  if (would && /gift_cards/.test(would)) ok('WOULD POST gift_cards (' + would + ')'); else fail('did not record a WOULD gift_cards call');
-  if (gcResult.email && gcResult.email.dryRun) ok('WOULD email the gift code to the customer (dry-run, no real send)'); else fail('gift email not dry-run');
-
-  console.log('  --- gift email clearly offers the 3 free samples ---');
+  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 _gc = shopify.getCustomer, _gm = shopify.getCustomerMetafield;
+  shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
+  shopify.getCustomerMetafield = async () => null; // no gift flag yet
+  const res1 = await retailWebhook.handleCustomerCreate({ id: 8675309, email: 'ATTACKER@evil.com' });
+  console.log('  result: ' + JSON.stringify(res1, null, 2));
+  if (res1.ok && res1.email === REAL.email) ok('issued to REAL on-file email (' + res1.email + '), NOT the payload/attacker email'); else fail('used the wrong email: ' + JSON.stringify(res1));
+  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('(b) forged / unknown customer id → REJECTED (the re-fetch IS the auth)');
+  shopify.getCustomer = async () => ({ ok: true, json: {} }); // customer not found
+  const res2 = await retailWebhook.handleCustomerCreate({ id: 999999, email: 'x@y.com' });
+  if (!res2.ok && /not_found/.test(res2.reason || '')) ok('unknown id rejected (' + res2.reason + ')'); else fail('forged id was not rejected: ' + JSON.stringify(res2));
+
+  // ---------------------------------------------------------------------------
+  hr('(b2) already-issued customer → skipped (idempotent — one gift ever)');
+  shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
+  shopify.getCustomerMetafield = async () => 'true'; // flag already set
+  const res3 = await retailWebhook.handleCustomerCreate({ id: 8675309 });
+  if (res3.ok && res3.skipped === 'already_issued') ok('replay/second event is a no-op (' + res3.skipped + ')'); else fail('idempotency failed: ' + JSON.stringify(res3));
+  shopify.getCustomer = _gc; shopify.getCustomerMetafield = _gm; // restore
+
+  // ---------------------------------------------------------------------------
+  hr('(b3) gift email clearly offers the 3 free samples + includes the code');
   const gtpl = require('../lib/email').retailGiftEmail({ firstName: 'Dana', code: 'DEMO-CODE-1234', value: 12.75, count: 3 });
   const blob = (gtpl.subject || '') + ' ' + (gtpl.html || '');
   if (/\b3\b/.test(blob) && /sample/i.test(blob)) ok('email references "3" and "sample"'); else fail('email does not clearly offer 3 free samples');
   if (/DEMO-CODE-1234/.test(blob)) ok('email includes the gift code'); else fail('email missing the code');
 
-  console.log('  --- alternate (NOT wired) shared-code path still shape-checks ---');
-  const rcResult = await retailCode.issueRetailCode(fakeCustomer);
-  if (rcResult.path === 'shared_code') ok('alternate shared_code path present — kept for reference only'); else fail('alternate shared-code path broken: ' + rcResult.path);
-
-  // ---------------------------------------------------------------------------
-  hr('(b) customers/create webhook — INVALID HMAC → rejected');
-  if (!webhook.verify(raw, 'this-is-not-the-right-signature')) ok('HMAC verify REJECTED a bad signature'); else fail('bad HMAC was accepted');
-
   // ---------------------------------------------------------------------------
   hr('(c) trade application → moderated approve');
   const created = trade.apply({ email: 'Studio@BigDesignCo.com', business_name: 'Big Design Co', resale_cert: 'CA-RESALE-99887', phone: '310-555-0142', shopify_customer_id: 5551234 });
diff --git a/server.js b/server.js
index e141bc5..a6e0356 100644
--- a/server.js
+++ b/server.js
@@ -10,7 +10,7 @@
 // no real emails, no webhook registration until Steve flips DRY_RUN=0 at go-live.
 const express = require('express');
 const config = require('./lib/config');
-const webhook = require('./lib/webhook');
+const retailWebhook = require('./lib/retail-webhook'); // WIRED: secret-less re-fetch auth + idempotent gift card
 const giftcard = require('./lib/giftcard');            // WIRED default (FINAL — Steve, memo §2): unique gift-card code per signup
 const retailCode = require('./lib/retail-code');       // alternate (shared function code — needs a DW Free Samples admin discount)
 const giftcodeDiscount = require('./lib/giftcode-discount'); // alternate (not wired)
@@ -41,32 +41,22 @@ app.get('/', (_req, res) => {
   </body></html>`);
 });
 
-// --- Webhook receiver needs the RAW body for HMAC. Mount raw parser on that path
-//     ONLY, before the global JSON parser. ---
+// --- Webhook receiver. AUTH IS SECRET-LESS (Steve's choice, TK-10006): we do NOT
+//     verify HMAC (that needs the Full Access app secret). Instead retail-webhook.js
+//     re-fetches the customer from Shopify (forged ids rejected), uses the REAL on-file
+//     email (a forged event can't redirect the code), and is idempotent (one gift per
+//     customer). So the endpoint is public but abuse-safe. ---
 app.post('/webhooks/customers/create',
-  express.raw({ type: '*/*', limit: '2mb' }),
+  express.json({ type: '*/*', limit: '2mb' }),
   async (req, res) => {
-    const hmac = req.get('X-Shopify-Hmac-Sha256');
-    const raw = req.body; // Buffer
-    if (!webhook.verify(raw, hmac)) {
-      console.warn('[webhook] HMAC verification FAILED');
-      return res.status(401).json({ ok: false, error: 'hmac_invalid' });
-    }
-    let customer;
-    try { customer = JSON.parse(raw.toString('utf8')); }
-    catch { return res.status(400).json({ ok: false, error: 'bad_json' }); }
-
+    const customer = req.body || {};
     // Respond fast (Shopify wants a quick 200); do the work then log.
     res.status(200).json({ ok: true, received: true });
     try {
-      console.log(`[webhook] customers/create id=${customer.id} email=${customer.email}`);
-      // WIRED retail path (FINAL — Steve 2026-07-28, memo §2): email the new customer a
-      // unique ~$12.75 gift-card code for their 3 free samples. Works with our token, no
-      // admin discount needed, risk bounded to the balance. The sample-locked shared-code
-      // path is retained as a labeled alternate (it needs a DW Free Samples admin discount
-      // that was never created — only a stray "3FREE" basic discount existed).
-      const result = await giftcard.issueRetailGiftCode(customer);
-      console.log('[webhook] retail gift-card result:', JSON.stringify(result));
+      console.log(`[webhook] customers/create id=${customer.id}`);
+      // Re-fetch-authenticated, idempotent gift-card issuance (3 free samples).
+      const result = await retailWebhook.handleCustomerCreate(customer);
+      console.log('[webhook] retail result:', JSON.stringify(result));
     } catch (e) {
       console.error('[webhook] fulfillment error:', e.message);
     }

← 4a5a683 auto-save: 2026-07-28T11:30:09 (1 files) — lib/shopify.js  ·  back to Dw Signup Fulfillment  ·  auto-save: 2026-07-28T12:00:18 (1 files) — theme-backups/ b6f0091 →