[object Object]

← back to Wallco Ai

shopify orders/paid webhook: mint per-order download token per bundle line item

c15a6912a4fd1ecf2d0e7a3ee33e1f492839fd5c · 2026-05-28 17:14:27 -0700 · Steve Abrams

Replaces the shared wallco_ai.bundle_download_url pattern (one URL stamped on
each bundle product → every buyer gets the same /downloads/<slug>/<token>.zip)
with a per-order token minted at orders/paid webhook time.

Flow:
  1. Shopify fires orders/paid → POST /api/shopify/webhooks/orders-paid.
  2. Express.raw runs FIRST (early-mount before global express.json) so HMAC
     can be computed over the raw request bytes.
  3. HMAC SHA256 verified against process.env.SHOPIFY_WEBHOOK_SECRET; 401 if
     bad, 503 if unset in NODE_ENV=production (refuses to mint unsigned).
  4. For each line_item: GET /admin/api/2026-01/products/<id>/metafields.json,
     look for wallco_ai.bundle_slug. Skip if absent (sample/wallpaper rolls).
  5. Mint 32-char hex token (crypto.randomBytes(16)), INSERT into
     wallco_download_tokens with shopify_order_id / shopify_product_id /
     customer_email / max_downloads=5 / expires_at=now()+90 days.
  6. PUT note_attribute download_url_<slug> on the order so the URL surfaces
     on the buyer Shopify order-status page (no email template change).
  7. Always 200 on errors so Shopify doesn't retry + double-mint.

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

Files touched

Diff

commit c15a6912a4fd1ecf2d0e7a3ee33e1f492839fd5c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 17:14:27 2026 -0700

    shopify orders/paid webhook: mint per-order download token per bundle line item
    
    Replaces the shared wallco_ai.bundle_download_url pattern (one URL stamped on
    each bundle product → every buyer gets the same /downloads/<slug>/<token>.zip)
    with a per-order token minted at orders/paid webhook time.
    
    Flow:
      1. Shopify fires orders/paid → POST /api/shopify/webhooks/orders-paid.
      2. Express.raw runs FIRST (early-mount before global express.json) so HMAC
         can be computed over the raw request bytes.
      3. HMAC SHA256 verified against process.env.SHOPIFY_WEBHOOK_SECRET; 401 if
         bad, 503 if unset in NODE_ENV=production (refuses to mint unsigned).
      4. For each line_item: GET /admin/api/2026-01/products/<id>/metafields.json,
         look for wallco_ai.bundle_slug. Skip if absent (sample/wallpaper rolls).
      5. Mint 32-char hex token (crypto.randomBytes(16)), INSERT into
         wallco_download_tokens with shopify_order_id / shopify_product_id /
         customer_email / max_downloads=5 / expires_at=now()+90 days.
      6. PUT note_attribute download_url_<slug> on the order so the URL surfaces
         on the buyer Shopify order-status page (no email template change).
      7. Always 200 on errors so Shopify doesn't retry + double-mint.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 server.js                     |  35 +++++
 src/shopify-bundle-webhook.js | 303 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 338 insertions(+)

