@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.
@@ -1,15 +1,11 @@
1
1
  /**
2
- * <CheckoutComplianceGate> — the storefront's pre-handoff compliance ENFORCEMENT
3
- * seam for regulated (age/nicotine) tenants.
2
+ * <CheckoutComplianceGate> — browser preflight for a regulated add-to-cart submit.
4
3
  *
5
- * Checkout completion happens in the EXTERNAL managed cart, so the last moment the
6
- * storefront controls is the add-to-cart submit. This island intercepts that
7
- * submit for any `form[data-managed-cart]`, collects the ship-to state + 21+
8
- * affirmation, and asks the server-authoritative gate (`/api/compliance/preflight`,
9
- * F12) whether the cart may proceed. Only a `clear` decision releases the handoff;
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 the
22
- * handoff, here as everywhere else. See `checkoutVerificationFlow.ts`.
17
+ * `decision.status === "clear"` from the server is the only thing that releases
18
+ * the submit. See `checkoutVerificationFlow.ts`.
23
19
  *
24
- * This is progressive enhancement: the underlying form still posts the real
25
- * (server-pre-signed) cart fields. If this island never hydrates, the form submits
26
- * as before — so the gate hardens the flow without being a single point of failure
27
- * for rendering the buy button. The REAL production enforcement is defense-in-depth
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 { resolveManagedCheckout, SHARED_DEMO_CHECKOUT } from "@tot/public-runtime";
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 { parseConsentSnapshot, defaultConsentSnapshot } from "@tot/public-runtime";
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 "&amp;";
51
+ if (ch === "<") return "&lt;";
52
+ if (ch === ">") return "&gt;";
53
+ if (ch === '"') return "&quot;";
54
+ return "&#39;";
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
- buildMemberSignedCartPayload,
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 { enabled, secret } = await resolveCartSigning(commerce);
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
- async (purchaseOption) =>
235
- [
236
- purchaseOption.id,
237
- await buildMemberSignedCartPayload(product, v, {
238
- mode: commerce.mode,
239
- secret,
240
- purchaseOption,
241
- pricing: pricingCtx,
242
- autopilotEnabled,
243
- }),
244
- ] as const,
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: commerce.cartUrl,
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
- <div class="mt-7">
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={withBase(apiBasePath, "/api/commerce/checkout-handoff")}
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.49",
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.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
+ }