@tokenoftrust/storefront-runner 3.9.1 → 3.10.0
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.
|
@@ -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,
|
|
@@ -108,6 +110,16 @@ async function boundShippingPolicy(
|
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The page the shopper submitted from, when it is served by this request's host or the tenant's
|
|
115
|
+
* declared domain. The browser's Referer is the only source; a foreign or absent one yields none.
|
|
116
|
+
*/
|
|
117
|
+
function submittedFromUrl(request: Request, tenant: TenantConfig): string | null {
|
|
118
|
+
const domain = tenant.appDomain?.toLowerCase();
|
|
119
|
+
const hosts = [new URL(request.url).host, ...(domain ? [domain, `www.${domain}`] : [])];
|
|
120
|
+
return trustedCartReturnUrl(request.headers.get("referer"), hosts);
|
|
121
|
+
}
|
|
122
|
+
|
|
111
123
|
/** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
|
|
112
124
|
async function cartFormPost(context: APIContext): Promise<Response> {
|
|
113
125
|
const { tenant, commerce } = context.locals;
|
|
@@ -145,8 +157,11 @@ async function cartFormPost(context: APIContext): Promise<Response> {
|
|
|
145
157
|
return cartDenial(result.code, shopperCartDenialMessage(result.code, result.message), status);
|
|
146
158
|
}
|
|
147
159
|
const verified = await withVerificationRef(result.fields, { mode: commerce.mode, secret: signing.secret, ref });
|
|
148
|
-
const
|
|
149
|
-
if (!
|
|
160
|
+
const shippable = await boundShippingPolicy(verified, tenant, commerce.mode, signing.secret);
|
|
161
|
+
if (!shippable) return cartDenial("shipping", SHIPPING_UNAVAILABLE_CART_MESSAGE, 409);
|
|
162
|
+
const shipped = await withReturnUrl(shippable, {
|
|
163
|
+
mode: commerce.mode, secret: signing.secret, returnUrl: submittedFromUrl(context.request, tenant),
|
|
164
|
+
});
|
|
150
165
|
return new Response(null, {
|
|
151
166
|
status: 303,
|
|
152
167
|
headers: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
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";
|
|
@@ -331,12 +335,15 @@ export type CartFieldVisibility = "native" | "internal" | "visible" | "variant-o
|
|
|
331
335
|
|
|
332
336
|
/**
|
|
333
337
|
* Characters the checkout store does not keep in a field name, each mapped to a look-alike it
|
|
334
|
-
* keeps. The store
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* non-
|
|
338
|
+
* keeps. The store hashes `code + name + value` with the name as it received it
|
|
339
|
+
* (https://wiki.foxycart.com/v/2.0/hmac_validation), and it receives request names through PHP,
|
|
340
|
+
* which converts "dots and spaces in variable names ... to underscores"
|
|
341
|
+
* (https://www.php.net/manual/en/language.variables.external.php, "HTML Forms (GET and POST)").
|
|
342
|
+
* Not documented, measured 2026-10-23 against tot-preview.foxycart.com by submitting each name
|
|
343
|
+
* with a non-matching hash and reading it back from the "Cart Validation Error: <name>" refusal:
|
|
344
|
+
* `[` also becomes `_` (a `[…]` pair turns the field into an array), leading spaces are dropped,
|
|
345
|
+
* `||` ends the name, and a name holding `&` or `"` is never validated. Every other character
|
|
346
|
+
* (NBSP, `/`, `'`, `+`, `-`, `#`, `=`, `%`, `,`, `:`, a single `|`, non-ASCII) came back unchanged.
|
|
340
347
|
*/
|
|
341
348
|
const CHECKOUT_NAME_LOOKALIKES: Readonly<Record<string, string>> = {
|
|
342
349
|
" ": " ",
|
|
@@ -927,6 +934,51 @@ export async function withShippingPolicy(
|
|
|
927
934
|
return [...kept, await signField(opts.secret, code, field)];
|
|
928
935
|
}
|
|
929
936
|
|
|
937
|
+
const MAX_RETURN_URL_LENGTH = 1500;
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The page a cart was submitted from, or `null` when it is not one this store serves. Only an
|
|
941
|
+
* http(s) URL without credentials whose host is in `servedHosts` qualifies, so a form posted from
|
|
942
|
+
* another site, or a crafted cart link, can never make the checkout domain link to a foreign
|
|
943
|
+
* origin. The fragment is dropped. `servedHosts` comes from the request's own host and the
|
|
944
|
+
* tenant's declared domain, never from the client.
|
|
945
|
+
*/
|
|
946
|
+
export function trustedCartReturnUrl(
|
|
947
|
+
candidate: string | null | undefined,
|
|
948
|
+
servedHosts: readonly string[],
|
|
949
|
+
): string | null {
|
|
950
|
+
if (!candidate || candidate.length > MAX_RETURN_URL_LENGTH) return null;
|
|
951
|
+
if (!URL.canParse(candidate)) return null;
|
|
952
|
+
const url = new URL(candidate);
|
|
953
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
954
|
+
if (url.username || url.password) return null;
|
|
955
|
+
const served = servedHosts.map((host) => host.trim().toLowerCase()).filter(Boolean);
|
|
956
|
+
if (!served.includes(url.host.toLowerCase())) return null;
|
|
957
|
+
url.hash = "";
|
|
958
|
+
const href = url.toString();
|
|
959
|
+
return href.length <= MAX_RETURN_URL_LENGTH ? href : null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Bind the originating page into an enforced cart as the internal `return_url` option, signed
|
|
964
|
+
* against the cart's product code. Any `return_url` the shopper submitted is dropped first, so only
|
|
965
|
+
* the server-derived, host-checked value reaches the cart. No trusted URL leaves the cart without
|
|
966
|
+
* one, and the checkout falls back to its own Continue Shopping target.
|
|
967
|
+
*/
|
|
968
|
+
export async function withReturnUrl(
|
|
969
|
+
fields: readonly CartSubmissionField[],
|
|
970
|
+
opts: { mode: ManagedCheckoutConfig["mode"]; secret?: string; returnUrl: string | null },
|
|
971
|
+
): Promise<CartSubmissionField[]> {
|
|
972
|
+
const kept = fields.filter((field) => field.name.split("||")[0] !== RETURN_URL_CART_FIELD);
|
|
973
|
+
if (!opts.returnUrl) return kept;
|
|
974
|
+
const code = kept.find((field) => field.name.split("||")[0] === "code")?.value;
|
|
975
|
+
if (!code) return kept;
|
|
976
|
+
const field: CartField = { name: RETURN_URL_CART_FIELD, value: opts.returnUrl };
|
|
977
|
+
if (opts.mode !== "signed") return [...kept, field];
|
|
978
|
+
if (!opts.secret) throw new CartSigningUnavailableError();
|
|
979
|
+
return [...kept, await signField(opts.secret, code, field)];
|
|
980
|
+
}
|
|
981
|
+
|
|
930
982
|
/** Cart URL the browser may navigate to after enforcement. Not a payment. */
|
|
931
983
|
export function foxyCartRedirectUrl(
|
|
932
984
|
cartUrl: string,
|