[object Object]

← back to Dw Signup Fulfillment

auto-save: 2026-07-28T13:00:43 (3 files) — lib/config.js lib/mint-ledger.js theme-proposals/

447c4715b050572935c7e1a146fab07daeb4c707 · 2026-07-28 13:00:44 -0700 · Steve Abrams

Files touched

Diff

commit 447c4715b050572935c7e1a146fab07daeb4c707
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 13:00:44 2026 -0700

    auto-save: 2026-07-28T13:00:43 (3 files) — lib/config.js lib/mint-ledger.js theme-proposals/
---
 lib/config.js                                  |  17 +++
 lib/mint-ledger.js                             |  28 +++++
 theme-proposals/loggedin-trade-entry/deploy.py | 156 +++++++++++++++++++++++++
 3 files changed, 201 insertions(+)

diff --git a/lib/config.js b/lib/config.js
index b9c60c9..beeaea2 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -79,6 +79,23 @@ const config = {
   ADMIN_USER: process.env.ADMIN_USER || 'admin',
   ADMIN_PASS: process.env.ADMIN_PASS || 'DW2024!',
 
+  // --- Public webhook hardening (the mint endpoint is public + secret-less) ---
+  // 1) URL-token auth: register the webhook at /webhooks/customers/create/<token>.
+  //    Only Shopify (and whoever set it) knows the token → a caller who doesn't have
+  //    it is rejected. This is the secret-less-compatible replacement for HMAC. Set it
+  //    to a long random string (openssl rand -hex 24) at go-live. If unset, the service
+  //    REFUSES to serve the webhook live (503) — it only runs open in DRY_RUN dev.
+  WEBHOOK_URL_TOKEN: firstEnv('WEBHOOK_URL_TOKEN', SECRETS_ENVS),
+  // 2) Freshness gate: only gift a customer whose Shopify created_at is within this many
+  //    minutes (a real customers/create fires within seconds; blocks minting to the
+  //    existing customer base). Generous default tolerates Shopify delivery retries.
+  WEBHOOK_FRESHNESS_MIN: parseInt(process.env.WEBHOOK_FRESHNESS_MIN || '1440', 10),
+  // 3) Rate limit: max webhook POSTs accepted per IP per minute.
+  WEBHOOK_RATE_MAX: parseInt(process.env.WEBHOOK_RATE_MAX || '30', 10),
+  // 4) Money backstop: hard cap on gift cards minted per calendar day (UTC). Beyond it
+  //    the webhook skips + warns, so a runaway/abuse can't mint unbounded liability.
+  MINT_DAILY_CAP: parseInt(process.env.MINT_DAILY_CAP || '200', 10),
+
   get SAMPLE_GIFT_VALUE() { return +(this.SAMPLE_PRICE * this.FREE_SAMPLE_COUNT).toFixed(2); },
 };
 
