@tokenoftrust/storefront-runner 2.5.6 → 3.0.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/agency-kit/tools/lib/public-binaries.mjs +95 -0
- package/apps/storefront/astro.config.mjs +7 -0
- package/apps/storefront/env.d.ts +3 -0
- package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
- package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
- package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
- package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
- package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
- package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
- package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
- package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
- package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
- package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
- package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
- package/apps/storefront/src/config/devTenantSeed.ts +2 -1
- package/apps/storefront/src/layouts/Layout.astro +1 -1
- package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
- package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
- package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
- package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
- package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
- package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
- package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
- package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
- package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
- package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
- package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
- package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
- package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
- package/apps/storefront/src/lib/content/storyblok/provider.ts +4 -2
- package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
- package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
- package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
- package/apps/storefront/src/middleware/controlPlane.ts +14 -11
- package/apps/storefront/src/middleware/index.ts +33 -12
- package/apps/storefront/src/middleware/renderChain.ts +52 -26
- package/apps/storefront/src/pages/[...slug].astro +3 -0
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
- package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
- package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
- package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
- package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
- package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
- package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
- package/apps/storefront/src/pages/collections/[handle].astro +13 -198
- package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
- package/apps/storefront/src/pages/collections/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +10 -5
- package/apps/storefront/src/pages/search.astro +33 -33
- package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
- package/apps/storefront/vitest.config.cts +3 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +318 -32
- package/packages/cli/src/publication-spaces.mjs +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/activity/catalog.ts +8 -1
- package/packages/public-runtime/src/candidate-index.ts +5 -0
- package/packages/public-runtime/src/capabilities.ts +12 -9
- package/packages/public-runtime/src/capture-rewrites.ts +687 -0
- package/packages/public-runtime/src/checkout-orders.ts +6 -7
- package/packages/public-runtime/src/checkout.ts +190 -6
- package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
- package/packages/public-runtime/src/compliance/profile.ts +90 -14
- package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
- package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
- package/packages/public-runtime/src/customization-runtime.ts +64 -2
- package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
- package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
- package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
- package/packages/public-runtime/src/membership.ts +3 -0
- package/packages/public-runtime/src/publication-space.ts +1 -0
- package/packages/public-runtime/src/tenant.ts +28 -4
- package/scripts/tenant/validate.mjs +17 -8
|
@@ -345,6 +345,12 @@ export interface CommerceProviderCartSnapshot {
|
|
|
345
345
|
totals: MinorTotals;
|
|
346
346
|
destinationDigest: string;
|
|
347
347
|
destination: { country: string; region: string };
|
|
348
|
+
/**
|
|
349
|
+
* The shopper's verification correlation handle, from the signed `verification` cart option.
|
|
350
|
+
* Present only when the cart carries one; outside the cart digest. A handle, not a claim: the
|
|
351
|
+
* final-cart authority re-reads its status from the verification provider.
|
|
352
|
+
*/
|
|
353
|
+
verificationRef?: string;
|
|
348
354
|
}
|
|
349
355
|
|
|
350
356
|
/** Safe source references explaining each server-owned authority decision. */
|
|
@@ -357,13 +363,6 @@ export interface CommerceAuthoritySource {
|
|
|
357
363
|
}
|
|
358
364
|
export type CommerceEvidenceProvenance = Record<keyof CommerceEvidence, CommerceAuthoritySource>;
|
|
359
365
|
|
|
360
|
-
export interface SyntheticCommerceAuthorityPolicy {
|
|
361
|
-
purpose: "synthetic-nonbillable";
|
|
362
|
-
revision: string;
|
|
363
|
-
expiresAt: string;
|
|
364
|
-
requirements: Record<"verification" | "tax" | "risk", "required" | "not-required">;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
366
|
export interface CommerceAdmissionSnapshot extends CommerceProviderCartSnapshot {
|
|
368
367
|
authorizationId: string;
|
|
369
368
|
cartDigest: string;
|
|
@@ -24,8 +24,9 @@ import type {
|
|
|
24
24
|
ProductPurchaseOption,
|
|
25
25
|
SubscriptionPurchaseOption,
|
|
26
26
|
} from "./product.js";
|
|
27
|
+
import { resolveStoreCurrency } from "./declared-tenant-config.mjs";
|
|
27
28
|
import { DEFAULT_ONE_TIME_PURCHASE_OPTION } from "./product.js";
|
|
28
|
-
import type { TenantConfig } from "./tenant.js";
|
|
29
|
+
import type { ShippingPolicy, TenantConfig } from "./tenant.js";
|
|
29
30
|
|
|
30
31
|
/** Stable commerce scope shared by signed carts, provider callers and operator authorization. */
|
|
31
32
|
export function commerceTenantId(tenant: Pick<TenantConfig, "appDomain">): string {
|
|
@@ -159,6 +160,11 @@ export interface BuildVariantCartPayloadOptions {
|
|
|
159
160
|
* Signed into the cart when present. Never taken from the submitted form.
|
|
160
161
|
*/
|
|
161
162
|
tenantId?: string;
|
|
163
|
+
/**
|
|
164
|
+
* The store's currency (`TenantConfig.currency`), signed into the cart. Resolved through
|
|
165
|
+
* `resolveStoreCurrency`, so absent or invalid is USD. Never taken from the submitted form.
|
|
166
|
+
*/
|
|
167
|
+
currency?: string;
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
/** Declared public serving substrate (ADR 0013 `serving.provider`). */
|
|
@@ -180,6 +186,7 @@ export type CartDenialCode =
|
|
|
180
186
|
| "price"
|
|
181
187
|
| "quantity"
|
|
182
188
|
| "tenant"
|
|
189
|
+
| "shipping"
|
|
183
190
|
| "signing-unavailable";
|
|
184
191
|
|
|
185
192
|
export class CartSigningUnavailableError extends Error {
|
|
@@ -190,6 +197,120 @@ export class CartSigningUnavailableError extends Error {
|
|
|
190
197
|
}
|
|
191
198
|
}
|
|
192
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Signed cart fields the checkout consumes but a shopper must never see. The
|
|
202
|
+
* cart renders every non-native field as a line option, so each checkout store's
|
|
203
|
+
* hidden product options must list exactly these names. `tenant` stays a signed
|
|
204
|
+
* item option: `enforceCartSubmission` and the commerce pre-payment callback
|
|
205
|
+
* both bind the cart to it. `verification` carries the shopper's verification
|
|
206
|
+
* ref when the gate produced one (`withVerificationRef`). `shipping_policy` carries the
|
|
207
|
+
* store's declared shipping charge (`withShippingPolicy`).
|
|
208
|
+
*/
|
|
209
|
+
export const INTERNAL_CART_FIELDS = ["currency", "tenant", "verification", "shipping_policy"] as const;
|
|
210
|
+
|
|
211
|
+
/** The signed cart option the checkout store reads the shipping charge from. */
|
|
212
|
+
export const SHIPPING_POLICY_CART_FIELD = "shipping_policy";
|
|
213
|
+
|
|
214
|
+
/** Shopper copy when a cart cannot carry the store's shipping charge. */
|
|
215
|
+
export const SHIPPING_UNAVAILABLE_CART_MESSAGE =
|
|
216
|
+
"Checkout is unavailable because shipping for this order could not be calculated.";
|
|
217
|
+
|
|
218
|
+
/** A declared shipping policy cannot be charged exactly in the cart's currency. */
|
|
219
|
+
export class ShippingPolicyUnavailableError extends Error {
|
|
220
|
+
readonly code = "shipping" as const;
|
|
221
|
+
constructor() {
|
|
222
|
+
super("The store's shipping policy cannot be charged in this cart's currency.");
|
|
223
|
+
this.name = "ShippingPolicyUnavailableError";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A shipping policy in the minor units of one currency. */
|
|
228
|
+
export interface ShippingPolicyMinor {
|
|
229
|
+
currency: string;
|
|
230
|
+
digits: number;
|
|
231
|
+
flatMinor: number;
|
|
232
|
+
freeAtOrAboveMinor: number | null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function minorUnits(amount: number, digits: number): number {
|
|
236
|
+
const scaled = amount * 10 ** digits;
|
|
237
|
+
const rounded = Math.round(scaled);
|
|
238
|
+
if (!Number.isFinite(amount) || amount < 0 || Math.abs(scaled - rounded) > 0.000001) {
|
|
239
|
+
throw new ShippingPolicyUnavailableError();
|
|
240
|
+
}
|
|
241
|
+
return rounded;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The policy in `currency`'s minor units. Throws `ShippingPolicyUnavailableError` for an
|
|
246
|
+
* unknown currency or an amount that currency cannot state exactly, so no caller rounds a
|
|
247
|
+
* declared charge into a different one.
|
|
248
|
+
*/
|
|
249
|
+
export function shippingPolicyMinor(policy: ShippingPolicy, currency: string): ShippingPolicyMinor {
|
|
250
|
+
if (!/^[A-Z]{3}$/.test(currency)) throw new ShippingPolicyUnavailableError();
|
|
251
|
+
let digits: number | undefined;
|
|
252
|
+
try {
|
|
253
|
+
digits = new Intl.NumberFormat("en", { style: "currency", currency }).resolvedOptions().maximumFractionDigits;
|
|
254
|
+
} catch {
|
|
255
|
+
throw new ShippingPolicyUnavailableError();
|
|
256
|
+
}
|
|
257
|
+
if (digits === undefined) throw new ShippingPolicyUnavailableError();
|
|
258
|
+
const freeAtOrAboveMinor = policy.freeAtOrAbove === undefined ? null : minorUnits(policy.freeAtOrAbove, digits);
|
|
259
|
+
if (freeAtOrAboveMinor === 0) throw new ShippingPolicyUnavailableError();
|
|
260
|
+
return { currency, digits, flatMinor: minorUnits(policy.flatRate, digits), freeAtOrAboveMinor };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Shipping owed on an order whose item subtotal is `subtotalMinor`: the flat rate, or 0 at or above the free threshold. */
|
|
264
|
+
export function shippingMinorFor(policy: ShippingPolicy, currency: string, subtotalMinor: number): number {
|
|
265
|
+
const minor = shippingPolicyMinor(policy, currency);
|
|
266
|
+
return minor.freeAtOrAboveMinor !== null && subtotalMinor >= minor.freeAtOrAboveMinor ? 0 : minor.flatMinor;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The `shipping_policy` option value, e.g. `currency=USD;flat=5.99;free_at=100.00` (`free_at`
|
|
271
|
+
* only when declared). Amounts carry exactly the currency's minor-unit digits; the checkout
|
|
272
|
+
* store's shipping code parses this shape and refuses any other.
|
|
273
|
+
*/
|
|
274
|
+
export function shippingPolicyOptionValue(policy: ShippingPolicy, currency: string): string {
|
|
275
|
+
const minor = shippingPolicyMinor(policy, currency);
|
|
276
|
+
const amount = (value: number) => (value / 10 ** minor.digits).toFixed(minor.digits);
|
|
277
|
+
return [`currency=${currency}`, `flat=${amount(minor.flatMinor)}`,
|
|
278
|
+
...(minor.freeAtOrAboveMinor === null ? [] : [`free_at=${amount(minor.freeAtOrAboveMinor)}`])].join(";");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A verification correlation handle as a cart may carry it (the commerce adapter's shape). */
|
|
282
|
+
export const CART_VERIFICATION_REF = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,199}$/;
|
|
283
|
+
|
|
284
|
+
/** Native checkout item parameters. The cart consumes them; none renders as an option. */
|
|
285
|
+
export const NATIVE_CART_FIELDS = [
|
|
286
|
+
"name",
|
|
287
|
+
"price",
|
|
288
|
+
"code",
|
|
289
|
+
"quantity",
|
|
290
|
+
"image",
|
|
291
|
+
"sub_frequency",
|
|
292
|
+
"sub_startdate",
|
|
293
|
+
"sub_enddate",
|
|
294
|
+
] as const;
|
|
295
|
+
|
|
296
|
+
/** Non-variant fields deliberately shown on the shopper's cart line. */
|
|
297
|
+
export const VISIBLE_CART_FIELDS = ["Purchase option"] as const;
|
|
298
|
+
|
|
299
|
+
export type CartFieldVisibility = "native" | "internal" | "visible" | "variant-option" | "undeclared";
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* How a field emitted by `cartFieldsForVariant` reaches the shopper. A field
|
|
303
|
+
* that is none of native, internal, declared-visible, or one of the variant's
|
|
304
|
+
* own options is `undeclared`: it would render on the cart line unreviewed.
|
|
305
|
+
*/
|
|
306
|
+
export function cartFieldVisibility(name: string, variant: Pick<CatalogVariant, "option_values">): CartFieldVisibility {
|
|
307
|
+
if ((NATIVE_CART_FIELDS as readonly string[]).includes(name)) return "native";
|
|
308
|
+
if ((INTERNAL_CART_FIELDS as readonly string[]).includes(name)) return "internal";
|
|
309
|
+
if ((VISIBLE_CART_FIELDS as readonly string[]).includes(name)) return "visible";
|
|
310
|
+
if (Object.prototype.hasOwnProperty.call(variant.option_values, name)) return "variant-option";
|
|
311
|
+
return "undeclared";
|
|
312
|
+
}
|
|
313
|
+
|
|
193
314
|
const DEFAULT_CHECKOUT_HOST_SUFFIX = ".foxycart.com";
|
|
194
315
|
const SUBSCRIPTION_FREQUENCY_RE = /^(?:\d+(?:\.\d+)?|\.\d+)[dwmy]$/;
|
|
195
316
|
const PREVIEW_PIN_RE = /\/preview\/(?:staged|candidate)(?:\/|$)/;
|
|
@@ -292,7 +413,7 @@ export function cartFieldsForVariant(
|
|
|
292
413
|
product: CatalogProduct,
|
|
293
414
|
variant: CatalogVariant,
|
|
294
415
|
purchaseOption?: ProductPurchaseOption,
|
|
295
|
-
opts?: { signedPrice?: number; tenantId?: string },
|
|
416
|
+
opts?: { signedPrice?: number; tenantId?: string; currency?: string },
|
|
296
417
|
): { code: string; fields: CartField[] } {
|
|
297
418
|
const code = variant.sku && variant.sku.length ? variant.sku : variant.id;
|
|
298
419
|
const image = product.images.find((i) => i.id === variant.image_id)?.url ??
|
|
@@ -304,13 +425,16 @@ export function cartFieldsForVariant(
|
|
|
304
425
|
opts?.signedPrice !== undefined
|
|
305
426
|
? assertEffectivePrice(opts.signedPrice, variant.price)
|
|
306
427
|
: purchaseOptionPrice(variant.price, purchaseOption);
|
|
307
|
-
|
|
308
|
-
|
|
428
|
+
const currency = resolveStoreCurrency(opts?.currency);
|
|
429
|
+
if (variant.currency && variant.currency !== currency) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`cartFieldsForVariant: variant is priced in ${variant.currency}; this store sells in ${currency}`,
|
|
432
|
+
);
|
|
309
433
|
}
|
|
310
434
|
const fields: CartField[] = [
|
|
311
435
|
{ name: "name", value: cartLineName(product, variant) },
|
|
312
436
|
{ name: "price", value: price.toFixed(2) },
|
|
313
|
-
{ name: "currency", value:
|
|
437
|
+
{ name: "currency", value: currency },
|
|
314
438
|
{ name: "code", value: code },
|
|
315
439
|
{ name: "quantity", value: "1", open: true },
|
|
316
440
|
];
|
|
@@ -509,7 +633,7 @@ export async function buildVariantCartPayload(
|
|
|
509
633
|
product,
|
|
510
634
|
variant,
|
|
511
635
|
opts.purchaseOption,
|
|
512
|
-
{ signedPrice: opts.signedPrice, tenantId: opts.tenantId },
|
|
636
|
+
{ signedPrice: opts.signedPrice, tenantId: opts.tenantId, currency: opts.currency },
|
|
513
637
|
);
|
|
514
638
|
const signed = opts.mode === "signed";
|
|
515
639
|
if (signed && !opts.secret) {
|
|
@@ -532,6 +656,7 @@ const CART_DENIAL_MESSAGE: Record<CartDenialCode, string> = {
|
|
|
532
656
|
price: "Checkout was refused because the price does not match the signed price.",
|
|
533
657
|
quantity: "Checkout was refused because the quantity is not an allowed amount.",
|
|
534
658
|
tenant: "Checkout was refused because the cart tenant does not match this store.",
|
|
659
|
+
shipping: SHIPPING_UNAVAILABLE_CART_MESSAGE,
|
|
535
660
|
"signing-unavailable": CART_SIGNING_OPERATOR_ERROR,
|
|
536
661
|
};
|
|
537
662
|
|
|
@@ -673,6 +798,65 @@ export async function enforceCartSubmission(input: {
|
|
|
673
798
|
};
|
|
674
799
|
}
|
|
675
800
|
|
|
801
|
+
/**
|
|
802
|
+
* Split the shopper's verification ref out of a submitted cart form. The browser posts it as
|
|
803
|
+
* one plain `verification` field; every `verification` field is removed from the entries, so
|
|
804
|
+
* none reaches the signature check or the cart unsigned. More than one, or a value that is not
|
|
805
|
+
* a well-formed handle, yields no ref.
|
|
806
|
+
*/
|
|
807
|
+
export function takeVerificationRef(
|
|
808
|
+
entries: Iterable<[string, string]>,
|
|
809
|
+
): { entries: Array<[string, string]>; ref: string | null } {
|
|
810
|
+
const kept: Array<[string, string]> = [];
|
|
811
|
+
const refs: string[] = [];
|
|
812
|
+
for (const [name, value] of entries) {
|
|
813
|
+
if (name.split("||")[0] !== "verification") kept.push([name, value]);
|
|
814
|
+
else if (name === "verification") refs.push(value);
|
|
815
|
+
}
|
|
816
|
+
const ref = refs.length === 1 && CART_VERIFICATION_REF.test(refs[0]!) ? refs[0]! : null;
|
|
817
|
+
return { entries: kept, ref };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Add the verification ref to an enforced cart as the internal `verification` option, signed
|
|
822
|
+
* against the cart's product code like every other field. The ref is a correlation handle,
|
|
823
|
+
* not a claim: the final-cart authority re-reads its status from the provider.
|
|
824
|
+
*/
|
|
825
|
+
export async function withVerificationRef(
|
|
826
|
+
fields: readonly CartSubmissionField[],
|
|
827
|
+
opts: { mode: ManagedCheckoutConfig["mode"]; secret?: string; ref: string | null },
|
|
828
|
+
): Promise<CartSubmissionField[]> {
|
|
829
|
+
if (!opts.ref || !CART_VERIFICATION_REF.test(opts.ref)) return [...fields];
|
|
830
|
+
const code = fields.find((field) => field.name.split("||")[0] === "code")?.value;
|
|
831
|
+
if (!code) return [...fields];
|
|
832
|
+
const field: CartField = { name: "verification", value: opts.ref };
|
|
833
|
+
if (opts.mode !== "signed") return [...fields, field];
|
|
834
|
+
if (!opts.secret) throw new CartSigningUnavailableError();
|
|
835
|
+
return [...fields, await signField(opts.secret, code, field)];
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Bind the store's shipping policy into an enforced cart as the internal `shipping_policy`
|
|
840
|
+
* option, signed against the cart's product code. Any `shipping_policy` the shopper submitted is
|
|
841
|
+
* dropped first, so only the trusted tenant's declaration reaches the cart. No policy leaves the
|
|
842
|
+
* cart without one. The amounts are stated in the cart's own `currency` field. Throws
|
|
843
|
+
* `ShippingPolicyUnavailableError` when the cart names no currency the policy can be charged in.
|
|
844
|
+
*/
|
|
845
|
+
export async function withShippingPolicy(
|
|
846
|
+
fields: readonly CartSubmissionField[],
|
|
847
|
+
opts: { mode: ManagedCheckoutConfig["mode"]; secret?: string; policy: ShippingPolicy | undefined },
|
|
848
|
+
): Promise<CartSubmissionField[]> {
|
|
849
|
+
const kept = fields.filter((field) => field.name.split("||")[0] !== SHIPPING_POLICY_CART_FIELD);
|
|
850
|
+
if (!opts.policy) return kept;
|
|
851
|
+
const code = kept.find((field) => field.name.split("||")[0] === "code")?.value;
|
|
852
|
+
const currency = kept.find((field) => field.name.split("||")[0] === "currency")?.value;
|
|
853
|
+
if (!code || !currency) throw new ShippingPolicyUnavailableError();
|
|
854
|
+
const field: CartField = { name: SHIPPING_POLICY_CART_FIELD, value: shippingPolicyOptionValue(opts.policy, currency) };
|
|
855
|
+
if (opts.mode !== "signed") return [...kept, field];
|
|
856
|
+
if (!opts.secret) throw new CartSigningUnavailableError();
|
|
857
|
+
return [...kept, await signField(opts.secret, code, field)];
|
|
858
|
+
}
|
|
859
|
+
|
|
676
860
|
/** Cart URL the browser may navigate to after enforcement. Not a payment. */
|
|
677
861
|
export function foxyCartRedirectUrl(
|
|
678
862
|
cartUrl: string,
|
|
@@ -70,6 +70,8 @@ export interface ComplianceBlock {
|
|
|
70
70
|
message: string;
|
|
71
71
|
region?: string;
|
|
72
72
|
sku?: string;
|
|
73
|
+
/** `flavored_prohibited` only: the characterizing flavor labels that triggered it. */
|
|
74
|
+
flavors?: string[];
|
|
73
75
|
/** True when this block is a fail-closed safety default, not a real violation. */
|
|
74
76
|
failedClosed?: boolean;
|
|
75
77
|
}
|
|
@@ -214,13 +216,15 @@ export function evaluateCartCompliance(
|
|
|
214
216
|
continue;
|
|
215
217
|
}
|
|
216
218
|
if (stateRule?.banFlavored && line.profile.isFlavored) {
|
|
219
|
+
const flavors = line.profile.characterizingFlavors;
|
|
217
220
|
blocks.push({
|
|
218
221
|
code: "flavored_prohibited",
|
|
219
222
|
region,
|
|
220
223
|
sku: line.sku,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
+
flavors,
|
|
225
|
+
message: `${line.title ?? line.sku} cannot be shipped to ${region}: flavored (${
|
|
226
|
+
flavors.length > 0 ? flavors.join(", ") : "unnamed flavor"
|
|
227
|
+
})${stateRule?.note ? ` (${stateRule.note})` : ""}.`,
|
|
224
228
|
});
|
|
225
229
|
}
|
|
226
230
|
}
|
|
@@ -32,8 +32,17 @@ export interface ProductComplianceProfile {
|
|
|
32
32
|
* ban for ENDS. `false` for tobacco/menthol/unflavored.
|
|
33
33
|
*/
|
|
34
34
|
isFlavored: boolean;
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Flavor labels found (lowercased), e.g. ["mango", "ice"]. Nicotine-format
|
|
37
|
+
* labels filed under `F:` ("nicotine salt", "freebase") are not flavors and
|
|
38
|
+
* are excluded.
|
|
39
|
+
*/
|
|
36
40
|
flavorLabels: string[];
|
|
41
|
+
/**
|
|
42
|
+
* The subset of `flavorLabels` that is characterizing — the labels that make
|
|
43
|
+
* `isFlavored` true, so a flavored-ENDS block can name its trigger.
|
|
44
|
+
*/
|
|
45
|
+
characterizingFlavors: string[];
|
|
37
46
|
/** Derived regulated category. */
|
|
38
47
|
category: RegulatedCategory;
|
|
39
48
|
/**
|
|
@@ -44,10 +53,11 @@ export interface ProductComplianceProfile {
|
|
|
44
53
|
restrictedRegions: string[];
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Exact labels that do NOT count as "characterizing" for a flavored-ENDS ban.
|
|
58
|
+
* Tobacco labels are classified by `isTobaccoFlavorLabel`.
|
|
59
|
+
*/
|
|
48
60
|
const UNFLAVORED = new Set([
|
|
49
|
-
"tobacco",
|
|
50
|
-
"classic tobacco",
|
|
51
61
|
"menthol",
|
|
52
62
|
"unflavored",
|
|
53
63
|
"none",
|
|
@@ -55,6 +65,69 @@ const UNFLAVORED = new Set([
|
|
|
55
65
|
"plain",
|
|
56
66
|
]);
|
|
57
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Nicotine format/strength labels that catalogs file under `F:` tags. They
|
|
70
|
+
* describe the nicotine, not the taste, so they are dropped before
|
|
71
|
+
* classification.
|
|
72
|
+
*/
|
|
73
|
+
const NICOTINE_FORMAT_LABELS = new Set([
|
|
74
|
+
"nicotine salt",
|
|
75
|
+
"nicotine salts",
|
|
76
|
+
"salt nicotine",
|
|
77
|
+
"salt nic",
|
|
78
|
+
"nic salt",
|
|
79
|
+
"nic salts",
|
|
80
|
+
"salt",
|
|
81
|
+
"salts",
|
|
82
|
+
"freebase",
|
|
83
|
+
"free base",
|
|
84
|
+
"freebase nicotine",
|
|
85
|
+
"free base nicotine",
|
|
86
|
+
"synthetic",
|
|
87
|
+
"synthetic nicotine",
|
|
88
|
+
"tfn",
|
|
89
|
+
"tobacco free nicotine",
|
|
90
|
+
"tobacco-free nicotine",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Closed allowlist of tobacco type, origin and style words. A label is a plain
|
|
95
|
+
* tobacco flavor only when every word besides "tobacco" is in this list:
|
|
96
|
+
* "Virginia Tobacco" is tobacco, while "Caramel Tobacco" and "Golden Tobacco"
|
|
97
|
+
* are characterizing flavors. An unlisted word means flavored (fail closed),
|
|
98
|
+
* so extend this list only with words that name a tobacco, never a taste.
|
|
99
|
+
*/
|
|
100
|
+
const TOBACCO_QUALIFIERS = new Set([
|
|
101
|
+
"american",
|
|
102
|
+
"virginia",
|
|
103
|
+
"pipe",
|
|
104
|
+
"cuban",
|
|
105
|
+
"turkish",
|
|
106
|
+
"burley",
|
|
107
|
+
"kentucky",
|
|
108
|
+
"classic",
|
|
109
|
+
"traditional",
|
|
110
|
+
"original",
|
|
111
|
+
"regular",
|
|
112
|
+
"rich",
|
|
113
|
+
"bold",
|
|
114
|
+
"mild",
|
|
115
|
+
"blend",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
/** True when a label names tobacco, optionally qualified by `TOBACCO_QUALIFIERS` only. */
|
|
119
|
+
function isTobaccoFlavorLabel(label: string): boolean {
|
|
120
|
+
const words = label.split(/[^a-z0-9]+/).filter((w) => w.length > 0);
|
|
121
|
+
return (
|
|
122
|
+
words.includes("tobacco") &&
|
|
123
|
+
words.every((w) => w === "tobacco" || TOBACCO_QUALIFIERS.has(w))
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isCharacterizingFlavor(label: string): boolean {
|
|
128
|
+
return !UNFLAVORED.has(label) && !isTobaccoFlavorLabel(label);
|
|
129
|
+
}
|
|
130
|
+
|
|
58
131
|
const NICOTINE_HINTS = [
|
|
59
132
|
"nicotine",
|
|
60
133
|
"vape",
|
|
@@ -104,20 +177,24 @@ function parseRestrictedRegions(raw: string | undefined): string[] {
|
|
|
104
177
|
}
|
|
105
178
|
}
|
|
106
179
|
|
|
107
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* Collect flavor labels from the `F: <flavor>` tag convention + a chosen
|
|
182
|
+
* variant, dropping nicotine-format labels.
|
|
183
|
+
*/
|
|
108
184
|
function collectFlavors(
|
|
109
185
|
product: Pick<CatalogProduct, "tags">,
|
|
110
186
|
variant?: Pick<CatalogVariant, "option_values"> | null,
|
|
111
187
|
): string[] {
|
|
112
188
|
const labels = new Set<string>();
|
|
189
|
+
const add = (raw: string) => {
|
|
190
|
+
const v = raw.trim().toLowerCase();
|
|
191
|
+
if (v && !NICOTINE_FORMAT_LABELS.has(v)) labels.add(v);
|
|
192
|
+
};
|
|
113
193
|
for (const tag of product.tags ?? []) {
|
|
114
|
-
if (tag.startsWith("F: "))
|
|
115
|
-
const v = tag.slice(3).trim().toLowerCase();
|
|
116
|
-
if (v) labels.add(v);
|
|
117
|
-
}
|
|
194
|
+
if (tag.startsWith("F: ")) add(tag.slice(3));
|
|
118
195
|
}
|
|
119
196
|
const optFlavor = variant?.option_values?.["Flavor"];
|
|
120
|
-
if (optFlavor)
|
|
197
|
+
if (optFlavor) add(optFlavor);
|
|
121
198
|
return [...labels];
|
|
122
199
|
}
|
|
123
200
|
|
|
@@ -166,10 +243,8 @@ export function deriveProductProfile(
|
|
|
166
243
|
(!nicOptionSaysZero && containsAny(haystack, NICOTINE_HINTS)));
|
|
167
244
|
|
|
168
245
|
const flavorLabels = collectFlavors(product, variant);
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
flavorLabels.some((f) => !UNFLAVORED.has(f)) &&
|
|
172
|
-
flavorLabels.length > 0;
|
|
246
|
+
const characterizingFlavors = flavorLabels.filter(isCharacterizingFlavor);
|
|
247
|
+
const isFlavored = containsNicotine && characterizingFlavors.length > 0;
|
|
173
248
|
|
|
174
249
|
let cat: RegulatedCategory = "none";
|
|
175
250
|
if (isAccessory) cat = "accessory";
|
|
@@ -183,6 +258,7 @@ export function deriveProductProfile(
|
|
|
183
258
|
containsNicotine,
|
|
184
259
|
isFlavored,
|
|
185
260
|
flavorLabels,
|
|
261
|
+
characterizingFlavors,
|
|
186
262
|
category: cat,
|
|
187
263
|
restrictedRegions,
|
|
188
264
|
};
|
|
@@ -9,9 +9,18 @@ export interface CoherenceFailure {
|
|
|
9
9
|
hint: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** A resolved obligation, as `resolveObligations` returns it (only these fields are read). */
|
|
13
|
+
export interface AppliedObligation {
|
|
14
|
+
id: string;
|
|
15
|
+
surface: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
export const COHERENCE_CHECK_IDS: readonly CoherenceCheckId[];
|
|
13
|
-
export
|
|
19
|
+
export const OBLIGATION_EVIDENCE_CHECK: Readonly<Record<string, CoherenceCheckId | null>>;
|
|
20
|
+
export function isRegulatedObligationSet(obligations: readonly AppliedObligation[]): boolean;
|
|
21
|
+
export function coherenceChecksFor(obligations: readonly AppliedObligation[]): CoherenceCheckId[];
|
|
14
22
|
export function declaresJurisdictionNotice(compliance: object | null | undefined): boolean;
|
|
15
23
|
export function complianceCoherenceFailures(
|
|
16
24
|
compliance: object | null | undefined,
|
|
25
|
+
obligations: readonly AppliedObligation[],
|
|
17
26
|
): Partial<Record<CoherenceCheckId, CoherenceFailure>>;
|