@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.
Files changed (79) hide show
  1. package/agency-kit/tools/lib/public-binaries.mjs +95 -0
  2. package/apps/storefront/astro.config.mjs +7 -0
  3. package/apps/storefront/env.d.ts +3 -0
  4. package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
  5. package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
  6. package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
  7. package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
  8. package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
  9. package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
  10. package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
  11. package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
  12. package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
  13. package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
  14. package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
  15. package/apps/storefront/src/config/devTenantSeed.ts +2 -1
  16. package/apps/storefront/src/layouts/Layout.astro +1 -1
  17. package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
  18. package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
  19. package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
  20. package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
  21. package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
  22. package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
  23. package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
  24. package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
  25. package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
  26. package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
  27. package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
  28. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
  29. package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
  30. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
  31. package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
  32. package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
  33. package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
  34. package/apps/storefront/src/lib/content/storyblok/provider.ts +4 -2
  35. package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
  36. package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
  37. package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
  38. package/apps/storefront/src/middleware/controlPlane.ts +14 -11
  39. package/apps/storefront/src/middleware/index.ts +33 -12
  40. package/apps/storefront/src/middleware/renderChain.ts +52 -26
  41. package/apps/storefront/src/pages/[...slug].astro +3 -0
  42. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
  43. package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
  44. package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
  45. package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
  46. package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
  47. package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
  48. package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
  49. package/apps/storefront/src/pages/collections/[handle].astro +13 -198
  50. package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
  51. package/apps/storefront/src/pages/collections/index.astro +1 -1
  52. package/apps/storefront/src/pages/products/[handle].astro +10 -5
  53. package/apps/storefront/src/pages/search.astro +33 -33
  54. package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
  55. package/apps/storefront/vitest.config.cts +3 -0
  56. package/package.json +1 -1
  57. package/packages/cli/src/declared-config.mjs +318 -32
  58. package/packages/cli/src/publication-spaces.mjs +1 -1
  59. package/packages/public-runtime/package.json +1 -0
  60. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  61. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  62. package/packages/public-runtime/src/activity/catalog.ts +8 -1
  63. package/packages/public-runtime/src/candidate-index.ts +5 -0
  64. package/packages/public-runtime/src/capabilities.ts +12 -9
  65. package/packages/public-runtime/src/capture-rewrites.ts +687 -0
  66. package/packages/public-runtime/src/checkout-orders.ts +6 -7
  67. package/packages/public-runtime/src/checkout.ts +190 -6
  68. package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
  69. package/packages/public-runtime/src/compliance/profile.ts +90 -14
  70. package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
  71. package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
  72. package/packages/public-runtime/src/customization-runtime.ts +64 -2
  73. package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
  74. package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
  75. package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
  76. package/packages/public-runtime/src/membership.ts +3 -0
  77. package/packages/public-runtime/src/publication-space.ts +1 -0
  78. package/packages/public-runtime/src/tenant.ts +28 -4
  79. 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
- if (!variant.currency) {
308
- throw new Error("cartFieldsForVariant: variant currency is required");
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: variant.currency },
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
- message: `Flavored ${line.title ?? line.sku} cannot be shipped to ${region}${
222
- stateRule?.note ? ` (${stateRule.note})` : ""
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
- /** Flavor labels found (lowercased), e.g. ["mango", "ice"]. */
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
- /** Flavors that do NOT count as "characterizing" for a flavored-ENDS ban. */
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
- /** Collect flavor labels from the `F: <flavor>` tag convention + a chosen variant. */
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) labels.add(optFlavor.trim().toLowerCase());
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 isFlavored =
170
- containsNicotine &&
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 function isRegulatedCompliance(compliance: object | null | undefined): boolean;
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>>;