@tokenoftrust/storefront-runner 3.9.2 → 3.10.1

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.
@@ -14,13 +14,15 @@
14
14
  * allowlist set in middleware. It is loaded as a plain cross-origin script
15
15
  * (NO `crossorigin` attribute) because the CDN serves it without CORS headers.
16
16
  */
17
+ import { cartSessionCookiePath } from "@/lib/checkout/commerce/cartSession";
17
18
  import { lazyCheckoutLoaderJs } from "@/lib/checkout/shared/lazyCheckoutLoader";
18
19
 
19
20
  interface Props {
20
21
  nonce?: string;
21
22
  }
22
- const { commerce, storeNotPublic } = Astro.locals;
23
+ const { commerce, storeNotPublic, apiBasePath, basePath } = Astro.locals;
23
24
  const { nonce } = Astro.props;
25
+ const apiBase = apiBasePath ?? basePath ?? "";
24
26
  ---
25
27
  {
26
28
  commerce && (
@@ -28,6 +30,8 @@ const { nonce } = Astro.props;
28
30
  loaderSrc: commerce.loaderSrc,
29
31
  cartOrigin: commerce.origin,
30
32
  storeNotPublic: !!storeNotPublic,
33
+ sessionHref: `${apiBase}/api/commerce/cart-session`,
34
+ cookiePath: cartSessionCookiePath(apiBase),
31
35
  })} />
32
36
  )
33
37
  }
