@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.
Files changed (225) hide show
  1. package/apps/storefront/astro.config.mjs +15 -0
  2. package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
  3. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
  4. package/apps/storefront/drizzle.config.apps.ts +13 -0
  5. package/apps/storefront/env.d.ts +10 -0
  6. package/apps/storefront/migrations/README.md +13 -7
  7. package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
  8. package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
  10. package/apps/storefront/package.json +13 -1
  11. package/apps/storefront/perf/README.md +64 -0
  12. package/apps/storefront/perf/assert-budgets.ts +159 -0
  13. package/apps/storefront/playwright.config.ts +23 -0
  14. package/apps/storefront/public/js/dashboard-apps.js +173 -0
  15. package/apps/storefront/public/shared/commerce-marketing.css +221 -0
  16. package/apps/storefront/src/components/CollectionCard.astro +1 -0
  17. package/apps/storefront/src/components/ProductCard.astro +1 -0
  18. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
  19. package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
  20. package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
  21. package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
  22. package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
  23. package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
  24. package/apps/storefront/src/components/content/Callout.astro +75 -0
  25. package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
  26. package/apps/storefront/src/components/content/ProseSections.astro +121 -0
  27. package/apps/storefront/src/components/content/ProseToc.astro +34 -0
  28. package/apps/storefront/src/components/content/RichText.astro +44 -0
  29. package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
  30. package/apps/storefront/src/components/home/Hero.astro +14 -0
  31. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
  32. package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
  33. package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
  34. package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
  35. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
  36. package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
  37. package/apps/storefront/src/config/storeName.ts +34 -0
  38. package/apps/storefront/src/layouts/Layout.astro +97 -23
  39. package/apps/storefront/src/lib/analytics/budgets.json +69 -0
  40. package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
  41. package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
  42. package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
  43. package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
  44. package/apps/storefront/src/lib/apps/adminService.ts +106 -0
  45. package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
  46. package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
  47. package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
  48. package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
  49. package/apps/storefront/src/lib/apps/credentials.ts +153 -0
  50. package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
  51. package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
  52. package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
  53. package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
  54. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
  55. package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
  56. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
  57. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
  58. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
  59. package/apps/storefront/src/lib/apps/registryService.ts +579 -0
  60. package/apps/storefront/src/lib/apps/scopes.ts +79 -0
  61. package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
  62. package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
  63. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
  64. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
  65. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
  66. package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
  67. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
  68. package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
  69. package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
  70. package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
  71. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
  72. package/apps/storefront/src/lib/auth/route.ts +16 -1
  73. package/apps/storefront/src/lib/auth/session.ts +8 -0
  74. package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
  75. package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
  76. package/apps/storefront/src/lib/blog/provider.ts +39 -0
  77. package/apps/storefront/src/lib/blog/types.ts +26 -0
  78. package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
  79. package/apps/storefront/src/lib/chrome/model.ts +11 -0
  80. package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
  81. package/apps/storefront/src/lib/content/callout.ts +78 -0
  82. package/apps/storefront/src/lib/content/index.ts +27 -0
  83. package/apps/storefront/src/lib/content/needsReview.ts +58 -0
  84. package/apps/storefront/src/lib/content/prose.ts +186 -0
  85. package/apps/storefront/src/lib/content/richtext.ts +76 -0
  86. package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
  87. package/apps/storefront/src/lib/content-edit/client.ts +70 -14
  88. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  89. package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
  90. package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
  91. package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
  92. package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
  93. package/apps/storefront/src/lib/dev/obstacleStore.ts +214 -0
  94. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  95. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  96. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  97. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  98. package/apps/storefront/src/lib/env.ts +12 -0
  99. package/apps/storefront/src/lib/jsonld.ts +12 -17
  100. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  101. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  102. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  103. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  104. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  105. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  106. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  107. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  108. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  109. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  110. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  111. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  112. package/apps/storefront/src/lib/tot/query.ts +32 -0
  113. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  116. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  117. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  118. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  119. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  120. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  121. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  122. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  123. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  124. package/apps/storefront/src/middleware/index.ts +28 -13
  125. package/apps/storefront/src/pages/404.astro +21 -9
  126. package/apps/storefront/src/pages/[...slug].astro +36 -2
  127. package/apps/storefront/src/pages/admin.astro +29 -3
  128. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  129. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  130. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  131. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  132. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  133. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  134. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  135. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  137. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  138. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  139. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  142. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  143. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  144. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  146. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  147. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  149. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  150. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  151. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  152. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  153. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  154. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  155. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  156. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  157. package/apps/storefront/src/pages/auth/login.astro +197 -41
  158. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  159. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  160. package/apps/storefront/src/pages/blog/index.astro +98 -0
  161. package/apps/storefront/src/pages/capabilities.astro +8 -0
  162. package/apps/storefront/src/pages/cockpit.astro +496 -74
  163. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  164. package/apps/storefront/src/pages/collections/index.astro +10 -2
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  166. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  167. package/apps/storefront/src/pages/index.astro +57 -0
  168. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  169. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  170. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  171. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  175. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  176. package/apps/storefront/src/styles/fonts.css +54 -0
  177. package/apps/storefront/src/styles/global.css +22 -2
  178. package/apps/storefront/src/themes/schema.ts +3 -25
  179. package/apps/storefront/tsconfig.json +1 -1
  180. package/apps/storefront/vitest.config.ts +4 -1
  181. package/package.json +1 -1
  182. package/packages/public-runtime/src/candidate-index.ts +311 -0
  183. package/packages/public-runtime/src/checkout.ts +94 -2
  184. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  185. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  186. package/packages/public-runtime/src/compliance/index.ts +22 -0
  187. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  188. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  189. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  190. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  191. package/packages/public-runtime/src/csp.ts +27 -3
  192. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  193. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  194. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  195. package/packages/public-runtime/src/extension-contract.ts +2 -1
  196. package/packages/public-runtime/src/hash.ts +25 -0
  197. package/packages/public-runtime/src/index.ts +6 -0
  198. package/packages/public-runtime/src/membership.ts +353 -0
  199. package/packages/public-runtime/src/product.ts +24 -2
  200. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  201. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  202. package/packages/public-runtime/src/tenant.ts +236 -0
  203. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  204. package/scripts/dev/publish.mjs +158 -0
  205. package/scripts/dev/transient-files.mjs +2 -1
  206. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  223. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  224. package/tenants/home/public/pages/storefront.css +23 -0
  225. 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. Phase 1 is always "shopify". */
22
- platform: "shopify";
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
- * Point a channel at a snapshot versionId (read-check-write on `rev`). KV has no
334
- * hardware CAS; a single serialized writer per tenant (the publish/reconcile
335
- * pipeline) makes the optimistic rev check sufficient same trade-off as
336
- * `KVVersionStore.setPointer`.
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
- /** Promote the test snapshot to live (atomic pointer flip). */
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,