@tokenoftrust/storefront-runner 3.10.0 → 3.10.2
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.
- package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +5 -1
- package/apps/storefront/src/lib/checkout/commerce/cartSession.ts +182 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +21 -3
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +140 -19
- package/apps/storefront/src/pages/api/commerce/cart-session.ts +25 -0
- package/apps/storefront/src/pages/cart.ts +19 -5
- package/package.json +1 -1
- package/packages/public-runtime/scripts/generate-managed-analytics-api-v1-schemas.mjs +3 -1
- package/packages/public-runtime/src/checkout.ts +3 -1
- package/scripts/lib/step-cpus.cjs +35 -11
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 () {
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
183
|
-
|
|
301
|
+
form.dataset.checkoutReachable = '1';
|
|
302
|
+
afterGate();
|
|
303
|
+
}, function () {
|
|
304
|
+
form.dataset.checkoutReachable = '1';
|
|
305
|
+
afterGate();
|
|
306
|
+
});
|
|
184
307
|
return;
|
|
185
308
|
}
|
|
186
|
-
|
|
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 = (
|
|
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.
|
|
3
|
+
"version": "3.10.2",
|
|
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",
|
|
@@ -79,7 +79,9 @@ export function schemaCheckVerdict({ count, stale, orphans, foldGate }) {
|
|
|
79
79
|
|
|
80
80
|
async function check() {
|
|
81
81
|
const { files, stale, orphans } = await findSchemaDrift();
|
|
82
|
-
const
|
|
82
|
+
const phase = process.env.TOT_GATE_PHASE;
|
|
83
|
+
const foldGate = process.env.TOT_FOLD_GATE === "1" || phase === "fold" || phase === "prefold";
|
|
84
|
+
const verdict = schemaCheckVerdict({ count: files.size, stale, orphans, foldGate });
|
|
83
85
|
for (const line of verdict.lines) (verdict.ok ? console.log : console.error)(line);
|
|
84
86
|
if (!verdict.ok) process.exitCode = 1;
|
|
85
87
|
}
|
|
@@ -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 {
|
|
@@ -1,25 +1,49 @@
|
|
|
1
|
-
// The CPU budget of the check step this process runs in.
|
|
2
|
-
//
|
|
3
|
-
// TOT_STEP_CPUS
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// The CPU budget of the check step this process runs in.
|
|
2
|
+
//
|
|
3
|
+
// TOT_STEP_CPUS, when set, is that budget. tot-gate does not set it: while TOT_GATE_PHASE is
|
|
4
|
+
// set and TOT_STEP_CPUS is unset, the budget is the cgroup v2 cpu.max limit (the same quota
|
|
5
|
+
// tot-gate uses for its slot count), or null when the cgroup sets no limit. A hand run, with
|
|
6
|
+
// neither set, leaves every pool at its own default.
|
|
6
7
|
//
|
|
7
8
|
// CommonJS so the in-memory `.cts` vitest configs can require it as well as ESM can import it.
|
|
8
9
|
"use strict";
|
|
9
10
|
|
|
11
|
+
const { readFileSync } = require("node:fs");
|
|
12
|
+
|
|
10
13
|
const STEP_CPUS_ENV = "TOT_STEP_CPUS";
|
|
11
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The CPU count a cgroup v2 cpu.max allows ("<quota> <period>", or "max <period>"), or null
|
|
17
|
+
* when it sets no limit. Same arithmetic as tot-gate's cgroupCpuLimit.
|
|
18
|
+
* @param {string | null | undefined} cpuMax
|
|
19
|
+
* @returns {number | null}
|
|
20
|
+
*/
|
|
21
|
+
function cgroupCpuLimit(cpuMax) {
|
|
22
|
+
const match = /^(\d+|max)\s+(\d+)\s*$/.exec((cpuMax ?? "").trim());
|
|
23
|
+
if (!match || match[1] === "max" || Number(match[2]) === 0) return null;
|
|
24
|
+
return Math.max(1, Math.ceil(Number(match[1]) / Number(match[2])));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readCpuMax() {
|
|
28
|
+
try { return readFileSync("/sys/fs/cgroup/cpu.max", "utf8"); } catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
12
31
|
/**
|
|
13
32
|
* The step's CPU budget, or null outside a budgeted step.
|
|
33
|
+
* `cpuMax` overrides the cgroup file (tests); omit it to read `/sys/fs/cgroup/cpu.max`.
|
|
14
34
|
* @param {NodeJS.ProcessEnv} [env]
|
|
35
|
+
* @param {string | null} [cpuMax]
|
|
15
36
|
* @returns {number | null}
|
|
16
37
|
*/
|
|
17
|
-
function stepCpus(env = process.env) {
|
|
38
|
+
function stepCpus(env = process.env, cpuMax) {
|
|
18
39
|
const raw = env[STEP_CPUS_ENV];
|
|
19
|
-
if (raw
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
40
|
+
if (raw !== undefined && raw !== "") {
|
|
41
|
+
const cpus = Number(raw);
|
|
42
|
+
if (!Number.isInteger(cpus) || cpus < 1) throw new Error(`${STEP_CPUS_ENV} must be a positive integer, got "${raw}"`);
|
|
43
|
+
return cpus;
|
|
44
|
+
}
|
|
45
|
+
if (!env.TOT_GATE_PHASE) return null;
|
|
46
|
+
return cgroupCpuLimit(cpuMax === undefined ? readCpuMax() : cpuMax);
|
|
23
47
|
}
|
|
24
48
|
|
|
25
49
|
/**
|
|
@@ -45,4 +69,4 @@ function nodeTestFlags(flags, env = process.env) {
|
|
|
45
69
|
return [...flags, `--test-concurrency=${cpus}`];
|
|
46
70
|
}
|
|
47
71
|
|
|
48
|
-
module.exports = { STEP_CPUS_ENV, stepCpus, stepWorkers, nodeTestFlags };
|
|
72
|
+
module.exports = { STEP_CPUS_ENV, cgroupCpuLimit, stepCpus, stepWorkers, nodeTestFlags };
|