← 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/<token></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 →