← 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
M lib/config.jsA lib/mint-ledger.jsA theme-proposals/loggedin-trade-entry/deploy.py
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 →