← back to Dw Checkout Guard
harden checkout-guard per contrarian: $.cart target, fail-open on unreadable price, mandatory activate+typegen steps, mixed-cart regression + headless caveat
7b5d844a82337a30be0639886009f103cf49926b · 2026-07-30 08:48:24 -0700 · steve-office
Files touched
M README.mdM extensions/dw-checkout-guard/src/cart_validations_generate_run.js
Diff
commit 7b5d844a82337a30be0639886009f103cf49926b
Author: steve-office <steve@designerwallcoverings.com>
Date: Thu Jul 30 08:48:24 2026 -0700
harden checkout-guard per contrarian: $.cart target, fail-open on unreadable price, mandatory activate+typegen steps, mixed-cart regression + headless caveat
---
README.md | 50 +++++++++++++--
.../src/cart_validations_generate_run.js | 75 ++++++++++++++--------
2 files changed, 93 insertions(+), 32 deletions(-)
diff --git a/README.md b/README.md
index 5fd4feb..cd7550b 100644
--- a/README.md
+++ b/README.md
@@ -71,10 +71,37 @@ shopify app function typegen # optional, regenerates types
shopify app deploy # pushes the function version live
```
-After `deploy`, activate it in Admin:
-**Settings → Checkout → (Validations / "Manage checkout rules")** → add
-**DW Checkout Guard** → turn it on. (Some stores auto-enable a single validation
-on deploy; confirm it's toggled on.)
+### ⚠️ MANDATORY post-deploy step — ACTIVATE the validation
+
+`shopify app deploy` uploads the function but **does NOT enforce it** until you
+turn it on. A deployed-but-inactive validation lets every $0 line straight
+through — it looks done and isn't. So this is step 1, not a footnote:
+
+**Admin → Settings → Checkout → Validations (a.k.a. "Manage checkout rules")**
+→ add **DW Checkout Guard** → **toggle it ON**.
+
+Then confirm it's actually enforcing with the smoke test in "Verify after deploy"
+below — do not consider this shipped until a $0 line is provably blocked at the
+Checkout step in a real browser.
+
+### ⚠️ MANDATORY pre-deploy step — confirm the cost field name
+
+The one field this function depends on is the per-unit line price. Before you
+deploy, confirm the Function input schema actually exposes it (schemas differ
+from the Storefront API and can shift between `api_version`s):
+
+```sh
+shopify app function typegen
+# open the generated types for CartLineCost and confirm `amountPerQuantity`
+# exists. If the field is named differently in your api_version, update
+# src/cart_validations_generate_run.graphql to match.
+```
+
+If the field name is wrong, `shopify app function build` FAILS LOUDLY with an
+"unknown field" error before anything reaches the store — so a bad field can't
+silently ship. And even at runtime the JS **fails open** (an unreadable price is
+skipped, never treated as $0), so the worst case is "the $0 block doesn't fire,"
+never "all checkout blocked."
## Deploy — as-is path
@@ -103,9 +130,18 @@ curl -s -X POST https://www.designerwallcoverings.com/cart/add.js \
# (add.js still succeeds — that's expected; the block is at the CHECKOUT step)
```
-Then, in a browser cart with that line, click **Checkout** — you should see the
-guard message and be unable to proceed. A normal-priced line + the `$4.25`
-sample must still check out fine (regression check).
+Then, in a browser cart, verify all three cases:
+1. **$0 line alone** → click Checkout → blocked with the guard message. ✅
+2. **Mixed cart** ($0 line + a $4.25 sample) → blocked while the $0 line is
+ present; **remove the $0 line** → the sample alone checks out fine. ✅
+ (This proves the guard doesn't collateral-block the sample flow.)
+3. **A normal-priced product alone** → checks out fine (no false positive). ✅
+
+**Scope caveat:** Cart & Checkout Validation functions run on the standard
+browser checkout AND draft orders — which covers this exposure (the `/cart/add.js`
+vector). They do **not** necessarily run on a fully headless/custom Storefront-API
+checkout or a Subscriptions-app flow. DW doesn't use those today; revisit this
+guard if that ever changes.
## Rollback
diff --git a/extensions/dw-checkout-guard/src/cart_validations_generate_run.js b/extensions/dw-checkout-guard/src/cart_validations_generate_run.js
index c33bf0b..5742d46 100644
--- a/extensions/dw-checkout-guard/src/cart_validations_generate_run.js
+++ b/extensions/dw-checkout-guard/src/cart_validations_generate_run.js
@@ -4,7 +4,7 @@
// -------------------------------------------------------
// Rejects any cart line that is either:
// (1) priced $0.00 — the quote-only "$0 Standard variant" exposure
-// (1,744 Phillipe Romano / Fentucci Naturals products whose sellable
+// (~1,744 Phillipe Romano / Fentucci Naturals products whose sellable
// variant is $0 + available:true and is addable via /cart/add.js even
// though the PDP button is hidden), OR
// (2) discontinued — a product carrying a discontinued tag (future-proofing;
@@ -12,14 +12,25 @@
//
// Why a Validation Function and not per-variant availability:
// The live store does NOT gate /cart/add.js on inventory — setting a variant
-// to available:false (deny + qty 0) still lets the $0 line be added (proven
+// available:false (deny + qty 0) still lets the $0 line be added (proven
// 2026-07-30). Only a checkout-layer validation reliably blocks the $0 order
// without delisting the product (these lines must stay live for the $4.25
// sample flow). See memory: dw-shopify-addjs-inventory-not-gated.
//
-// This function runs at cart + checkout. When it returns any error, Shopify
-// blocks the "Checkout"/"Continue to checkout" button until the offending line
-// is removed. It is FREE to run (Shopify Functions have no per-invocation cost).
+// DESIGN NOTES (after /contrarian review, TK-10050):
+// * Error target is "$.cart" — the validation function does NOT support
+// line-level targets like "$.cart.lines[N]"; those are silently dropped.
+// We emit ONE consolidated blocking error on the whole cart.
+// * FAIL OPEN on an unreadable price. A missing/NaN cost field is an
+// infra/schema fault, NOT evidence of $0 — blocking on it would break
+// checkout for EVERY line. So we only block when the price parses to a
+// real number <= 0. (Worst case if the cost field is wrong: the $0 block
+// silently doesn't fire — a known-gap false negative, never a store-wide
+// false positive. The build step + README typegen check catch a bad field.)
+// * We read cost.amountPerQuantity (per-UNIT merchandise price), which is
+// discount-INDEPENDENT — so a 100%-off code / free-gift promo does NOT read
+// as $0 here and is never falsely blocked. (totalAmount would be wrong.)
+// * This function is FREE to run (Shopify Functions have no per-invocation cost).
/**
* @typedef {{ amount: string }} Money
@@ -36,42 +47,56 @@
* @typedef {{ cart: { lines: CartLine[] } }} RunInput
*/
-const ZERO_PRICE_MESSAGE =
- "This item can't be purchased online — please request a quote or remove it to continue to checkout.";
-const DISCONTINUED_MESSAGE =
- "This item has been discontinued and can no longer be ordered — please remove it to continue to checkout.";
+const ZERO_ONLY_MESSAGE =
+ "Your cart contains an item that can't be purchased online — please request a quote or remove it to continue to checkout.";
+const DISCONTINUED_ONLY_MESSAGE =
+ "Your cart contains a discontinued item that can no longer be ordered — please remove it to continue to checkout.";
+const MIXED_MESSAGE =
+ "Your cart contains items that can't be ordered online (discontinued or quote-only) — please remove them to continue to checkout.";
/**
* @param {RunInput} input
* @returns {{ operations: Array<{ validationAdd: { errors: Array<{ message: string, target: string }> } }> }}
*/
export function cartValidationsGenerateRun(input) {
- /** @type {Array<{ message: string, target: string }>} */
- const errors = [];
+ let anyDiscontinued = false;
+ let anyZeroPriced = false;
const lines = input?.cart?.lines ?? [];
- lines.forEach((line, index) => {
+ for (const line of lines) {
const merchandise = line.merchandise;
const isVariant = merchandise && merchandise.__typename === "ProductVariant";
// (2) discontinued — only meaningful for a real product variant
- const isDiscontinued = Boolean(isVariant && merchandise.product?.isDiscontinued);
+ if (isVariant && merchandise.product?.isDiscontinued) {
+ anyDiscontinued = true;
+ continue; // already offending; no need to also price-check this line
+ }
- // (1) $0.00 line — parse the per-unit cost; treat missing/NaN as 0 (block, don't leak)
+ // (1) $0.00 line — parse the per-unit cost. FAIL OPEN: only block when the
+ // price parses to a finite number <= 0; a missing/NaN field is skipped.
const rawAmount = line?.cost?.amountPerQuantity?.amount;
- const amount = rawAmount == null ? 0 : Number.parseFloat(rawAmount);
- const isZeroPriced = !Number.isFinite(amount) || amount <= 0;
-
- if (isDiscontinued || isZeroPriced) {
- errors.push({
- message: isDiscontinued ? DISCONTINUED_MESSAGE : ZERO_PRICE_MESSAGE,
- // JSON-path target: scope the error to the specific offending line
- target: `$.cart.lines[${index}]`,
- });
+ if (rawAmount != null) {
+ const amount = Number.parseFloat(rawAmount);
+ if (Number.isFinite(amount) && amount <= 0) {
+ anyZeroPriced = true;
+ }
}
- });
+ }
+
+ if (!anyDiscontinued && !anyZeroPriced) {
+ return { operations: [] };
+ }
+
+ const message =
+ anyDiscontinued && anyZeroPriced
+ ? MIXED_MESSAGE
+ : anyDiscontinued
+ ? DISCONTINUED_ONLY_MESSAGE
+ : ZERO_ONLY_MESSAGE;
+ // One consolidated blocking error on the supported "$.cart" target.
return {
- operations: errors.length > 0 ? [{ validationAdd: { errors } }] : [],
+ operations: [{ validationAdd: { errors: [{ message, target: "$.cart" }] } }],
};
}
← 1ba0a3e scaffold DW Checkout Guard validation function (rejects $0 o
·
back to Dw Checkout Guard
·
auto-data-snapshot: 2026-08-25T08:16:28 (9 data files) — ext bd7e501 →