@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.
@@ -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.title || product.title },
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 Error(
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
+ }