@tokenoftrust/storefront-runner 1.3.4-rc.3 → 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/obstacleStore.ts +214 -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 +496 -74
- 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,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Membership + Autopilot (subscription) purchase MECHANICS — the renderer-safe
|
|
3
|
+
* pricing/eligibility engine (F10). This is the real, testable side of the
|
|
4
|
+
* education-only widgets in `lib/membership` / `lib/subscription`: it computes
|
|
5
|
+
* member-vs-list pricing, composes a member discount with a subscription
|
|
6
|
+
* (Autopilot) discount under a tenant-configurable STACKING policy, and decides
|
|
7
|
+
* which purchase options a shopper actually sees.
|
|
8
|
+
*
|
|
9
|
+
* Node-free + dependency-free so it ships into the Cloudflare Worker (edge)
|
|
10
|
+
* bundle exactly like {@link ./checkout.ts} — it reuses that module's
|
|
11
|
+
* `purchaseOptionPrice` for the subscription leg so a subscription discount is
|
|
12
|
+
* computed in one place.
|
|
13
|
+
*
|
|
14
|
+
* HIDE-AT-LAUNCH is a first-class state. A production tenant that isn't ready to
|
|
15
|
+
* sell Autopilot / member pricing at launch turns each off with a config flag
|
|
16
|
+
* ({@link AutopilotConfig.enabled} / {@link MembershipProgramConfig.enabled}):
|
|
17
|
+
* the mechanics stay real and unit-tested, but nothing renders. See
|
|
18
|
+
* `docs/architecture/membership-subscription-mechanics.md`.
|
|
19
|
+
*/
|
|
20
|
+
import type {
|
|
21
|
+
CatalogProduct,
|
|
22
|
+
CatalogVariant,
|
|
23
|
+
ProductPurchaseOption,
|
|
24
|
+
PurchaseOptionKind,
|
|
25
|
+
PurchaseOptionPriceAdjustment,
|
|
26
|
+
} from "./product.js";
|
|
27
|
+
import { buildVariantCartPayload, purchaseOptionPrice } from "./checkout.js";
|
|
28
|
+
import type {
|
|
29
|
+
BuildVariantCartPayloadOptions,
|
|
30
|
+
VariantCartPayload,
|
|
31
|
+
} from "./checkout.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One membership tier's pricing rule: the adjustment applied to the LIST price
|
|
35
|
+
* for a shopper who belongs to `tierId`. The adjustment shape is the same one
|
|
36
|
+
* the catalog subscription options use, so a tenant authors member and
|
|
37
|
+
* subscription discounts in one vocabulary.
|
|
38
|
+
*/
|
|
39
|
+
export interface MembershipTierRule {
|
|
40
|
+
/** Tier identifier, matched against {@link MemberContext.tierId}. */
|
|
41
|
+
tierId: string;
|
|
42
|
+
/** Optional display label, e.g. "Member". */
|
|
43
|
+
label?: string;
|
|
44
|
+
/** Discount applied to the list price for this tier. */
|
|
45
|
+
priceAdjustment: PurchaseOptionPriceAdjustment;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How a member discount and a subscription (Autopilot) discount combine when a
|
|
50
|
+
* member buys a subscription option. Parameterized because real tenant rules
|
|
51
|
+
* are not yet known (F10 acceptance) — a tenant picks the policy that matches
|
|
52
|
+
* its program; tests exercise every branch.
|
|
53
|
+
*
|
|
54
|
+
* - `best_of` (default): the shopper pays the lower of {member-only, subscription-
|
|
55
|
+
* only}. Never double-discounts, never worse than either single offer.
|
|
56
|
+
* - `stack`: apply the member discount ON TOP of the subscription price.
|
|
57
|
+
* - `member_only`: the member discount wins; the subscription discount is
|
|
58
|
+
* ignored on subscription options (member price still applies to one-time).
|
|
59
|
+
* - `subscription_only`: the subscription discount wins; the member discount is
|
|
60
|
+
* ignored on subscription options (member price still applies to one-time).
|
|
61
|
+
*/
|
|
62
|
+
export type MembershipStackingPolicy =
|
|
63
|
+
| "best_of"
|
|
64
|
+
| "stack"
|
|
65
|
+
| "member_only"
|
|
66
|
+
| "subscription_only";
|
|
67
|
+
|
|
68
|
+
export const DEFAULT_STACKING_POLICY: MembershipStackingPolicy = "best_of";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tenant membership program config. Lives on `TenantConfig.commerce.membership`.
|
|
72
|
+
* ABSENT or `enabled !== true` → member pricing is OFF (the launch default, since
|
|
73
|
+
* shopper membership accounts are a Phase-2 managed-checkout capability). A
|
|
74
|
+
* tenant turns it on only once it has a real member-eligibility source.
|
|
75
|
+
*/
|
|
76
|
+
export interface MembershipProgramConfig {
|
|
77
|
+
/** Master switch. Omit/false → no member pricing renders (hide-at-launch). */
|
|
78
|
+
enabled?: boolean;
|
|
79
|
+
/** Pricing rules per tier. */
|
|
80
|
+
tiers: MembershipTierRule[];
|
|
81
|
+
/** How member + subscription discounts combine. Default {@link DEFAULT_STACKING_POLICY}. */
|
|
82
|
+
stacking?: MembershipStackingPolicy;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Tenant Autopilot (subscription) config. Lives on `TenantConfig.commerce.autopilot`.
|
|
87
|
+
* ABSENT → Autopilot is visible wherever a product authors subscription purchase
|
|
88
|
+
* options (the framework/dev default). `enabled: false` HIDES every subscription
|
|
89
|
+
* purchase option at launch — the recorded PVC readiness state (Autopilot is
|
|
90
|
+
* marketing-education-only until the Phase-2 managed checkout ships).
|
|
91
|
+
*/
|
|
92
|
+
export interface AutopilotConfig {
|
|
93
|
+
/** `false` hides all subscription purchase options. Absent/true → visible. */
|
|
94
|
+
enabled?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The resolved shopper membership context for a request. Phase 1 storefronts
|
|
99
|
+
* have no shopper-login source, so this is `{ isMember: false }`; production
|
|
100
|
+
* wires it from the resolved account claim. See
|
|
101
|
+
* `apps/storefront/src/lib/membership/eligibility.ts`.
|
|
102
|
+
*/
|
|
103
|
+
export interface MemberContext {
|
|
104
|
+
isMember: boolean;
|
|
105
|
+
/** The member's tier, matched against {@link MembershipTierRule.tierId}. */
|
|
106
|
+
tierId?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A non-member context — the safe default when no membership source resolves. */
|
|
110
|
+
export const ANONYMOUS_MEMBER: MemberContext = { isMember: false };
|
|
111
|
+
|
|
112
|
+
/** True when Autopilot (subscription options) should render for this tenant. */
|
|
113
|
+
export function isAutopilotEnabled(config?: AutopilotConfig | null): boolean {
|
|
114
|
+
return config?.enabled !== false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** True when member pricing should render for this tenant (opt-in). */
|
|
118
|
+
export function isMembershipEnabled(
|
|
119
|
+
config?: MembershipProgramConfig | null,
|
|
120
|
+
): boolean {
|
|
121
|
+
return config?.enabled === true && (config?.tiers?.length ?? 0) > 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The membership rule that applies to a shopper right now, or `undefined` when
|
|
126
|
+
* member pricing is off, the shopper is not a member, or no tier matches. A
|
|
127
|
+
* single-tier program matches any member (the common case); a multi-tier program
|
|
128
|
+
* matches on {@link MemberContext.tierId}.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveMembershipRule(
|
|
131
|
+
member: MemberContext,
|
|
132
|
+
program?: MembershipProgramConfig | null,
|
|
133
|
+
): MembershipTierRule | undefined {
|
|
134
|
+
if (!member.isMember || !isMembershipEnabled(program)) return undefined;
|
|
135
|
+
const tiers = program!.tiers;
|
|
136
|
+
if (member.tierId) {
|
|
137
|
+
return tiers.find((t) => t.tierId === member.tierId);
|
|
138
|
+
}
|
|
139
|
+
return tiers.length === 1 ? tiers[0] : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Apply a price adjustment to a base price, with the same validation floor as
|
|
144
|
+
* the subscription adjustment in checkout.ts: no negative/over-100% percent, no
|
|
145
|
+
* amount larger than the base. Returns a cent-rounded price.
|
|
146
|
+
*/
|
|
147
|
+
export function applyPriceAdjustment(
|
|
148
|
+
basePrice: number,
|
|
149
|
+
adjustment: PurchaseOptionPriceAdjustment,
|
|
150
|
+
): number {
|
|
151
|
+
if (!Number.isFinite(adjustment.value) || adjustment.value < 0) {
|
|
152
|
+
throw new Error("membership price adjustment must be a non-negative number");
|
|
153
|
+
}
|
|
154
|
+
if (adjustment.type === "percent_off" && adjustment.value > 100) {
|
|
155
|
+
throw new Error("membership percent discount cannot exceed 100%");
|
|
156
|
+
}
|
|
157
|
+
if (adjustment.type === "amount_off" && adjustment.value > basePrice) {
|
|
158
|
+
throw new Error("membership amount discount cannot exceed the price");
|
|
159
|
+
}
|
|
160
|
+
const adjusted =
|
|
161
|
+
adjustment.type === "percent_off"
|
|
162
|
+
? basePrice * (1 - adjustment.value / 100)
|
|
163
|
+
: basePrice - adjustment.value;
|
|
164
|
+
return Number(adjusted.toFixed(2));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Which discount(s) produced a priced option's effective price. */
|
|
168
|
+
export type AppliedDiscount = "member" | "subscription";
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A purchase option priced for a specific shopper: the list price, the effective
|
|
172
|
+
* price after every applicable discount, and which discounts applied. `compareAt`
|
|
173
|
+
* is the list price when the effective price beats it (drives strike-through).
|
|
174
|
+
*/
|
|
175
|
+
export interface PricedPurchaseOption {
|
|
176
|
+
optionId: string;
|
|
177
|
+
kind: PurchaseOptionKind;
|
|
178
|
+
/** Undiscounted variant price. */
|
|
179
|
+
listPrice: number;
|
|
180
|
+
/** Effective price the shopper pays under the resolved discounts + stacking. */
|
|
181
|
+
price: number;
|
|
182
|
+
/** Discounts that shaped `price`. Empty when the shopper pays list. */
|
|
183
|
+
appliedDiscounts: AppliedDiscount[];
|
|
184
|
+
/** List price to strike through, present only when `price < listPrice`. */
|
|
185
|
+
compareAt?: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface PurchaseOptionPricingContext {
|
|
189
|
+
member: MemberContext;
|
|
190
|
+
program?: MembershipProgramConfig | null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Price ONE purchase option for a shopper, composing the member discount (if the
|
|
195
|
+
* shopper is an eligible member) with the option's subscription discount (if any)
|
|
196
|
+
* under the program's stacking policy.
|
|
197
|
+
*/
|
|
198
|
+
export function priceForPurchaseOption(
|
|
199
|
+
listPrice: number,
|
|
200
|
+
option: ProductPurchaseOption,
|
|
201
|
+
ctx: PurchaseOptionPricingContext,
|
|
202
|
+
): PricedPurchaseOption {
|
|
203
|
+
const rule = resolveMembershipRule(ctx.member, ctx.program);
|
|
204
|
+
const stacking = ctx.program?.stacking ?? DEFAULT_STACKING_POLICY;
|
|
205
|
+
|
|
206
|
+
const memberPrice =
|
|
207
|
+
rule !== undefined ? applyPriceAdjustment(listPrice, rule.priceAdjustment) : undefined;
|
|
208
|
+
|
|
209
|
+
if (option.kind !== "subscription") {
|
|
210
|
+
// One-time: member discount is the only lever.
|
|
211
|
+
const price = memberPrice ?? listPrice;
|
|
212
|
+
return finalize(option, listPrice, price, memberPrice !== undefined ? ["member"] : []);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Subscription: the option's own discount, then compose with the member discount.
|
|
216
|
+
const subPrice = purchaseOptionPrice(listPrice, option);
|
|
217
|
+
const subApplied = subPrice < listPrice;
|
|
218
|
+
|
|
219
|
+
if (memberPrice === undefined) {
|
|
220
|
+
return finalize(option, listPrice, subPrice, subApplied ? ["subscription"] : []);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
switch (stacking) {
|
|
224
|
+
case "member_only":
|
|
225
|
+
return finalize(option, listPrice, memberPrice, ["member"]);
|
|
226
|
+
case "subscription_only":
|
|
227
|
+
return finalize(option, listPrice, subPrice, subApplied ? ["subscription"] : []);
|
|
228
|
+
case "stack": {
|
|
229
|
+
// Apply the member adjustment on top of the already-subscription price.
|
|
230
|
+
const stacked = applyPriceAdjustment(subPrice, rule!.priceAdjustment);
|
|
231
|
+
const applied: AppliedDiscount[] = [];
|
|
232
|
+
if (subApplied) applied.push("subscription");
|
|
233
|
+
if (stacked < subPrice) applied.push("member");
|
|
234
|
+
return finalize(option, listPrice, stacked, applied);
|
|
235
|
+
}
|
|
236
|
+
case "best_of":
|
|
237
|
+
default: {
|
|
238
|
+
// Whichever single discount is cheaper for the shopper.
|
|
239
|
+
if (memberPrice <= subPrice) {
|
|
240
|
+
return finalize(option, listPrice, memberPrice, ["member"]);
|
|
241
|
+
}
|
|
242
|
+
return finalize(option, listPrice, subPrice, subApplied ? ["subscription"] : []);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function finalize(
|
|
248
|
+
option: ProductPurchaseOption,
|
|
249
|
+
listPrice: number,
|
|
250
|
+
price: number,
|
|
251
|
+
appliedDiscounts: AppliedDiscount[],
|
|
252
|
+
): PricedPurchaseOption {
|
|
253
|
+
return {
|
|
254
|
+
optionId: option.id,
|
|
255
|
+
kind: option.kind,
|
|
256
|
+
listPrice,
|
|
257
|
+
price,
|
|
258
|
+
appliedDiscounts,
|
|
259
|
+
compareAt: price < listPrice ? listPrice : undefined,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Options for {@link buildMemberSignedCartPayload}. */
|
|
264
|
+
export interface MemberSignedCartPayloadOptions {
|
|
265
|
+
/** Signing mode — same as the base cart builder ("signed" | "unsigned-demo"). */
|
|
266
|
+
mode: BuildVariantCartPayloadOptions["mode"];
|
|
267
|
+
/** Cart-validation secret (required in `signed` mode). */
|
|
268
|
+
secret?: string;
|
|
269
|
+
/** The purchase option this payload buys (already visibility-filtered). */
|
|
270
|
+
purchaseOption: ProductPurchaseOption;
|
|
271
|
+
/** The shopper's resolved member pricing context for THIS request. */
|
|
272
|
+
pricing: PurchaseOptionPricingContext;
|
|
273
|
+
/**
|
|
274
|
+
* Whether Autopilot is enabled for the tenant. Forwarded to the signer's
|
|
275
|
+
* hide-at-launch guard so a subscription option can't be signed when Autopilot
|
|
276
|
+
* is off. Callers should still pre-filter with {@link visiblePurchaseOptions}.
|
|
277
|
+
*/
|
|
278
|
+
autopilotEnabled: boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Build a signed add-to-cart payload whose price is the MEMBER-ADJUSTED effective
|
|
283
|
+
* price for the resolved shopper (F11b) — never the raw list price. The effective
|
|
284
|
+
* price is computed here, server-side, from {@link priceForPurchaseOption} (the
|
|
285
|
+
* single signed source of truth) using the membership eligibility resolved for
|
|
286
|
+
* this request, then HMAC-signed by {@link buildVariantCartPayload}.
|
|
287
|
+
*
|
|
288
|
+
* Because the price is signed per (variant, purchaseOption, memberContext), a
|
|
289
|
+
* member price is only valid on a page the server rendered for that member: a
|
|
290
|
+
* non-member's page carries the list-price signature, so a member price submitted
|
|
291
|
+
* against it fails the checkout engine's HMAC verification — and vice-versa. The
|
|
292
|
+
* client NEVER re-derives the price it pays; it can only submit what the server
|
|
293
|
+
* signed for its resolved eligibility. An anonymous/non-eligible context simply
|
|
294
|
+
* resolves to the list/subscription price, exactly matching the pre-F11b behavior.
|
|
295
|
+
*/
|
|
296
|
+
export async function buildMemberSignedCartPayload(
|
|
297
|
+
product: CatalogProduct,
|
|
298
|
+
variant: CatalogVariant,
|
|
299
|
+
opts: MemberSignedCartPayloadOptions,
|
|
300
|
+
): Promise<VariantCartPayload> {
|
|
301
|
+
const effective = priceForPurchaseOption(
|
|
302
|
+
variant.price,
|
|
303
|
+
opts.purchaseOption,
|
|
304
|
+
opts.pricing,
|
|
305
|
+
);
|
|
306
|
+
return buildVariantCartPayload(product, variant, {
|
|
307
|
+
mode: opts.mode,
|
|
308
|
+
secret: opts.secret,
|
|
309
|
+
purchaseOption: opts.purchaseOption,
|
|
310
|
+
signedPrice: effective.price,
|
|
311
|
+
autopilotEnabled: opts.autopilotEnabled,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Filter a product's purchase options down to what a shopper should see for this
|
|
317
|
+
* tenant. When Autopilot is hidden ({@link isAutopilotEnabled} false) every
|
|
318
|
+
* subscription option is dropped; a one-time option is always preserved so the
|
|
319
|
+
* product stays buyable (falls back to a synthetic default if the product only
|
|
320
|
+
* authored subscription options).
|
|
321
|
+
*/
|
|
322
|
+
export function visiblePurchaseOptions(
|
|
323
|
+
options: ProductPurchaseOption[],
|
|
324
|
+
opts: { autopilotEnabled: boolean },
|
|
325
|
+
): ProductPurchaseOption[] {
|
|
326
|
+
if (opts.autopilotEnabled) return options;
|
|
327
|
+
const oneTime = options.filter((o) => o.kind !== "subscription");
|
|
328
|
+
if (oneTime.length > 0) return oneTime;
|
|
329
|
+
return [{ id: "one_time", kind: "one_time", label: "One-time purchase" }];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const INTERVAL_UNIT_LABELS: Record<string, { one: string; many: string }> = {
|
|
333
|
+
d: { one: "day", many: "days" },
|
|
334
|
+
w: { one: "week", many: "weeks" },
|
|
335
|
+
m: { one: "month", many: "months" },
|
|
336
|
+
y: { one: "year", many: "years" },
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Human-readable delivery-interval label for a subscription `frequency` (the
|
|
341
|
+
* compact checkout recurrence code, e.g. "1m", "60d", "2w"). Returns "Every
|
|
342
|
+
* month" / "Every 60 days" style copy; falls back to the raw code for anything
|
|
343
|
+
* it can't parse (never throws — display helper).
|
|
344
|
+
*/
|
|
345
|
+
export function describeInterval(frequency: string): string {
|
|
346
|
+
const match = /^(\d+(?:\.\d+)?|\.\d+)([dwmy])$/.exec(frequency);
|
|
347
|
+
if (!match) return frequency;
|
|
348
|
+
const amount = Number.parseFloat(match[1]!);
|
|
349
|
+
const unit = INTERVAL_UNIT_LABELS[match[2]!];
|
|
350
|
+
if (!unit || !Number.isFinite(amount) || amount <= 0) return frequency;
|
|
351
|
+
if (amount === 1) return `Every ${unit.one}`;
|
|
352
|
+
return `Every ${amount} ${unit.many}`;
|
|
353
|
+
}
|
|
@@ -17,9 +17,23 @@ export type ProductStatus = "active" | "draft" | "archived";
|
|
|
17
17
|
|
|
18
18
|
export type Currency = string; // ISO 4217, e.g. "USD"
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Known catalog ingestion sources. "shopify" is the Phase-1 ETL source; the F7
|
|
22
|
+
* ingestion contract adds source-agnostic paths — a directory extraction, a
|
|
23
|
+
* CSV / PIM / ERP export, or a hand-authored record. Nothing branches on this
|
|
24
|
+
* at runtime; it is honest provenance for audit + the importer's source seam.
|
|
25
|
+
* Widen this union (not a per-package copy) when a new source lands.
|
|
26
|
+
*/
|
|
27
|
+
export type SourcePlatform =
|
|
28
|
+
| "shopify"
|
|
29
|
+
| "directory"
|
|
30
|
+
| "csv"
|
|
31
|
+
| "manual"
|
|
32
|
+
| "synthesized";
|
|
33
|
+
|
|
20
34
|
export interface SourceRef {
|
|
21
|
-
/** Origin platform
|
|
22
|
-
platform:
|
|
35
|
+
/** Origin platform (provenance only; not branched on at runtime). */
|
|
36
|
+
platform: SourcePlatform;
|
|
23
37
|
/** Stable source id, e.g. Shopify legacyResourceId or gid. Upsert key. */
|
|
24
38
|
source_id: string;
|
|
25
39
|
/** Original handle/slug from the source platform (pre-collision-resolution). */
|
|
@@ -99,6 +113,14 @@ export interface CatalogImage {
|
|
|
99
113
|
variant_ids?: string[];
|
|
100
114
|
/** True if rehosted to our own storage; false if still pointing at Shopify CDN. */
|
|
101
115
|
rehosted?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* The ORIGINAL source URL this image was rehosted from (provenance). Set once,
|
|
118
|
+
* on the first rehost, and preserved verbatim across idempotent re-runs — so
|
|
119
|
+
* `url` can flip to the R2/CDN address while the audit trail back to the
|
|
120
|
+
* merchant's source (Shopify CDN, etc.) is never lost. Absent on images that
|
|
121
|
+
* were never rehosted (their live `url` is still the source).
|
|
122
|
+
*/
|
|
123
|
+
source_url?: string;
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
export interface Metafield {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review / trust-proof contract (spec §F9; docs/architecture/review-markup-policy.md).
|
|
3
|
+
*
|
|
4
|
+
* The governing rule: ratings, counts, reviews, and "verified"/social-proof
|
|
5
|
+
* badges are REAL merchant data or ABSENT — never fabricated. This module is the
|
|
6
|
+
* single, node-free home for that contract's two enforcement pieces:
|
|
7
|
+
*
|
|
8
|
+
* 1. `isFabricatedTrustProof` / `auditTrustProof` — the go-live GUARD. Catches
|
|
9
|
+
* scraped third-party review-widget markup (Judge.me / Stamped / Yotpo /
|
|
10
|
+
* Loox / Okendo …) with baked-in aggregate numbers before it reaches a
|
|
11
|
+
* rendered surface. Used at ingestion (migration strips it) AND as a
|
|
12
|
+
* fixture lint (the storefront asserts the shipped catalog is clean).
|
|
13
|
+
* 2. `readReviewAggregate` — the single reader of a product's REAL aggregate
|
|
14
|
+
* rating from migrated metafields, real-or-absent by construction.
|
|
15
|
+
*
|
|
16
|
+
* Kept in @tot/public-runtime (renderer-safe, node-free) so both the migration
|
|
17
|
+
* pipeline (via @tot/private-controlplane's re-export) and the edge storefront
|
|
18
|
+
* share ONE definition of "what fabricated trust proof looks like".
|
|
19
|
+
*/
|
|
20
|
+
import type { CatalogProduct, Metafield } from "./product.js";
|
|
21
|
+
import type { Review } from "./product.js";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Fabricated-trust-proof detection.
|
|
25
|
+
//
|
|
26
|
+
// A migrated Shopify store carries the raw HTML of whatever reviews app it used
|
|
27
|
+
// (Judge.me `jdgm-*` badges/widgets, Stamped/Yotpo/Loox/Okendo snippets) dumped
|
|
28
|
+
// into product metafields. That markup has aggregate numbers baked in — "Based
|
|
29
|
+
// on 369 reviews", "95% (351) reviews with 5 star rating", data-average-rating —
|
|
30
|
+
// which are NOT backed by any live review source on the new storefront. Rendering
|
|
31
|
+
// it manufactures social proof, so it is forbidden: reviews may only enter through
|
|
32
|
+
// the real `Review[]` ingestion seam (see packages/migration/src/reviews/*).
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** Markup/text signatures of a scraped third-party review widget or badge. */
|
|
36
|
+
const FABRICATED_TRUST_PROOF_SIGNATURES: readonly RegExp[] = [
|
|
37
|
+
/jdgm[-_]/i, // Judge.me widget/badge classes (jdgm-prev-badge, jdgm-rev-widg)
|
|
38
|
+
/data-(?:average-rating|number-of-reviews)\b/i, // Judge.me / Shopify review data-attrs
|
|
39
|
+
/class=["'][^"']*\bstamped[-_]/i, // Stamped.io widget markup
|
|
40
|
+
/\byotpo[-_]/i, // Yotpo widget/classes
|
|
41
|
+
/\b(?:loox|okendo|kudobuzz|shopperapproved)\b/i, // other review apps' widget hooks
|
|
42
|
+
/reviews\.io/i, // Reviews.io widget
|
|
43
|
+
/class=["'][^"']*\breview[-_]?(?:widget|badge|snippet|stars?)\b/i, // generic review-widget class
|
|
44
|
+
/\bBased on\s+\d+\s+reviews?\b/i, // baked-in aggregate sentence
|
|
45
|
+
/\d+%\s*\(\d+\)\s*reviews?\s+with\s+\d+\s+star/i, // baked-in star-breakdown row
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when a value (a metafield value or a chunk of content) carries scraped
|
|
50
|
+
* review-widget markup / a baked-in aggregate — i.e. fabricated trust proof.
|
|
51
|
+
* Plain product copy, and the numeric aggregate metafields (`reviews.rating`,
|
|
52
|
+
* `reviews.rating_count`, `stamped.reviews_*`), do NOT match.
|
|
53
|
+
*/
|
|
54
|
+
export function isFabricatedTrustProof(value: unknown): boolean {
|
|
55
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
56
|
+
return FABRICATED_TRUST_PROOF_SIGNATURES.some((re) => re.test(value));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A metafield is fabricated trust proof when its value carries widget markup. */
|
|
60
|
+
export function isFabricatedTrustProofMetafield(m: Pick<Metafield, "value">): boolean {
|
|
61
|
+
return isFabricatedTrustProof(m.value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Real aggregate reader (real-or-absent).
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export interface ReviewAggregate {
|
|
69
|
+
/** 0 when there is no real aggregate. */
|
|
70
|
+
average: number;
|
|
71
|
+
/** 0 when there is no real aggregate. */
|
|
72
|
+
count: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ABSENT: ReviewAggregate = { average: 0, count: 0 };
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse a metafield rating value. Merchants store it either as a bare number
|
|
79
|
+
* ("4.9") or as a Shopify rating JSON blob
|
|
80
|
+
* ({"scale_min":"1.0","scale_max":"5.0","value":"4.94"}). Anything unparseable
|
|
81
|
+
* (or non-finite) reads as 0 — never a fabricated fallback.
|
|
82
|
+
*/
|
|
83
|
+
export function parseReviewAverage(
|
|
84
|
+
raw: string | number | null | undefined,
|
|
85
|
+
): number {
|
|
86
|
+
if (raw == null || raw === "") return 0;
|
|
87
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : 0;
|
|
88
|
+
const s = raw.trim();
|
|
89
|
+
if (s.startsWith("{")) {
|
|
90
|
+
try {
|
|
91
|
+
const o = JSON.parse(s) as Record<string, unknown>;
|
|
92
|
+
const v = Number(o.value ?? o.average ?? o.rating);
|
|
93
|
+
return Number.isFinite(v) ? v : 0;
|
|
94
|
+
} catch {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const n = Number(s);
|
|
99
|
+
return Number.isFinite(n) ? n : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read a product's REAL aggregate rating from its migrated metafields. Surfaces
|
|
104
|
+
* the aggregate ONLY when BOTH the average and the count are real, positive,
|
|
105
|
+
* finite numbers. A half-populated aggregate (a rating with no count, or a
|
|
106
|
+
* count with no rating) is treated as ABSENT — the render/JSON-LD layers then
|
|
107
|
+
* emit nothing, so no fabricated rating can appear.
|
|
108
|
+
*/
|
|
109
|
+
export function readReviewAggregate(
|
|
110
|
+
product: Pick<CatalogProduct, "metafields">,
|
|
111
|
+
): ReviewAggregate {
|
|
112
|
+
const mf = (ns: string, key: string): string | undefined =>
|
|
113
|
+
product.metafields.find((m) => m.namespace === ns && m.key === key)?.value;
|
|
114
|
+
|
|
115
|
+
const count = Number(
|
|
116
|
+
mf("stamped", "reviews_count") ??
|
|
117
|
+
mf("reviews", "count") ??
|
|
118
|
+
mf("reviews", "rating_count") ??
|
|
119
|
+
"",
|
|
120
|
+
);
|
|
121
|
+
const average = parseReviewAverage(
|
|
122
|
+
mf("stamped", "reviews_average") ?? mf("reviews", "rating"),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (!Number.isFinite(count) || count <= 0 || average <= 0) return ABSENT;
|
|
126
|
+
return { average, count };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Per-review reality + the go-live audit.
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A review is "real" when it carries a genuine author, a rating in 1–5, and
|
|
135
|
+
* some content (body or title). Empty/placeholder/synthetic entries fail.
|
|
136
|
+
*/
|
|
137
|
+
export function isRealReview(r: Partial<Review>): boolean {
|
|
138
|
+
const hasAuthor = typeof r.author === "string" && r.author.trim().length > 0;
|
|
139
|
+
const hasRating =
|
|
140
|
+
typeof r.rating === "number" && r.rating >= 1 && r.rating <= 5;
|
|
141
|
+
const hasContent =
|
|
142
|
+
(typeof r.body === "string" && r.body.trim().length > 0) ||
|
|
143
|
+
(typeof r.title === "string" && r.title.trim().length > 0);
|
|
144
|
+
return hasAuthor && hasRating && hasContent;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface TrustProofViolation {
|
|
148
|
+
tenant?: string;
|
|
149
|
+
handle?: string;
|
|
150
|
+
kind: "fabricated-metafield" | "invalid-review";
|
|
151
|
+
detail: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Audit a tenant's catalog for fabricated trust proof — the go-live guard.
|
|
156
|
+
* Flags (a) any product metafield carrying scraped review-widget markup, and
|
|
157
|
+
* (b) any ingested review that isn't structurally real. `ok` is the gate:
|
|
158
|
+
* a clean tenant (e.g. PVC, 0 reviews) passes; fabricated markup fails.
|
|
159
|
+
*/
|
|
160
|
+
export function auditTrustProof(input: {
|
|
161
|
+
products: ReadonlyArray<Pick<CatalogProduct, "handle" | "metafields">>;
|
|
162
|
+
reviews?: Record<string, ReadonlyArray<Partial<Review>>>;
|
|
163
|
+
tenant?: string;
|
|
164
|
+
}): { ok: boolean; violations: TrustProofViolation[] } {
|
|
165
|
+
const violations: TrustProofViolation[] = [];
|
|
166
|
+
|
|
167
|
+
for (const p of input.products) {
|
|
168
|
+
for (const m of p.metafields ?? []) {
|
|
169
|
+
if (isFabricatedTrustProofMetafield(m)) {
|
|
170
|
+
violations.push({
|
|
171
|
+
tenant: input.tenant,
|
|
172
|
+
handle: p.handle,
|
|
173
|
+
kind: "fabricated-metafield",
|
|
174
|
+
detail: `metafield ${m.namespace}.${m.key} carries scraped review-widget markup / a baked-in aggregate`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const [handle, list] of Object.entries(input.reviews ?? {})) {
|
|
181
|
+
list.forEach((r, i) => {
|
|
182
|
+
if (!isRealReview(r)) {
|
|
183
|
+
violations.push({
|
|
184
|
+
tenant: input.tenant,
|
|
185
|
+
handle,
|
|
186
|
+
kind: "invalid-review",
|
|
187
|
+
detail: `review #${i} is missing a real author / rating(1–5) / content`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { ok: violations.length === 0, violations };
|
|
194
|
+
}
|
|
@@ -330,18 +330,44 @@ export async function readAssetsPointer(
|
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
* `
|
|
333
|
+
* Optimistic-concurrency conflict on the assets pointer — the local analogue of
|
|
334
|
+
* `@tot/private-controlplane`'s `CustomizationError("pointer-conflict")` (which
|
|
335
|
+
* this renderer-safe module cannot import, to keep the purity boundary intact).
|
|
336
|
+
* Carries the same `"pointer-conflict"` code so callers can branch identically.
|
|
337
|
+
*/
|
|
338
|
+
export class AssetsPointerConflictError extends Error {
|
|
339
|
+
readonly code = "pointer-conflict" as const;
|
|
340
|
+
constructor(
|
|
341
|
+
readonly tenantId: string,
|
|
342
|
+
readonly expectedRev: number,
|
|
343
|
+
readonly foundRev: number,
|
|
344
|
+
) {
|
|
345
|
+
super(
|
|
346
|
+
`Assets pointer for "${tenantId}" changed (expected rev ${expectedRev}, found ${foundRev}).`,
|
|
347
|
+
);
|
|
348
|
+
this.name = "AssetsPointerConflictError";
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Point a channel at a snapshot versionId (CAS on `rev`). KV has no hardware CAS,
|
|
354
|
+
* so the caller reads the pointer once, passes its `rev` as `expectedRev`, and
|
|
355
|
+
* this rejects the write if the stored `rev` has since moved — two concurrent
|
|
356
|
+
* asset-promotions for one tenant can no longer silently clobber each other
|
|
357
|
+
* (last-write-wins on the whole record). Same shape as
|
|
358
|
+
* `KVVersionStore.setChannel(..., expectedRev)`.
|
|
337
359
|
*/
|
|
338
360
|
export async function setAssetsChannel(
|
|
339
361
|
kv: AssetsKv,
|
|
340
362
|
tenantId: string,
|
|
341
363
|
channel: CustomizationEnv,
|
|
342
364
|
versionId: string,
|
|
365
|
+
expectedRev: number,
|
|
343
366
|
): Promise<AssetsPointer> {
|
|
344
367
|
const current = await readAssetsPointer(kv, tenantId);
|
|
368
|
+
if (current.rev !== expectedRev) {
|
|
369
|
+
throw new AssetsPointerConflictError(tenantId, expectedRev, current.rev);
|
|
370
|
+
}
|
|
345
371
|
const next: AssetsPointer = {
|
|
346
372
|
...current,
|
|
347
373
|
rev: current.rev + 1,
|
|
@@ -351,12 +377,21 @@ export async function setAssetsChannel(
|
|
|
351
377
|
return next;
|
|
352
378
|
}
|
|
353
379
|
|
|
354
|
-
/**
|
|
380
|
+
/**
|
|
381
|
+
* Promote the test snapshot to live (atomic pointer flip), CAS-guarded on `rev`.
|
|
382
|
+
* The caller supplies `expectedRev` from a single prior `readAssetsPointer`; a
|
|
383
|
+
* concurrent write that lands in between throws `AssetsPointerConflictError`
|
|
384
|
+
* rather than being silently overwritten.
|
|
385
|
+
*/
|
|
355
386
|
export async function promoteAssets(
|
|
356
387
|
kv: AssetsKv,
|
|
357
388
|
tenantId: string,
|
|
389
|
+
expectedRev: number,
|
|
358
390
|
): Promise<AssetsPointer> {
|
|
359
391
|
const current = await readAssetsPointer(kv, tenantId);
|
|
392
|
+
if (current.rev !== expectedRev) {
|
|
393
|
+
throw new AssetsPointerConflictError(tenantId, expectedRev, current.rev);
|
|
394
|
+
}
|
|
360
395
|
const next: AssetsPointer = {
|
|
361
396
|
...current,
|
|
362
397
|
rev: current.rev + 1,
|