@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.
- package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +19 -2
- package/apps/storefront/src/lib/checkout/commerce/cardCartForm.ts +11 -3
- package/apps/storefront/src/lib/checkout/commerce/cartSession.ts +21 -2
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +41 -15
- package/apps/storefront/src/pages/cart.ts +31 -9
- package/package.json +1 -1
- package/packages/public-runtime/src/checkout.ts +9 -1
|
@@ -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={
|
|
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.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
|
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 =>
|
|
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,
|
|
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
|
-
|
|
193
|
-
|
|
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(
|
|
196
|
-
if (matches.length !== 1) return
|
|
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
|
|
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
|
|
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
|
|
208
|
-
const variants = detail.variants.filter(
|
|
209
|
-
if (variants.length !== 1 || variants[0]!.id !== match.variant.id) return
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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":
|
|
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":
|
|
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
|
+
"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
|
|
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
|