@tokenoftrust/storefront-runner 1.3.4-rc.4 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) 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/previewStatus.ts +112 -0
  94. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  95. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  96. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  97. package/apps/storefront/src/lib/env.ts +12 -0
  98. package/apps/storefront/src/lib/jsonld.ts +12 -17
  99. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  100. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  101. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  102. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  103. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  104. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  105. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  106. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  107. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  108. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  109. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  110. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  111. package/apps/storefront/src/lib/tot/query.ts +32 -0
  112. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  113. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  116. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  117. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  118. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  119. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  120. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  121. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  122. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  123. package/apps/storefront/src/middleware/index.ts +28 -13
  124. package/apps/storefront/src/pages/404.astro +21 -9
  125. package/apps/storefront/src/pages/[...slug].astro +36 -2
  126. package/apps/storefront/src/pages/admin.astro +29 -3
  127. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  128. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  129. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  130. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  131. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  132. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  133. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  134. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  135. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  137. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  138. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  139. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  140. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  142. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  143. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  144. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  146. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  147. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  149. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  150. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  151. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  152. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  153. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  154. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  155. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  156. package/apps/storefront/src/pages/auth/login.astro +197 -41
  157. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  158. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  159. package/apps/storefront/src/pages/blog/index.astro +98 -0
  160. package/apps/storefront/src/pages/capabilities.astro +8 -0
  161. package/apps/storefront/src/pages/cockpit.astro +433 -69
  162. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  163. package/apps/storefront/src/pages/collections/index.astro +10 -2
  164. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  166. package/apps/storefront/src/pages/index.astro +57 -0
  167. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  168. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  169. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  170. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  171. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  175. package/apps/storefront/src/styles/fonts.css +54 -0
  176. package/apps/storefront/src/styles/global.css +22 -2
  177. package/apps/storefront/src/themes/schema.ts +3 -25
  178. package/apps/storefront/tsconfig.json +1 -1
  179. package/apps/storefront/vitest.config.ts +4 -1
  180. package/package.json +1 -1
  181. package/packages/public-runtime/src/candidate-index.ts +311 -0
  182. package/packages/public-runtime/src/checkout.ts +94 -2
  183. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  184. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  185. package/packages/public-runtime/src/compliance/index.ts +22 -0
  186. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  187. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  188. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  189. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  190. package/packages/public-runtime/src/csp.ts +27 -3
  191. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  192. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  193. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  194. package/packages/public-runtime/src/extension-contract.ts +2 -1
  195. package/packages/public-runtime/src/hash.ts +25 -0
  196. package/packages/public-runtime/src/index.ts +6 -0
  197. package/packages/public-runtime/src/membership.ts +353 -0
  198. package/packages/public-runtime/src/product.ts +24 -2
  199. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  200. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  201. package/packages/public-runtime/src/tenant.ts +236 -0
  202. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  203. package/scripts/dev/publish.mjs +158 -0
  204. package/scripts/dev/transient-files.mjs +2 -1
  205. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  206. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  223. package/tenants/home/public/pages/storefront.css +23 -0
  224. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -0,0 +1,311 @@
