@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.
- 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 +37 -4
- 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/src/checkout.ts +54 -3
|
@@ -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
|
|
149
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,
|