@@ -0,0 +1,182 @@
1
+ import type { APIContext } from "astro";
2
+
3
+ /**
4
+ * Per-tenant cart session on a shared Foxy store.
5
+ *
6
+ * The hosted cart session lives on the Foxy host, so two stores on that host
7
+ * otherwise share one cart. This store keeps the session id Foxy issued in an
8
+ * HttpOnly cookie on the tenant's own API prefix and sends it as `fcsid` on
9
+ * every cart open. A stored id Foxy does not echo, or a cart whose lines are
10
+ * not all this tenant, is discarded for a new empty session. The browser never
11
+ * asks Foxy for the id: that request would send the shared Foxy cookie.
12
+ */
13
+ export const CART_SESSION_COOKIE = "tot_fcsid";
14
+
15
+ /** Issued ids are lowercase alphanumerics. Measured length is 26; garbage fails closed. */
16
+ export const CART_SESSION_ID = /^[a-z0-9]{10,64}$/;
17
+
18
+ /** Same lifetime Foxy gives its browse-site session cookie. */
19
+ export const CART_SESSION_MAX_AGE = 2_592_000;
20
+
21
+ export const CART_UNAVAILABLE_MESSAGE =
22
+ "Checkout is unavailable because this store's cart could not be opened.";
23
+
24
+ const FOXY_TIMEOUT_MS = 5_000;
25
+
26
+ export interface CartSessionBinding {
27
+ cookies: APIContext["cookies"];
28
+ locals: { apiBasePath?: string; basePath?: string };
29
+ }
30
+
31
+ /** Cookie path for every revision of one tenant. A host-routed store uses `/`. */
32
+ export function cartSessionCookiePath(basePath: string): string {
33
+ const trimmed = basePath.trim();
34
+ if (trimmed === "" || trimmed === "/") return "/";
35
+ const withLead = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
36
+ return `${withLead.replace(/\/+$/, "")}/`;
37
+ }
38
+
39
+ /** Cart URL that selects one Foxy session. Other params are kept. */
40
+ export function withCartSession(cartUrl: string, id: string): string {
41
+ const url = new URL(cartUrl);
42
+ url.searchParams.set("fcsid", id);
43
+ return url.toString();
44
+ }
45
+
46
+ function saneSessionId(value: unknown): value is string {
47
+ return typeof value === "string" && CART_SESSION_ID.test(value);
48
+ }
49
+
50
+ function optionBaseName(option: unknown): string | null {
51
+ if (!option || typeof option !== "object") return null;
52
+ const raw = (option as { name?: unknown; class?: unknown }).name
53
+ ?? (option as { class?: unknown }).class;
54
+ if (typeof raw !== "string" || raw.length === 0) return null;
55
+ return raw.split("||")[0] ?? "";
56
+ }
57
+
58
+ function optionValue(option: unknown): string | null {
59
+ if (!option || typeof option !== "object") return null;
60
+ const value = (option as { value?: unknown }).value;
61
+ return typeof value === "string" ? value : null;
62
+ }
63
+
64
+ function itemTenant(item: unknown): string | null {
65
+ if (!item || typeof item !== "object") return null;
66
+ const options = (item as { options?: unknown }).options;
67
+ if (Array.isArray(options)) {
68
+ for (const option of options) {
69
+ if (optionBaseName(option) === "tenant") return optionValue(option);
70
+ }
71
+ return null;
72
+ }
73
+ if (options && typeof options === "object") {
74
+ for (const [key, value] of Object.entries(options as Record<string, unknown>)) {
75
+ if (key.split("||")[0] === "tenant" && typeof value === "string") return value;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * An empty cart is usable. A line with no tenant option, or another tenant's
83
+ * line, is not: a missed session must not check out a mixed cart.
84
+ */
85
+ export function cartAcceptsTenant(body: unknown, tenantId: string): boolean {
86
+ if (!tenantId || !body || typeof body !== "object") return false;
87
+ const items = (body as { items?: unknown }).items;
88
+ if (items == null) {
89
+ const count = (body as { item_count?: unknown }).item_count;
90
+ return count == null || count === 0;
91
+ }
92
+ if (!Array.isArray(items)) return false;
93
+ return items.every((item) => itemTenant(item) === tenantId);
94
+ }
95
+
96
+ type CartRead = { status: "cart"; body: unknown } | { status: "unreadable"; reason: string };
97
+
98
+ /** A cart Foxy returned, or a read that failed. A failure is not an empty cart. */
99
+ async function readFoxyCart(
100
+ cartUrl: string,
101
+ fcsid: string | null,
102
+ fetchImpl: typeof fetch,
103
+ ): Promise<CartRead> {
104
+ let url: URL;
105
+ try {
106
+ url = new URL(cartUrl);
107
+ } catch (error) {
108
+ return { status: "unreadable", reason: error instanceof Error ? error.name : "Error" };
109
+ }
110
+ url.searchParams.set("output", "json");
111
+ if (fcsid) url.searchParams.set("fcsid", fcsid);
112
+ try {
113
+ const response = await fetchImpl(url.toString(), {
114
+ method: "GET",
115
+ redirect: "manual",
116
+ credentials: "omit",
117
+ headers: { accept: "application/json" },
118
+ signal: AbortSignal.timeout(FOXY_TIMEOUT_MS),
119
+ });
120
+ if (!response.ok) return { status: "unreadable", reason: `HTTP ${response.status}` };
121
+ return { status: "cart", body: await response.json() };
122
+ } catch (error) {
123
+ return { status: "unreadable", reason: error instanceof Error ? error.name : "Error" };
124
+ }
125
+ }
126
+
127
+ function sessionIdOf(body: unknown): string | null {
128
+ if (!body || typeof body !== "object") return null;
129
+ const id = (body as { session_id?: unknown }).session_id;
130
+ return saneSessionId(id) ? id : null;
131
+ }
132
+
133
+ /**
134
+ * The session this tenant may add to. A sane stored id is reused only when
135
+ * Foxy echoes it and every line is this tenant. A read that fails stays closed
136
+ * and does not mint. Otherwise Foxy is asked for a new empty session, with no
137
+ * cookie and no id. Null when no usable id comes back.
138
+ */
139
+ export async function resolveTenantCartSession(input: {
140
+ cartUrl: string;
141
+ tenantId: string;
142
+ existing: string | null;
143
+ fetch?: typeof fetch;
144
+ }): Promise<string | null> {
145
+ if (!input.tenantId) return null;
146
+ const fetchImpl = input.fetch ?? fetch;
147
+ const existing = saneSessionId(input.existing) ? input.existing : null;
148
+ if (existing) {
149
+ const read = await readFoxyCart(input.cartUrl, existing, fetchImpl);
150
+ if (read.status === "unreadable") return null;
151
+ if (sessionIdOf(read.body) === existing && cartAcceptsTenant(read.body, input.tenantId)) return existing;
152
+ }
153
+ const fresh = await readFoxyCart(input.cartUrl, null, fetchImpl);
154
+ if (fresh.status !== "cart") return null;
155
+ const id = sessionIdOf(fresh.body);
156
+ if (id && cartAcceptsTenant(fresh.body, input.tenantId)) return id;
157
+ return null;
158
+ }
159
+
160
+ /** Read the tenant cookie, resolve a Foxy session, and store the id when one is usable. */
161
+ export async function bindCartSession(
162
+ context: CartSessionBinding,
163
+ commerce: { cartUrl: string },
164
+ tenantId: string,
165
+ fetchImpl?: typeof fetch,
166
+ ): Promise<string | null> {
167
+ const id = await resolveTenantCartSession({
168
+ cartUrl: commerce.cartUrl,
169
+ tenantId,
170
+ existing: context.cookies.get(CART_SESSION_COOKIE)?.value ?? null,
171
+ fetch: fetchImpl,
172
+ });
173
+ if (!id) return null;
174
+ context.cookies.set(CART_SESSION_COOKIE, id, {
175
+ httpOnly: true,
176
+ secure: true,
177
+ sameSite: "lax",
178
+ path: cartSessionCookiePath(context.locals.apiBasePath ?? context.locals.basePath ?? ""),
179
+ maxAge: CART_SESSION_MAX_AGE,
180
+ });
181
+ return id;
182
+ }
@@ -30,6 +30,8 @@ import {
30
30
  takeVerificationRef,
31
31
  withVerificationRef,
32
32
  withShippingPolicy,
33
+ withReturnUrl,
34
+ trustedCartReturnUrl,
33
35
  SHIPPING_UNAVAILABLE_CART_MESSAGE,
34
36
  ShippingPolicyUnavailableError,
35
37
  type CartDenialCode,
@@ -39,6 +41,7 @@ import {
39
41
  type ResolvedManagedCheckout,
40
42
  } from "@tot/public-runtime";
41
43
  import { resolveCheckoutHandoffCorrelation } from "@/lib/checkout/commerce/checkoutHandoffCorrelation";
44
+ import { bindCartSession, CART_UNAVAILABLE_MESSAGE, withCartSession } from "@/lib/checkout/commerce/cartSession";
42
45
  import { resolveCartSigning, shopperCartDenialMessage } from "@/lib/checkout/shared/checkoutCommerce";
43
46
 
44
47
  export type SampleProductReader = (tenant: TenantConfig, handle: string, commerce: ResolvedManagedCheckout) => Promise<CatalogProduct | null>;
@@ -108,6 +111,29 @@ async function boundShippingPolicy(
108
111
  }
109
112
  }
110
113
 
114
+ /** Redirect onto this tenant's Foxy session. A shopper-supplied fcsid is dropped. */
115
+ async function sessionedCartLocation(
116
+ context: APIContext,
117
+ commerce: ResolvedManagedCheckout,
118
+ tenant: TenantConfig,
119
+ fields: readonly CartSubmissionField[],
120
+ ): Promise<string | null> {
121
+ const fcsid = await bindCartSession(context, commerce, commerceTenantId(tenant));
122
+ if (!fcsid) return null;
123
+ const kept = fields.filter((field) => field.name.split("||")[0] !== "fcsid");
124
+ return foxyCartRedirectUrl(withCartSession(commerce.cartUrl, fcsid), kept);
125
+ }
126
+
127
+ /**
128
+ * The page the shopper submitted from, when it is served by this request's host or the tenant's
129
+ * declared domain. The browser's Referer is the only source; a foreign or absent one yields none.
130
+ */
131
+ function submittedFromUrl(request: Request, tenant: TenantConfig): string | null {
132
+ const domain = tenant.appDomain?.toLowerCase();
133
+ const hosts = [new URL(request.url).host, ...(domain ? [domain, `www.${domain}`] : [])];
134
+ return trustedCartReturnUrl(request.headers.get("referer"), hosts);
135
+ }
136
+
111
137
  /** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
112
138
  async function cartFormPost(context: APIContext): Promise<Response> {
113
139
  const { tenant, commerce } = context.locals;
@@ -145,12 +171,17 @@ async function cartFormPost(context: APIContext): Promise<Response> {
145
171
  return cartDenial(result.code, shopperCartDenialMessage(result.code, result.message), status);
146
172
  }
147
173
  const verified = await withVerificationRef(result.fields, { mode: commerce.mode, secret: signing.secret, ref });
148
- const shipped = await boundShippingPolicy(verified, tenant, commerce.mode, signing.secret);
149
- if (!shipped) return cartDenial("shipping", SHIPPING_UNAVAILABLE_CART_MESSAGE, 409);
174
+ const shippable = await boundShippingPolicy(verified, tenant, commerce.mode, signing.secret);
175
+ if (!shippable) return cartDenial("shipping", SHIPPING_UNAVAILABLE_CART_MESSAGE, 409);
176
+ const returned = await withReturnUrl(shippable, {
177
+ mode: commerce.mode, secret: signing.secret, returnUrl: submittedFromUrl(context.request, tenant),
178
+ });
179
+ const location = await sessionedCartLocation(context, commerce, tenant, returned);
180
+ if (!location) return cartDenial("signing-unavailable", CART_UNAVAILABLE_MESSAGE, 503);
150
181
  return new Response(null, {
151
182
  status: 303,
152
183
  headers: {
153
- location: foxyCartRedirectUrl(commerce.cartUrl, shipped),
184
+ location,
154
185
  "cache-control": "no-store",
155
186
  "x-tot-payment-authorized": "false",
156
187
  },
@@ -252,7 +283,9 @@ export async function sampleCartGet(
252
283
  if (!result.ok) return invalid();
253
284
  const shipped = await boundShippingPolicy(result.fields, tenant, "signed", signing.secret);
254
285
  if (!shipped) return sampleResponse('{"error":"shipping_unavailable"}', 409);
255
- return sampleResponse(null, 303, foxyCartRedirectUrl(commerce.cartUrl, shipped));
286
+ const location = await sessionedCartLocation(context, commerce, tenant, shipped);
287
+ if (!location) return sampleResponse('{"error":"cart_unavailable"}', 503);
288
+ return sampleResponse(null, 303, location);
256
289
  } catch { return sampleResponse('{"error":"sample_unavailable"}', 503); }
257
290
  }
258
291
 
@@ -22,16 +22,24 @@
22
22
  * after every page handler, so an action another handler already took over (the
23
23
  * compliance gate) is left alone.
24
24
  *
25
+ * Every cart open waits for this store's Foxy session. The id comes from the
26
+ * same-origin session endpoint (an HttpOnly cookie the browser cannot read) and
27
+ * is applied in the loader's `onLoad`, before the drawer reads its own cookie.
28
+ * Cart links get the same id on the URL. If the session cannot be opened, the
29
+ * cart is not followed.
30
+ *
25
31
  * On a store that is not public (`storeNotPublic`), a managed add-to-cart is
26
32
  * checked before it leaves the page: the checkout route refuses a viewer admitted
27
33
  * only by a preview grant, so instead of navigating to the gate's holding page
28
- * the form says checkout is unavailable here and offers sign-in. A public store
29
- * never pays that round trip.
34
+ * the form says checkout is unavailable here and offers sign-in.
30
35
  *
31
36
  * Emitted by <CheckoutLoader> with the page nonce (an attribute cannot carry
32
37
  * one), and identical on the Cloudflare-served preview and the statically
33
- * published (CloudFront/S3) output — both render Layout.astro.
38
+ * published (CloudFront/S3) output — both render Layout.astro. The session id
39
+ * is not part of that HTML.
34
40
  */
41
+ import { CART_UNAVAILABLE_MESSAGE } from "@/lib/checkout/commerce/cartSession";
42
+
35
43
  export const LAZY_CHECKOUT_LOADER_IDLE_MS = 8000;
36
44
 
37
45
  /** Upper bound on waiting for the drawer to report ready before replaying a held action. */
@@ -49,6 +57,10 @@ export interface LazyCheckoutLoaderOptions {
49
57
  loaderSrc: string;
50
58
  /** `https://<store domain>` — the cart origin whose links and forms the drawer takes over. */
51
59
  cartOrigin: string;
60
+ /** Same-origin URL that returns this store's `{ fcsid }`. A path, never an id. */
61
+ sessionHref: string;
62
+ /** Foxy browse-site cookie path for this host. `/` or a tenant prefix with a trailing slash. */
63
+ cookiePath: string;
52
64
  /** The store is not public on this host, so its checkout route may refuse this viewer. */
53
65
  storeNotPublic?: boolean;
54
66
  }
@@ -63,10 +75,14 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
63
75
  return `(function () {
64
76
  var src = ${literal(options.loaderSrc)};
65
77
  var cartOrigin = ${literal(options.cartOrigin)};
78
+ var sessionHref = ${literal(options.sessionHref)};
79
+ var cookiePath = ${literal(options.cookiePath)};
66
80
  var storeNotPublic = ${options.storeNotPublic ? "true" : "false"};
67
81
  var events = ${literal(LAZY_CHECKOUT_LOADER_EVENTS)};
68
82
  var state = 'idle';
69
83
  var waiters = [];
84
+ var sessionId = null;
85
+ var sessionPromise = null;
70
86
  function targetsCart(url) {
71
87
  if (!url) return false;
72
88
  try { return new URL(url, document.baseURI).origin === cartOrigin; } catch (e) { return false; }
@@ -80,6 +96,37 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
80
96
  }
81
97
  return false;
82
98
  }
99
+ function ensureSession() {
100
+ if (sessionId) return Promise.resolve(sessionId);
101
+ if (sessionPromise) return sessionPromise;
102
+ sessionPromise = fetch(sessionHref, {
103
+ credentials: 'same-origin',
104
+ cache: 'no-store',
105
+ signal: AbortSignal.timeout(5000),
106
+ }).then(function (res) {
107
+ return res.ok ? res.json() : null;
108
+ }).then(function (body) {
109
+ var id = body && typeof body.fcsid === 'string' ? body.fcsid : '';
110
+ if (!/^[a-z0-9]{10,64}$/.test(id)) {
111
+ sessionPromise = null;
112
+ return null;
113
+ }
114
+ sessionId = id;
115
+ return id;
116
+ }, function () {
117
+ sessionPromise = null;
118
+ return null;
119
+ });
120
+ return sessionPromise;
121
+ }
122
+ function markSessionUnavailable() {
123
+ waiters = [];
124
+ state = 'idle';
125
+ document.documentElement.setAttribute('data-cart-session', 'unavailable');
126
+ for (var i = 0; i < events.length; i++) {
127
+ document.addEventListener(events[i], onInput, { capture: true, passive: true });
128
+ }
129
+ }
83
130
  function ready() {
84
131
  if (state === 'ready') return;
85
132
  state = 'ready';
@@ -87,10 +134,15 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
87
134
  waiters = [];
88
135
  for (var i = 0; i < run.length; i++) run[i]();
89
136
  }
90
- function start() {
91
- if (state !== 'idle') return;
92
- state = 'loading';
93
- for (var i = 0; i < events.length; i++) document.removeEventListener(events[i], onInput, true);
137
+ function armDrawer(id) {
138
+ document.documentElement.removeAttribute('data-cart-session');
139
+ window.FC = window.FC || {};
140
+ window.FC.onLoad = function () {
141
+ window.FC.settings = window.FC.settings || {};
142
+ window.FC.settings.session_id = id;
143
+ window.FC.settings.cookiepath = cookiePath;
144
+ window.FC.settings.sitedomain = location.hostname;
145
+ };
94
146
  setTimeout(ready, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
95
147
  var s = document.createElement('script');
96
148
  s.src = src;
@@ -110,6 +162,15 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
110
162
  s.addEventListener('error', ready);
111
163
  document.body.appendChild(s);
112
164
  }
165
+ function start() {
166
+ if (state !== 'idle') return;
167
+ state = 'loading';
168
+ for (var i = 0; i < events.length; i++) document.removeEventListener(events[i], onInput, true);
169
+ ensureSession().then(function (id) {
170
+ if (!id) return markSessionUnavailable();
171
+ armDrawer(id);
172
+ });
173
+ }
113
174
  function whenReady(fn) {
114
175
  if (state === 'ready') return fn();
115
176
  waiters.push(fn);
@@ -124,14 +185,40 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
124
185
  function arm() { setTimeout(start, ${LAZY_CHECKOUT_LOADER_IDLE_MS}); }
125
186
  if (document.readyState === 'complete') arm();
126
187
  else window.addEventListener('load', arm);
188
+ function stampAnchor(anchor) {
189
+ if (!sessionId) return;
190
+ try {
191
+ var url = new URL(anchor.href, document.baseURI);
192
+ url.searchParams.set('fcsid', sessionId);
193
+ anchor.href = url.toString();
194
+ } catch (e) {}
195
+ }
196
+ function stampForm(form) {
197
+ if (!sessionId || !targetsCart(form.getAttribute('action'))) return;
198
+ var input = form.querySelector('input[name="fcsid"]');
199
+ if (!input) {
200
+ input = document.createElement('input');
201
+ input.type = 'hidden';
202
+ input.name = 'fcsid';
203
+ form.appendChild(input);
204
+ }
205
+ input.value = sessionId;
206
+ }
127
207
 
128
208
  window.addEventListener('click', function (event) {
129
- if (state === 'ready' || event.defaultPrevented) return;
130
209
  if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
131
210
  var anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null;
132
211
  if (!anchor || anchor.target === '_blank' || !targetsCart(anchor.href)) return;
212
+ if (state === 'ready') {
213
+ stampAnchor(anchor);
214
+ return;
215
+ }
216
+ if (event.defaultPrevented) return;
133
217
  event.preventDefault();
134
- whenReady(function () { anchor.click(); });
218
+ whenReady(function () {
219
+ stampAnchor(anchor);
220
+ anchor.click();
221
+ });
135
222
  });
136
223
 
137
224
  function resubmit(form) {
@@ -156,19 +243,51 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
156
243
  note.appendChild(link);
157
244
  form.insertAdjacentElement('afterend', note);
158
245
  }
159
- function proceed(form) {
160
- form.dataset.checkoutReachable = '1';
161
- resubmit(form);
246
+ function showCartUnavailable(form) {
247
+ var next = form.nextElementSibling;
248
+ if (next && next.hasAttribute('data-cart-unavailable')) return;
249
+ var note = document.createElement('p');
250
+ note.setAttribute('role', 'alert');
251
+ note.setAttribute('data-cart-unavailable', '');
252
+ note.className = 'mt-2 text-sm';
253
+ note.textContent = ${literal(CART_UNAVAILABLE_MESSAGE)};
254
+ form.insertAdjacentElement('afterend', note);
255
+ }
256
+ function openSessionThen(form, fn) {
257
+ ensureSession().then(function (id) {
258
+ if (!id) { showCartUnavailable(form); return; }
259
+ form.dataset.cartSession = '1';
260
+ fn();
261
+ });
162
262
  }
163
263
  window.addEventListener('submit', function (event) {
164
264
  if (event.defaultPrevented) return;
165
265
  var form = event.target;
166
266
  if (!form || typeof form.getAttribute !== 'function') return;
167
- if (storeNotPublic && form.matches(${managedCart}) && form.dataset.checkoutReachable !== '1') {
267
+ var managed = form.matches(${managedCart});
268
+ var cartForm = targetsCart(form.getAttribute('action'));
269
+ if (!managed && !cartForm) return;
270
+ if (form.dataset.cartSession === '1') {
271
+ if (!cartForm || state === 'ready') return;
168
272
  event.preventDefault();
273
+ whenReady(function () { stampForm(form); resubmit(form); });
274
+ return;
275
+ }
276
+ event.preventDefault();
277
+ function afterGate() {
278
+ openSessionThen(form, function () {
279
+ if (cartForm) {
280
+ stampForm(form);
281
+ whenReady(function () { resubmit(form); });
282
+ return;
283
+ }
284
+ resubmit(form);
285
+ });
286
+ }
287
+ if (storeNotPublic && managed && form.dataset.checkoutReachable !== '1') {
169
288
  // The handoff route's JSON form only mints an unattributed correlation, so it answers
170
289
  // "may this viewer reach checkout?" without starting one. Any other answer, or no
171
- // answer, submits natively and the route decides.
290
+ // answer, continues to this store's session and the route decides.
172
291
  fetch(form.action, {
173
292
  method: 'POST',
174
293
  headers: { 'content-type': 'application/json' },
@@ -179,13 +298,15 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
179
298
  return res.json().then(function (body) { return body && body.signIn; }, function () { return null; })
180
299
  .then(function (signIn) { showUnavailable(form, signIn); });
181
300
  }
182
- proceed(form);
183
- }, function () { proceed(form); });
301
+ form.dataset.checkoutReachable = '1';
302
+ afterGate();
303
+ }, function () {
304
+ form.dataset.checkoutReachable = '1';
305
+ afterGate();
306
+ });
184
307
  return;
185
308
  }
186
- if (state === 'ready' || !targetsCart(form.getAttribute('action'))) return;
187
- event.preventDefault();
188
- whenReady(function () { resubmit(form); });
309
+ afterGate();
189
310
  });
190
311
  })();`;
191
312
  }
@@ -0,0 +1,25 @@
1
+ /** This store's Foxy session id. The id is never written into a page. */
2
+ import type { APIRoute } from "astro";
3
+ import { commerceTenantId } from "@tot/public-runtime";
4
+ import { bindCartSession } from "@/lib/checkout/commerce/cartSession";
5
+
6
+ export const prerender = false;
7
+
8
+ function json(body: unknown, status: number): Response {
9
+ return new Response(JSON.stringify(body), {
10
+ status,
11
+ headers: {
12
+ "content-type": "application/json; charset=utf-8",
13
+ "cache-control": "no-store",
14
+ },
15
+ });
16
+ }
17
+
18
+ export const GET: APIRoute = async (context) => {
19
+ const { commerce, tenant } = context.locals;
20
+ if (!commerce) return json({ error: "no-store" }, 409);
21
+ const tenantId = tenant ? commerceTenantId(tenant) : "";
22
+ const fcsid = tenantId ? await bindCartSession(context, commerce, tenantId) : null;
23
+ if (!fcsid) return json({ error: "cart-unavailable" }, 503);
24
+ return json({ fcsid }, 200);
25
+ };
@@ -1,4 +1,6 @@
1
1
  import type { APIRoute } from "astro";
2
+ import { commerceTenantId } from "@tot/public-runtime";
3
+ import { bindCartSession, CART_UNAVAILABLE_MESSAGE, withCartSession } from "@/lib/checkout/commerce/cartSession.js";
2
4
  import { managedCartHref } from "@/lib/content/chrome/managedCart.js";
3
5
 
4
6
  export const prerender = false;
@@ -8,15 +10,27 @@ export const prerender = false;
8
10
  * Chrome now links straight to managed checkout, but this route prevents stale
9
11
  * links from falling through the content catch-all to a misleading storefront
10
12
  * 404. Tenant resolution and preview authorization have already run in
11
- * middleware before this redirect is selected.
13
+ * middleware before this redirect is selected. The redirect carries this
14
+ * tenant's Foxy session and never the shared cart.
12
15
  */
13
- export const GET: APIRoute = ({ locals }) => {
14
- const location = managedCartHref(locals.commerce);
15
- if (!location) return new Response(null, { status: 404 });
16
+ export const GET: APIRoute = async (context) => {
17
+ const location = managedCartHref(context.locals.commerce);
18
+ if (!location || !context.locals.commerce) return new Response(null, { status: 404 });
19
+ const tenantId = context.locals.tenant ? commerceTenantId(context.locals.tenant) : "";
20
+ const sessionId = tenantId ? await bindCartSession(context, context.locals.commerce, tenantId) : null;
21
+ if (!sessionId) {
22
+ return new Response(CART_UNAVAILABLE_MESSAGE, {
23
+ status: 503,
24
+ headers: {
25
+ "cache-control": "private, no-store",
26
+ "content-type": "text/plain; charset=utf-8",
27
+ },
28
+ });
29
+ }
16
30
  return new Response(null, {
17
31
  status: 302,
18
32
  headers: {
19
- location,
33
+ location: withCartSession(location, sessionId),
20
34
  "cache-control": "private, no-store",
21
35
  },
22
36
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.9.2",
3
+ "version": "3.10.1",
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",
@@ -204,9 +204,13 @@ export class CartSigningUnavailableError extends Error {
204
204
  * item option: `enforceCartSubmission` and the commerce pre-payment callback
205
205
  * both bind the cart to it. `verification` carries the shopper's verification
206
206
  * ref when the gate produced one (`withVerificationRef`). `shipping_policy` carries the
207
- * store's declared shipping charge (`withShippingPolicy`).
207
+ * store's declared shipping charge (`withShippingPolicy`). `return_url` carries the page the
208
+ * shopper submitted from (`withReturnUrl`).
208
209
  */
209
- export const INTERNAL_CART_FIELDS = ["currency", "tenant", "verification", "shipping_policy"] as const;
210
+ export const INTERNAL_CART_FIELDS = ["currency", "tenant", "verification", "shipping_policy", "return_url"] as const;
211
+
212
+ /** The signed cart option the checkout store's Continue Shopping link reads (`withReturnUrl`). */
213
+ export const RETURN_URL_CART_FIELD = "return_url";
210
214
 
211
215
  /** The signed cart option the checkout store reads the shipping charge from. */
212
216
  export const SHIPPING_POLICY_CART_FIELD = "shipping_policy";
@@ -379,6 +383,8 @@ const DEFAULT_CHECKOUT_HOST_SUFFIX = ".foxycart.com";
379
383
  const SUBSCRIPTION_FREQUENCY_RE = /^(?:\d+(?:\.\d+)?|\.\d+)[dwmy]$/;
380
384
  const PREVIEW_PIN_RE = /\/preview\/(?:staged|candidate)(?:\/|$)/;
381
385
  const UNSIGNED_PREFIXES = ["h:", "x:", "__", "utm_"] as const;
386
+ /** Cart-level selector. Not a product field, so it never enters the signature. */
387
+ const CART_LEVEL_PARAMS = new Set(["fcsid"]);
382
388
 
383
389
  /**
384
390
  * Cart line name. The product title is the line. A multi-variant product appends
@@ -752,7 +758,7 @@ interface ParsedCartField {
752
758
  }
753
759
 
754
760
  function isPassthroughName(name: string): boolean {
755
- return UNSIGNED_PREFIXES.some((prefix) => name.startsWith(prefix));
761
+ return CART_LEVEL_PARAMS.has(name) || UNSIGNED_PREFIXES.some((prefix) => name.startsWith(prefix));
756
762
  }
757
763
 
758
764
  function parseSubmittedField(rawName: string, value: string): ParsedCartField {
@@ -930,6 +936,51 @@ export async function withShippingPolicy(
930
936
  return [...kept, await signField(opts.secret, code, field)];
931
937
  }
932
938
 
939
+ const MAX_RETURN_URL_LENGTH = 1500;
940
+
941
+ /**
942
+ * The page a cart was submitted from, or `null` when it is not one this store serves. Only an
943
+ * http(s) URL without credentials whose host is in `servedHosts` qualifies, so a form posted from
944
+ * another site, or a crafted cart link, can never make the checkout domain link to a foreign
945
+ * origin. The fragment is dropped. `servedHosts` comes from the request's own host and the
946
+ * tenant's declared domain, never from the client.
947
+ */
948
+ export function trustedCartReturnUrl(
949
+ candidate: string | null | undefined,
950
+ servedHosts: readonly string[],
951
+ ): string | null {
952
+ if (!candidate || candidate.length > MAX_RETURN_URL_LENGTH) return null;
953
+ if (!URL.canParse(candidate)) return null;
954
+ const url = new URL(candidate);
955
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
956
+ if (url.username || url.password) return null;
957
+ const served = servedHosts.map((host) => host.trim().toLowerCase()).filter(Boolean);
958
+ if (!served.includes(url.host.toLowerCase())) return null;
959
+ url.hash = "";
960
+ const href = url.toString();
961
+ return href.length <= MAX_RETURN_URL_LENGTH ? href : null;
962
+ }
963
+
964
+ /**
965
+ * Bind the originating page into an enforced cart as the internal `return_url` option, signed
966
+ * against the cart's product code. Any `return_url` the shopper submitted is dropped first, so only
967
+ * the server-derived, host-checked value reaches the cart. No trusted URL leaves the cart without
968
+ * one, and the checkout falls back to its own Continue Shopping target.
969
+ */
970
+ export async function withReturnUrl(
971
+ fields: readonly CartSubmissionField[],
972
+ opts: { mode: ManagedCheckoutConfig["mode"]; secret?: string; returnUrl: string | null },
973
+ ): Promise<CartSubmissionField[]> {
974
+ const kept = fields.filter((field) => field.name.split("||")[0] !== RETURN_URL_CART_FIELD);
975
+ if (!opts.returnUrl) return kept;
976
+ const code = kept.find((field) => field.name.split("||")[0] === "code")?.value;
977
+ if (!code) return kept;
978
+ const field: CartField = { name: RETURN_URL_CART_FIELD, value: opts.returnUrl };
979
+ if (opts.mode !== "signed") return [...kept, field];
980
+ if (!opts.secret) throw new CartSigningUnavailableError();
981
+ return [...kept, await signField(opts.secret, code, field)];
982
+ }
983
+
933
984
  /** Cart URL the browser may navigate to after enforcement. Not a payment. */
934
985
  export function foxyCartRedirectUrl(
935
986
  cartUrl: string,