@tokenoftrust/storefront-runner 4.3.4 → 4.4.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.
@@ -16,7 +16,8 @@ import PriceDisplay from "@/components/catalog/storefront/commerce/PriceDisplay.
16
16
  import RatingStars from "@/components/catalog/storefront/commerce/RatingStars.astro";
17
17
  import { withBase } from "@/lib/publication/shared/basePath";
18
18
  import { pageUrlForm } from "@/lib/content/seo/urlForm";
19
- import { buildCardCartForm } from "@/lib/checkout/commerce/cardCartForm";
19
+ import { buildCardCartForm, cardNeedsOptionChoice } from "@/lib/checkout/commerce/cardCartForm";
20
+ import { isLocalSampleCheckout } from "@/config/declaredTenantConfig";
20
21
 
21
22
  interface Props {
22
23
  product: CatalogProduct;
@@ -95,6 +96,10 @@ const cardCart = await buildCardCartForm({
95
96
  environment: browserEnvironment.environment,
96
97
  contentVersionId,
97
98
  });
99
+ // A product with options is chosen on its product page, never from the card.
100
+ const productHref = withBase(basePath, pageUrlForm(`/products/${product.handle}`, tenant.seo));
101
+ const selectOptions = available && cardNeedsOptionChoice(product) &&
102
+ (!!commerce || isLocalSampleCheckout(tenant.tenant_id));
98
103
 
99
104
  const quickview = {
100
105
  ...snapshot,
@@ -201,7 +206,7 @@ const quickview = {
201
206
  {/* `py-3` on an inline box raises the tap target to the 44px minimum
202
207
  without changing the line box, so the card layout is untouched. */}
203
208
  <a
204
- href={withBase(basePath, pageUrlForm(`/products/${product.handle}`, tenant.seo))}
209
+ href={productHref}
205
210
  data-astro-prefetch
206
211
  class="py-3 after:absolute after:inset-0 after:z-0 transition-colors motion-safe:group-hover:text-primary"
207
212
  >
@@ -234,6 +239,18 @@ const quickview = {
234
239
  </form>
235
240
  )
236
241
  }
242
+ {
243
+ selectOptions && (
244
+ <a
245
+ href={productHref}
246
+ data-select-options
247
+ data-product-handle={product.handle}
248
+ class="relative z-10 mt-3 block w-full rounded-full bg-[var(--color-primary)] px-4 py-3 text-center font-display text-sm font-medium text-[var(--color-primary-contrast)]"
249
+ >
250
+ Select options
251
+ </a>
252
+ )
253
+ }
237
254
 
238
255
  {rating != null && <RatingStars rating={rating} count={reviewCount} class="mt-1.5" />}
239
256
 
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Add-to-cart form for a catalog card. One default variant — the first
3
- * available one — signed the same way the product page signs its handoff.
4
- * No form when the product cannot be bought or the cart cannot be signed.
2
+ * Add-to-cart form for a catalog card. Only a product with a single variant
3
+ * buys from its card, signed the same way the product page signs its handoff;
4
+ * a product with options links to its product page ("Select options") so the
5
+ * card never adds a variant the shopper did not choose. No form when the
6
+ * product cannot be bought or the cart cannot be signed.
5
7
  */
6
8
  import type { CatalogProduct, CatalogVariant, ResolvedManagedCheckout, TenantConfig } from "@tot/public-runtime";
7
9
  import {
@@ -31,6 +33,11 @@ export function defaultCardVariant(product: CatalogProduct): CatalogVariant | nu
31
33
  return product.variants.find((variant) => variant.inventory.available) ?? null;
32
34
  }
33
35
 
36
+ /** A product with more than one variant needs the shopper's choice on its product page. */
37
+ export function cardNeedsOptionChoice(product: CatalogProduct): boolean {
38
+ return product.variants.length > 1;
39
+ }
40
+
34
41
  export async function buildCardCartForm(input: {
35
42
  product: CatalogProduct;
36
43
  tenant: TenantConfig;
@@ -40,6 +47,7 @@ export async function buildCardCartForm(input: {
40
47
  /** Candidate content pin this card rendered. Ignored on a live page. */
41
48
  contentVersionId?: string;
42
49
  }): Promise<CardCartForm | null> {
50
+ if (cardNeedsOptionChoice(input.product)) return null;
43
51
  const variant = defaultCardVariant(input.product);
44
52
  if (!variant) return null;
45
53
  const sampleHandoff = isLocalSampleCheckout(input.tenant.tenant_id);
@@ -36,6 +36,16 @@ export function cartSessionCookiePath(basePath: string): string {
36
36
  return `${withLead.replace(/\/+$/, "")}/`;
37
37
  }
38
38
 
39
+ /**
40
+ * True when a browser at `pathname` sends the cookie stored at `cookiePath`. A
41
+ * candidate preview page (`/preview/render/<tenant>/<change>/…`) is outside the
42
+ * tenant prefix, so the browser holds this store's session but does not send it there.
43
+ */
44
+ export function cartSessionCookieVisible(pathname: string, cookiePath: string): boolean {
45
+ if (cookiePath === "/") return true;
46
+ return pathname.startsWith(cookiePath) || `${pathname}/` === cookiePath;
47
+ }
48
+
39
49
  /** Cart URL that selects one Foxy session. Other params are kept. */
40
50
  export function withCartSession(cartUrl: string, id: string): string {
41
51
  const url = new URL(cartUrl);
@@ -47,12 +57,21 @@ function saneSessionId(value: unknown): value is string {
47
57
  return typeof value === "string" && CART_SESSION_ID.test(value);
48
58
  }
49
59
 
60
+ /**
61
+ * An option name as one key. FC.json spells names for display (measured 2026-10-23:
62
+ * `return_url` reads "return url"), and a signed name carries `||<hash>`, so names
63
+ * compare trimmed, lowercased, and with spaces and underscores as one `_`.
64
+ */
65
+ function optionKey(raw: string): string {
66
+ return (raw.split("||")[0] ?? "").trim().toLowerCase().replace(/[\s_]+/g, "_");
67
+ }
68
+
50
69
  function optionBaseName(option: unknown): string | null {
51
70
  if (!option || typeof option !== "object") return null;
52
71
  const raw = (option as { name?: unknown; class?: unknown }).name
53
72
  ?? (option as { class?: unknown }).class;
54
73
  if (typeof raw !== "string" || raw.length === 0) return null;
55
- return raw.split("||")[0] ?? "";
74
+ return optionKey(raw);
56
75
  }
57
76
 
58
77
  function optionValue(option: unknown): string | null {
@@ -72,7 +91,7 @@ function itemTenant(item: unknown): string | null {
72
91
  }
73
92
  if (options && typeof options === "object") {
74
93
  for (const [key, value] of Object.entries(options as Record<string, unknown>)) {
75
- if (key.split("||")[0] === "tenant" && typeof value === "string") return value;
94
+ if (optionKey(key) === "tenant" && typeof value === "string") return value;
76
95
  }
77
96
  }
78
97
  return null;
@@ -2,8 +2,8 @@
2
2
  import type { APIContext } from "astro";
3
3
  import { createLocalJWKSet, decodeProtectedHeader, jwtVerify, type JSONWebKeySet } from "jose";
4
4
  import {
5
- commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding,
6
- type CallerToken, type CatalogProduct, type CommerceEvidence, type CommerceEvidenceProvenance,
5
+ commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding, cartLineCode,
6
+ type CallerToken, type CatalogProduct, type CatalogVariant, type CommerceEvidence, type CommerceEvidenceProvenance,
7
7
  type CommerceProviderCartSnapshot, type FinalCartBinding, type TenantConfig,
8
8
  } from "@tot/public-runtime";
9
9
  import { fromD1 } from "@/lib/catalog/d1/catalog.js";
@@ -30,6 +30,17 @@ function json(body: unknown, status = 200): Response {
30
30
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
31
31
  }
32
32
  function deny(finding: string, status = 409, owner?: string): Response { return json({ ok: false, finding, ...(owner ? { owner } : {}) }, status); }
33
+ /**
34
+ * Which check refused, and on which cart line (its index in the presented snapshot). Logged on
35
+ * the decision line only: closed check names and an index, never a SKU, price or cart value.
36
+ */
37
+ interface RefusalDetail { check: string; line?: number }
38
+ const refusalDetails = new WeakMap<Response, RefusalDetail>();
39
+ function refuse(finding: string, detail: RefusalDetail, status = 409): Response {
40
+ const response = deny(finding, status);
41
+ refusalDetails.set(response, detail);
42
+ return response;
43
+ }
33
44
  function list(value: string | undefined): string[] | null {
34
45
  if (!value) return null;
35
46
  try {
@@ -85,7 +96,11 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
85
96
  const { decision } = await evaluatePreflight({ locals: { tenant: context.locals.tenant, tot: {
86
97
  getProductByHandle: async (handle: string) => selected.get(handle) ?? null,
87
98
  } } } as APIContext, {
88
- lines: snapshot.lines.map(line => ({ handle: products.find(p => p.variants.some(v => v.sku === line.sku))!.handle, sku: line.sku, quantity: line.quantity })),
99
+ lines: snapshot.lines.map(line => {
100
+ const product = products.find(p => p.variants.some(v => cartLineCode(v) === line.sku))!;
101
+ const variant = product.variants.find(v => cartLineCode(v) === line.sku)!;
102
+ return { handle: product.handle, variantId: variant.id, sku: line.sku, quantity: line.quantity };
103
+ }),
89
104
  destination: snapshot.destination,
90
105
  ...(policy.verification === "required" && snapshot.verificationRef ? { verificationRef: snapshot.verificationRef } : {}),
91
106
  }, { verification: policy.verification, rules: policy.rules });
@@ -132,8 +147,11 @@ export async function POST(context: APIContext): Promise<Response> {
132
147
  if (response.status !== 200 || threw) {
133
148
  try { finding = String((await response.clone().json() as { finding?: unknown }).finding ?? "unreadable").slice(0, 80); } catch { finding = "unreadable"; }
134
149
  }
150
+ const detail = refusalDetails.get(response);
135
151
  try {
136
- console.info("commerce-final-cart-decision", JSON.stringify({ correlationId, ok: response.status === 200, finding, status: response.status, threw, ms: Date.now() - started }));
152
+ console.info("commerce-final-cart-decision", JSON.stringify({ correlationId, ok: response.status === 200, finding,
153
+ ...(detail ? { check: detail.check, ...(detail.line === undefined ? {} : { line: detail.line }) } : {}),
154
+ status: response.status, threw, ms: Date.now() - started }));
137
155
  } catch { /* Diagnostic delivery cannot alter the decision. */ }
138
156
  return response;
139
157
  }
@@ -189,31 +207,39 @@ async function authorize(context: APIContext): Promise<Response> {
189
207
  try { listing = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
190
208
  if (!Array.isArray(listing)) return deny("catalog-unavailable", 503);
191
209
  const selected = new Map<string, CatalogProduct>();
192
- if (snapshot.currency !== resolveStoreCurrency(tenant.currency)) return deny("catalog-cart-mismatch");
193
- for (const line of snapshot.lines) {
210
+ const mismatch = (check: string, line?: number) => refuse("catalog-cart-mismatch", line === undefined ? { check } : { check, line });
211
+ if (snapshot.currency !== resolveStoreCurrency(tenant.currency)) return mismatch("store-currency");
212
+ // A line's `sku` is the product code the page signed (`cartLineCode`): the SKU, or the
213
+ // variant id for a variant with none.
214
+ const coded = (line: { sku: string }) => (variant: CatalogVariant | null | undefined) => !!variant && cartLineCode(variant) === line.sku;
215
+ for (const [index, line] of snapshot.lines.entries()) {
194
216
  const matches = listing.flatMap(product => product && product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope && Array.isArray(product.variants)
195
- ? product.variants.filter(v => v && v.sku === line.sku).map(variant => ({ product, variant })) : []);
196
- if (matches.length !== 1) return deny("catalog-cart-mismatch");
217
+ ? product.variants.filter(coded(line)).map(variant => ({ product, variant })) : []);
218
+ if (matches.length !== 1) return mismatch(matches.length === 0 ? "sku-not-in-catalog" : "sku-ambiguous", index);
197
219
  const match = matches[0]!;
198
220
  const handle = match.product.handle;
199
- if (typeof handle !== "string" || !handle) return deny("catalog-cart-mismatch");
221
+ if (typeof handle !== "string" || !handle) return mismatch("product-handle", index);
200
222
  if (!selected.has(handle)) {
201
223
  let detail: CatalogProduct | null;
202
224
  try { detail = await tot.getProductByHandle(handle); } catch { return deny("catalog-unavailable", 503); }
203
- if (!fullCartProduct(detail, handle, tenant.tot_data_scope)) return deny("catalog-cart-mismatch");
225
+ if (!fullCartProduct(detail, handle, tenant.tot_data_scope)) return mismatch("product-detail", index);
204
226
  selected.set(handle, detail);
205
227
  }
206
228
  const detail = selected.get(handle)!;
207
- if (detail.id !== match.product.id) return deny("catalog-cart-mismatch");
208
- const variants = detail.variants.filter(variant => variant.sku === line.sku);
209
- if (variants.length !== 1 || variants[0]!.id !== match.variant.id) return deny("catalog-cart-mismatch");
229
+ if (detail.id !== match.product.id) return mismatch("product-id", index);
230
+ const variants = detail.variants.filter(coded(line));
231
+ if (variants.length !== 1 || variants[0]!.id !== match.variant.id) return mismatch("variant-detail", index);
210
232
  const variant = variants[0]!;
211
233
  const digits = new Intl.NumberFormat("en", { style: "currency", currency: snapshot.currency }).resolvedOptions().maximumFractionDigits ?? 2;
212
234
  const scaled = variant.price * 10 ** digits;
213
- if (!variant.inventory.available || variant.currency !== snapshot.currency || Math.abs(scaled - Math.round(scaled)) > 0.000001 || Math.round(scaled) !== line.unitMinor) return deny("catalog-cart-mismatch");
235
+ if (!variant.inventory.available) return mismatch("unavailable", index);
236
+ if (variant.currency !== snapshot.currency) return mismatch("variant-currency", index);
237
+ if (Math.abs(scaled - Math.round(scaled)) > 0.000001) return mismatch("price-precision", index);
238
+ if (Math.round(scaled) !== line.unitMinor) return mismatch("price", index);
214
239
  }
215
240
  const products = [...selected.values()];
216
- if (snapshot.lines.some(line => products.flatMap(product => product.variants.filter(variant => variant.sku === line.sku)).length !== 1)) return deny("catalog-cart-mismatch");
241
+ const ambiguous = snapshot.lines.findIndex(line => products.flatMap(product => product.variants.filter(coded(line))).length !== 1);
242
+ if (ambiguous !== -1) return mismatch("sku-ambiguous", ambiguous);
217
243
  // Shipping is re-derived from the store's own declaration, never from the cart: a cart that
218
244
  // dropped, replayed or altered its signed `shipping_policy` option was charged another amount.
219
245
  if (tenant.shipping) {
@@ -1,28 +1,50 @@
1
1
  import type { APIRoute } from "astro";
2
2
  import { commerceTenantId } from "@tot/public-runtime";
3
- import { bindCartSession, CART_UNAVAILABLE_MESSAGE, withCartSession } from "@/lib/checkout/commerce/cartSession.js";
3
+ import {
4
+ bindCartSession,
5
+ CART_UNAVAILABLE_MESSAGE,
6
+ cartSessionCookiePath,
7
+ cartSessionCookieVisible,
8
+ withCartSession,
9
+ } from "@/lib/checkout/commerce/cartSession.js";
4
10
  import { managedCartHref } from "@/lib/content/chrome/managedCart.js";
5
11
 
6
12
  export const prerender = false;
7
13
 
14
+ const NO_STORE = "private, no-store";
15
+
8
16
  /**
9
- * Backward-compatible entry for bookmarked or tenant-authored `/cart` links.
10
- * Chrome now links straight to managed checkout, but this route prevents stale
11
- * links from falling through the content catch-all to a misleading storefront
12
- * 404. Tenant resolution and preview authorization have already run in
13
- * middleware before this redirect is selected. The redirect carries this
14
- * tenant's Foxy session and never the shared cart.
17
+ * Entry for bookmarked or tenant-authored `/cart` links. Chrome links straight to
18
+ * managed checkout, but this route keeps those links from falling through the
19
+ * content catch-all to a misleading storefront 404. Tenant resolution and preview
20
+ * authorization have already run in middleware before this redirect is selected.
21
+ * The redirect carries this tenant's Foxy session and never the shared cart.
22
+ *
23
+ * The session cookie lives on the tenant prefix. A link on a pinned preview page
24
+ * arrives from outside it, without the cookie, so it is sent to the tenant prefix
25
+ * first: binding there reads the shopper's own session instead of minting an empty
26
+ * one over it.
15
27
  */
16
28
  export const GET: APIRoute = async (context) => {
17
29
  const location = managedCartHref(context.locals.commerce);
18
30
  if (!location || !context.locals.commerce) return new Response(null, { status: 404 });
31
+ // `basePath` is the prefix the browser is on: a pinned render overrides it to the
32
+ // pin's own prefix, while `apiBasePath` stays the tenant prefix the cookie lives on.
33
+ const cookiePath = cartSessionCookiePath(context.locals.apiBasePath ?? context.locals.basePath ?? "");
34
+ const pagePrefix = cartSessionCookiePath(context.locals.basePath ?? context.locals.apiBasePath ?? "");
35
+ if (!cartSessionCookieVisible(pagePrefix, cookiePath)) {
36
+ return new Response(null, {
37
+ status: 302,
38
+ headers: { location: `${cookiePath}cart`, "cache-control": NO_STORE },
39
+ });
40
+ }
19
41
  const tenantId = context.locals.tenant ? commerceTenantId(context.locals.tenant) : "";
20
42
  const sessionId = tenantId ? await bindCartSession(context, context.locals.commerce, tenantId) : null;
21
43
  if (!sessionId) {
22
44
  return new Response(CART_UNAVAILABLE_MESSAGE, {
23
45
  status: 503,
24
46
  headers: {
25
- "cache-control": "private, no-store",
47
+ "cache-control": NO_STORE,
26
48
  "content-type": "text/plain; charset=utf-8",
27
49
  },
28
50
  });
@@ -31,7 +53,7 @@ export const GET: APIRoute = async (context) => {
31
53
  status: 302,
32
54
  headers: {
33
55
  location: withCartSession(location, sessionId),
34
- "cache-control": "private, no-store",
56
+ "cache-control": NO_STORE,
35
57
  },
36
58
  });
37
59
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.3.4",
3
+ "version": "4.4.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",
@@ -500,6 +500,14 @@ export function resolveManagedCheckout(
500
500
  };
501
501
  }
502
502
 
503
+ /**
504
+ * The checkout product code for a variant: its SKU, or its id when it has none. Every field
505
+ * is hashed against it, and the final-cart authority finds a cart line's variant by it.
506
+ */
507
+ export function cartLineCode(variant: Pick<CatalogVariant, "id" | "sku">): string {
508
+ return variant.sku && variant.sku.length ? variant.sku : variant.id;
509
+ }
510
+
503
511
  /**
504
512
  * The raw (unsigned) cart fields for a variant: name, price, code, image, the
505
513
  * variant's options, and an editable quantity. `code` is the checkout product code
@@ -511,7 +519,7 @@ export function cartFieldsForVariant(
511
519
  purchaseOption?: ProductPurchaseOption,
512
520
  opts?: { signedPrice?: number; tenantId?: string; currency?: string; contentVersionId?: string },
513
521
  ): { code: string; fields: CartField[] } {
514
- const code = variant.sku && variant.sku.length ? variant.sku : variant.id;
522
+ const code = cartLineCode(variant);
515
523
  const image = product.images.find((i) => i.id === variant.image_id)?.url ??
516
524
  product.images[0]?.url;
517
525
  // The signed price is either the caller's member-adjusted EFFECTIVE price