@tokenoftrust/storefront-runner 6.4.0 → 6.5.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.
- package/apps/storefront/src/lib/assets/preview-pinned-image-urls.ts +29 -3
- package/apps/storefront/src/lib/checkout/commerce/cardCartForm.ts +2 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +16 -0
- package/apps/storefront/src/pages/products/[handle].astro +2 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/checkout.ts +73 -11
- package/packages/public-runtime/src/membership.ts +3 -0
|
@@ -9,11 +9,37 @@ export async function scopePreviewPinnedImageUrls(response: Response, basePath:
|
|
|
9
9
|
return new Response(scoped, { status: response.status, statusText: response.statusText, headers });
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
const CAPTURED_ENTRY = String.raw`\/_a\/([0-9a-f]{64}(?:\.[A-Za-z0-9]+)?)`;
|
|
13
|
+
/** A URL-bearing attribute and its quoted or bare value. Form data (`value=`, `data-*`) never matches. */
|
|
14
|
+
const URL_ATTRIBUTE = /(\s(?:src|srcset|imagesrcset|xlink:href|href|poster)\s*=\s*)("[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
|
15
|
+
/** A captured address that opens a URL or a srcset candidate inside an attribute value. */
|
|
16
|
+
const ATTRIBUTE_ENTRY = new RegExp(String.raw`(^["']?|[\s,])${CAPTURED_ENTRY}(?=[\s,"'?#]|$)`, "g");
|
|
17
|
+
/** A CSS `url(...)` reference, in a style element or a style attribute. */
|
|
18
|
+
const CSS_URL_ENTRY = new RegExp(String.raw`(url\(\s*["']?)${CAPTURED_ENTRY}(?=["'?#)\s])`, "gi");
|
|
19
|
+
/** A CSS `image-set(...)` / `image(...)` body, whose candidates may be bare quoted strings. */
|
|
20
|
+
const CSS_IMAGE_FUNCTION = /((?<![\w-])(?:-webkit-)?image(?:-set)?\()([^()]*(?:\([^()]*\)[^()]*)*)\)/g;
|
|
21
|
+
/** A quoted captured address inside an image function body. */
|
|
22
|
+
const QUOTED_ENTRY = new RegExp(String.raw`(["'])${CAPTURED_ENTRY}(?=["'?#])`, "g");
|
|
23
|
+
/** A social-image `<meta>` tag (og:image, twitter:image and their URL variants). */
|
|
24
|
+
const IMAGE_META = /<meta\b(?=[^>]*\b(?:property|name)\s*=\s*["']?(?:og|twitter):image(?::url|:secure_url|:src)?["'\s>/])[^>]*>/gi;
|
|
25
|
+
/** The `content` attribute of a tag and its quoted or bare value. */
|
|
26
|
+
const CONTENT_ATTRIBUTE = /(\scontent\s*=\s*)("[^"]*"|'[^']*'|[^\s"'=<>`]+)/i;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scope only exact captured-image addresses in URL-bearing attributes (src, srcset, href,
|
|
30
|
+
* xlink:href, poster, a social-image meta `content`) and CSS url() / image-set() / image().
|
|
31
|
+
* Platform assets, external URLs and every other attribute — signed form values included — are
|
|
32
|
+
* left byte-for-byte.
|
|
33
|
+
*/
|
|
13
34
|
export function prefixPreviewPinnedImageUrls(html: string, basePath: string): string {
|
|
14
35
|
if (!basePath) return html;
|
|
15
|
-
|
|
16
|
-
|
|
36
|
+
const scope = (_match: string, before: string, entry: string) => `${before}${basePath}/_a/${entry}`;
|
|
37
|
+
const scopeValue = (_match: string, name: string, value: string) => `${name}${value.replace(ATTRIBUTE_ENTRY, scope)}`;
|
|
38
|
+
return html
|
|
39
|
+
.replace(URL_ATTRIBUTE, scopeValue)
|
|
40
|
+
.replace(IMAGE_META, (tag) => tag.replace(CONTENT_ATTRIBUTE, scopeValue))
|
|
41
|
+
.replace(CSS_URL_ENTRY, scope)
|
|
42
|
+
.replace(CSS_IMAGE_FUNCTION, (_match, open: string, body: string) => `${open}${body.replace(QUOTED_ENTRY, scope)})`);
|
|
17
43
|
}
|
|
18
44
|
|
|
19
45
|
/** Choose asset routing from the resolved request tenant, never a second host-membership check. */
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
sampleVariantCartPayload,
|
|
16
16
|
visiblePurchaseOptions,
|
|
17
17
|
isAutopilotEnabled,
|
|
18
|
+
tenantPublicOrigin,
|
|
18
19
|
workerCartApiPath,
|
|
19
20
|
} from "@tot/public-runtime";
|
|
20
21
|
import { isLocalSampleCheckout } from "@/config/declaredTenantConfig";
|
|
@@ -69,6 +70,7 @@ export async function buildCardCartForm(input: {
|
|
|
69
70
|
tenantId: commerceTenantId(input.tenant),
|
|
70
71
|
currency: input.tenant.currency,
|
|
71
72
|
contentVersionId: input.environment === "live" ? undefined : input.contentVersionId,
|
|
73
|
+
publicOrigin: tenantPublicOrigin(input.tenant),
|
|
72
74
|
});
|
|
73
75
|
const provider = input.tenant.serving?.provider === "s3-overwrite" ? "s3-overwrite" : "worker-r2-pointer";
|
|
74
76
|
return {
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
withShippingPolicy,
|
|
33
33
|
withReturnUrl,
|
|
34
34
|
trustedCartReturnUrl,
|
|
35
|
+
tenantPublicOrigin,
|
|
35
36
|
CHECKOUT_NAVIGATION_REFERRER_POLICY,
|
|
36
37
|
SHIPPING_UNAVAILABLE_CART_MESSAGE,
|
|
37
38
|
ShippingPolicyUnavailableError,
|
|
@@ -174,6 +175,19 @@ function submittedFromUrl(request: Request, tenant: TenantConfig): string | null
|
|
|
174
175
|
return trustedCartReturnUrl(request.headers.get("referer"), hosts);
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The operator record of a refused cart: tenant, stage, denial code and the base names of the
|
|
180
|
+
* signed fields that failed. Never a field value, hash, the secret or any shopper data.
|
|
181
|
+
*/
|
|
182
|
+
function logCartRefusal(context: APIContext, code: CartDenialCode, failedFields: readonly string[]): void {
|
|
183
|
+
console.warn(`[storefront] checkout-handoff refused ${JSON.stringify({
|
|
184
|
+
tenant: context.locals.tenant.tenant_id,
|
|
185
|
+
stage: context.locals.browserEnvironment?.environment ?? "unknown",
|
|
186
|
+
code,
|
|
187
|
+
failedFields,
|
|
188
|
+
})}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
177
191
|
/** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
|
|
178
192
|
async function cartFormPost(context: APIContext): Promise<Response> {
|
|
179
193
|
const { tenant, commerce } = context.locals;
|
|
@@ -207,6 +221,7 @@ async function cartFormPost(context: APIContext): Promise<Response> {
|
|
|
207
221
|
trustedTenantId: commerceTenantId(tenant),
|
|
208
222
|
});
|
|
209
223
|
if (!result.ok) {
|
|
224
|
+
logCartRefusal(context, result.code, result.failedFields);
|
|
210
225
|
const status = result.code === "signing-unavailable" ? 503 : 422;
|
|
211
226
|
return cartDenial(result.code, shopperCartDenialMessage(result.code, result.message), status);
|
|
212
227
|
}
|
|
@@ -333,6 +348,7 @@ export async function sampleCartGet(
|
|
|
333
348
|
if (!signing.enabled) return sampleResponse('{"error":"sample_unavailable"}', 503);
|
|
334
349
|
const payload = await buildVariantCartPayload(product, variant, {
|
|
335
350
|
mode: "signed", secret: signing.secret, tenantId: commerceTenantId(tenant), currency: tenant.currency,
|
|
351
|
+
publicOrigin: tenantPublicOrigin(tenant),
|
|
336
352
|
});
|
|
337
353
|
const result = await enforceCartSubmission({
|
|
338
354
|
entries: [...payload.hidden.map((f): [string, string] => [f.name, f.value]), [payload.quantityName, quantity]],
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
DEFAULT_STACKING_POLICY,
|
|
34
34
|
readReviewAggregate,
|
|
35
35
|
resolveProductDetails,
|
|
36
|
+
tenantPublicOrigin,
|
|
36
37
|
} from "@tot/public-runtime";
|
|
37
38
|
import type {
|
|
38
39
|
ProductPurchaseOption,
|
|
@@ -268,6 +269,7 @@ if (commerce || sampleHandoff) {
|
|
|
268
269
|
tenantId: commerceTenantId(tenant),
|
|
269
270
|
currency: tenant.currency,
|
|
270
271
|
contentVersionId: Astro.locals.browserEnvironment.environment === "live" ? undefined : Astro.locals.contentVersionId,
|
|
272
|
+
publicOrigin: tenantPublicOrigin(tenant),
|
|
271
273
|
}),
|
|
272
274
|
] as const;
|
|
273
275
|
}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.5.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",
|
|
@@ -172,6 +172,32 @@ export interface BuildVariantCartPayloadOptions {
|
|
|
172
172
|
* A publication page and a shared-channel preview omit it. Never taken from the form.
|
|
173
173
|
*/
|
|
174
174
|
contentVersionId?: string;
|
|
175
|
+
/**
|
|
176
|
+
* The tenant's canonical public origin (`tenantPublicOrigin`), from server-side tenant config.
|
|
177
|
+
* A root-relative catalog image is signed as an absolute URL on it, so the checkout can load it
|
|
178
|
+
* and no page rewrite of the relative path can break the signature. Never a request value.
|
|
179
|
+
*/
|
|
180
|
+
publicOrigin?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `https://<canonicalDomain>` from server-side tenant config; undefined when it is not a bare host. */
|
|
184
|
+
export function tenantPublicOrigin(tenant: Pick<TenantConfig, "canonicalDomain">): string | undefined {
|
|
185
|
+
const host = tenant.canonicalDomain?.trim().toLowerCase();
|
|
186
|
+
return host && /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d{1,5})?$/.test(host) ? `https://${host}` : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A root-relative image resolved against the public origin. Absolute and protocol-relative values,
|
|
191
|
+
* and any that fail to parse or would resolve onto another origin, are signed as they are.
|
|
192
|
+
*/
|
|
193
|
+
function cartImageUrl(image: string, publicOrigin: string | undefined): string {
|
|
194
|
+
if (!publicOrigin || !image.startsWith("/") || image.startsWith("//")) return image;
|
|
195
|
+
try {
|
|
196
|
+
const resolved = new URL(image, publicOrigin);
|
|
197
|
+
return resolved.origin === new URL(publicOrigin).origin ? resolved.href : image;
|
|
198
|
+
} catch {
|
|
199
|
+
return image;
|
|
200
|
+
}
|
|
175
201
|
}
|
|
176
202
|
|
|
177
203
|
/** Declared public serving substrate (ADR 0013 `serving.provider`). */
|
|
@@ -527,11 +553,12 @@ export function cartFieldsForVariant(
|
|
|
527
553
|
product: CatalogProduct,
|
|
528
554
|
variant: CatalogVariant,
|
|
529
555
|
purchaseOption?: ProductPurchaseOption,
|
|
530
|
-
opts?: { signedPrice?: number; tenantId?: string; currency?: string; contentVersionId?: string },
|
|
556
|
+
opts?: { signedPrice?: number; tenantId?: string; currency?: string; contentVersionId?: string; publicOrigin?: string },
|
|
531
557
|
): { code: string; fields: CartField[] } {
|
|
532
558
|
const code = cartLineCode(variant);
|
|
533
|
-
const
|
|
559
|
+
const catalogImage = product.images.find((i) => i.id === variant.image_id)?.url ??
|
|
534
560
|
product.images[0]?.url;
|
|
561
|
+
const image = catalogImage ? cartImageUrl(catalogImage, opts?.publicOrigin) : undefined;
|
|
535
562
|
// The signed price is either the caller's member-adjusted EFFECTIVE price
|
|
536
563
|
// (F11b — the single signed source of truth, computed server-side from the
|
|
537
564
|
// shopper's resolved eligibility) or, absent that, the list/subscription price.
|
|
@@ -762,7 +789,10 @@ export async function buildVariantCartPayload(
|
|
|
762
789
|
product,
|
|
763
790
|
variant,
|
|
764
791
|
opts.purchaseOption,
|
|
765
|
-
{
|
|
792
|
+
{
|
|
793
|
+
signedPrice: opts.signedPrice, tenantId: opts.tenantId, currency: opts.currency,
|
|
794
|
+
contentVersionId: opts.contentVersionId, publicOrigin: opts.publicOrigin,
|
|
795
|
+
},
|
|
766
796
|
);
|
|
767
797
|
const signed = opts.mode === "signed";
|
|
768
798
|
if (signed && !opts.secret) {
|
|
@@ -796,7 +826,15 @@ export interface CartSubmissionField {
|
|
|
796
826
|
|
|
797
827
|
export type CartEnforcement =
|
|
798
828
|
| { ok: true; fields: CartSubmissionField[]; paymentAuthorized: false }
|
|
799
|
-
| {
|
|
829
|
+
| {
|
|
830
|
+
ok: false; code: CartDenialCode; message: string; paymentAuthorized: false;
|
|
831
|
+
/**
|
|
832
|
+
* Base names of the signed fields that failed verification or were missing, for the operator
|
|
833
|
+
* log. Names only, never a value, hash or the secret; a name that is not a plain field name is
|
|
834
|
+
* reported as `(unrecognized)`.
|
|
835
|
+
*/
|
|
836
|
+
failedFields: string[];
|
|
837
|
+
};
|
|
800
838
|
|
|
801
839
|
interface ParsedCartField {
|
|
802
840
|
rawName: string;
|
|
@@ -839,12 +877,26 @@ function parseSubmittedField(rawName: string, value: string): ParsedCartField {
|
|
|
839
877
|
return { rawName, baseName: rawName, value, open: false, passthrough: false };
|
|
840
878
|
}
|
|
841
879
|
|
|
842
|
-
|
|
880
|
+
const MAX_REPORTED_CART_FIELDS = 20;
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* A submitted base name safe to log. Native and internal names are reported as-is; any other
|
|
884
|
+
* (a variant option) only when it is a short letters-only label, so a crafted name can never
|
|
885
|
+
* carry digits, an address or markup into the log.
|
|
886
|
+
*/
|
|
887
|
+
function reportedFieldName(baseName: string): string {
|
|
888
|
+
if ((NATIVE_CART_FIELDS as readonly string[]).includes(baseName) ||
|
|
889
|
+
(INTERNAL_CART_FIELDS as readonly string[]).includes(baseName)) return baseName;
|
|
890
|
+
return /^[A-Za-z][A-Za-z _.-]{0,63}$/.test(baseName) ? baseName : "(unrecognized)";
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function denial(code: CartDenialCode, failedFields: Iterable<string> = []): CartEnforcement {
|
|
843
894
|
return {
|
|
844
895
|
ok: false,
|
|
845
896
|
code,
|
|
846
897
|
message: CART_DENIAL_MESSAGE[code],
|
|
847
898
|
paymentAuthorized: false,
|
|
899
|
+
failedFields: [...new Set([...failedFields].map(reportedFieldName))].slice(0, MAX_REPORTED_CART_FIELDS),
|
|
848
900
|
};
|
|
849
901
|
}
|
|
850
902
|
|
|
@@ -875,14 +927,15 @@ export async function enforceCartSubmission(input: {
|
|
|
875
927
|
const byBase = new Map<string, ParsedCartField>();
|
|
876
928
|
for (const field of parsed) {
|
|
877
929
|
if (field.passthrough) continue;
|
|
878
|
-
if (byBase.has(field.baseName)) return denial("signature");
|
|
930
|
+
if (byBase.has(field.baseName)) return denial("signature", [field.baseName]);
|
|
879
931
|
byBase.set(field.baseName, field);
|
|
880
932
|
}
|
|
881
933
|
|
|
882
934
|
const codeField = byBase.get("code");
|
|
883
|
-
if (!codeField || !codeField.value) return denial("signature");
|
|
935
|
+
if (!codeField || !codeField.value) return denial("signature", ["code"]);
|
|
884
936
|
|
|
885
937
|
const failures = new Set<CartDenialCode>();
|
|
938
|
+
const failedFields = new Set<string>();
|
|
886
939
|
const signed = input.mode === "signed";
|
|
887
940
|
const secret = input.secret ?? "";
|
|
888
941
|
|
|
@@ -894,10 +947,16 @@ export async function enforceCartSubmission(input: {
|
|
|
894
947
|
};
|
|
895
948
|
|
|
896
949
|
if (signed) {
|
|
897
|
-
if (!(await hashOk(codeField)))
|
|
950
|
+
if (!(await hashOk(codeField))) {
|
|
951
|
+
failures.add("signature");
|
|
952
|
+
failedFields.add("code");
|
|
953
|
+
}
|
|
898
954
|
for (const field of byBase.values()) {
|
|
899
955
|
if (field.baseName === "code") continue;
|
|
900
|
-
if (!(await hashOk(field)))
|
|
956
|
+
if (!(await hashOk(field))) {
|
|
957
|
+
failures.add(specificCode(field.baseName));
|
|
958
|
+
failedFields.add(field.baseName);
|
|
959
|
+
}
|
|
901
960
|
}
|
|
902
961
|
}
|
|
903
962
|
|
|
@@ -911,13 +970,16 @@ export async function enforceCartSubmission(input: {
|
|
|
911
970
|
if (!quantityField || !quantityAllowed(quantityField.value)) failures.add("quantity");
|
|
912
971
|
if (signed) {
|
|
913
972
|
for (const required of ["name", "currency"]) {
|
|
914
|
-
if (!byBase.get(required))
|
|
973
|
+
if (!byBase.get(required)) {
|
|
974
|
+
failures.add("signature");
|
|
975
|
+
failedFields.add(required);
|
|
976
|
+
}
|
|
915
977
|
}
|
|
916
978
|
}
|
|
917
979
|
|
|
918
980
|
const order: CartDenialCode[] = ["tenant", "price", "quantity", "signature"];
|
|
919
981
|
for (const code of order) {
|
|
920
|
-
if (failures.has(code)) return denial(code);
|
|
982
|
+
if (failures.has(code)) return denial(code, failedFields);
|
|
921
983
|
}
|
|
922
984
|
|
|
923
985
|
return {
|
|
@@ -282,6 +282,8 @@ export interface MemberSignedCartPayloadOptions {
|
|
|
282
282
|
currency?: string;
|
|
283
283
|
/** Candidate content pin the preview page rendered. Omitted for publication and the shared channel. */
|
|
284
284
|
contentVersionId?: string;
|
|
285
|
+
/** Tenant public origin a relative image is signed on (see {@link BuildVariantCartPayloadOptions.publicOrigin}). */
|
|
286
|
+
publicOrigin?: string;
|
|
285
287
|
}
|
|
286
288
|
|
|
287
289
|
/**
|
|
@@ -318,6 +320,7 @@ export async function buildMemberSignedCartPayload(
|
|
|
318
320
|
tenantId: opts.tenantId,
|
|
319
321
|
currency: opts.currency,
|
|
320
322
|
contentVersionId: opts.contentVersionId,
|
|
323
|
+
publicOrigin: opts.publicOrigin,
|
|
321
324
|
});
|
|
322
325
|
}
|
|
323
326
|
|