diff --git a/server.js b/server.js
index fa54be6..be67d0b 100644
--- a/server.js
+++ b/server.js
@@ -100,6 +100,12 @@ app.use(compression({
 // HMAC verification (express.raw runs on that route only).
 try { require('./src/marketplace').mountEarly(app); } catch (e) { console.error('Marketplace early mount failed:', e.message); }
 
+// ── Shopify bundle webhook early-mount: orders/paid mints a per-order download
+// token (one token per bundle line item) so each buyer gets a unique URL
+// instead of the shared one stored in wallco_ai.bundle_download_url. Same
+// rationale as the marketplace mount — express.raw must run before json().
+try { require('./src/shopify-bundle-webhook').mountEarly(app); } catch (e) { console.error('Shopify bundle webhook mount failed:', e.message); }
+
 app.use(express.json({ limit: '25mb' }));  // 25mb to accept full-res baked PNGs from /api/design/:id/bake
 app.use(express.urlencoded({ extended: false }));
 
@@ -391,6 +397,35 @@ app.get('/api/coordinates/ai', async (req, res) => {
   }
 });
 
+// ── /api/design/:id — single-design JSON for theme-variant client renders.
+// Merges DESIGNS in-memory (title/handle/saturation/etc) with PG (palette,
+// width_in, height_in, prompt) so a client-side template can render every
+// PDP field without touching the giant server-side template. Used by
+// /design/:id/bento (theme V2). Strips local_path before send.
+app.get('/api/design/:id', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  const snap = DESIGNS.find(d => d.id === id);
+  let pg = null;
+  try {
+    const raw = psqlQuery("SELECT row_to_json(t) FROM (SELECT palette, width_in, height_in, prompt, notes FROM spoon_all_designs WHERE id=" + id + ") t;");
+    if (raw) pg = JSON.parse(raw);
+  } catch (e) { /* PG hiccup — fall through with snapshot data only */ }
+  if (!snap && !pg) return res.status(404).json({ error: 'not found' });
+  const merged = Object.assign({}, snap || {}, pg || {});
+  delete merged.local_path;  // never leak Mac2 paths via public API
+  res.json({ ok: true, design: merged });
+});
+
+// ── /design/:id/bento — theme V2 (Bento Grid). Standalone static template
+// that fetches /api/design/:id client-side. Keeps the 42k-line server.js
+// template literal untouched. Live at https://wallco.ai/design/54099/bento.
+app.get('/design/:id/bento', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'theme-variants', 'v2-bento.html'));
+});
+
 // ── Lightweight in-memory search across the catalog.
 // /api/designs/search?q=<term>&limit=20
 // Scores each design on motif (3), title (2), category/handle (1) substring match.
