@tokenoftrust/storefront-runner 2.4.49 → 2.4.51

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.
@@ -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.
@@ -0,0 +1,366 @@
1
+ /**
2
+ * POST /api/commerce/final-cart-authorization — time-limited final-cart admission.
3
+ * Caller-owned asymmetric tokens only. Catalog availability, a browser confirmation,
4
+ * and missing verification evidence do not authorize payment.
5
+ */
6
+ import type { APIContext } from "astro";
7
+ import { createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify, type JSONWebKeySet, type JWTPayload } from "jose";
8
+ import type { TenantConfig } from "@tot/public-runtime";
9
+ import {
10
+ type CallerToken,
11
+ type CommerceEvidence,
12
+ type CommerceStage,
13
+ type EvidenceState,
14
+ type FinalCartBinding,
15
+ type FulfillmentState,
16
+ type LabelState,
17
+ type PaymentState,
18
+ type StockState,
19
+ } from "@tot/public-runtime";
20
+ import { fromD1, listAllActiveProducts } from "@/lib/d1/catalog.js";
21
+ import { readD1, readEnv } from "@/lib/env";
22
+ import { evaluateFinalCart, type SeamView } from "@/lib/commerce/finalCartAuthorization.js";
23
+ import { d1CommerceRunner, mirrorOnHand, reserveAllocation } from "@/lib/inventory/allocations.js";
24
+ import { POST as compliancePreflight } from "../compliance/preflight.js";
25
+
26
+ export const prerender = false;
27
+
28
+ const EVIDENCE_STATES = new Set(["fresh", "UNKNOWN", "stale", "unreadable", "absent"]);
29
+ const STAGES = new Set(["preview", "publication"]);
30
+
31
+ function json(body: unknown, status = 200): Response {
32
+ return new Response(JSON.stringify(body), {
33
+ status,
34
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
35
+ });
36
+ }
37
+
38
+ function deny(finding: string, status: number, owner?: string): Response {
39
+ return json(owner ? { ok: false, finding, owner } : { ok: false, finding }, status);
40
+ }
41
+
42
+ function statusFor(finding: string): number {
43
+ if (finding === "store-unconfigured" || finding === "caller-unconfigured") return 503;
44
+ if (
45
+ finding === "browser-not-authority" ||
46
+ finding === "tenant-unbound" ||
47
+ finding === "shared-secret" ||
48
+ finding === "signature" ||
49
+ finding === "kid" ||
50
+ finding === "issuer" ||
51
+ finding === "audience" ||
52
+ finding === "expired" ||
53
+ finding === "lifetime" ||
54
+ finding === "jti" ||
55
+ finding === "tenant" ||
56
+ finding === "stage"
57
+ ) {
58
+ return 401;
59
+ }
60
+ return 409;
61
+ }
62
+
63
+ function claimString(payload: JWTPayload, key: string): string {
64
+ const value = payload[key];
65
+ return typeof value === "string" ? value : "";
66
+ }
67
+
68
+ function parseList(value: string | undefined): string[] | null {
69
+ if (!value || value.trim() === "") return null;
70
+ const trimmed = value.trim();
71
+ if (trimmed.startsWith("[")) {
72
+ try {
73
+ const parsed = JSON.parse(trimmed) as unknown;
74
+ if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string" || item.trim() === "")) return null;
75
+ return parsed as string[];
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+ const parts = trimmed.split(",").map((part) => part.trim()).filter(Boolean);
81
+ return parts.length > 0 ? parts : null;
82
+ }
83
+
84
+ function publicJwks(value: string | undefined): JSONWebKeySet | null {
85
+ if (!value) return null;
86
+ try {
87
+ const parsed = JSON.parse(value) as unknown;
88
+ if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as { keys?: unknown }).keys)) return null;
89
+ const keys = (parsed as { keys: unknown[] }).keys;
90
+ if (keys.length < 1) return null;
91
+ for (const key of keys) {
92
+ if (!key || typeof key !== "object") return null;
93
+ const record = key as Record<string, unknown>;
94
+ if (typeof record.d === "string" || record.kty === "oct" || typeof record.k === "string") return null;
95
+ }
96
+ return parsed as JSONWebKeySet;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ function readCart(value: unknown): FinalCartBinding | null {
103
+ if (!value || typeof value !== "object") return null;
104
+ const record = value as Record<string, unknown>;
105
+ if (typeof record.tenantId !== "string" || typeof record.currency !== "string" || typeof record.destinationDigest !== "string") {
106
+ return null;
107
+ }
108
+ if (typeof record.stage !== "string" || !STAGES.has(record.stage)) return null;
109
+ if (typeof record.verificationExpiresAt !== "string" || typeof record.taxExpiresAt !== "string") return null;
110
+ if (!Array.isArray(record.lines) || record.lines.length > 50) return null;
111
+ const lines = [];
112
+ for (const line of record.lines) {
113
+ if (!line || typeof line !== "object") return null;
114
+ const row = line as Record<string, unknown>;
115
+ if (typeof row.sku !== "string" || typeof row.quantity !== "number" || typeof row.unitMinor !== "number") return null;
116
+ lines.push({ sku: row.sku, quantity: row.quantity, unitMinor: row.unitMinor });
117
+ }
118
+ return {
119
+ tenantId: record.tenantId,
120
+ stage: record.stage as CommerceStage,
121
+ currency: record.currency,
122
+ lines,
123
+ destinationDigest: record.destinationDigest,
124
+ verificationExpiresAt: record.verificationExpiresAt,
125
+ taxExpiresAt: record.taxExpiresAt,
126
+ };
127
+ }
128
+
129
+ function readEvidence(value: unknown): CommerceEvidence | null {
130
+ if (!value || typeof value !== "object") return null;
131
+ const record = value as Record<string, unknown>;
132
+ const evidence = {} as CommerceEvidence;
133
+ for (const field of ["verification", "tax", "risk", "stock"] as const) {
134
+ const state = record[field];
135
+ if (typeof state !== "string" || !EVIDENCE_STATES.has(state)) return null;
136
+ evidence[field] = state as EvidenceState;
137
+ }
138
+ return evidence;
139
+ }
140
+
141
+ function readState<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
142
+ return typeof value === "string" && (allowed as readonly string[]).includes(value) ? (value as T) : undefined;
143
+ }
144
+
145
+ function destinationOf(value: unknown): { region: string; country: string } | null {
146
+ if (!value || typeof value !== "object") return null;
147
+ const record = value as Record<string, unknown>;
148
+ const region = typeof record.region === "string" ? record.region.trim() : "";
149
+ const country = typeof record.country === "string" ? record.country.trim() : "";
150
+ if (!/^[A-Za-z]{2}$/.test(region)) return null;
151
+ if (country !== "" && !/^[A-Za-z]{2}$/.test(country)) return null;
152
+ return { region: region.toUpperCase(), country: country === "" ? "US" : country.toUpperCase() };
153
+ }
154
+
155
+ async function catalogOffers(sku: string): Promise<boolean> {
156
+ const database = await readD1("CATALOG_DB");
157
+ if (!database) return false;
158
+ try {
159
+ const products = await listAllActiveProducts(fromD1(database));
160
+ for (const product of products) {
161
+ for (const variant of product.variants) {
162
+ if (variant.sku === sku && variant.inventory.available === true) return true;
163
+ }
164
+ }
165
+ } catch {
166
+ return false;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ async function verificationSeam(
172
+ context: APIContext,
173
+ cart: FinalCartBinding,
174
+ destination: { region: string; country: string } | null,
175
+ ): Promise<SeamView> {
176
+ const tot = (context.locals as { tot?: { getProductByHandle: (handle: string) => Promise<unknown> } }).tot ?? {
177
+ getProductByHandle: async () => null,
178
+ };
179
+ const response = await compliancePreflight({
180
+ request: new Request("https://synthetic-tenant.invalid/api/compliance/preflight", {
181
+ method: "POST",
182
+ headers: { "content-type": "application/json" },
183
+ body: JSON.stringify({
184
+ lines: cart.lines.map((line) => ({ handle: line.sku, sku: line.sku, quantity: line.quantity })),
185
+ ...(destination ? { destination: { region: destination.region, country: destination.country } } : {}),
186
+ }),
187
+ }),
188
+ locals: { tenant: context.locals.tenant, tot },
189
+ } as APIContext);
190
+ let body: unknown;
191
+ try {
192
+ body = await response.json();
193
+ } catch {
194
+ return { status: "absent" };
195
+ }
196
+ const decision = body && typeof body === "object" ? (body as { decision?: { status?: unknown; failedClosed?: unknown; blocks?: unknown } }).decision : undefined;
197
+ if (!decision || decision.status === "clear") return { status: decision ? "clear" : "absent" };
198
+ const blocks = Array.isArray(decision.blocks) ? decision.blocks : [];
199
+ const codes = blocks
200
+ .map((block) => (block && typeof block === "object" ? (block as { code?: unknown }).code : undefined))
201
+ .filter((code): code is string => typeof code === "string");
202
+ return { status: "blocked", failedClosed: decision.failedClosed === true, codes };
203
+ }
204
+
205
+ export async function POST(context: APIContext): Promise<Response> {
206
+ const tenant = context.locals.tenant as TenantConfig | undefined;
207
+ const appDomain = tenant?.appDomain ?? "";
208
+ if (!tenant || appDomain === "") return deny("tenant-unbound", 401);
209
+
210
+ let body: Record<string, unknown>;
211
+ try {
212
+ const parsed = await context.request.json();
213
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return deny("malformed-request", 409);
214
+ body = parsed as Record<string, unknown>;
215
+ } catch {
216
+ return deny("malformed-request", 409);
217
+ }
218
+
219
+ if (body.scheme === "shared-secret") {
220
+ const decision = await evaluateFinalCart({
221
+ now: new Date().toISOString(),
222
+ routedTenantId: appDomain,
223
+ quoted: readCart(body.quoted) ?? emptyCart(appDomain),
224
+ presented: readCart(body.presented) ?? emptyCart(appDomain),
225
+ evidence: readEvidence(body.evidence),
226
+ seam: { status: "clear" },
227
+ catalogAvailable: false,
228
+ browserConfirmed: body.browserConfirmed === true,
229
+ token: { scheme: "shared-secret" },
230
+ trustedIssuers: [],
231
+ expectedAudience: "",
232
+ activeKids: [],
233
+ authorities: [],
234
+ });
235
+ return deny(decision.ok ? "shared-secret" : decision.finding, statusFor(decision.ok ? "shared-secret" : decision.finding), decision.ok ? undefined : decision.owner);
236
+ }
237
+
238
+ const quoted = readCart(body.quoted);
239
+ const presented = readCart(body.presented);
240
+ if (!quoted || !presented) return deny("malformed-request", 409);
241
+
242
+ const jwks = publicJwks(await readEnv("SCRAPPY_CALLER_JWKS"));
243
+ const issuers = parseList(await readEnv("SCRAPPY_CALLER_ISSUERS"));
244
+ const audience = await readEnv("SCRAPPY_CALLER_AUDIENCE");
245
+ const activeKids = parseList(await readEnv("SCRAPPY_CALLER_ACTIVE_KIDS"));
246
+ if (!jwks || !issuers || !audience || !activeKids) return deny("caller-unconfigured", 503);
247
+
248
+ const header = context.request.headers.get("authorization") ?? "";
249
+ const bearer = header.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : "";
250
+ let token: CallerToken | null = null;
251
+ if (bearer !== "") {
252
+ try {
253
+ const decoded = decodeJwt(bearer);
254
+ const protectedHeader = decodeProtectedHeader(bearer);
255
+ let signatureValid = false;
256
+ try {
257
+ await jwtVerify(bearer, createLocalJWKSet(jwks), { algorithms: ["EdDSA"] });
258
+ signatureValid = true;
259
+ } catch (error) {
260
+ const code = error && typeof error === "object" && "code" in error ? String((error as { code: unknown }).code) : "";
261
+ signatureValid = code === "ERR_JWT_EXPIRED" || code === "ERR_JWT_CLAIM_VALIDATION_FAILED";
262
+ }
263
+ const exp = typeof decoded.exp === "number" ? new Date(decoded.exp * 1000).toISOString() : "";
264
+ const audienceClaim = typeof decoded.aud === "string" ? decoded.aud : Array.isArray(decoded.aud) ? decoded.aud[0] ?? "" : "";
265
+ const stageClaim = claimString(decoded, "stage");
266
+ const stage: CommerceStage =
267
+ stageClaim === "preview" || stageClaim === "publication"
268
+ ? stageClaim
269
+ : presented.stage === "preview"
270
+ ? "publication"
271
+ : "preview";
272
+ token = {
273
+ scheme: "asymmetric-jwks",
274
+ signatureValid,
275
+ kid: typeof protectedHeader.kid === "string" ? protectedHeader.kid : "",
276
+ issuer: typeof decoded.iss === "string" ? decoded.iss : "",
277
+ audience: audienceClaim,
278
+ expiresAt: exp,
279
+ jti: claimString(decoded, "jti"),
280
+ tenantId: claimString(decoded, "tenantId"),
281
+ stage,
282
+ };
283
+ } catch {
284
+ token = null;
285
+ }
286
+ }
287
+
288
+ const provider = tenant.inventory?.provider ?? "mirror";
289
+ const authorities: unknown[] = [];
290
+ const now = new Date().toISOString();
291
+ if (provider !== "mirror") {
292
+ authorities.push({ provider, sku: presented.lines[0]?.sku });
293
+ } else {
294
+ const skus = [...new Set(presented.lines.map((line) => line.sku))];
295
+ for (const sku of skus) {
296
+ const onHand = mirrorOnHand(sku, "unspecified");
297
+ authorities.push(
298
+ onHand == null
299
+ ? null
300
+ : { provider: "mirror", readable: true, locationId: "unspecified", sku, onHand, asOf: now },
301
+ );
302
+ }
303
+ }
304
+ let catalogAvailable = false;
305
+ for (const line of presented.lines) {
306
+ if (await catalogOffers(line.sku)) catalogAvailable = true;
307
+ }
308
+
309
+ const decision = await evaluateFinalCart({
310
+ now,
311
+ routedTenantId: appDomain,
312
+ quoted,
313
+ presented,
314
+ evidence: readEvidence(body.evidence),
315
+ seam: await verificationSeam(context, presented, destinationOf(body.destination)),
316
+ catalogAvailable,
317
+ browserConfirmed: body.browserConfirmed === true,
318
+ token,
319
+ trustedIssuers: issuers,
320
+ expectedAudience: audience,
321
+ activeKids,
322
+ authorities,
323
+ payment: readState<PaymentState>(body.payment, ["UNKNOWN", "pending", "authorized", "captured", "capture_unknown", "voided", "refund_requested", "refunded", "refund_unknown"]),
324
+ stock: readState<StockState>(body.stock, ["UNKNOWN", "unallocated", "reserved", "confirmed", "released", "expired"]),
325
+ fulfillment: readState<FulfillmentState>(body.fulfillment, ["UNKNOWN", "held", "ready", "handed_off", "cancelled"]),
326
+ label: readState<LabelState>(body.label, ["UNKNOWN", "none", "intent", "purchased", "printed", "void_requested", "voided"]),
327
+ });
328
+ if (!decision.ok) return deny(decision.finding, statusFor(decision.finding), decision.owner);
329
+
330
+ const database = await readD1("STOREFRONT_APPS_DB");
331
+ if (!database || typeof database.prepare !== "function") return deny("store-unconfigured", 503);
332
+ const reserved = await reserveAllocation(d1CommerceRunner(database), {
333
+ tenantId: decision.tenantId,
334
+ stage: decision.stage,
335
+ orderId: `a${crypto.randomUUID()}`,
336
+ cartDigest: decision.digest,
337
+ expiresAt: decision.expiresAt,
338
+ now,
339
+ currency: presented.currency,
340
+ amountMinor: decision.amountMinor,
341
+ issuer: decision.issuer,
342
+ jti: decision.jti,
343
+ holds: decision.holds,
344
+ });
345
+ if (!reserved.ok) return deny(reserved.finding, statusFor(reserved.finding));
346
+ return json({
347
+ ok: true,
348
+ authorizationId: reserved.authorizationId,
349
+ cartDigest: decision.digest,
350
+ expiresAt: reserved.expiresAt,
351
+ repeated: reserved.repeated,
352
+ stage: decision.stage,
353
+ });
354
+ }
355
+
356
+ function emptyCart(tenantId: string): FinalCartBinding {
357
+ return {
358
+ tenantId,
359
+ stage: "preview",
360
+ currency: "USD",
361
+ lines: [{ sku: "sku-1", quantity: 1, unitMinor: 0 }],
362
+ destinationDigest: "dest-synthetic",
363
+ verificationExpiresAt: "1970-01-01T00:00:00.000Z",
364
+ taxExpiresAt: "1970-01-01T00:00:00.000Z",
365
+ };
366
+ }
@@ -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.51",
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",