diff --git a/lib/mint-ledger.js b/lib/mint-ledger.js
new file mode 100644
index 0000000..60f178f
--- /dev/null
+++ b/lib/mint-ledger.js
@@ -0,0 +1,28 @@
+'use strict';
+// Daily gift-card mint ledger — the money backstop for the public webhook. Persists a
+// per-UTC-day {count,total} to data/mint-ledger.json so a restart doesn't reset the cap,
+// and so there is an auditable record of how much store liability was minted each day.
+const fs = require('fs');
+const path = require('path');
+
+const P = path.join(__dirname, '..', 'data', 'mint-ledger.json');
+
+function today() { return new Date().toISOString().slice(0, 10); } // UTC YYYY-MM-DD
+function read() { try { return JSON.parse(fs.readFileSync(P, 'utf8')); } catch { return {}; } }
+function write(o) { fs.mkdirSync(path.dirname(P), { recursive: true }); fs.writeFileSync(P, JSON.stringify(o, null, 2)); }
+
+function todayCount() { const d = read()[today()]; return d ? d.count : 0; }
+function todayTotal() { const d = read()[today()]; return d ? d.total : 0; }
+
+// Record one mint of `value` dollars; returns the updated day record {count,total}.
+function recordMint(value) {
+  const o = read();
+  const d = today();
+  o[d] = o[d] || { count: 0, total: 0 };
+  o[d].count += 1;
+  o[d].total = +(o[d].total + (Number(value) || 0)).toFixed(2);
+  write(o);
+  return o[d];
+}
+
+module.exports = { today, todayCount, todayTotal, recordMint };
diff --git a/theme-proposals/loggedin-trade-entry/deploy.py b/theme-proposals/loggedin-trade-entry/deploy.py
new file mode 100644
index 0000000..a0174eb
--- /dev/null
+++ b/theme-proposals/loggedin-trade-entry/deploy.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+"""
+TK-10021 — Logged-in designers can now reach Apply-for-Trade (auto-render trade-only view).
+
+Live theme: 144396058675 (main) on designer-laboratory-sandbox.myshopify.com
+Token: SHOPIFY_THEME_TOKEN (ends 2954) from ~/Projects/secrets-manager/.env
+
+Three anchored edits:
+  1. layout/theme.liquid:1360        gate  {% unless customer %} -> {% unless customer.tags contains 'trade' %}
+  2. snippets/dw-signin-modal.liquid  wrap sign-in/retail/toggle/google in {% unless customer %};
+                                       add logged-in trade header; default-show + prefill the trade form.
+  3. templates/customers/account.liquid  add an "Apply for Trade Pricing" trigger for non-trade customers.
+
+DRY-RUN by default: pulls each live asset, applies edits in-memory, prints a unified diff, writes a
+timestamped local backup. Nothing is pushed. Pass --apply to PUT the patched assets back (Steve-gated).
+
+Every anchor is asserted to appear EXACTLY ONCE before patching; any drift aborts the whole run so a
+theme change under our feet can never produce a half-applied edit.
+"""
+import os, sys, json, difflib, urllib.request, urllib.parse, datetime, pathlib
+
+STORE = "designer-laboratory-sandbox.myshopify.com"
+THEME = "144396058675"
+API   = "2024-10"
+HERE  = pathlib.Path(__file__).resolve().parent
+BKP   = HERE / "backups"
+APPLY = "--apply" in sys.argv
+
+def token():
+    env = pathlib.Path.home() / "Projects/secrets-manager/.env"
+    for line in env.read_text().splitlines():
+        if line.startswith("SHOPIFY_THEME_TOKEN="):
+            return line.split("=", 1)[1].strip().strip('"').strip("'")
+    sys.exit("SHOPIFY_THEME_TOKEN not found")
+
+TOK = token()
+
+def _req(method, key, value=None):
+    url = f"https://{STORE}/admin/api/{API}/themes/{THEME}/assets.json"
+    if method == "GET":
+        url += "?asset[key]=" + urllib.parse.quote(key)
+        data = None
+    else:
+        data = json.dumps({"asset": {"key": key, "value": value}}).encode()
+    r = urllib.request.Request(url, data=data, method=method,
+                              headers={"X-Shopify-Access-Token": TOK,
+                                       "Content-Type": "application/json"})
+    with urllib.request.urlopen(r) as resp:
+        return json.load(resp)
+
+def get_asset(key):
+    return _req("GET", key)["asset"]["value"]
+
+def put_asset(key, value):
+    _req("PUT", key, value)          # Shopify validates Liquid on PUT; 200 == valid
+
+def once(hay, needle, key):
+    n = hay.count(needle)
+    if n != 1:
+        sys.exit(f"ABORT [{key}]: anchor appears {n}x (expected 1). Live theme drifted:\n  {needle[:80]!r}")
+
+# ---- edit 1: theme.liquid gate --------------------------------------------
+def patch_theme(v):
+    a = "{% unless customer %}{% render 'dw-signin-modal' %}{% endunless %}"
+    b = "{% unless customer.tags contains 'trade' %}{% render 'dw-signin-modal' %}{% endunless %}"
+    once(v, a, "theme.liquid"); return v.replace(a, b)
+
+# ---- edit 2: dw-signin-modal.liquid ---------------------------------------
+def patch_modal(v):
+    # (2a) open the logged-out-only run right before the returning-sign-in block
+    a1 = '    <div class="dwsm-return">'
+    once(v, a1, "modal/return-open")
+    v = v.replace(a1, '    {%- unless customer -%}\n' + a1, 1)
+
+    # (2b) close that run right after the Google block, before the trade form comment,
+    #      then add the logged-in trade header.
+    a2 = "    {%- comment -%} Moderated trade application"
+    once(v, a2, "modal/trade-comment")
+    header = (
+        '    {%- endunless -%}\n'
+        '    {%- if customer -%}\n'
+        '    <div class="dwsm-return">\n'
+        '      <h2>Apply for Trade Pricing</h2>\n'
+        '      <p class="dwsm-return-cap">You\'re signed in — submit your business details and our team will review your trade account.</p>\n'
+        '    </div>\n'
+        '    {%- endif -%}\n'
+    )
+    v = v.replace(a2, header + a2, 1)
+
+    # (2c) trade form: default-show for logged-in, prefill business name + email
+    a3 = '<form class="dwsm-trade" data-dwsm-trade style="display:none;" novalidate>'
+    once(v, a3, "modal/form")
+    v = v.replace(a3, '<form class="dwsm-trade" data-dwsm-trade style="display:{% if customer %}block{% else %}none{% endif %};" novalidate>')
+
+    a4 = '<input type="text" name="business_name" autocomplete="organization" required>'
+    once(v, a4, "modal/bizname")
+    v = v.replace(a4, '<input type="text" name="business_name" autocomplete="organization" value="{{ customer.default_address.company }}" required>')
+
+    a5 = '<input type="email" name="email" autocomplete="email" required>'
+    once(v, a5, "modal/email")
+    v = v.replace(a5, '<input type="email" name="email" autocomplete="email" value="{{ customer.email }}" required>')
+
+    # (2d) wrap the retail sign-in/create block as logged-out-only
+    a6 = ('    <div data-dwsm-retail>\n'
+          '      <input type="button" class="dwsm-submit" data-dwsm-login="{{ routes.account_login_url }}" value="Sign In / Create Account">\n\n'
+          '      <div class="dwsm-foot">\n'
+          '        New to Designer Wallcoverings? <a href="{{ routes.account_register_url }}">Create account</a>\n'
+          '      </div>\n'
+          '    </div>')
+    once(v, a6, "modal/retail-block")
+    v = v.replace(a6, '    {%- unless customer -%}\n' + a6 + '\n    {%- endunless -%}')
+    return v
+
+# ---- edit 3: account.liquid trigger ---------------------------------------
+def patch_account(v):
+    a = "  {% render 'breadcrumbs' %}"
+    once(v, a, "account/breadcrumbs")
+    cta = (a + '\n\n'
+           "  {%- unless customer.tags contains 'trade' -%}\n"
+           '    <p class="dw-trade-cta" style="margin:16px 0;">\n'
+           '      <a href="#dw-signin" data-dw-signin style="color:#b08212;font-weight:600;text-decoration:none;">Apply for Trade Pricing →</a>\n'
+           '    </p>\n'
+           '  {%- endunless -%}')
+    return v.replace(a, cta, 1)
+
+EDITS = [
+    ("layout/theme.liquid",              patch_theme),
+    ("snippets/dw-signin-modal.liquid",  patch_modal),
+    ("templates/customers/account.liquid", patch_account),
+]
+
+def main():
+    stamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S")
+    BKP.mkdir(parents=True, exist_ok=True)
+    changed = []
+    for key, fn in EDITS:
+        live = get_asset(key)
+        (BKP / (key.replace("/", "__") + f".{stamp}.bak")).write_text(live)
+        new = fn(live)
+        if new == live:
+            print(f"[= ] {key}: no change (already applied?)"); continue
+        diff = difflib.unified_diff(live.splitlines(), new.splitlines(),
+                                    fromfile=f"LIVE/{key}", tofile=f"NEW/{key}", lineterm="")
+        print("\n".join(diff)); print()
+        changed.append((key, new))
+    if not changed:
+        print("Nothing to change."); return
+    if not APPLY:
+        print(f"\nDRY-RUN. Backups in {BKP}. Re-run with --apply to PUT {len(changed)} asset(s) to the LIVE theme.")
+        return
+    for key, new in changed:
+        put_asset(key, new); print(f"[PUT] {key}  (200 = Liquid valid)")
+    print("\nDeployed. Verify: open the store signed in as a NON-trade customer -> account page shows 'Apply for Trade Pricing'.")
+
+if __name__ == "__main__":
+    main()

← b6f0091 auto-save: 2026-07-28T12:00:18 (1 files) — theme-backups/  ·  back to Dw Signup Fulfillment  ·  TK-10021: stage logged-in Apply-for-Trade fix (proposal + de 302af39 →