@tokenoftrust/storefront-runner 3.10.0 → 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
+ }
@@ -41,6 +41,7 @@ import {
41
41
  type ResolvedManagedCheckout,
42
42
  } from "@tot/public-runtime";
43
43
  import { resolveCheckoutHandoffCorrelation } from "@/lib/checkout/commerce/checkoutHandoffCorrelation";
44
+ import { bindCartSession, CART_UNAVAILABLE_MESSAGE, withCartSession } from "@/lib/checkout/commerce/cartSession";
44
45
  import { resolveCartSigning, shopperCartDenialMessage } from "@/lib/checkout/shared/checkoutCommerce";
45
46
 
46
47
  export type SampleProductReader = (tenant: TenantConfig, handle: string, commerce: ResolvedManagedCheckout) => Promise<CatalogProduct | null>;
@@ -110,6 +111,19 @@ async function boundShippingPolicy(
110
111
  }
111
112
  }
112
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
+
113
127
  /**
114
128
  * The page the shopper submitted from, when it is served by this request's host or the tenant's
115
129
  * declared domain. The browser's Referer is the only source; a foreign or absent one yields none.
@@ -159,13 +173,15 @@ async function cartFormPost(context: APIContext): Promise<Response> {
159
173
  const verified = await withVerificationRef(result.fields, { mode: commerce.mode, secret: signing.secret, ref });
160
174
  const shippable = await boundShippingPolicy(verified, tenant, commerce.mode, signing.secret);
161
175
  if (!shippable) return cartDenial("shipping", SHIPPING_UNAVAILABLE_CART_MESSAGE, 409);
162
- const shipped = await withReturnUrl(shippable, {
176
+ const returned = await withReturnUrl(shippable, {
163
177
  mode: commerce.mode, secret: signing.secret, returnUrl: submittedFromUrl(context.request, tenant),
164
178
  });
179
+ const location = await sessionedCartLocation(context, commerce, tenant, returned);
180
+ if (!location) return cartDenial("signing-unavailable", CART_UNAVAILABLE_MESSAGE, 503);
165
181
  return new Response(null, {
166
182
  status: 303,
167
183
  headers: {
168
- location: foxyCartRedirectUrl(commerce.cartUrl, shipped),
184
+ location,
169
185
  "cache-control": "no-store",
170
186
  "x-tot-payment-authorized": "false",
171
187
  },
@@ -267,7 +283,9 @@ export async function sampleCartGet(
267
283
  if (!result.ok) return invalid();
268
284
  const shipped = await boundShippingPolicy(result.fields, tenant, "signed", signing.secret);
269
285
  if (!shipped) return sampleResponse('{"error":"shipping_unavailable"}', 409);
270
- 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);
271
289
  } catch { return sampleResponse('{"error":"sample_unavailable"}', 503); }
272
290
  }
273
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.10.0",
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",
@@ -383,6 +383,8 @@ const DEFAULT_CHECKOUT_HOST_SUFFIX = ".foxycart.com";
383
383
  const SUBSCRIPTION_FREQUENCY_RE = /^(?:\d+(?:\.\d+)?|\.\d+)[dwmy]$/;
384
384
  const PREVIEW_PIN_RE = /\/preview\/(?:staged|candidate)(?:\/|$)/;
385
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"]);
386
388
 
387
389
  /**
388
390
  * Cart line name. The product title is the line. A multi-variant product appends
@@ -756,7 +758,7 @@ interface ParsedCartField {
756
758
  }
757
759
 
758
760
  function isPassthroughName(name: string): boolean {
759
- return UNSIGNED_PREFIXES.some((prefix) => name.startsWith(prefix));
761
+ return CART_LEVEL_PARAMS.has(name) || UNSIGNED_PREFIXES.some((prefix) => name.startsWith(prefix));
760
762
  }
761
763
 
762
764
  function parseSubmittedField(rawName: string, value: string): ParsedCartField {