← 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
A lib/retail-webhook.jsM scripts/selftest.jsM server.js
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 →