1
+ /**
2
+ * KV data layer for the `ReviewEnvironment` projection — the canonical read model
3
+ * that answers, for one change/PR, *"what is the hosted preview, is it current, is
4
+ * it reviewable, and can it be merged?"* (see `docs/review-environment-contract.md`,
5
+ * the u0 keystone this unit — s1 — implements).
6
+ *
7
+ * This module is the STORE + TYPES only: it persists and enumerates records; it
8
+ * does NOT assemble/derive `status` (that join over `ChangeState` + `PreviewState`
9
+ * is a separate concern) and it never authors any of the projected fields — every
10
+ * field is a projection of an upstream fact.
11
+ *
12
+ * Backends are injected via a `get`/`put` KV interface (Cloudflare KV in prod, an
13
+ * in-memory map in dev + tests), mirroring `tenant-assets.ts`'s `AssetsKv` pattern,
14
+ * so this module stays storage-agnostic and unit-testable.
15
+ *
16
+ * NOTE (contract discrepancy, resolved): the u0 doc types `evidence` as the
17
+ * `EvidenceReport` from `@tot/private-controlplane`. This package is the public,
18
+ * edge-shipped runtime and MUST NOT import `@tot/private-controlplane` (machine-
19
+ * enforced — see index.ts). So `evidence` uses the structural mirror this package
20
+ * already carries for exactly this reason: the same shape `PreviewState.evidence`
21
+ * uses in `customization-reconcile.ts` (`PreviewStateEvidenceCheck[]` + `promotable`
22
+ * + `generatedAt`). It is surfaced from the reconcile route's evidence, never
23
+ * re-derived here.
24
+ */
25
+ import type {
26
+ PreviewStateEvidenceCheck,
27
+ PreviewStateFinding,
28
+ PreviewStateOverride,
29
+ } from "./customization-reconcile.js";
30
+
31
+ /**
32
+ * The hosted review-environment status — a fourth, deliberately narrow vocabulary
33
+ * scoped to the review environment (distinct from controlplane `ChangeState`,
34
+ * storefront `ChangeStatus`, and evidence `EvidenceStatus`). Derived by joining the
35
+ * change lifecycle with the reconcile state; see the u0 contract's "Deriving status".
36
+ */
37
+ export type ReviewEnvironmentStatus =
38
+ | "queued"
39
+ | "reconciling"
40
+ | "ready"
41
+ | "blocked"
42
+ | "merged"
43
+ | "closed";
44
+
45
+ /**
46
+ * The compliance + SEO verdict carried on a `ReviewEnvironment`. Structural mirror
47
+ * of `@tot/private-controlplane`'s `EvidenceReport` (this package cannot import it —
48
+ * see the module note), identical to `PreviewState.evidence`'s shape.
49
+ */
50
+ export interface ReviewEnvironmentEvidence {
51
+ checks: PreviewStateEvidenceCheck[];
52
+ /** Derived upstream: every `REQUIRED_FOR_PROMOTE` check is `pass`. */
53
+ promotable: boolean;
54
+ generatedAt: string;
55
+ }
56
+
57
+ /**
58
+ * One canonical read model for a change's hosted review environment. Assembled
59
+ * (never authored) from the change record, the forge PR, and the preview reconcile
60
+ * state + its evidence verdict. Persisted at `cust:change:<tenantId>:<changeId>`.
61
+ */
62
+ export interface ReviewEnvironment {
63
+ /** Tenant/owner scope (matches `PreviewState.tenantId` + the `cust:*` key scope). */
64
+ tenantId: string;
65
+ /** Opaque change-record id — the projection is keyed on it. */
66
+ changeId: string;
67
+ /** Forge PR number, or `null` in a change-first flow before a PR exists. */
68
+ prNumber: number | null;
69
+ /** Source branch the PR ships from, or `null` until a branch/PR exists. */
70
+ branch: string | null;
71
+ /** PR head commit — the reconcile TARGET; may not yet be what renders (`versionId`). */
72
+ headSha: string | null;
73
+ /** Target-branch tip the PR merges into (merge base), or `null` before a PR. */
74
+ baseSha: string | null;
75
+ /**
76
+ * The immutable site `versionId` the `preview` channel currently points at (last
77
+ * successfully materialized commit). Equals `headSha` when `ready`; LAGS it when
78
+ * `blocked` (last-good preview keeps rendering). `null` before the first reconcile.
79
+ */
80
+ versionId: string | null;
81
+ /** Projected review-environment status (the join). Always present. */
82
+ status: ReviewEnvironmentStatus;
83
+ /** Absolute deep-link to the hosted preview, or `null` until a preview exists. */
84
+ previewUrl: string | null;
85
+ /** Compliance + SEO verdict for the rendered preview, or `null` before evidence. */
86
+ evidence: ReviewEnvironmentEvidence | null;
87
+ /**
88
+ * Validation findings from the candidate's last reconcile — advisory findings on a
89
+ * materialized candidate, or the blocking-safety findings that stopped one —
90
+ * surfaced in the PR review console. Absent/empty ⇒ validated clean.
91
+ */
92
+ findings?: PreviewStateFinding[];
93
+ /** Auditable safety-block override applied to this candidate (who/why), if any. */
94
+ overriddenBy?: PreviewStateOverride | null;
95
+ /**
96
+ * Environment-side merge eligibility only: `evidence?.promotable === true` AND no
97
+ * merge conflict AND `status === "ready"`. Deliberately excludes the acting
98
+ * operator's per-viewer capability (resolved server-side by `acceptGate`).
99
+ */
100
+ mergeable: boolean;
101
+ /** Human-legible reason the environment is not healthy, or `null` when healthy. */
102
+ lastError: string | null;
103
+ /** ISO 8601 timestamp of the last time this projection was recomputed. */
104
+ updatedAt: string;
105
+ }
106
+
107
+ /** KV key for one tenant-change `ReviewEnvironment` projection. */
108
+ export function reviewEnvironmentKey(tenantId: string, changeId: string): string {
109
+ return `cust:change:${tenantId}:${changeId}`;
110
+ }
111
+ /** KV key for a tenant's change index (list of changeIds). */
112
+ export function reviewEnvironmentIndexKey(tenantId: string): string {
113
+ return `cust:changes:${tenantId}`;
114
+ }
115
+
116
+ /**
117
+ * Injectable KV backend for the candidate store (Cloudflare KV in prod, an
118
+ * in-memory map in dev + tests). Mirrors `tenant-assets.ts`'s `AssetsKv`.
119
+ */
120
+ export interface CandidateKv {
121
+ get(key: string): Promise<string | null>;
122
+ put(key: string, value: string): Promise<void>;
123
+ }
124
+
125
+ /**
126
+ * A {@link CandidateKv} that can also DELETE a key — the eviction surface the
127
+ * retire/GC path (unit U7) needs. Kept separate from `CandidateKv` so the many
128
+ * write-only `{ get, put }` adapters (the webhook reconcile, build-on-demand,
129
+ * accept) are unaffected: only the retire endpoint constructs a delete-capable
130
+ * adapter. A real Cloudflare KV namespace satisfies this structurally.
131
+ */
132
+ export interface CandidateKvDeletable extends CandidateKv {
133
+ delete(key: string): Promise<void>;
134
+ }
135
+
136
+ /**
137
+ * Read one tenant-change `ReviewEnvironment`, or `null` when absent/malformed.
138
+ * Never throws — a malformed record reads as `null` (fall back to a recompute).
139
+ */
140
+ export async function readReviewEnvironment(
141
+ kv: Pick<CandidateKv, "get">,
142
+ tenantId: string,
143
+ changeId: string,
144
+ ): Promise<ReviewEnvironment | null> {
145
+ const raw = await kv.get(reviewEnvironmentKey(tenantId, changeId));
146
+ if (!raw) return null;
147
+ try {
148
+ return JSON.parse(raw) as ReviewEnvironment;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Read a tenant's change index (the list of `changeId`s), or `[]` when unset or
156
+ * malformed. Never throws — the index is a read-optimization, never a write gate.
157
+ */
158
+ export async function readChangeIndex(
159
+ kv: Pick<CandidateKv, "get">,
160
+ tenantId: string,
161
+ ): Promise<string[]> {
162
+ const raw = await kv.get(reviewEnvironmentIndexKey(tenantId));
163
+ if (!raw) return [];
164
+ try {
165
+ const parsed = JSON.parse(raw);
166
+ return Array.isArray(parsed)
167
+ ? parsed.filter((id): id is string => typeof id === "string")
168
+ : [];
169
+ } catch {
170
+ return [];
171
+ }
172
+ }
173
+
174
+ /**
175
+ * In-process serialization for the per-tenant change-index read-modify-write.
176
+ *
177
+ * The tenant index (`cust:changes:<tenantId>`) is updated by a read-modify-write:
178
+ * read the current id list, append if absent, write it back. The injected KV is a
179
+ * bare Cloudflare KV namespace in prod (`{ get, put }` — see the `candidateKv`
180
+ * adapter in `apps/storefront/src/pages/api/changes/accept.ts`) with NO
181
+ * compare-and-swap / conditional-write primitive, so two concurrent writes for
182
+ * different candidates of one tenant can interleave — both read the index before
183
+ * either writes it back — and the second `put` silently drops the first's append.
184
+ *
185
+ * Chaining every index write for a given key through one promise removes that
186
+ * interleaving *within a Worker isolate*, where concurrent requests share this
187
+ * module instance — which is the realistic concurrent-reconcile shape (two open
188
+ * PRs for one tenant reconciling on two near-simultaneous invocations on the same
189
+ * isolate). Cross-isolate writes to the same key remain last-write-wins; CF KV
190
+ * exposes no primitive to prevent that, so this closes the in-isolate race the
191
+ * repro test exercises rather than pretending to a distributed lock the backend
192
+ * cannot provide.
193
+ */
194
+ const indexWriteChains = new Map<string, Promise<unknown>>();
195
+
196
+ function serializeIndexWrite<T>(
197
+ key: string,
198
+ task: () => Promise<T>,
199
+ ): Promise<T> {
200
+ const prev = indexWriteChains.get(key) ?? Promise.resolve();
201
+ // Run `task` after the prior write settles (success OR failure), so one
202
+ // rejected write never wedges the chain for the key.
203
+ const run = prev.then(task, task);
204
+ const settled = run.then(
205
+ () => undefined,
206
+ () => undefined,
207
+ );
208
+ indexWriteChains.set(key, settled);
209
+ void settled.then(() => {
210
+ if (indexWriteChains.get(key) === settled) indexWriteChains.delete(key);
211
+ });
212
+ return run;
213
+ }
214
+
215
+ /**
216
+ * Test-only: clear the per-index write-serialization chains. `indexWriteChains` is a
217
+ * MODULE singleton (deliberately — the in-isolate serialization is process-wide), so
218
+ * under a shared-module test runner it leaks ACROSS test files: a settled chain left
219
+ * by one file is inherited by the next, making otherwise-isolated tests order-
220
+ * dependent. Call in `beforeEach` to give each test a clean slate. No-op in prod.
221
+ */
222
+ export function resetReviewEnvIndexWriteChainsForTest(): void {
223
+ indexWriteChains.clear();
224
+ }
225
+
226
+ /**
227
+ * Upsert one `ReviewEnvironment` record and add its `changeId` to the tenant index
228
+ * (idempotent — a re-write of an existing change leaves the index unchanged). The
229
+ * per-record write goes to its own key (`cust:change:<tenantId>:<changeId>`) with
230
+ * no cross-candidate contention, so it stays outside the lock. The index update is
231
+ * a read-modify-write over the SHARED per-tenant key, so it is serialized per index
232
+ * key (see `serializeIndexWrite`) to keep concurrent writes for different
233
+ * candidates from clobbering each other's append.
234
+ */
235
+ export async function writeReviewEnvironment(
236
+ kv: CandidateKv,
237
+ record: ReviewEnvironment,
238
+ ): Promise<void> {
239
+ await kv.put(
240
+ reviewEnvironmentKey(record.tenantId, record.changeId),
241
+ JSON.stringify(record),
242
+ );
243
+ await serializeIndexWrite(reviewEnvironmentIndexKey(record.tenantId), async () => {
244
+ const index = await readChangeIndex(kv, record.tenantId);
245
+ if (!index.includes(record.changeId)) {
246
+ await kv.put(
247
+ reviewEnvironmentIndexKey(record.tenantId),
248
+ JSON.stringify([...index, record.changeId]),
249
+ );
250
+ }
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Evict one `ReviewEnvironment` record AND remove its `changeId` from the tenant
256
+ * index — the KV half of retire/GC (unit U7). The per-record key is deleted
257
+ * outright; the tenant index is updated by the SAME serialized read-modify-write
258
+ * `writeReviewEnvironment` uses (see {@link serializeIndexWrite}), so a retire that
259
+ * races a concurrent write for a DIFFERENT candidate never resurrects a dropped
260
+ * entry or leaves a dangling index id (the corruption risk this helper exists to
261
+ * prevent — the mirror-image of `writeReviewEnvironment`'s append).
262
+ *
263
+ * IDEMPOTENT by contract: deleting an already-absent candidate is a clean no-op.
264
+ * Returns whether anything was actually removed (`true` iff the record existed OR
265
+ * the index carried the id), so the caller can report `evicted` honestly while a
266
+ * repeat retire still succeeds.
267
+ */
268
+ export async function deleteReviewEnvironment(
269
+ kv: CandidateKvDeletable,
270
+ tenantId: string,
271
+ changeId: string,
272
+ ): Promise<boolean> {
273
+ // Did the record exist before we removed it? (drives the honest `evicted` flag).
274
+ const recordExisted =
275
+ (await kv.get(reviewEnvironmentKey(tenantId, changeId))) !== null;
276
+ await kv.delete(reviewEnvironmentKey(tenantId, changeId));
277
+
278
+ // Update the SHARED per-tenant index under the same per-key serialization as the
279
+ // append, so a concurrent write for another candidate can't clobber the removal
280
+ // (and vice-versa). Only rewrite when the id is actually present — an idempotent
281
+ // repeat leaves the index byte-identical.
282
+ let indexHadId = false;
283
+ await serializeIndexWrite(reviewEnvironmentIndexKey(tenantId), async () => {
284
+ const index = await readChangeIndex(kv, tenantId);
285
+ if (index.includes(changeId)) {
286
+ indexHadId = true;
287
+ await kv.put(
288
+ reviewEnvironmentIndexKey(tenantId),
289
+ JSON.stringify(index.filter((id) => id !== changeId)),
290
+ );
291
+ }
292
+ });
293
+
294
+ return recordExisted || indexHadId;
295
+ }
296
+
297
+ /**
298
+ * Enumerate a tenant's `ReviewEnvironment` records via the index (one `get` per
299
+ * indexed change, no scan). Skips any index entry whose record is missing/malformed,
300
+ * so a dangling index id never breaks the list.
301
+ */
302
+ export async function listReviewEnvironments(
303
+ kv: Pick<CandidateKv, "get">,
304
+ tenantId: string,
305
+ ): Promise<ReviewEnvironment[]> {
306
+ const index = await readChangeIndex(kv, tenantId);
307
+ const records = await Promise.all(
308
+ index.map((changeId) => readReviewEnvironment(kv, tenantId, changeId)),
309
+ );
310
+ return records.filter((r): r is ReviewEnvironment => r !== null);
311
+ }
@@ -24,6 +24,7 @@ import type {
24
24
  ProductPurchaseOption,
25
25
  SubscriptionPurchaseOption,
26
26
  } from "./product.js";
27
+ import { DEFAULT_ONE_TIME_PURCHASE_OPTION } from "./product.js";
27
28
 
28
29
  /**
29
30
  * Per-tenant managed-checkout wiring. Public config only — the signing SECRET is never
@@ -95,6 +96,26 @@ export interface BuildVariantCartPayloadOptions {
95
96
  mode: ManagedCheckoutConfig["mode"];
96
97
  secret?: string;
97
98
  purchaseOption?: ProductPurchaseOption;
99
+ /**
100
+ * The member-adjusted EFFECTIVE price to sign (F11b), computed server-side via
101
+ * membership.ts `priceForPurchaseOption` for the shopper's resolved eligibility.
102
+ * When set it REPLACES the list/subscription price as the single signed source
103
+ * of truth, so a given price is only ever valid for the member context it was
104
+ * signed under (see {@link ../membership.ts buildMemberSignedCartPayload}).
105
+ * Absent → the list/subscription price (anonymous shopper). Never a client-
106
+ * supplied value: only the server, holding the cart secret, ever signs it.
107
+ */
108
+ signedPrice?: number;
109
+ /**
110
+ * Whether Autopilot (subscription purchase options) is enabled for the tenant.
111
+ * `visiblePurchaseOptions` already drops subscription options when Autopilot is
112
+ * off, so a subscription option reaching the signer means that filter was
113
+ * bypassed; when `false` we REFUSE to sign it (hide-at-launch). The server thus
114
+ * never mints a signed subscription payload, so a hidden Autopilot plan can't be
115
+ * resurrected via a crafted form post. Defaults to `true` (the framework default
116
+ * — subscriptions visible) so existing callers are unaffected.
117
+ */
118
+ autopilotEnabled?: boolean;
98
119
  }
99
120
 
100
121
  const DEFAULT_CHECKOUT_HOST_SUFFIX = ".foxycart.com";
@@ -126,11 +147,18 @@ export function cartFieldsForVariant(
126
147
  product: CatalogProduct,
127
148
  variant: CatalogVariant,
128
149
  purchaseOption?: ProductPurchaseOption,
150
+ opts?: { signedPrice?: number },
129
151
  ): { code: string; fields: CartField[] } {
130
152
  const code = variant.sku && variant.sku.length ? variant.sku : variant.id;
131
153
  const image = product.images.find((i) => i.id === variant.image_id)?.url ??
132
154
  product.images[0]?.url;
133
- const price = purchaseOptionPrice(variant.price, purchaseOption);
155
+ // The signed price is either the caller's member-adjusted EFFECTIVE price
156
+ // (F11b — the single signed source of truth, computed server-side from the
157
+ // shopper's resolved eligibility) or, absent that, the list/subscription price.
158
+ const price =
159
+ opts?.signedPrice !== undefined
160
+ ? assertEffectivePrice(opts.signedPrice, variant.price)
161
+ : purchaseOptionPrice(variant.price, purchaseOption);
134
162
  const fields: CartField[] = [
135
163
  { name: "name", value: variant.title || product.title },
136
164
  { name: "price", value: price.toFixed(2) },
@@ -143,12 +171,45 @@ export function cartFieldsForVariant(
143
171
  for (const [optName, optVal] of Object.entries(variant.option_values)) {
144
172
  if (optName && optVal) fields.push({ name: optName, value: optVal });
145
173
  }
174
+ // Bind the SELECTED purchase option into the signed payload so a shopper can't
175
+ // switch one-time↔subscription (or between plans) after the form is signed —
176
+ // e.g. keep a subscription's discounted `price` while dropping the recurring
177
+ // commitment. The synthesized default one-time option carries no real choice,
178
+ // so we omit it to avoid a redundant "Purchase option: One-time purchase" line
179
+ // on every non-subscription cart. Any authored option (a real one-time plan or
180
+ // a subscription) is emitted and signed → modifying it fails validation.
181
+ if (purchaseOption && !isDefaultOneTimeOption(purchaseOption)) {
182
+ fields.push({ name: "Purchase option", value: purchaseOption.label });
183
+ }
146
184
  if (purchaseOption?.kind === "subscription") {
147
185
  fields.push(...subscriptionCartFields(purchaseOption));
148
186
  }
149
187
  return { code, fields };
150
188
  }
151
189
 
190
+ /**
191
+ * Validate a caller-supplied EFFECTIVE price before it becomes the signed source
192
+ * of truth (F11b). The member-adjusted price (membership.ts
193
+ * `priceForPurchaseOption`) is computed server-side and threaded in so the SIGNED
194
+ * price reflects the shopper's resolved eligibility. It must be a finite,
195
+ * non-negative amount no greater than the list price — an effective price is
196
+ * always a discount off list, never an increase, so anything above list signals a
197
+ * pricing bug and is refused rather than signed.
198
+ */
199
+ function assertEffectivePrice(signedPrice: number, listPrice: number): number {
200
+ if (!Number.isFinite(signedPrice) || signedPrice < 0) {
201
+ throw new Error(
202
+ `signed price must be a finite non-negative number (got ${signedPrice})`,
203
+ );
204
+ }
205
+ if (signedPrice > listPrice) {
206
+ throw new Error(
207
+ `signed price ${signedPrice} exceeds the list price ${listPrice} — an effective price must be a discount off list`,
208
+ );
209
+ }
210
+ return Number(signedPrice.toFixed(2));
211
+ }
212
+
152
213
  export function purchaseOptionPrice(
153
214
  basePrice: number,
154
215
  purchaseOption?: ProductPurchaseOption,
@@ -184,6 +245,19 @@ export function isSubscriptionFrequency(value: string): boolean {
184
245
  return Number.isFinite(amount) && amount > 0;
185
246
  }
186
247
 
248
+ /**
249
+ * True for the synthesized default one-time option (see
250
+ * `DEFAULT_ONE_TIME_PURCHASE_OPTION`) — the "no real choice" case where binding a
251
+ * "Purchase option" field would only add cart-line noise. An authored one-time
252
+ * option (distinct id) is a genuine choice and is NOT treated as the default.
253
+ */
254
+ function isDefaultOneTimeOption(option: ProductPurchaseOption): boolean {
255
+ return (
256
+ option.kind === "one_time" &&
257
+ option.id === DEFAULT_ONE_TIME_PURCHASE_OPTION.id
258
+ );
259
+ }
260
+
187
261
  function subscriptionCartFields(
188
262
  option: SubscriptionPurchaseOption,
189
263
  ): CartField[] {
@@ -193,8 +267,10 @@ function subscriptionCartFields(
193
267
  );
194
268
  }
195
269
 
270
+ // NB: the shopper-facing "Purchase option" line is emitted (and signed) by the
271
+ // general purchase-option branch in cartFieldsForVariant, not here — this adds
272
+ // only the checkout engine's reserved subscription params.
196
273
  const fields: CartField[] = [
197
- { name: "Purchase option", value: option.label },
198
274
  { name: "sub_frequency", value: option.frequency },
199
275
  ];
200
276
  if (option.startDate) {
@@ -249,10 +325,26 @@ export async function buildVariantCartPayload(
249
325
  variant: CatalogVariant,
250
326
  opts: BuildVariantCartPayloadOptions,
251
327
  ): Promise<VariantCartPayload> {
328
+ // Hide-at-launch guard (F11b): refuse to SIGN a subscription option while
329
+ // Autopilot is disabled. `visiblePurchaseOptions` removes subscription options
330
+ // for such tenants, so one reaching the signer means the visibility filter was
331
+ // bypassed (e.g. a crafted request) — signing it would mint a valid subscription
332
+ // payload that a crafted form post could then replay into the cart. Fail closed.
333
+ if (
334
+ opts.autopilotEnabled === false &&
335
+ opts.purchaseOption?.kind === "subscription"
336
+ ) {
337
+ throw new Error(
338
+ "buildVariantCartPayload: refusing to sign a subscription purchase option " +
339
+ "while Autopilot is disabled (hide-at-launch) — visiblePurchaseOptions must " +
340
+ "be applied before signing",
341
+ );
342
+ }
252
343
  const { code, fields } = cartFieldsForVariant(
253
344
  product,
254
345
  variant,
255
346
  opts.purchaseOption,
347
+ { signedPrice: opts.signedPrice },
256
348
  );
257
349
  const signed = opts.mode === "signed";
258
350
  if (signed && !opts.secret) {