@tokenoftrust/storefront-runner 1.3.4-rc.4 → 1.4.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/astro.config.mjs +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
- package/apps/storefront/package.json +13 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- package/apps/storefront/src/lib/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
- package/apps/storefront/src/lib/auth/route.ts +16 -1
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +12 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
- package/apps/storefront/src/middleware/index.ts +28 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin.astro +29 -3
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +197 -41
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +433 -69
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/candidate-index.ts +311 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cart compliance EVALUATOR — the single enforcement authority. Given a cart's
|
|
3
|
+
* regulated lines, a ship-to destination, and a verification state, it returns a
|
|
4
|
+
* structured decision on whether checkout may COMPLETE. This is the "not just a
|
|
5
|
+
* notice" core: the notices render the same facts, but this function DECIDES.
|
|
6
|
+
*
|
|
7
|
+
* Checkout completion + order creation are EXTERNAL (managed cart). So this
|
|
8
|
+
* evaluator is consumed at two real fail-closed seams:
|
|
9
|
+
* 1. the checkout PREFLIGHT (`/api/compliance/preflight`) — the storefront must
|
|
10
|
+
* clear the cart here before handing off to the managed cart;
|
|
11
|
+
* 2. the evidence PROMOTE gate — a regulated tenant is only promotable when its
|
|
12
|
+
* enforcement wiring resolves (see `evidence-signals.ts`).
|
|
13
|
+
*
|
|
14
|
+
* Fail-closed everywhere: a regulated cart with no resolvable ruleset, an
|
|
15
|
+
* unconfigured verification provider, or an unsatisfied excise obligation BLOCKS.
|
|
16
|
+
* Pure + edge-safe.
|
|
17
|
+
*/
|
|
18
|
+
import type { ComplianceRuleset } from "./ruleset.js";
|
|
19
|
+
import { stateRuleFor, effectivePerOrderLimit } from "./ruleset.js";
|
|
20
|
+
import type { ProductComplianceProfile } from "./profile.js";
|
|
21
|
+
import { isRegulatedProfile } from "./profile.js";
|
|
22
|
+
import type { VerificationState } from "./verification.js";
|
|
23
|
+
import { resolveVerificationDecision } from "./verification.js";
|
|
24
|
+
|
|
25
|
+
export interface CartComplianceLine {
|
|
26
|
+
sku: string;
|
|
27
|
+
quantity: number;
|
|
28
|
+
profile: ProductComplianceProfile;
|
|
29
|
+
/** Display name for block messages. */
|
|
30
|
+
title?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ShipDestination {
|
|
34
|
+
/** State/province code, e.g. "MA". */
|
|
35
|
+
region: string;
|
|
36
|
+
/** ISO country code, e.g. "US". */
|
|
37
|
+
country: string;
|
|
38
|
+
postalCode?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CartComplianceInput {
|
|
42
|
+
/** Resolved ruleset, or null when it could NOT be resolved for a regulated tenant. */
|
|
43
|
+
ruleset: ComplianceRuleset | null;
|
|
44
|
+
/**
|
|
45
|
+
* True when the tenant is regulated (age/nicotine). Drives fail-closed when the
|
|
46
|
+
* ruleset is null. Independent of the lines so an empty/mis-tagged cart on a
|
|
47
|
+
* regulated tenant still fails closed rather than sneaking through.
|
|
48
|
+
*/
|
|
49
|
+
regulated: boolean;
|
|
50
|
+
lines: CartComplianceLine[];
|
|
51
|
+
destination: ShipDestination | null;
|
|
52
|
+
verification: VerificationState | null;
|
|
53
|
+
/** Soft front-door affirmation (UX only; never a substitute for verification). */
|
|
54
|
+
ageAffirmed?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ComplianceBlockCode =
|
|
58
|
+
| "ruleset_unavailable"
|
|
59
|
+
| "verification_unconfigured"
|
|
60
|
+
| "verification_required"
|
|
61
|
+
| "verification_failed"
|
|
62
|
+
| "destination_required"
|
|
63
|
+
| "region_prohibited"
|
|
64
|
+
| "flavored_prohibited"
|
|
65
|
+
| "purchase_limit_exceeded"
|
|
66
|
+
| "excise_unavailable";
|
|
67
|
+
|
|
68
|
+
export interface ComplianceBlock {
|
|
69
|
+
code: ComplianceBlockCode;
|
|
70
|
+
message: string;
|
|
71
|
+
region?: string;
|
|
72
|
+
sku?: string;
|
|
73
|
+
/** True when this block is a fail-closed safety default, not a real violation. */
|
|
74
|
+
failedClosed?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CartComplianceRequirements {
|
|
78
|
+
ageVerification: "satisfied" | "required" | "blocked" | "not_required";
|
|
79
|
+
/** Adult signature on delivery required (PACT baseline and/or a state rule). */
|
|
80
|
+
adultSignature: boolean;
|
|
81
|
+
exciseHandling: "managed-cart" | "provider" | "unavailable" | "not_required";
|
|
82
|
+
destination: "provided" | "required" | "not_required";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CartComplianceDecision {
|
|
86
|
+
/**
|
|
87
|
+
* - "clear" — may hand off to checkout.
|
|
88
|
+
* - "action_required" — a shopper step remains (verify, choose ship-to state).
|
|
89
|
+
* - "blocked" — a hard prohibition or fail-closed safety default.
|
|
90
|
+
*/
|
|
91
|
+
status: "clear" | "action_required" | "blocked";
|
|
92
|
+
blocks: ComplianceBlock[];
|
|
93
|
+
requirements: CartComplianceRequirements;
|
|
94
|
+
purchaseLimit: {
|
|
95
|
+
region?: string;
|
|
96
|
+
limit: number;
|
|
97
|
+
units: number;
|
|
98
|
+
exceeded: boolean;
|
|
99
|
+
} | null;
|
|
100
|
+
/** True when any block is a fail-closed safety default. */
|
|
101
|
+
failedClosed: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Codes that are HARD stops (vs. actionable steps the shopper can still complete). */
|
|
105
|
+
const HARD_BLOCK_CODES: ReadonlySet<ComplianceBlockCode> = new Set([
|
|
106
|
+
"ruleset_unavailable",
|
|
107
|
+
"verification_unconfigured",
|
|
108
|
+
"verification_failed",
|
|
109
|
+
"region_prohibited",
|
|
110
|
+
"flavored_prohibited",
|
|
111
|
+
"purchase_limit_exceeded",
|
|
112
|
+
"excise_unavailable",
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
export function evaluateCartCompliance(
|
|
116
|
+
input: CartComplianceInput,
|
|
117
|
+
): CartComplianceDecision {
|
|
118
|
+
const blocks: ComplianceBlock[] = [];
|
|
119
|
+
const regulatedLines = input.lines.filter((l) => isRegulatedProfile(l.profile));
|
|
120
|
+
const hasRegulatedGoods = regulatedLines.length > 0;
|
|
121
|
+
|
|
122
|
+
const requirements: CartComplianceRequirements = {
|
|
123
|
+
ageVerification: "not_required",
|
|
124
|
+
adultSignature: false,
|
|
125
|
+
exciseHandling: "not_required",
|
|
126
|
+
destination: "not_required",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// 1. Fail closed: a regulated tenant with no resolvable ruleset cannot be evaluated.
|
|
130
|
+
if (input.regulated && !input.ruleset) {
|
|
131
|
+
blocks.push({
|
|
132
|
+
code: "ruleset_unavailable",
|
|
133
|
+
failedClosed: true,
|
|
134
|
+
message:
|
|
135
|
+
"Compliance ruleset could not be resolved for this regulated tenant. " +
|
|
136
|
+
"Checkout is blocked (safe default = BLOCK).",
|
|
137
|
+
});
|
|
138
|
+
return finalize(blocks, requirements, null);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ruleset = input.ruleset;
|
|
142
|
+
if (!ruleset || (!input.regulated && !hasRegulatedGoods)) {
|
|
143
|
+
// Unregulated tenant, no regulated goods → nothing to enforce.
|
|
144
|
+
return finalize(blocks, requirements, null);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2. Verification gate (fail-closed when required + no provider).
|
|
148
|
+
const vDecision = resolveVerificationDecision(
|
|
149
|
+
ruleset.verification,
|
|
150
|
+
input.verification,
|
|
151
|
+
);
|
|
152
|
+
requirements.ageVerification = vDecision.status;
|
|
153
|
+
if (vDecision.status === "blocked") {
|
|
154
|
+
blocks.push({
|
|
155
|
+
code: vDecision.failedClosed
|
|
156
|
+
? "verification_unconfigured"
|
|
157
|
+
: "verification_failed",
|
|
158
|
+
failedClosed: vDecision.failedClosed,
|
|
159
|
+
message: vDecision.reason ?? "Age/identity verification failed.",
|
|
160
|
+
});
|
|
161
|
+
} else if (vDecision.status === "required") {
|
|
162
|
+
blocks.push({
|
|
163
|
+
code: "verification_required",
|
|
164
|
+
message: vDecision.reason ?? "Age/identity verification required.",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 3. Excise obligation (fail-closed when required + unavailable).
|
|
169
|
+
if (ruleset.excise.required) {
|
|
170
|
+
requirements.exciseHandling = ruleset.excise.handling;
|
|
171
|
+
if (ruleset.excise.handling === "unavailable") {
|
|
172
|
+
blocks.push({
|
|
173
|
+
code: "excise_unavailable",
|
|
174
|
+
failedClosed: true,
|
|
175
|
+
message:
|
|
176
|
+
"Excise tax is required for these products but no excise path is wired. " +
|
|
177
|
+
"Checkout is blocked (safe default = BLOCK).",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 4. Adult signature (PACT baseline; a state rule can also require it).
|
|
183
|
+
requirements.adultSignature =
|
|
184
|
+
ruleset.pactAct.enabled && ruleset.pactAct.adultSignatureRequired;
|
|
185
|
+
|
|
186
|
+
// 5. Destination-dependent rules. If regulated goods but no ship-to state, the
|
|
187
|
+
// cart cannot clear — the state must be known to apply jurisdiction rules.
|
|
188
|
+
if (hasRegulatedGoods) {
|
|
189
|
+
if (!input.destination) {
|
|
190
|
+
requirements.destination = "required";
|
|
191
|
+
blocks.push({
|
|
192
|
+
code: "destination_required",
|
|
193
|
+
message:
|
|
194
|
+
"A ship-to state is required to confirm this order can be legally shipped.",
|
|
195
|
+
});
|
|
196
|
+
} else {
|
|
197
|
+
requirements.destination = "provided";
|
|
198
|
+
const region = input.destination.region.trim().toUpperCase();
|
|
199
|
+
const stateRule = stateRuleFor(ruleset, region);
|
|
200
|
+
if (stateRule?.adultSignatureRequired) requirements.adultSignature = true;
|
|
201
|
+
|
|
202
|
+
for (const line of regulatedLines) {
|
|
203
|
+
// Per-product ToT-synced restriction (data-driven per SKU).
|
|
204
|
+
const perProductBanned = line.profile.restrictedRegions.includes(region);
|
|
205
|
+
if (stateRule?.banAll || perProductBanned) {
|
|
206
|
+
blocks.push({
|
|
207
|
+
code: "region_prohibited",
|
|
208
|
+
region,
|
|
209
|
+
sku: line.sku,
|
|
210
|
+
message: `${line.title ?? line.sku} cannot be shipped to ${region}${
|
|
211
|
+
stateRule?.note ? ` (${stateRule.note})` : ""
|
|
212
|
+
}.`,
|
|
213
|
+
});
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (stateRule?.banFlavored && line.profile.isFlavored) {
|
|
217
|
+
blocks.push({
|
|
218
|
+
code: "flavored_prohibited",
|
|
219
|
+
region,
|
|
220
|
+
sku: line.sku,
|
|
221
|
+
message: `Flavored ${line.title ?? line.sku} cannot be shipped to ${region}${
|
|
222
|
+
stateRule?.note ? ` (${stateRule.note})` : ""
|
|
223
|
+
}.`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 6. Purchase limit (per-order regulated-unit cap for the destination region).
|
|
231
|
+
let purchaseLimit: CartComplianceDecision["purchaseLimit"] = null;
|
|
232
|
+
if (hasRegulatedGoods && input.destination) {
|
|
233
|
+
const region = input.destination.region.trim().toUpperCase();
|
|
234
|
+
const limit = effectivePerOrderLimit(ruleset, region);
|
|
235
|
+
if (limit != null && Number.isFinite(limit)) {
|
|
236
|
+
const units = regulatedLines.reduce((n, l) => n + Math.max(0, l.quantity), 0);
|
|
237
|
+
const exceeded = units > limit;
|
|
238
|
+
purchaseLimit = { region, limit, units, exceeded };
|
|
239
|
+
if (exceeded) {
|
|
240
|
+
blocks.push({
|
|
241
|
+
code: "purchase_limit_exceeded",
|
|
242
|
+
region,
|
|
243
|
+
message: `This order (${units} units) exceeds the ${limit}-unit per-order limit for ${region}.`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return finalize(blocks, requirements, purchaseLimit);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function finalize(
|
|
253
|
+
blocks: ComplianceBlock[],
|
|
254
|
+
requirements: CartComplianceRequirements,
|
|
255
|
+
purchaseLimit: CartComplianceDecision["purchaseLimit"],
|
|
256
|
+
): CartComplianceDecision {
|
|
257
|
+
const failedClosed = blocks.some((b) => b.failedClosed === true);
|
|
258
|
+
const hasHardBlock = blocks.some((b) => HARD_BLOCK_CODES.has(b.code));
|
|
259
|
+
const status: CartComplianceDecision["status"] = hasHardBlock
|
|
260
|
+
? "blocked"
|
|
261
|
+
: blocks.length > 0
|
|
262
|
+
? "action_required"
|
|
263
|
+
: "clear";
|
|
264
|
+
return { status, blocks, requirements, purchaseLimit, failedClosed };
|
|
265
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforcement → EVIDENCE signal bridge. The evidence document's compliance-critical
|
|
3
|
+
* checks (`age_gate`, `excise`, `jurisdiction`) gate whether a tenant is
|
|
4
|
+
* PROMOTABLE. Before this unit those checks asserted only config COHERENCE (the
|
|
5
|
+
* tenant declared a minAge / warning / restriction string). That is not
|
|
6
|
+
* enforcement — a store could declare "21+" and still ship untaxed to a banned
|
|
7
|
+
* state. This bridge reports whether real enforcement is WIRED, so `promotable`
|
|
8
|
+
* reflects enforcement, not just declared intent.
|
|
9
|
+
*
|
|
10
|
+
* The signal is intentionally per-tenant (not per-cart): it answers "can this
|
|
11
|
+
* regulated tenant enforce?", which is exactly the promote question. Fail-closed:
|
|
12
|
+
* a regulated tenant with no resolvable ruleset reports every signal false → not
|
|
13
|
+
* promotable.
|
|
14
|
+
*
|
|
15
|
+
* Pure + edge-safe.
|
|
16
|
+
*/
|
|
17
|
+
import type { ComplianceRuleset } from "./ruleset.js";
|
|
18
|
+
|
|
19
|
+
export interface EnforcementEvidenceSignals {
|
|
20
|
+
/** A data-driven ruleset resolved for this (regulated) tenant. */
|
|
21
|
+
rulesetPresent: boolean;
|
|
22
|
+
/** The ruleset carries jurisdiction rules (state bans/limits) — not an empty list. */
|
|
23
|
+
jurisdictionRulesPresent: boolean;
|
|
24
|
+
/** Verification is satisfiable: a live provider is wired, OR it isn't required. */
|
|
25
|
+
verificationWired: boolean;
|
|
26
|
+
/** Excise obligation has a real handling path (not "unavailable"), OR not required. */
|
|
27
|
+
exciseWired: boolean;
|
|
28
|
+
/** PACT per-order reporting capture is wired when required. */
|
|
29
|
+
reportingWired: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EnforcementSignalInput {
|
|
33
|
+
/** True when the tenant is regulated (age/nicotine). */
|
|
34
|
+
regulated: boolean;
|
|
35
|
+
/** Whether the PACT per-order reporting capture sink is wired in this deployment. */
|
|
36
|
+
reportingCaptureWired: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Derive the enforcement evidence signals for a tenant from its resolved ruleset.
|
|
41
|
+
* Unregulated tenants pass vacuously. A regulated tenant with no ruleset fails
|
|
42
|
+
* every signal (fail-closed → blocks promote).
|
|
43
|
+
*/
|
|
44
|
+
export function enforcementSignals(
|
|
45
|
+
ruleset: ComplianceRuleset | null,
|
|
46
|
+
input: EnforcementSignalInput,
|
|
47
|
+
): EnforcementEvidenceSignals {
|
|
48
|
+
if (!input.regulated) {
|
|
49
|
+
return {
|
|
50
|
+
rulesetPresent: true,
|
|
51
|
+
jurisdictionRulesPresent: true,
|
|
52
|
+
verificationWired: true,
|
|
53
|
+
exciseWired: true,
|
|
54
|
+
reportingWired: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (!ruleset) {
|
|
58
|
+
return {
|
|
59
|
+
rulesetPresent: false,
|
|
60
|
+
jurisdictionRulesPresent: false,
|
|
61
|
+
verificationWired: false,
|
|
62
|
+
exciseWired: false,
|
|
63
|
+
reportingWired: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const verificationWired =
|
|
67
|
+
!ruleset.verification.required || !!ruleset.verification.provider;
|
|
68
|
+
const exciseWired =
|
|
69
|
+
!ruleset.excise.required || ruleset.excise.handling !== "unavailable";
|
|
70
|
+
const reportingWired =
|
|
71
|
+
!ruleset.pactAct.enabled ||
|
|
72
|
+
!ruleset.pactAct.reportingRequired ||
|
|
73
|
+
input.reportingCaptureWired;
|
|
74
|
+
return {
|
|
75
|
+
rulesetPresent: true,
|
|
76
|
+
jurisdictionRulesPresent: ruleset.states.length > 0,
|
|
77
|
+
verificationWired,
|
|
78
|
+
exciseWired,
|
|
79
|
+
reportingWired,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tot/public-runtime — compliance ENFORCEMENT engine (regulated commerce).
|
|
3
|
+
*
|
|
4
|
+
* The pure, edge-safe decision core that turns the compliance NOTICES from
|
|
5
|
+
* decoration into enforcement. Assembled from:
|
|
6
|
+
* - profile.ts — derive a product's regulated characteristics
|
|
7
|
+
* - ruleset.ts — the data-driven jurisdiction/federal rule model
|
|
8
|
+
* - verification.ts — the fail-closed age/identity verification gate
|
|
9
|
+
* - evaluate.ts — the cart→decision evaluator (the single authority)
|
|
10
|
+
* - pact-report.ts — PACT Act monthly-report per-order capture
|
|
11
|
+
* - evidence-signals.ts — enforcement→evidence promote-gate bridge
|
|
12
|
+
*
|
|
13
|
+
* The concrete per-tenant rule DATA lives in the app
|
|
14
|
+
* (`apps/storefront/src/config/compliance/rulesets.ts`); this package owns the
|
|
15
|
+
* SHAPES + the decision logic so they can be unit-tested in one place.
|
|
16
|
+
*/
|
|
17
|
+
export * from "./profile.js";
|
|
18
|
+
export * from "./ruleset.js";
|
|
19
|
+
export * from "./verification.js";
|
|
20
|
+
export * from "./evaluate.js";
|
|
21
|
+
export * from "./pact-report.js";
|
|
22
|
+
export * from "./evidence-signals.js";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PACT Act monthly-report CAPTURE. The Prevent All Cigarette Trafficking (PACT)
|
|
3
|
+
* Act requires ENDS sellers to file monthly reports with each state's tobacco tax
|
|
4
|
+
* administrator listing every shipment into that state. This framework CAPTURES
|
|
5
|
+
* the per-order data those filings need, in a stable record shape; assembling and
|
|
6
|
+
* FILING the actual monthly report is the merchant's / their counsel's job.
|
|
7
|
+
*
|
|
8
|
+
* The record is a SEPARATE compliance sink from the app-forwarded contract order
|
|
9
|
+
* (which is `customerHash`-only by the compliance floor) — PACT reporting
|
|
10
|
+
* legitimately needs buyer name + ship-to address, so it is captured server-side
|
|
11
|
+
* into a compliance sink, never leaked to installed apps.
|
|
12
|
+
*
|
|
13
|
+
* Pure + edge-safe: builds the record from data the caller already holds.
|
|
14
|
+
*/
|
|
15
|
+
import type { ShipDestination } from "./evaluate.js";
|
|
16
|
+
|
|
17
|
+
export const PACT_REPORT_RECORD_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
/** One shipped regulated line, as the PACT report enumerates it. */
|
|
20
|
+
export interface PactReportLine {
|
|
21
|
+
sku: string;
|
|
22
|
+
productName: string;
|
|
23
|
+
/** Category for the report (e.g. "e-liquid", "ends"). */
|
|
24
|
+
category: string;
|
|
25
|
+
quantity: number;
|
|
26
|
+
/** Volume in millilitres when known (state reports often want ml of e-liquid). */
|
|
27
|
+
volumeMl?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The buyer identity a PACT filing needs (captured to the compliance sink only). */
|
|
31
|
+
export interface PactReportBuyer {
|
|
32
|
+
name: string;
|
|
33
|
+
shipTo: ShipDestination & { line1?: string; line2?: string; city?: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PactReportOrderRecord {
|
|
37
|
+
recordVersion: number;
|
|
38
|
+
tenantId: string;
|
|
39
|
+
orderId: string;
|
|
40
|
+
/** ISO 8601 order placed-at. */
|
|
41
|
+
placedAt: string;
|
|
42
|
+
/** Destination state — the report is filed per-state. */
|
|
43
|
+
region: string;
|
|
44
|
+
buyer: PactReportBuyer;
|
|
45
|
+
lines: PactReportLine[];
|
|
46
|
+
/** Total regulated units shipped. */
|
|
47
|
+
totalUnits: number;
|
|
48
|
+
/** Verification evidence reference at time of sale (proves the 21+ check). */
|
|
49
|
+
verificationEvidenceRef?: string;
|
|
50
|
+
/** Adult-signature-on-delivery was required for this shipment. */
|
|
51
|
+
adultSignatureRequired: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BuildPactReportInput {
|
|
55
|
+
tenantId: string;
|
|
56
|
+
orderId: string;
|
|
57
|
+
placedAt: string;
|
|
58
|
+
buyer: PactReportBuyer;
|
|
59
|
+
lines: PactReportLine[];
|
|
60
|
+
verificationEvidenceRef?: string;
|
|
61
|
+
adultSignatureRequired: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the per-order PACT report record. Throws if the ship-to state is missing —
|
|
66
|
+
* a regulated shipment MUST have a filing jurisdiction; capturing it without one
|
|
67
|
+
* would silently drop it from the monthly report.
|
|
68
|
+
*/
|
|
69
|
+
export function buildPactReportRecord(
|
|
70
|
+
input: BuildPactReportInput,
|
|
71
|
+
): PactReportOrderRecord {
|
|
72
|
+
const region = input.buyer.shipTo.region?.trim().toUpperCase();
|
|
73
|
+
if (!region) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"buildPactReportRecord: ship-to region is required for PACT reporting",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const totalUnits = input.lines.reduce(
|
|
79
|
+
(n, l) => n + Math.max(0, l.quantity),
|
|
80
|
+
0,
|
|
81
|
+
);
|
|
82
|
+
return {
|
|
83
|
+
recordVersion: PACT_REPORT_RECORD_VERSION,
|
|
84
|
+
tenantId: input.tenantId,
|
|
85
|
+
orderId: input.orderId,
|
|
86
|
+
placedAt: input.placedAt,
|
|
87
|
+
region,
|
|
88
|
+
buyer: { ...input.buyer, shipTo: { ...input.buyer.shipTo, region } },
|
|
89
|
+
lines: input.lines,
|
|
90
|
+
totalUnits,
|
|
91
|
+
verificationEvidenceRef: input.verificationEvidenceRef,
|
|
92
|
+
adultSignatureRequired: input.adultSignatureRequired,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product compliance PROFILE — the regulated characteristics an enforcement
|
|
3
|
+
* decision matches on, derived from the canonical `CatalogProduct` (which carries
|
|
4
|
+
* NO native compliance attributes — see `product.ts`). The signal sources, in
|
|
5
|
+
* precedence order, are the ones the ToT catalog sync already populates:
|
|
6
|
+
*
|
|
7
|
+
* 1. `metafields[compliance/*]` — ToT-synced per-SKU compliance data
|
|
8
|
+
* (`restricted_states` JSON, `product_category`). This is the authoritative,
|
|
9
|
+
* data-driven per-product restriction source; it is NOT a hardcoded list.
|
|
10
|
+
* 2. `product_type` / `tags` — merchant taxonomy (nicotine/vape/e-liquid, the
|
|
11
|
+
* `F: <flavor>` tag convention the PDP already reads).
|
|
12
|
+
* 3. the chosen variant's `option_values` (Flavor, Nicotine strength).
|
|
13
|
+
*
|
|
14
|
+
* Pure + edge-safe (no node, no I/O). The storefront resolves a product's profile
|
|
15
|
+
* once and feeds it to `evaluateCartCompliance`.
|
|
16
|
+
*/
|
|
17
|
+
import type { CatalogProduct, CatalogVariant } from "../product.js";
|
|
18
|
+
|
|
19
|
+
/** Coarse regulated category. `none` ⇒ not an age/nicotine-restricted product. */
|
|
20
|
+
export type RegulatedCategory =
|
|
21
|
+
| "ends" // electronic nicotine delivery system (device / pod / disposable)
|
|
22
|
+
| "e-liquid" // nicotine e-liquid / salts
|
|
23
|
+
| "other-nicotine"
|
|
24
|
+
| "accessory" // batteries, coils — regulated-adjacent, no nicotine of its own
|
|
25
|
+
| "none";
|
|
26
|
+
|
|
27
|
+
export interface ProductComplianceProfile {
|
|
28
|
+
/** Carries nicotine / is an ENDS product (the age-gate + excise axis). */
|
|
29
|
+
containsNicotine: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Has a characterizing flavor beyond tobacco/menthol — the axis several states
|
|
32
|
+
* ban for ENDS. `false` for tobacco/menthol/unflavored.
|
|
33
|
+
*/
|
|
34
|
+
isFlavored: boolean;
|
|
35
|
+
/** Flavor labels found (lowercased), e.g. ["mango", "ice"]. */
|
|
36
|
+
flavorLabels: string[];
|
|
37
|
+
/** Derived regulated category. */
|
|
38
|
+
category: RegulatedCategory;
|
|
39
|
+
/**
|
|
40
|
+
* Per-product state/jurisdiction restrictions synced from ToT
|
|
41
|
+
* (`compliance.restricted_states`), uppercased region codes. The data-driven
|
|
42
|
+
* per-SKU no-ship list — complements the tenant ruleset's jurisdiction rules.
|
|
43
|
+
*/
|
|
44
|
+
restrictedRegions: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Flavors that do NOT count as "characterizing" for a flavored-ENDS ban. */
|
|
48
|
+
const UNFLAVORED = new Set([
|
|
49
|
+
"tobacco",
|
|
50
|
+
"classic tobacco",
|
|
51
|
+
"menthol",
|
|
52
|
+
"unflavored",
|
|
53
|
+
"none",
|
|
54
|
+
"original",
|
|
55
|
+
"plain",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const NICOTINE_HINTS = [
|
|
59
|
+
"nicotine",
|
|
60
|
+
"vape",
|
|
61
|
+
"vapor",
|
|
62
|
+
"vaping",
|
|
63
|
+
"e-liquid",
|
|
64
|
+
"eliquid",
|
|
65
|
+
"e-juice",
|
|
66
|
+
"ejuice",
|
|
67
|
+
"e liquid",
|
|
68
|
+
"salt nic",
|
|
69
|
+
"nic salt",
|
|
70
|
+
"pod",
|
|
71
|
+
"disposable",
|
|
72
|
+
"ends",
|
|
73
|
+
"cig",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const ACCESSORY_HINTS = ["battery", "coil", "charger", "case", "lanyard"];
|
|
77
|
+
|
|
78
|
+
function readMetafield(
|
|
79
|
+
product: Pick<CatalogProduct, "metafields">,
|
|
80
|
+
namespace: string,
|
|
81
|
+
key: string,
|
|
82
|
+
): string | undefined {
|
|
83
|
+
const mf = product.metafields.find(
|
|
84
|
+
(m) => m.namespace === namespace && m.key === key,
|
|
85
|
+
);
|
|
86
|
+
return mf?.value?.trim() || undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Parse `compliance.restricted_states` — a JSON array OR an object keyed by code. */
|
|
90
|
+
function parseRestrictedRegions(raw: string | undefined): string[] {
|
|
91
|
+
if (!raw) return [];
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
const codes = Array.isArray(parsed) ? parsed : Object.keys(parsed);
|
|
95
|
+
return codes
|
|
96
|
+
.map((c) => String(c).trim().toUpperCase())
|
|
97
|
+
.filter((c) => c.length > 0);
|
|
98
|
+
} catch {
|
|
99
|
+
// Not JSON — tolerate a comma/space separated list.
|
|
100
|
+
return raw
|
|
101
|
+
.split(/[,\s]+/)
|
|
102
|
+
.map((c) => c.trim().toUpperCase())
|
|
103
|
+
.filter((c) => c.length > 0);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Collect flavor labels from the `F: <flavor>` tag convention + a chosen variant. */
|
|
108
|
+
function collectFlavors(
|
|
109
|
+
product: Pick<CatalogProduct, "tags">,
|
|
110
|
+
variant?: Pick<CatalogVariant, "option_values"> | null,
|
|
111
|
+
): string[] {
|
|
112
|
+
const labels = new Set<string>();
|
|
113
|
+
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
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const optFlavor = variant?.option_values?.["Flavor"];
|
|
120
|
+
if (optFlavor) labels.add(optFlavor.trim().toLowerCase());
|
|
121
|
+
return [...labels];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function containsAny(haystack: string, needles: string[]): boolean {
|
|
125
|
+
return needles.some((n) => haystack.includes(n));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Derive a product's compliance profile. Optionally pass the chosen `variant` to
|
|
130
|
+
* refine flavor/nicotine from the selected option values (e.g. a "0mg" variant is
|
|
131
|
+
* not treated as nicotine-bearing on the nicotine-strength axis).
|
|
132
|
+
*/
|
|
133
|
+
export function deriveProductProfile(
|
|
134
|
+
product: Pick<
|
|
135
|
+
CatalogProduct,
|
|
136
|
+
"metafields" | "tags" | "product_type" | "title"
|
|
137
|
+
>,
|
|
138
|
+
variant?: Pick<CatalogVariant, "option_values"> | null,
|
|
139
|
+
): ProductComplianceProfile {
|
|
140
|
+
const category = readMetafield(product, "compliance", "product_category");
|
|
141
|
+
const type = (product.product_type ?? category ?? "").toLowerCase();
|
|
142
|
+
const haystack = [
|
|
143
|
+
type,
|
|
144
|
+
(product.title ?? "").toLowerCase(),
|
|
145
|
+
...(product.tags ?? []).map((t) => t.toLowerCase()),
|
|
146
|
+
].join(" ");
|
|
147
|
+
|
|
148
|
+
const restrictedRegions = parseRestrictedRegions(
|
|
149
|
+
readMetafield(product, "compliance", "restricted_states"),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const isAccessory =
|
|
153
|
+
containsAny(haystack, ACCESSORY_HINTS) &&
|
|
154
|
+
!containsAny(haystack, ["e-liquid", "eliquid", "juice", "salt"]);
|
|
155
|
+
|
|
156
|
+
// Nicotine strength option: a stated non-zero strength is a strong signal; an
|
|
157
|
+
// explicit "0mg"/"0 mg" variant is NOT treated as nicotine on that axis.
|
|
158
|
+
const nicStrength = variant?.option_values?.["Nicotine strength"]?.toLowerCase();
|
|
159
|
+
const nicOptionSaysZero = nicStrength != null && /^0\s*mg\b/.test(nicStrength);
|
|
160
|
+
const nicOptionSaysNonZero =
|
|
161
|
+
nicStrength != null && /\d/.test(nicStrength) && !nicOptionSaysZero;
|
|
162
|
+
|
|
163
|
+
const containsNicotine =
|
|
164
|
+
!isAccessory &&
|
|
165
|
+
(nicOptionSaysNonZero ||
|
|
166
|
+
(!nicOptionSaysZero && containsAny(haystack, NICOTINE_HINTS)));
|
|
167
|
+
|
|
168
|
+
const flavorLabels = collectFlavors(product, variant);
|
|
169
|
+
const isFlavored =
|
|
170
|
+
containsNicotine &&
|
|
171
|
+
flavorLabels.some((f) => !UNFLAVORED.has(f)) &&
|
|
172
|
+
flavorLabels.length > 0;
|
|
173
|
+
|
|
174
|
+
let cat: RegulatedCategory = "none";
|
|
175
|
+
if (isAccessory) cat = "accessory";
|
|
176
|
+
else if (containsAny(haystack, ["e-liquid", "eliquid", "juice", "salt"]))
|
|
177
|
+
cat = "e-liquid";
|
|
178
|
+
else if (containsNicotine && containsAny(haystack, ["pod", "device", "kit", "disposable", "vape", "ends"]))
|
|
179
|
+
cat = "ends";
|
|
180
|
+
else if (containsNicotine) cat = "other-nicotine";
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
containsNicotine,
|
|
184
|
+
isFlavored,
|
|
185
|
+
flavorLabels,
|
|
186
|
+
category: cat,
|
|
187
|
+
restrictedRegions,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** True when a profile carries ANY regulated characteristic that enforcement covers. */
|
|
192
|
+
export function isRegulatedProfile(p: ProductComplianceProfile): boolean {
|
|
193
|
+
return (
|
|
194
|
+
p.containsNicotine ||
|
|
195
|
+
p.category !== "none" ||
|
|
196
|
+
p.restrictedRegions.length > 0
|
|
197
|
+
);
|
|
198
|
+
}
|