@tokenoftrust/storefront-runner 2.4.48 → 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/migrations-apps/0016_scrappy_commerce_contract.sql +166 -0
- 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/fixtures/scrappy/orders-v1.json +847 -0
- package/packages/public-runtime/src/checkout-orders.ts +602 -16
- package/packages/public-runtime/src/checkout.ts +272 -6
|
@@ -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
|
+
}
|