@tokenoftrust/storefront-runner 3.8.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -329,16 +329,46 @@ export const VISIBLE_CART_FIELDS = ["Purchase option"] as const;
329
329
 
330
330
  export type CartFieldVisibility = "native" | "internal" | "visible" | "variant-option" | "undeclared";
331
331
 
332
+ /**
333
+ * Characters the checkout store does not keep in a field name, each mapped to a look-alike it
334
+ * keeps. The store validates the name as it rewrites it, not as it was signed: ASCII space, `.`
335
+ * and `[` become `_` (a `[…]` pair turns the field into an array), `||` ends the name, and a name
336
+ * holding `&` or `"` is dropped from validation. Measured 2026-10-23 against tot-preview.foxycart.com
337
+ * by submitting each name with a non-matching hash and reading the name back from its
338
+ * "Cart Validation Error: <name>" refusal. Every other character (NBSP, `/`, `'`, `+`, `-`,
339
+ * non-ASCII) came back unchanged.
340
+ */
341
+ const CHECKOUT_NAME_LOOKALIKES: Readonly<Record<string, string>> = {
342
+ " ": " ",
343
+ ".": "․",
344
+ "[": "(",
345
+ "]": ")",
346
+ "&": "&",
347
+ '"': "”",
348
+ "|": "¦",
349
+ };
350
+ const CHECKOUT_NAME_REWRITTEN = /[ .[\]&"|]/g;
351
+
352
+ /**
353
+ * The name a cart field is emitted AND signed under. The checkout store validates this exact
354
+ * string, and the shopper's cart line shows it, so "Nic Strength" stays readable as
355
+ * "Nic Strength" (with a no-break space) instead of failing as `Nic_Strength`.
356
+ */
357
+ export function checkoutFieldName(name: string): string {
358
+ return name.trim().replace(CHECKOUT_NAME_REWRITTEN, (ch) => CHECKOUT_NAME_LOOKALIKES[ch] ?? ch);
359
+ }
360
+
332
361
  /**
333
362
  * How a field emitted by `cartFieldsForVariant` reaches the shopper. A field
334
363
  * that is none of native, internal, declared-visible, or one of the variant's
335
364
  * own options is `undeclared`: it would render on the cart line unreviewed.
365
+ * `name` is the emitted name (`checkoutFieldName`).
336
366
  */
337
367
  export function cartFieldVisibility(name: string, variant: Pick<CatalogVariant, "option_values">): CartFieldVisibility {
338
368
  if ((NATIVE_CART_FIELDS as readonly string[]).includes(name)) return "native";
339
369
  if ((INTERNAL_CART_FIELDS as readonly string[]).includes(name)) return "internal";
340
- if ((VISIBLE_CART_FIELDS as readonly string[]).includes(name)) return "visible";
341
- if (Object.prototype.hasOwnProperty.call(variant.option_values, name)) return "variant-option";
370
+ if (VISIBLE_CART_FIELDS.some((visible) => checkoutFieldName(visible) === name)) return "visible";
371
+ if (Object.keys(variant.option_values).some((option) => checkoutFieldName(option) === name)) return "variant-option";
342
372
  return "undeclared";
343
373
  }
344
374
 
@@ -476,7 +506,9 @@ export function cartFieldsForVariant(
476
506
  // Variant options (Flavor, Nicotine strength, …) travel as product options so
477
507
  // the cart line reflects the exact variant the shopper picked.
478
508
  for (const [optName, optVal] of Object.entries(variant.option_values)) {
479
- if (optName && optVal) fields.push({ name: optName, value: optVal });
509
+ const name = checkoutFieldName(optName);
510
+ const value = optVal.trim();
511
+ if (name && value) fields.push({ name, value });
480
512
  }
481
513
  // Bind the SELECTED purchase option into the signed payload so a shopper can't
482
514
  // switch one-time↔subscription (or between plans) after the form is signed —
@@ -486,7 +518,7 @@ export function cartFieldsForVariant(
486
518
  // on every non-subscription cart. Any authored option (a real one-time plan or
487
519
  // a subscription) is emitted and signed → modifying it fails validation.
488
520
  if (purchaseOption && !isDefaultOneTimeOption(purchaseOption)) {
489
- fields.push({ name: "Purchase option", value: purchaseOption.label });
521
+ fields.push({ name: checkoutFieldName("Purchase option"), value: purchaseOption.label });
490
522
  }
491
523
  if (purchaseOption?.kind === "subscription") {
492
524
  fields.push(...subscriptionCartFields(purchaseOption));
@@ -617,8 +649,14 @@ function constantTimeHexEqual(expected: string, presented: string): boolean {
617
649
  return acc === 0;
618
650
  }
619
651
 
652
+ /** A field as the checkout receives it unsigned: under its `checkoutFieldName`. */
653
+ function unsignedField(field: CartField): RenderedCartField {
654
+ return { name: checkoutFieldName(field.name), value: field.value };
655
+ }
656
+
620
657
  /**
621
- * Sign one field for product `code`. Editable fields hash the sentinel
658
+ * Sign one field for product `code`. The hashed name and the emitted name are the
659
+ * same `checkoutFieldName`, so they cannot diverge. Editable fields hash the sentinel
622
660
  * `--OPEN--` and gain the `||open` suffix so checkout accepts a user-entered value.
623
661
  */
624
662
  async function signField(
@@ -626,11 +664,12 @@ async function signField(
626
664
  code: string,
627
665
  field: CartField,
628
666
  ): Promise<RenderedCartField> {
667
+ const fieldName = checkoutFieldName(field.name);
629
668
  const valueToHash = field.open ? "--OPEN--" : field.value;
630
- const hash = await hmacHex(secret, code + field.name + valueToHash);
669
+ const hash = await hmacHex(secret, code + fieldName + valueToHash);
631
670
  const name = field.open
632
- ? `${field.name}||${hash}||open`
633
- : `${field.name}||${hash}`;
671
+ ? `${fieldName}||${hash}||open`
672
+ : `${fieldName}||${hash}`;
634
673
  return { name, value: field.value };
635
674
  }
636
675
 
@@ -673,7 +712,7 @@ export async function buildVariantCartPayload(
673
712
 
674
713
  const rendered: RenderedCartField[] = signed
675
714
  ? await Promise.all(fields.map((f) => signField(opts.secret!, code, f)))
676
- : fields.map((f) => ({ name: f.name, value: f.value }));
715
+ : fields.map(unsignedField);
677
716
 
678
717
  // Split the quantity field out: the island puts it on the visible stepper.
679
718
  const qtyIdx = fields.findIndex((f) => f.name === "quantity");
@@ -861,7 +900,7 @@ export async function withVerificationRef(
861
900
  const code = fields.find((field) => field.name.split("||")[0] === "code")?.value;
862
901
  if (!code) return [...fields];
863
902
  const field: CartField = { name: "verification", value: opts.ref };
864
- if (opts.mode !== "signed") return [...fields, field];
903
+ if (opts.mode !== "signed") return [...fields, unsignedField(field)];
865
904
  if (!opts.secret) throw new CartSigningUnavailableError();
866
905
  return [...fields, await signField(opts.secret, code, field)];
867
906
  }
@@ -883,7 +922,7 @@ export async function withShippingPolicy(
883
922
  const currency = kept.find((field) => field.name.split("||")[0] === "currency")?.value;
884
923
  if (!code || !currency) throw new ShippingPolicyUnavailableError();
885
924
  const field: CartField = { name: SHIPPING_POLICY_CART_FIELD, value: shippingPolicyOptionValue(opts.policy, currency) };
886
- if (opts.mode !== "signed") return [...kept, field];
925
+ if (opts.mode !== "signed") return [...kept, unsignedField(field)];
887
926
  if (!opts.secret) throw new CartSigningUnavailableError();
888
927
  return [...kept, await signField(opts.secret, code, field)];
889
928
  }