@tokenoftrust/storefront-runner 2.4.49 → 2.4.50
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/islands/CheckoutComplianceGate.tsx +16 -20
- package/apps/storefront/src/lib/checkoutCommerce.ts +8 -2
- package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +79 -1
- package/apps/storefront/src/pages/products/[handle].astro +48 -21
- package/package.json +1 -1
- package/packages/public-runtime/src/checkout.ts +272 -6
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* <CheckoutComplianceGate> —
|
|
3
|
-
* seam for regulated (age/nicotine) tenants.
|
|
2
|
+
* <CheckoutComplianceGate> — browser preflight for a regulated add-to-cart submit.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* a hard block (restricted destination, flavored ban, per-order limit, failed /
|
|
11
|
-
* unconfigured verification) STOPS it and shows why. Fail-closed by construction:
|
|
12
|
-
* if the preflight itself errors on a regulated tenant, we do NOT hand off.
|
|
4
|
+
* The dialog collects the ship-to state and the age affirmation, then asks
|
|
5
|
+
* `/api/compliance/preflight` whether this browser may submit the form. Only a
|
|
6
|
+
* server `clear` releases that submit. A hard block (restricted destination,
|
|
7
|
+
* flavored ban, per-order limit, failed or unconfigured verification) stops it.
|
|
8
|
+
* If the preflight cannot be read, the dialog does not submit.
|
|
13
9
|
*
|
|
14
10
|
* On a tenant wired to the LIVE Token of Trust verification provider, preflight
|
|
15
11
|
* additionally echoes a `verification` block (ref / provider status / continuation
|
|
@@ -18,15 +14,13 @@
|
|
|
18
14
|
* continuation URL in a new tab, then re-read the SERVER's decision on an interval
|
|
19
15
|
* until it clears or denies. Sandbox / no-provider tenants never receive the block
|
|
20
16
|
* and see exactly the flow they always did. The client never decides a pass:
|
|
21
|
-
* `decision.status === "clear"` from the server is the only thing that releases
|
|
22
|
-
*
|
|
17
|
+
* `decision.status === "clear"` from the server is the only thing that releases
|
|
18
|
+
* the submit. See `checkoutVerificationFlow.ts`.
|
|
23
19
|
*
|
|
24
|
-
* This is
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* in the managed-cart pre-cart webhook (Track B); this is the storefront-side check
|
|
29
|
-
* and the demo/preview's visible compliance checkpoint.
|
|
20
|
+
* This preflight is browser UX. It is not payment authorization. A `clear`
|
|
21
|
+
* decision only lets the native form submit; the server checks the cart again
|
|
22
|
+
* and never treats this dialog as authority to take payment. If the island
|
|
23
|
+
* never hydrates, or scripts are disabled, the form posts on its own.
|
|
30
24
|
*/
|
|
31
25
|
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
32
26
|
import type { CartComplianceDecision } from "@tot/public-runtime";
|
|
@@ -177,11 +171,12 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
177
171
|
const proceed = useCallback((line: PendingLine) => {
|
|
178
172
|
stopPolling();
|
|
179
173
|
clearStoredRef(verificationRefKey(preflightUrl, line));
|
|
174
|
+
// UX latch so this dialog does not intercept the resubmit. Not payment
|
|
175
|
+
// authority — the form's server action checks the cart.
|
|
180
176
|
line.form.dataset.complianceCleared = "1";
|
|
177
|
+
line.form.dataset.paymentAuthority = "none";
|
|
181
178
|
document.documentElement.style.overflow = "";
|
|
182
179
|
setPending(null);
|
|
183
|
-
// requestSubmit re-runs submit; our listener now passes it through, and the
|
|
184
|
-
// managed-cart loader takes over the (still server-pre-signed) POST.
|
|
185
180
|
if (typeof line.form.requestSubmit === "function") line.form.requestSubmit();
|
|
186
181
|
else line.form.submit();
|
|
187
182
|
}, [preflightUrl, stopPolling]);
|
|
@@ -377,6 +372,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
377
372
|
<div
|
|
378
373
|
class="fixed inset-0 z-[110] bg-[color-mix(in_srgb,var(--color-text)_75%,transparent)] backdrop-blur-sm"
|
|
379
374
|
role="dialog"
|
|
375
|
+
data-payment-authority="none"
|
|
380
376
|
aria-modal="true"
|
|
381
377
|
aria-labelledby="cgate-title"
|
|
382
378
|
onClick={(e) => {
|
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
* wins; this only fills in when `commerce.checkout` is absent.
|
|
25
25
|
*/
|
|
26
26
|
import type { TenantConfig, ResolvedManagedCheckout } from "@tot/public-runtime";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
CART_SIGNING_OPERATOR_ERROR,
|
|
29
|
+
resolveManagedCheckout,
|
|
30
|
+
SHARED_DEMO_CHECKOUT,
|
|
31
|
+
} from "@tot/public-runtime";
|
|
28
32
|
import { readEnv } from "@/lib/runtimeEnv";
|
|
29
33
|
|
|
30
34
|
/**
|
|
@@ -86,6 +90,8 @@ export async function readCartSecret(
|
|
|
86
90
|
export interface CartSigningResolution {
|
|
87
91
|
enabled: boolean;
|
|
88
92
|
secret?: string;
|
|
93
|
+
/** Set when signed mode has no secret. Actionable for the operator. No secret value. */
|
|
94
|
+
operatorError?: string;
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
/**
|
|
@@ -108,5 +114,5 @@ export async function resolveCartSigning(
|
|
|
108
114
|
"[storefront] signed checkout is configured but FOXY_CART_SECRET is missing — " +
|
|
109
115
|
"buy forms disabled (fail-closed). Set the cart-validation secret for this environment.",
|
|
110
116
|
);
|
|
111
|
-
return { enabled: false };
|
|
117
|
+
return { enabled: false, operatorError: CART_SIGNING_OPERATOR_ERROR };
|
|
112
118
|
}
|
|
@@ -18,8 +18,15 @@
|
|
|
18
18
|
* unattributed response; the handoff proceeds, checkout is never blocked.
|
|
19
19
|
*/
|
|
20
20
|
import type { APIContext } from "astro";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
enforceCartSubmission,
|
|
23
|
+
foxyCartRedirectUrl,
|
|
24
|
+
parseConsentSnapshot,
|
|
25
|
+
defaultConsentSnapshot,
|
|
26
|
+
type CartDenialCode,
|
|
27
|
+
} from "@tot/public-runtime";
|
|
22
28
|
import { resolveCheckoutHandoffCorrelation } from "@/lib/commerce/checkoutHandoffCorrelation";
|
|
29
|
+
import { resolveCartSigning } from "@/lib/checkoutCommerce";
|
|
23
30
|
|
|
24
31
|
export const prerender = false;
|
|
25
32
|
|
|
@@ -38,7 +45,78 @@ interface HandoffBody {
|
|
|
38
45
|
consent?: unknown;
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
function escapeHtml(value: string): string {
|
|
49
|
+
return value.replace(/[&<>"']/g, (ch) => {
|
|
50
|
+
if (ch === "&") return "&";
|
|
51
|
+
if (ch === "<") return "<";
|
|
52
|
+
if (ch === ">") return ">";
|
|
53
|
+
if (ch === '"') return """;
|
|
54
|
+
return "'";
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cartDenial(code: CartDenialCode, message: string, status: number): Response {
|
|
59
|
+
const body =
|
|
60
|
+
`<!doctype html><html><body><p data-cart-denial="${code}" data-payment-authorized="false">${escapeHtml(message)}</p></body></html>`;
|
|
61
|
+
return new Response(body, {
|
|
62
|
+
status,
|
|
63
|
+
headers: {
|
|
64
|
+
"content-type": "text/html; charset=utf-8",
|
|
65
|
+
"cache-control": "no-store",
|
|
66
|
+
"x-tot-payment-authorized": "false",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isCartForm(request: Request): boolean {
|
|
72
|
+
const type = request.headers.get("content-type") ?? "";
|
|
73
|
+
return (
|
|
74
|
+
type.includes("application/x-www-form-urlencoded") ||
|
|
75
|
+
type.includes("multipart/form-data")
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
|
|
80
|
+
async function cartFormPost(context: APIContext): Promise<Response> {
|
|
81
|
+
const { tenant, commerce } = context.locals;
|
|
82
|
+
if (!commerce) {
|
|
83
|
+
return cartDenial(
|
|
84
|
+
"signing-unavailable",
|
|
85
|
+
"Checkout is unavailable because this store has no cart configured.",
|
|
86
|
+
409,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const signing = await resolveCartSigning(commerce);
|
|
90
|
+
if (!signing.enabled) {
|
|
91
|
+
return cartDenial("signing-unavailable", signing.operatorError ?? "Signed checkout is unavailable.", 503);
|
|
92
|
+
}
|
|
93
|
+
const form = await context.request.formData();
|
|
94
|
+
const entries: Array<[string, string]> = [];
|
|
95
|
+
for (const [name, value] of form.entries()) {
|
|
96
|
+
if (typeof value === "string") entries.push([name, value]);
|
|
97
|
+
}
|
|
98
|
+
const result = await enforceCartSubmission({
|
|
99
|
+
entries,
|
|
100
|
+
mode: commerce.mode,
|
|
101
|
+
secret: signing.secret,
|
|
102
|
+
trustedTenantId: tenant.tenant_id,
|
|
103
|
+
});
|
|
104
|
+
if (!result.ok) {
|
|
105
|
+
const status = result.code === "signing-unavailable" ? 503 : 422;
|
|
106
|
+
return cartDenial(result.code, result.message, status);
|
|
107
|
+
}
|
|
108
|
+
return new Response(null, {
|
|
109
|
+
status: 303,
|
|
110
|
+
headers: {
|
|
111
|
+
location: foxyCartRedirectUrl(commerce.cartUrl, result.fields),
|
|
112
|
+
"cache-control": "no-store",
|
|
113
|
+
"x-tot-payment-authorized": "false",
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
41
118
|
export async function POST(context: APIContext): Promise<Response> {
|
|
119
|
+
if (isCartForm(context.request)) return cartFormPost(context);
|
|
42
120
|
const { tenant, browserEnvironment, commerce } = context.locals;
|
|
43
121
|
|
|
44
122
|
// No managed checkout on this tenant → nothing to attribute.
|
|
@@ -21,7 +21,9 @@ import ReviewSummary from "@/components/commerce/ReviewSummary.astro";
|
|
|
21
21
|
import ReviewList from "@/components/commerce/ReviewList.astro";
|
|
22
22
|
import { isOnSale, isNew, isAvailable, fromPrice } from "@/lib/tot/query";
|
|
23
23
|
import {
|
|
24
|
-
|
|
24
|
+
buildVariantCartPayload,
|
|
25
|
+
priceForPurchaseOption,
|
|
26
|
+
workerCartApiPath,
|
|
25
27
|
normalizePurchaseOptions,
|
|
26
28
|
visiblePurchaseOptions,
|
|
27
29
|
isAutopilotEnabled,
|
|
@@ -208,6 +210,13 @@ const autopilotEnabled = isAutopilotEnabled(tenant.commerce?.autopilot);
|
|
|
208
210
|
// list/subscription price; a Phase-2 member request signs their member price.
|
|
209
211
|
const pricingCtx: PurchaseOptionPricingContext = { member, program: membershipProgram };
|
|
210
212
|
|
|
213
|
+
const cartApiPath = workerCartApiPath({
|
|
214
|
+
provider: tenant.serving?.provider ?? "worker-r2-pointer",
|
|
215
|
+
phase: Astro.locals.browserEnvironment.environment === "live" ? "publication" : "preview",
|
|
216
|
+
apiBasePath,
|
|
217
|
+
endpoint: "/api/commerce/checkout-handoff",
|
|
218
|
+
});
|
|
219
|
+
|
|
211
220
|
let checkoutCart:
|
|
212
221
|
| {
|
|
213
222
|
action: string;
|
|
@@ -215,12 +224,15 @@ let checkoutCart:
|
|
|
215
224
|
byVariantId: Record<string, Record<string, VariantCartPayload>>;
|
|
216
225
|
}
|
|
217
226
|
| undefined;
|
|
227
|
+
let cartOperatorError: string | undefined;
|
|
218
228
|
if (commerce) {
|
|
219
229
|
// Fail closed for production signed checkout: a signed tenant with no
|
|
220
230
|
// cart-validation secret builds NO cart (never a tamperable form). See
|
|
221
231
|
// resolveCartSigning — it also logs the misconfiguration loudly.
|
|
222
|
-
const
|
|
223
|
-
if (enabled) {
|
|
232
|
+
const signing = await resolveCartSigning(commerce);
|
|
233
|
+
if (!signing.enabled) {
|
|
234
|
+
cartOperatorError = signing.operatorError;
|
|
235
|
+
} else {
|
|
224
236
|
try {
|
|
225
237
|
const purchaseOptions = visiblePurchaseOptions(
|
|
226
238
|
normalizePurchaseOptions(product),
|
|
@@ -230,33 +242,41 @@ if (commerce) {
|
|
|
230
242
|
product.variants.map(
|
|
231
243
|
async (v) => {
|
|
232
244
|
const payloads = await Promise.all(
|
|
233
|
-
purchaseOptions.map(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
245
|
+
purchaseOptions.map(async (purchaseOption) => {
|
|
246
|
+
const effective = priceForPurchaseOption(
|
|
247
|
+
v.price,
|
|
248
|
+
purchaseOption,
|
|
249
|
+
pricingCtx,
|
|
250
|
+
);
|
|
251
|
+
return [
|
|
252
|
+
purchaseOption.id,
|
|
253
|
+
await buildVariantCartPayload(product, v, {
|
|
254
|
+
mode: commerce.mode,
|
|
255
|
+
secret: signing.secret,
|
|
256
|
+
purchaseOption,
|
|
257
|
+
signedPrice: effective.price,
|
|
258
|
+
autopilotEnabled,
|
|
259
|
+
tenantId: tenant.tenant_id,
|
|
260
|
+
}),
|
|
261
|
+
] as const;
|
|
262
|
+
}),
|
|
246
263
|
);
|
|
247
264
|
return [v.id, Object.fromEntries(payloads)] as const;
|
|
248
265
|
},
|
|
249
266
|
),
|
|
250
267
|
);
|
|
251
268
|
checkoutCart = {
|
|
252
|
-
action:
|
|
269
|
+
action: cartApiPath,
|
|
253
270
|
purchaseOptions,
|
|
254
271
|
byVariantId: Object.fromEntries(entries),
|
|
255
272
|
};
|
|
256
273
|
} catch (err) {
|
|
274
|
+
cartOperatorError =
|
|
275
|
+
err instanceof Error && err.name === "CartSigningUnavailableError"
|
|
276
|
+
? err.message
|
|
277
|
+
: undefined;
|
|
257
278
|
console.warn(
|
|
258
279
|
`[storefront] Invalid checkout purchase options for ${product.handle}; cart disabled for this product.`,
|
|
259
|
-
err,
|
|
260
280
|
);
|
|
261
281
|
}
|
|
262
282
|
}
|
|
@@ -368,8 +388,15 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
368
388
|
</div>
|
|
369
389
|
)}
|
|
370
390
|
|
|
371
|
-
{/* Variant selector island owns live price/SKU/availability + buy CTA.
|
|
372
|
-
|
|
391
|
+
{/* Variant selector island owns live price/SKU/availability + buy CTA.
|
|
392
|
+
The form action is the worker cart route, so a native submit reaches
|
|
393
|
+
the server check. A clear browser preflight is not payment authority. */}
|
|
394
|
+
<div class="mt-7" data-payment-authority="none">
|
|
395
|
+
{cartOperatorError && (
|
|
396
|
+
<p class="mb-3 text-sm text-[var(--color-text)]" role="alert" data-cart-operator-error>
|
|
397
|
+
{cartOperatorError}
|
|
398
|
+
</p>
|
|
399
|
+
)}
|
|
373
400
|
<VariantSelector
|
|
374
401
|
client:load
|
|
375
402
|
variants={publicSelectorVariants(product.variants)}
|
|
@@ -380,7 +407,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
380
407
|
productTitle={product.title}
|
|
381
408
|
checkoutCart={checkoutCart}
|
|
382
409
|
pricing={pricing}
|
|
383
|
-
handoffUrl={
|
|
410
|
+
handoffUrl={cartApiPath}
|
|
384
411
|
/>
|
|
385
412
|
{/* Regulated-tenant checkout ENFORCEMENT gate. Intercepts the
|
|
386
413
|
managed-cart handoff to clear age + ship-to compliance server-side
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.50",
|
|
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",
|
|
@@ -129,10 +129,116 @@ export interface BuildVariantCartPayloadOptions {
|
|
|
129
129
|
* — subscriptions visible) so existing callers are unaffected.
|
|
130
130
|
*/
|
|
131
131
|
autopilotEnabled?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Tenant id from the request's authenticated context (`locals.tenant`).
|
|
134
|
+
* Signed into the cart when present. Never taken from the submitted form.
|
|
135
|
+
*/
|
|
136
|
+
tenantId?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Declared public serving substrate (ADR 0013 `serving.provider`). */
|
|
140
|
+
export type CartRouteProvider = "worker-r2-pointer" | "s3-overwrite";
|
|
141
|
+
|
|
142
|
+
/** Preview is any non-live stage. Publication is the live public site. */
|
|
143
|
+
export type CartRoutePhase = "preview" | "publication";
|
|
144
|
+
|
|
145
|
+
export const CART_API_ENDPOINT = "/api/commerce/checkout-handoff";
|
|
146
|
+
|
|
147
|
+
/** Inclusive upper bound for a cart line quantity. */
|
|
148
|
+
export const MAX_CART_QUANTITY = 99;
|
|
149
|
+
|
|
150
|
+
export const CART_SIGNING_OPERATOR_ERROR =
|
|
151
|
+
"Signed checkout is unavailable: set FOXY_CART_SECRET (the cart secret) for this environment. No cart was signed.";
|
|
152
|
+
|
|
153
|
+
export type CartDenialCode =
|
|
154
|
+
| "signature"
|
|
155
|
+
| "price"
|
|
156
|
+
| "quantity"
|
|
157
|
+
| "tenant"
|
|
158
|
+
| "signing-unavailable";
|
|
159
|
+
|
|
160
|
+
export class CartSigningUnavailableError extends Error {
|
|
161
|
+
readonly code = "signing-unavailable" as const;
|
|
162
|
+
constructor() {
|
|
163
|
+
super(CART_SIGNING_OPERATOR_ERROR);
|
|
164
|
+
this.name = "CartSigningUnavailableError";
|
|
165
|
+
}
|
|
132
166
|
}
|
|
133
167
|
|
|
134
168
|
const DEFAULT_CHECKOUT_HOST_SUFFIX = ".foxycart.com";
|
|
135
169
|
const SUBSCRIPTION_FREQUENCY_RE = /^(?:\d+(?:\.\d+)?|\.\d+)[dwmy]$/;
|
|
170
|
+
const PREVIEW_PIN_RE = /\/preview\/(?:staged|candidate)(?:\/|$)/;
|
|
171
|
+
const UNSIGNED_PREFIXES = ["h:", "x:", "__", "utm_"] as const;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Cart line name. The product title is the line. A multi-variant product appends
|
|
175
|
+
* the variant title unless that title is the placeholder "Default Title".
|
|
176
|
+
*/
|
|
177
|
+
export function cartLineName(
|
|
178
|
+
product: CatalogProduct,
|
|
179
|
+
variant: CatalogVariant,
|
|
180
|
+
): string {
|
|
181
|
+
const productTitle = product.title.trim();
|
|
182
|
+
const variantTitle = variant.title.trim();
|
|
183
|
+
const generic =
|
|
184
|
+
variantTitle.length === 0 || variantTitle.toLowerCase() === "default title";
|
|
185
|
+
if (product.variants.length > 1 && !generic) {
|
|
186
|
+
return `${productTitle} — ${variantTitle}`;
|
|
187
|
+
}
|
|
188
|
+
return productTitle || variantTitle;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Drop a preview pin if one was passed as the API prefix. */
|
|
192
|
+
export function unpinnedApiBase(apiBasePath: string): string {
|
|
193
|
+
const trimmed = apiBasePath.trim().replace(/\/+$/, "");
|
|
194
|
+
const idx = trimmed.search(PREVIEW_PIN_RE);
|
|
195
|
+
if (idx === -1) return trimmed;
|
|
196
|
+
return trimmed.slice(0, idx).replace(/\/+$/, "");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Worker route for a cart API call. Preview (both providers) and Cloudflare
|
|
201
|
+
* publication use the unpinned tenant prefix. CloudFront publication uses a
|
|
202
|
+
* root `/api/...` path, which is the declared CDN behavior. Never a preview pin
|
|
203
|
+
* and never a public-page origin.
|
|
204
|
+
*/
|
|
205
|
+
export function workerCartApiPath(input: {
|
|
206
|
+
provider: CartRouteProvider;
|
|
207
|
+
phase: CartRoutePhase;
|
|
208
|
+
apiBasePath: string;
|
|
209
|
+
endpoint?: string;
|
|
210
|
+
}): string {
|
|
211
|
+
const endpoint = input.endpoint ?? CART_API_ENDPOINT;
|
|
212
|
+
if (!endpoint.startsWith("/api/") || endpoint.includes("?") || endpoint.includes("#")) {
|
|
213
|
+
throw new Error("workerCartApiPath: endpoint must be an /api path");
|
|
214
|
+
}
|
|
215
|
+
if (PREVIEW_PIN_RE.test(endpoint)) {
|
|
216
|
+
throw new Error("workerCartApiPath: endpoint must not include a preview pin");
|
|
217
|
+
}
|
|
218
|
+
if (input.phase === "publication" && input.provider === "s3-overwrite") {
|
|
219
|
+
return endpoint;
|
|
220
|
+
}
|
|
221
|
+
return `${unpinnedApiBase(input.apiBasePath)}${endpoint}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** True when `path` is a worker-routed cart API path for this provider and phase. */
|
|
225
|
+
export function cartApiReachesWorker(
|
|
226
|
+
path: string,
|
|
227
|
+
input: { provider: CartRouteProvider; phase: CartRoutePhase },
|
|
228
|
+
): boolean {
|
|
229
|
+
const pathname = path.replace(/^https?:\/\/[^/]+/, "").split("?")[0] ?? "";
|
|
230
|
+
if (PREVIEW_PIN_RE.test(pathname)) return false;
|
|
231
|
+
if (input.phase === "publication" && input.provider === "s3-overwrite") {
|
|
232
|
+
return /^\/api\/.+/.test(pathname);
|
|
233
|
+
}
|
|
234
|
+
return /^(?:\/[^/]+)?\/api\/.+/.test(pathname);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function quantityAllowed(value: string): boolean {
|
|
238
|
+
if (!/^[1-9]\d*$/.test(value)) return false;
|
|
239
|
+
const n = Number(value);
|
|
240
|
+
return n >= 1 && n <= MAX_CART_QUANTITY;
|
|
241
|
+
}
|
|
136
242
|
|
|
137
243
|
/** Resolve the full request-scoped commerce facts from a tenant's config. */
|
|
138
244
|
export function resolveManagedCheckout(
|
|
@@ -160,7 +266,7 @@ export function cartFieldsForVariant(
|
|
|
160
266
|
product: CatalogProduct,
|
|
161
267
|
variant: CatalogVariant,
|
|
162
268
|
purchaseOption?: ProductPurchaseOption,
|
|
163
|
-
opts?: { signedPrice?: number },
|
|
269
|
+
opts?: { signedPrice?: number; tenantId?: string },
|
|
164
270
|
): { code: string; fields: CartField[] } {
|
|
165
271
|
const code = variant.sku && variant.sku.length ? variant.sku : variant.id;
|
|
166
272
|
const image = product.images.find((i) => i.id === variant.image_id)?.url ??
|
|
@@ -172,12 +278,19 @@ export function cartFieldsForVariant(
|
|
|
172
278
|
opts?.signedPrice !== undefined
|
|
173
279
|
? assertEffectivePrice(opts.signedPrice, variant.price)
|
|
174
280
|
: purchaseOptionPrice(variant.price, purchaseOption);
|
|
281
|
+
if (!variant.currency) {
|
|
282
|
+
throw new Error("cartFieldsForVariant: variant currency is required");
|
|
283
|
+
}
|
|
175
284
|
const fields: CartField[] = [
|
|
176
|
-
{ name: "name", value: variant
|
|
285
|
+
{ name: "name", value: cartLineName(product, variant) },
|
|
177
286
|
{ name: "price", value: price.toFixed(2) },
|
|
287
|
+
{ name: "currency", value: variant.currency },
|
|
178
288
|
{ name: "code", value: code },
|
|
179
289
|
{ name: "quantity", value: "1", open: true },
|
|
180
290
|
];
|
|
291
|
+
if (opts?.tenantId) {
|
|
292
|
+
fields.push({ name: "tenant", value: opts.tenantId });
|
|
293
|
+
}
|
|
181
294
|
if (image) fields.push({ name: "image", value: image });
|
|
182
295
|
// Variant options (Flavor, Nicotine strength, …) travel as product options so
|
|
183
296
|
// the cart line reflects the exact variant the shopper picked.
|
|
@@ -357,13 +470,11 @@ export async function buildVariantCartPayload(
|
|
|
357
470
|
product,
|
|
358
471
|
variant,
|
|
359
472
|
opts.purchaseOption,
|
|
360
|
-
{ signedPrice: opts.signedPrice },
|
|
473
|
+
{ signedPrice: opts.signedPrice, tenantId: opts.tenantId },
|
|
361
474
|
);
|
|
362
475
|
const signed = opts.mode === "signed";
|
|
363
476
|
if (signed && !opts.secret) {
|
|
364
|
-
throw new
|
|
365
|
-
"buildVariantCartPayload: signed mode requires a cart secret",
|
|
366
|
-
);
|
|
477
|
+
throw new CartSigningUnavailableError();
|
|
367
478
|
}
|
|
368
479
|
|
|
369
480
|
const rendered: RenderedCartField[] = signed
|
|
@@ -376,3 +487,158 @@ export async function buildVariantCartPayload(
|
|
|
376
487
|
const hidden = rendered.filter((_, i) => i !== qtyIdx);
|
|
377
488
|
return { hidden, quantityName };
|
|
378
489
|
}
|
|
490
|
+
|
|
491
|
+
const CART_DENIAL_MESSAGE: Record<CartDenialCode, string> = {
|
|
492
|
+
signature: "Checkout was refused because the cart signature does not match.",
|
|
493
|
+
price: "Checkout was refused because the price does not match the signed price.",
|
|
494
|
+
quantity: "Checkout was refused because the quantity is not an allowed amount.",
|
|
495
|
+
tenant: "Checkout was refused because the cart tenant does not match this store.",
|
|
496
|
+
"signing-unavailable": CART_SIGNING_OPERATOR_ERROR,
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
export interface CartSubmissionField {
|
|
500
|
+
name: string;
|
|
501
|
+
value: string;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export type CartEnforcement =
|
|
505
|
+
| { ok: true; fields: CartSubmissionField[]; paymentAuthorized: false }
|
|
506
|
+
| { ok: false; code: CartDenialCode; message: string; paymentAuthorized: false };
|
|
507
|
+
|
|
508
|
+
interface ParsedCartField {
|
|
509
|
+
rawName: string;
|
|
510
|
+
baseName: string;
|
|
511
|
+
value: string;
|
|
512
|
+
hash?: string;
|
|
513
|
+
open: boolean;
|
|
514
|
+
passthrough: boolean;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function isPassthroughName(name: string): boolean {
|
|
518
|
+
return UNSIGNED_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function parseSubmittedField(rawName: string, value: string): ParsedCartField {
|
|
522
|
+
if (isPassthroughName(rawName)) {
|
|
523
|
+
return { rawName, baseName: rawName, value, open: false, passthrough: true };
|
|
524
|
+
}
|
|
525
|
+
const parts = rawName.split("||");
|
|
526
|
+
if (parts.length === 3 && parts[2] === "open") {
|
|
527
|
+
return {
|
|
528
|
+
rawName,
|
|
529
|
+
baseName: parts[0] ?? "",
|
|
530
|
+
value,
|
|
531
|
+
hash: parts[1],
|
|
532
|
+
open: true,
|
|
533
|
+
passthrough: false,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
if (parts.length === 2) {
|
|
537
|
+
return {
|
|
538
|
+
rawName,
|
|
539
|
+
baseName: parts[0] ?? "",
|
|
540
|
+
value,
|
|
541
|
+
hash: parts[1],
|
|
542
|
+
open: false,
|
|
543
|
+
passthrough: false,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
return { rawName, baseName: rawName, value, open: false, passthrough: false };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function denial(code: CartDenialCode): CartEnforcement {
|
|
550
|
+
return {
|
|
551
|
+
ok: false,
|
|
552
|
+
code,
|
|
553
|
+
message: CART_DENIAL_MESSAGE[code],
|
|
554
|
+
paymentAuthorized: false,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function specificCode(baseName: string): CartDenialCode {
|
|
559
|
+
if (baseName === "price") return "price";
|
|
560
|
+
if (baseName === "quantity") return "quantity";
|
|
561
|
+
if (baseName === "tenant") return "tenant";
|
|
562
|
+
return "signature";
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Server cart check. A browser preflight result is not an input and cannot
|
|
567
|
+
* authorize payment. Tenant is compared to the trusted request tenant. Price,
|
|
568
|
+
* quantity, and signature failures are distinct. The returned fields are what
|
|
569
|
+
* may be handed to the cart; `paymentAuthorized` is always false.
|
|
570
|
+
*/
|
|
571
|
+
export async function enforceCartSubmission(input: {
|
|
572
|
+
entries: Iterable<[string, string]>;
|
|
573
|
+
mode: ManagedCheckoutConfig["mode"];
|
|
574
|
+
secret?: string;
|
|
575
|
+
trustedTenantId: string;
|
|
576
|
+
}): Promise<CartEnforcement> {
|
|
577
|
+
if (input.mode === "signed" && !input.secret) return denial("signing-unavailable");
|
|
578
|
+
if (!input.trustedTenantId) return denial("tenant");
|
|
579
|
+
|
|
580
|
+
const parsed = [...input.entries].map(([name, value]) => parseSubmittedField(name, value));
|
|
581
|
+
const byBase = new Map<string, ParsedCartField>();
|
|
582
|
+
for (const field of parsed) {
|
|
583
|
+
if (field.passthrough) continue;
|
|
584
|
+
if (byBase.has(field.baseName)) return denial("signature");
|
|
585
|
+
byBase.set(field.baseName, field);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const codeField = byBase.get("code");
|
|
589
|
+
if (!codeField || !codeField.value) return denial("signature");
|
|
590
|
+
|
|
591
|
+
const failures = new Set<CartDenialCode>();
|
|
592
|
+
const signed = input.mode === "signed";
|
|
593
|
+
const secret = input.secret ?? "";
|
|
594
|
+
|
|
595
|
+
const hashOk = async (field: ParsedCartField): Promise<boolean> => {
|
|
596
|
+
if (!field.hash) return false;
|
|
597
|
+
const valueToHash = field.open ? "--OPEN--" : field.value;
|
|
598
|
+
const expected = await hmacHex(secret, codeField.value + field.baseName + valueToHash);
|
|
599
|
+
return expected === field.hash;
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
if (signed) {
|
|
603
|
+
if (!(await hashOk(codeField))) failures.add("signature");
|
|
604
|
+
for (const field of byBase.values()) {
|
|
605
|
+
if (field.baseName === "code") continue;
|
|
606
|
+
if (!(await hashOk(field))) failures.add(specificCode(field.baseName));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const tenantField = byBase.get("tenant");
|
|
611
|
+
if (!tenantField || tenantField.value !== input.trustedTenantId) {
|
|
612
|
+
failures.add("tenant");
|
|
613
|
+
}
|
|
614
|
+
const priceField = byBase.get("price");
|
|
615
|
+
if (!priceField || !/^\d+\.\d{2}$/.test(priceField.value)) failures.add("price");
|
|
616
|
+
const quantityField = byBase.get("quantity");
|
|
617
|
+
if (!quantityField || !quantityAllowed(quantityField.value)) failures.add("quantity");
|
|
618
|
+
if (signed) {
|
|
619
|
+
for (const required of ["name", "currency"]) {
|
|
620
|
+
if (!byBase.get(required)) failures.add("signature");
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const order: CartDenialCode[] = ["tenant", "price", "quantity", "signature"];
|
|
625
|
+
for (const code of order) {
|
|
626
|
+
if (failures.has(code)) return denial(code);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return {
|
|
630
|
+
ok: true,
|
|
631
|
+
paymentAuthorized: false,
|
|
632
|
+
fields: parsed.map((field) => ({ name: field.rawName, value: field.value })),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Cart URL the browser may navigate to after enforcement. Not a payment. */
|
|
637
|
+
export function foxyCartRedirectUrl(
|
|
638
|
+
cartUrl: string,
|
|
639
|
+
fields: readonly CartSubmissionField[],
|
|
640
|
+
): string {
|
|
641
|
+
const url = new URL(cartUrl);
|
|
642
|
+
for (const field of fields) url.searchParams.append(field.name, field.value);
|
|
643
|
+
return url.toString();
|
|
644
|
+
}
|