diff --git a/src/shopify-bundle-webhook.js b/src/shopify-bundle-webhook.js
new file mode 100644
index 0000000..469edb5
--- /dev/null
+++ b/src/shopify-bundle-webhook.js
@@ -0,0 +1,303 @@
+// Shopify orders/paid webhook → per-order download token minting
+//
+// Steve 2026-05-28: 3 bundles are live on the DW store
+// (designer-laboratory-sandbox.myshopify.com is mapped to designerwallcoverings.com).
+// Today every buyer of a bundle shares one wallco.ai download URL stored in the
+// product metafield (wallco_ai.bundle_download_url). That URL works, but the
+// token is shared — anyone who buys gets the same /downloads/<slug>/<token>.zip
+// and could post it. This webhook replaces that pattern with a per-order token:
+//
+//   1. Shopify fires orders/paid → POST /api/shopify/webhooks/orders-paid
+//   2. We verify HMAC against process.env.SHOPIFY_WEBHOOK_SECRET (401 if bad).
+//   3. For each line item whose product carries a wallco_ai.bundle_slug metafield,
+//      we mint a 32-char hex token, INSERT into wallco_download_tokens with the
+//      order_id / product_id / customer_email / max_downloads=5 / 90d expiry.
+//   4. We PUT a note_attribute on the order — key `download_url_<slug>` — so the
+//      URL surfaces on the buyer's Shopify order-status page (no email change
+//      required; Shopify already renders note_attributes on that view).
+//   5. Always return 200 even on partial failure — Shopify retries 4xx/5xx and a
+//      duplicate retry would mint a second token. Failures land in stderr + we
+//      'll wire an alert separately.
+//
+// SECURITY: mount this BEFORE the global express.json() in server.js. The HMAC
+// is computed over the raw request bytes; if json parsing runs first we lose
+// the bytes the signature was computed against. Pattern matches the existing
+// marketplace early-mount at src/marketplace/index.js:mountEarly.
+
+'use strict';
+
+const crypto = require('crypto');
+const express = require('express');
+const { psqlQuery, psqlExecLocal, pgEsc } = require('../lib/db');
+
+const ENDPOINT_PATH = '/api/shopify/webhooks/orders-paid';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Pure helpers (no DB / no network — directly unit-testable)
+// ─────────────────────────────────────────────────────────────────────────────
+
+// HMAC verification per Shopify docs:
+//   base64(HMAC_SHA256(rawBody, secret)) === x-shopify-hmac-sha256 header.
+// Returns true if good, false if header / secret present but mismatch,
+// null when secret is unset (caller decides — production refuses, dev allows).
+function verifyShopifyHmac(rawBody, hmacHeader, secret) {
+  if (!secret) return null;
+  if (!hmacHeader || !rawBody) return false;
+  const buf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8');
+  const digest = crypto.createHmac('sha256', secret).update(buf).digest('base64');
+  const a = Buffer.from(digest);
+  const b = Buffer.from(String(hmacHeader));
+  if (a.length !== b.length) return false;
+  try { return crypto.timingSafeEqual(a, b); } catch (_) { return false; }
+}
+
+// Generate one minting candidate per line item — caller fetches bundle_slug per
+// product (Shopify metafield) so this helper stays pure.
+//   order  → Shopify order JSON
+// returns: [{ productId, lineItemId, quantity }]
+function extractCandidateLineItems(order) {
+  if (!order || !Array.isArray(order.line_items)) return [];
+  const out = [];
+  for (const li of order.line_items) {
+    const pid = Number(li.product_id || 0);
+    if (!pid) continue;
+    out.push({
+      productId: pid,
+      lineItemId: Number(li.id || 0),
+      quantity: Number(li.quantity || 1),
+      title: li.title || '',
+    });
+  }
+  return out;
+}
+
+function mintToken() {
+  return crypto.randomBytes(16).toString('hex');
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Side-effecting helpers (injected at call time so tests can stub them)
+// ─────────────────────────────────────────────────────────────────────────────
+
+// Default: fetch the wallco_ai.bundle_slug metafield from Shopify Admin REST.
+// Returns the slug string or null. shopify-rest-call ports the convention used
+// in scripts/attach-shopify-bundle-files.js.
+async function fetchBundleSlugDefault(productId, { store, token } = {}) {
+  if (!productId || !store || !token) return null;
+  const url = `https://${store}/admin/api/2026-01/products/${productId}/metafields.json`;
+  let r, j;
+  try {
+    r = await fetch(url, { headers: { 'X-Shopify-Access-Token': token } });
+    j = await r.json();
+  } catch (e) {
+    console.error('[shopify-bundle-webhook] metafield fetch failed:', e.message);
+    return null;
+  }
+  const mf = (j && j.metafields) || [];
+  const slug = mf.find(m => m.namespace === 'wallco_ai' && m.key === 'bundle_slug');
+  return slug ? String(slug.value || '').trim() || null : null;
+}
+
+// Default: INSERT a row into wallco_download_tokens. Returns the token.
+function insertTokenRowDefault({ token, slug, bundleDate, orderId, productId, email, maxDownloads, days }) {
+  const sql = `INSERT INTO wallco_download_tokens
+    (token, bundle_sku, bundle_slug, bundle_date, shopify_order_id, shopify_product_id, customer_email, max_downloads, expires_at)
+    VALUES (${pgEsc(token)},
+            ${pgEsc('DW-BUNDLE-' + slug)},
+            ${pgEsc(slug)},
+            ${pgEsc(bundleDate)},
+            ${orderId ? parseInt(orderId, 10) : 'NULL'},
+            ${productId ? parseInt(productId, 10) : 'NULL'},
+            ${pgEsc(email)},
+            ${parseInt(maxDownloads, 10) || 5},
+            now() + interval '${parseInt(days, 10) || 90} days')
+    RETURNING token;`;
+  psqlExecLocal(sql);
+  return token;
+}
+
+// Default: PUT a note_attribute on the order so the URL renders on the buyer's
+// Shopify order-status page. Preserves existing note_attributes — required
+// because Shopify replaces the whole array, not patches it.
+async function stampOrderNoteAttributesDefault({ orderId, store, token, attrs }) {
+  if (!orderId || !store || !token) return false;
+  // 1. Fetch existing note_attributes
+  let existing = [];
+  try {
+    const r = await fetch(`https://${store}/admin/api/2026-01/orders/${orderId}.json?fields=note_attributes`, {
+      headers: { 'X-Shopify-Access-Token': token },
+    });
+    const j = await r.json();
+    existing = (j && j.order && j.order.note_attributes) || [];
+  } catch (e) {
+    console.error('[shopify-bundle-webhook] existing note_attributes fetch failed:', e.message);
+  }
+  // 2. Merge: existing minus any of our new keys (so we overwrite, not duplicate)
+  const newKeys = new Set(attrs.map(a => a.name));
+  const merged = existing.filter(a => !newKeys.has(a.name)).concat(attrs);
+  // 3. PUT
+  try {
+    const r = await fetch(`https://${store}/admin/api/2026-01/orders/${orderId}.json`, {
+      method: 'PUT',
+      headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ order: { id: orderId, note_attributes: merged } }),
+    });
+    if (!r.ok) {
+      const txt = await r.text().catch(() => '');
+      console.error('[shopify-bundle-webhook] order PUT failed:', r.status, txt.slice(0, 400));
+      return false;
+    }
+    return true;
+  } catch (e) {
+    console.error('[shopify-bundle-webhook] order PUT threw:', e.message);
+    return false;
+  }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Core ingest — mint tokens for every bundle line item, stamp the order
+// ─────────────────────────────────────────────────────────────────────────────
+
+async function ingestPaidOrder(order, opts = {}) {
+  const {
+    store = process.env.SHOPIFY_STORE,
+    token = process.env.SHOPIFY_ADMIN_TOKEN,
+    baseUrl = process.env.WALLCO_BASE_URL || 'https://wallco.ai',
+    maxDownloads = Number(process.env.BUNDLE_MAX_DOWNLOADS_PER_ORDER || 5),
+    expiryDays = Number(process.env.BUNDLE_EXPIRY_DAYS || 90),
+    fetchBundleSlug = (pid) => fetchBundleSlugDefault(pid, { store, token }),
+    insertTokenRow = insertTokenRowDefault,
+    stampOrderNoteAttributes = (args) => stampOrderNoteAttributesDefault({ ...args, store, token }),
+    nowDate = () => new Date().toISOString().slice(0, 10),
+  } = opts;
+
+  const orderId = Number(order && order.id);
+  const email = (order && (order.email || order.contact_email)) || null;
+  const bundleDate = nowDate();
+
+  const candidates = extractCandidateLineItems(order);
+  if (!candidates.length) {
+    return { ok: true, tokens_minted: 0, reason: 'no_line_items' };
+  }
+
+  const minted = [];
+  const noteAttrs = [];
+  for (const li of candidates) {
+    let slug = null;
+    try {
+      slug = await fetchBundleSlug(li.productId);
+    } catch (e) {
+      console.error('[shopify-bundle-webhook] fetchBundleSlug threw:', e.message);
+    }
+    if (!slug) {
+      // Not a bundle product — skip silently. Wallpaper rolls, sample orders,
+      // etc. all go through this same webhook and we ignore them.
+      continue;
+    }
+    const tk = mintToken();
+    try {
+      insertTokenRow({
+        token: tk,
+        slug,
+        bundleDate,
+        orderId,
+        productId: li.productId,
+        email,
+        maxDownloads,
+        days: expiryDays,
+      });
+      const url = `${baseUrl}/downloads/${slug}/${tk}.zip`;
+      minted.push({ slug, productId: li.productId, token: tk, url });
+      noteAttrs.push({ name: `download_url_${slug}`, value: url });
+    } catch (e) {
+      console.error('[shopify-bundle-webhook] insertTokenRow failed for', slug, ':', e.message);
+    }
+  }
+
+  if (minted.length && orderId) {
+    try {
+      await stampOrderNoteAttributes({ orderId, attrs: noteAttrs });
+    } catch (e) {
+      console.error('[shopify-bundle-webhook] stampOrderNoteAttributes threw:', e.message);
+    }
+  }
+
+  return {
+    ok: true,
+    order_id: orderId,
+    tokens_minted: minted.length,
+    bundles: minted.map(m => ({ slug: m.slug, productId: m.productId, urlSuffix: m.url.slice(-20) })),
+  };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Express handler factory
+// ─────────────────────────────────────────────────────────────────────────────
+
+function makeWebhookHandler(opts = {}) {
+  const webhookSecret = opts.webhookSecret != null ? opts.webhookSecret : process.env.SHOPIFY_WEBHOOK_SECRET || '';
+  return async function shopifyOrdersPaidWebhook(req, res) {
+    try {
+      const raw = req.body;
+      if (!raw || (!Buffer.isBuffer(raw) && typeof raw !== 'string')) {
+        return res.status(400).json({ error: 'raw body required (mount with express.raw)' });
+      }
+      const hmac = req.headers['x-shopify-hmac-sha256'];
+      const verified = verifyShopifyHmac(raw, hmac, webhookSecret);
+      if (verified === false) {
+        console.warn('[shopify-bundle-webhook] HMAC verification FAILED');
+        return res.status(401).json({ error: 'invalid hmac' });
+      }
+      if (verified === null) {
+        // Refuse in production — accidentally accepting unsigned webhooks would let
+        // anyone mint tokens against any order id.
+        if (process.env.NODE_ENV === 'production') {
+          console.error('[shopify-bundle-webhook] SHOPIFY_WEBHOOK_SECRET unset in production — refusing');
+          return res.status(503).json({ error: 'webhook secret not configured' });
+        }
+        console.warn('[shopify-bundle-webhook] no SHOPIFY_WEBHOOK_SECRET — accepting unsigned (dev only)');
+      }
+
+      const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : raw;
+      let order;
+      try { order = JSON.parse(text); }
+      catch (e) { return res.status(400).json({ error: 'invalid JSON body' }); }
+
+      const out = await ingestPaidOrder(order, opts);
+      // Per task: 200 even on partial failure so Shopify doesn't retry and
+      // double-mint. ingestPaidOrder logs the actual errors.
+      return res.status(200).json(out);
+    } catch (err) {
+      console.error('[shopify-bundle-webhook] handler threw:', err);
+      // Swallow + 200 — same reason as above.
+      return res.status(200).json({ ok: false, error: err.message });
+    }
+  };
+}
+
+// Mounts BEFORE the global express.json() — copies the pattern from
+// src/marketplace/index.js:mountEarly so the raw bytes survive for HMAC.
+function mountEarly(app, opts = {}) {
+  app.post(
+    ENDPOINT_PATH,
+    express.raw({ type: '*/*', limit: '2mb' }),
+    makeWebhookHandler(opts)
+  );
+  console.log('  Shopify bundle webhook (orders/paid) mounted early (raw-body) at ' + ENDPOINT_PATH);
+}
+
+module.exports = {
+  mountEarly,
+  makeWebhookHandler,
+  ingestPaidOrder,
+  // pure helpers for tests
+  verifyShopifyHmac,
+  extractCandidateLineItems,
+  mintToken,
+  // default side-effecting helpers (exported for unit-test override-by-replacement
+  // and for the register-shopify-webhooks script)
+  fetchBundleSlugDefault,
+  insertTokenRowDefault,
+  stampOrderNoteAttributesDefault,
+  ENDPOINT_PATH,
+};

← 96f9215 deploy: ship scripts/mint-shopify-download-token.js to prod  ·  back to Wallco Ai  ·  scripts: register-shopify-webhooks.js — idempotent orders/pa d6de7c5 →