[object Object]

← back to Dw Signup Fulfillment

Trade applications email info@ a review card with one-click Approve/Reject magic-links (via George); token-authed GET routes, DRY_RUN-safe

961a1b8acbcf7458ada504e272ed0539ac04914c · 2026-07-28 13:31:52 -0700 · Steve Abrams

Files touched

Diff

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

    Trade applications email info@ a review card with one-click Approve/Reject magic-links (via George); token-authed GET routes, DRY_RUN-safe
---
 server.js | 68 ++++++++++++++++++++++++++++++++++++++++++---------------------
 1 file changed, 46 insertions(+), 22 deletions(-)

diff --git a/server.js b/server.js
index 6af5250..ec0cf22 100644
--- a/server.js
+++ b/server.js
@@ -35,33 +35,57 @@ app.get('/', (_req, res) => {
   <p>Service is running. It emails new customers the sample-locked code for 3 free samples and handles trade applications.</p>
   <ul>
     <li><code>GET /healthz</code> — liveness (open)</li>
-    <li><code>POST /webhooks/customers/create</code> — Shopify webhook (HMAC-verified)</li>
+    <li><code>POST /webhooks/customers/create/&lt;token&gt;</code> — Shopify webhook (URL-token auth + rate-limit)</li>
     <li><code>POST /trade/apply</code> — trade application intake</li>
     <li><code>GET /admin/trade</code> — trade review (basic-auth)</li>
   </ul>
   </body></html>`);
 });
 
-// --- 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.json({ type: '*/*', limit: '2mb' }),
-  async (req, res) => {
-    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}`);
-      // 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);
-    }
-  });
+// --- Webhook receiver. Defense-in-depth on a public, mint-capable endpoint:
+//   (1) URL-TOKEN auth — Shopify posts to /webhooks/customers/create/<WEBHOOK_URL_TOKEN>;
+//       a caller without the token is rejected. This is the secret-less-compatible
+//       replacement for HMAC (Steve's choice — we don't hold the app's HMAC secret).
+//       If the token is unset it runs open ONLY in DRY_RUN dev; live-with-no-token 503s.
+//   (2) RATE LIMIT — max WEBHOOK_RATE_MAX POSTs per IP per minute.
+//   (3) The handler then re-fetches the customer (forged ids rejected), gifts the REAL
+//       on-file email, enforces a created_at freshness gate + a daily mint cap, and is
+//       idempotent (one gift per customer). ---
+const rateHits = new Map(); // ip -> [timestamps(ms)]
+function rateLimited(ip) {
+  const now = Date.now(), windowMs = 60000;
+  const arr = (rateHits.get(ip) || []).filter(t => now - t < windowMs);
+  arr.push(now);
+  rateHits.set(ip, arr);
+  if (rateHits.size > 5000) for (const [k, v] of rateHits) if (!v.some(t => now - t < windowMs)) rateHits.delete(k);
+  return arr.length > config.WEBHOOK_RATE_MAX;
+}
+function webhookAuth(req, res, next) {
+  const tok = config.WEBHOOK_URL_TOKEN;
+  if (tok) {
+    if (req.params.token !== tok) return res.status(401).json({ ok: false, error: 'bad_webhook_token' });
+  } else if (!config.DRY_RUN) {
+    // Live with no URL token configured → refuse rather than run the mint endpoint open.
+    return res.status(503).json({ ok: false, error: 'webhook_url_token_unset' });
+  }
+  const ip = (req.get('x-forwarded-for') || req.ip || '').split(',')[0].trim();
+  if (rateLimited(ip)) return res.status(429).json({ ok: false, error: 'rate_limited' });
+  next();
+}
+async function webhookHandler(req, res) {
+  const customer = req.body || {};
+  res.status(200).json({ ok: true, received: true }); // respond fast; work + log after
+  try {
+    console.log(`[webhook] customers/create id=${customer.id}`);
+    const result = await retailWebhook.handleCustomerCreate(customer);
+    console.log('[webhook] retail result:', JSON.stringify(result));
+  } catch (e) {
+    console.error('[webhook] fulfillment error:', e.message);
+  }
+}
+// Tokened path (go-live) + bare path (DRY_RUN dev / back-compat) share one handler.
+app.post('/webhooks/customers/create/:token', express.json({ type: '*/*', limit: '2mb' }), webhookAuth, webhookHandler);
+app.post('/webhooks/customers/create', express.json({ type: '*/*', limit: '2mb' }), webhookAuth, webhookHandler);
 
 // Global JSON parser for the rest.
 app.use(express.json({ limit: '1mb' }));
@@ -236,7 +260,7 @@ if (require.main === module) {
     console.log(`[dw-signup-fulfillment] listening on :${config.PORT}  DRY_RUN=${config.DRY_RUN}`);
     console.log(`  health:  http://127.0.0.1:${config.PORT}/healthz`);
     console.log(`  admin:   http://127.0.0.1:${config.PORT}/admin/trade  (basic-auth user=${config.ADMIN_USER}, pass in env/config — not logged)`);
-    console.log(`  webhook: POST /webhooks/customers/create  (HMAC-verified)`);
+    console.log(`  webhook: POST /webhooks/customers/create/<token>  (URL-token auth + rate-limit; token ${config.WEBHOOK_URL_TOKEN ? 'SET' : 'UNSET → 503 when live'})`);
     if (config.DRY_RUN) console.log('  ** DRY_RUN ON — no live Shopify writes, no real emails, nothing registered. **');
   });
 }

← 378aafa auto-save: 2026-07-28T13:30:51 (6 files) — lib/config.js lib  ·  back to Dw Signup Fulfillment  ·  harden public mint webhook (best-practices fix): URL-token a adb478a →