@tokenoftrust/storefront-runner 1.3.2

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 (310) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +42 -0
  3. package/apps/storefront/astro.config.mjs +66 -0
  4. package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
  5. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
  6. package/apps/storefront/env.d.ts +162 -0
  7. package/apps/storefront/migrations/0000_baseline.sql +54 -0
  8. package/apps/storefront/migrations/0001_products_fts.sql +9 -0
  9. package/apps/storefront/migrations/README.md +54 -0
  10. package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
  11. package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
  12. package/apps/storefront/migrations/meta/_journal.json +20 -0
  13. package/apps/storefront/package.json +42 -0
  14. package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
  15. package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
  16. package/apps/storefront/public/brand/northwind-og.svg +13 -0
  17. package/apps/storefront/public/favicon.svg +5 -0
  18. package/apps/storefront/public/js/dashboard-team.js +147 -0
  19. package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
  20. package/apps/storefront/public/shared/commerce-chrome.css +369 -0
  21. package/apps/storefront/src/components/Badge.astro +34 -0
  22. package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
  23. package/apps/storefront/src/components/CollectionCard.astro +56 -0
  24. package/apps/storefront/src/components/EmptyState.astro +55 -0
  25. package/apps/storefront/src/components/Img.astro +68 -0
  26. package/apps/storefront/src/components/ProductCard.astro +228 -0
  27. package/apps/storefront/src/components/Section.astro +39 -0
  28. package/apps/storefront/src/components/Seo.astro +70 -0
  29. package/apps/storefront/src/components/WidgetFrame.astro +34 -0
  30. package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +531 -0
  31. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
  32. package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
  33. package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
  34. package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
  35. package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
  36. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
  37. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -0
  38. package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
  39. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
  40. package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
  41. package/apps/storefront/src/components/chrome/NavDropdown.astro +201 -0
  42. package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
  43. package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
  44. package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
  45. package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
  46. package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
  47. package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
  48. package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -0
  49. package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
  50. package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
  51. package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
  52. package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
  53. package/apps/storefront/src/components/commerce/ReviewList.astro +54 -0
  54. package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
  55. package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
  56. package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
  57. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
  58. package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
  59. package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
  60. package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
  61. package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
  62. package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
  63. package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
  64. package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
  65. package/apps/storefront/src/components/home/Hero.astro +164 -0
  66. package/apps/storefront/src/components/islands/AgeGate.tsx +109 -0
  67. package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
  68. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
  69. package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
  70. package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
  71. package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
  72. package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
  73. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
  74. package/apps/storefront/src/components/islands/VariantSelector.tsx +375 -0
  75. package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
  76. package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
  77. package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
  78. package/apps/storefront/src/components/marketing/Faq.astro +37 -0
  79. package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
  80. package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
  81. package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
  82. package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
  83. package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
  84. package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
  85. package/apps/storefront/src/components/marketing/Steps.astro +36 -0
  86. package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
  87. package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
  88. package/apps/storefront/src/components/membership/IllustrativeLabel.astro +56 -0
  89. package/apps/storefront/src/components/membership/TierCards.astro +164 -0
  90. package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
  91. package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
  92. package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -0
  93. package/apps/storefront/src/components/plp/Pagination.astro +99 -0
  94. package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
  95. package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +95 -0
  96. package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
  97. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
  98. package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
  99. package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
  100. package/apps/storefront/src/config/devTenantSeed.ts +78 -0
  101. package/apps/storefront/src/config/devTenants.ts +16 -0
  102. package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
  103. package/apps/storefront/src/config/resolver.ts +102 -0
  104. package/apps/storefront/src/config/tenants.ts +87 -0
  105. package/apps/storefront/src/layouts/Layout.astro +368 -0
  106. package/apps/storefront/src/lib/admin/README.md +122 -0
  107. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
  108. package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
  109. package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
  110. package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
  111. package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
  112. package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
  113. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
  114. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
  115. package/apps/storefront/src/lib/admin/envelope.ts +10 -0
  116. package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
  117. package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
  118. package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
  119. package/apps/storefront/src/lib/admin/seed.ts +54 -0
  120. package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
  121. package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
  122. package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
  123. package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
  124. package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
  125. package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
  126. package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
  127. package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
  128. package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
  129. package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
  130. package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
  131. package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
  132. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
  133. package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
  134. package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
  135. package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
  136. package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
  137. package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
  138. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
  139. package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -0
  140. package/apps/storefront/src/lib/analytics/index.ts +47 -0
  141. package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
  142. package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
  143. package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
  144. package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
  145. package/apps/storefront/src/lib/auth/loginGate.ts +119 -0
  146. package/apps/storefront/src/lib/auth/route.ts +146 -0
  147. package/apps/storefront/src/lib/auth/session.ts +203 -0
  148. package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
  149. package/apps/storefront/src/lib/basePath.ts +41 -0
  150. package/apps/storefront/src/lib/cfImage.ts +55 -0
  151. package/apps/storefront/src/lib/chrome/model.ts +244 -0
  152. package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
  153. package/apps/storefront/src/lib/colors.ts +97 -0
  154. package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
  155. package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -0
  156. package/apps/storefront/src/lib/content-edit/client.ts +230 -0
  157. package/apps/storefront/src/lib/content-edit/index.ts +14 -0
  158. package/apps/storefront/src/lib/content-edit/route.ts +25 -0
  159. package/apps/storefront/src/lib/content-edit/types.ts +19 -0
  160. package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
  161. package/apps/storefront/src/lib/d1/catalog.ts +366 -0
  162. package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
  163. package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
  164. package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
  165. package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
  166. package/apps/storefront/src/lib/demoGate.ts +113 -0
  167. package/apps/storefront/src/lib/dev/activityIngestToken.ts +78 -0
  168. package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
  169. package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
  170. package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
  171. package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
  172. package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
  173. package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
  174. package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
  175. package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
  176. package/apps/storefront/src/lib/dev/storeStatus.ts +142 -0
  177. package/apps/storefront/src/lib/diag/redact.ts +20 -0
  178. package/apps/storefront/src/lib/edgeCache.ts +128 -0
  179. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
  180. package/apps/storefront/src/lib/env.ts +60 -0
  181. package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
  182. package/apps/storefront/src/lib/format.ts +38 -0
  183. package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
  184. package/apps/storefront/src/lib/imageAttrs.ts +55 -0
  185. package/apps/storefront/src/lib/jsonld.ts +166 -0
  186. package/apps/storefront/src/lib/maintenance.ts +71 -0
  187. package/apps/storefront/src/lib/membership/model.ts +97 -0
  188. package/apps/storefront/src/lib/memoryKv.ts +32 -0
  189. package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
  190. package/apps/storefront/src/lib/placeholder.ts +99 -0
  191. package/apps/storefront/src/lib/previewReload.ts +125 -0
  192. package/apps/storefront/src/lib/priceBuckets.ts +33 -0
  193. package/apps/storefront/src/lib/rawChrome.ts +505 -0
  194. package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -0
  195. package/apps/storefront/src/lib/search/index.ts +74 -0
  196. package/apps/storefront/src/lib/sections.ts +129 -0
  197. package/apps/storefront/src/lib/securityHeaders.ts +31 -0
  198. package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
  199. package/apps/storefront/src/lib/storyblok/provider.ts +607 -0
  200. package/apps/storefront/src/lib/subscription/model.ts +68 -0
  201. package/apps/storefront/src/lib/tenantRouting.ts +91 -0
  202. package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
  203. package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
  204. package/apps/storefront/src/lib/tot/query.ts +249 -0
  205. package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
  206. package/apps/storefront/src/lib/url.ts +142 -0
  207. package/apps/storefront/src/lib/wishlist.ts +63 -0
  208. package/apps/storefront/src/middleware/index.ts +455 -0
  209. package/apps/storefront/src/pages/404.astro +61 -0
  210. package/apps/storefront/src/pages/[...slug].astro +90 -0
  211. package/apps/storefront/src/pages/admin.astro +1675 -0
  212. package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
  213. package/apps/storefront/src/pages/api/admin/orders.ts +71 -0
  214. package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
  215. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
  216. package/apps/storefront/src/pages/api/auth/send.ts +85 -0
  217. package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
  218. package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
  219. package/apps/storefront/src/pages/api/checkout-style.css.ts +43 -0
  220. package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
  221. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -0
  222. package/apps/storefront/src/pages/api/csp-report.ts +25 -0
  223. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
  224. package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
  225. package/apps/storefront/src/pages/api/dev/activity.ts +124 -0
  226. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
  227. package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
  228. package/apps/storefront/src/pages/api/dev/file.ts +57 -0
  229. package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
  230. package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
  231. package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
  232. package/apps/storefront/src/pages/api/dev/write.ts +116 -0
  233. package/apps/storefront/src/pages/api/newsletter.ts +104 -0
  234. package/apps/storefront/src/pages/api/request-access.ts +172 -0
  235. package/apps/storefront/src/pages/auth/login.astro +149 -0
  236. package/apps/storefront/src/pages/auth/magic.astro +105 -0
  237. package/apps/storefront/src/pages/capabilities.astro +292 -0
  238. package/apps/storefront/src/pages/collections/[handle].astro +177 -0
  239. package/apps/storefront/src/pages/collections/index.astro +59 -0
  240. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
  241. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
  242. package/apps/storefront/src/pages/dashboard/index.astro +160 -0
  243. package/apps/storefront/src/pages/index.astro +290 -0
  244. package/apps/storefront/src/pages/llms.txt.ts +92 -0
  245. package/apps/storefront/src/pages/products/[handle].astro +402 -0
  246. package/apps/storefront/src/pages/robots.txt.ts +24 -0
  247. package/apps/storefront/src/pages/saved.astro +35 -0
  248. package/apps/storefront/src/pages/search.astro +150 -0
  249. package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
  250. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +1280 -0
  251. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
  252. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
  253. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
  254. package/apps/storefront/src/pages/style-guide/index.astro +98 -0
  255. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
  256. package/apps/storefront/src/styles/global.css +292 -0
  257. package/apps/storefront/src/themes/reference.ts +68 -0
  258. package/apps/storefront/src/themes/schema.ts +111 -0
  259. package/apps/storefront/src/themes/tenants/index.ts +51 -0
  260. package/apps/storefront/tsconfig.json +16 -0
  261. package/apps/storefront/vitest.config.ts +22 -0
  262. package/package.json +26 -0
  263. package/packages/public-runtime/LICENSE +58 -0
  264. package/packages/public-runtime/package.json +22 -0
  265. package/packages/public-runtime/src/binary-path.ts +68 -0
  266. package/packages/public-runtime/src/capabilities.ts +89 -0
  267. package/packages/public-runtime/src/catalog-api.ts +81 -0
  268. package/packages/public-runtime/src/catalog-d1.ts +192 -0
  269. package/packages/public-runtime/src/checkout-style-publish.ts +200 -0
  270. package/packages/public-runtime/src/checkout-style.ts +375 -0
  271. package/packages/public-runtime/src/csp.ts +109 -0
  272. package/packages/public-runtime/src/customization-preview.ts +197 -0
  273. package/packages/public-runtime/src/customization-reconcile.ts +63 -0
  274. package/packages/public-runtime/src/customization-runtime.ts +157 -0
  275. package/packages/public-runtime/src/customization-scripts.ts +64 -0
  276. package/packages/public-runtime/src/customization-versioning.ts +95 -0
  277. package/packages/public-runtime/src/foxy-orders.ts +136 -0
  278. package/packages/public-runtime/src/foxy.ts +196 -0
  279. package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
  280. package/packages/public-runtime/src/index.ts +34 -0
  281. package/packages/public-runtime/src/product.ts +159 -0
  282. package/packages/public-runtime/src/search.ts +45 -0
  283. package/packages/public-runtime/src/tenant-assets.ts +444 -0
  284. package/packages/public-runtime/src/tenant.ts +384 -0
  285. package/packages/public-runtime/tsconfig.json +8 -0
  286. package/packages/public-runtime/vitest.config.ts +8 -0
  287. package/pnpm-workspace.yaml +7 -0
  288. package/scripts/build/copy-tenant-assets.mjs +69 -0
  289. package/scripts/dev/ai-edit.mjs +183 -0
  290. package/scripts/dev/checkout-watch.mjs +116 -0
  291. package/scripts/dev/file-browser.mjs +159 -0
  292. package/scripts/dev/file-writer.mjs +141 -0
  293. package/scripts/dev/git-status.mjs +171 -0
  294. package/scripts/dev/locate-handler.mjs +163 -0
  295. package/scripts/dev/locate.mjs +112 -0
  296. package/scripts/dev/port-check.mjs +48 -0
  297. package/scripts/dev/safe-path.mjs +109 -0
  298. package/scripts/dev/shot.mjs +46 -0
  299. package/scripts/dev/tenant-source-root.mjs +76 -0
  300. package/scripts/dev/unified-diff.mjs +81 -0
  301. package/scripts/tot-dev.mjs +376 -0
  302. package/tenants/README.md +14 -0
  303. package/tenants/home/content/chrome.json +7 -0
  304. package/tenants/home/content/home.html +58 -0
  305. package/tenants/home/content/home.json +11 -0
  306. package/tenants/home/public/js/storefront.js +210 -0
  307. package/tenants/home/public/logo-wordmark.png +0 -0
  308. package/tenants/home/public/pages/storefront.css +212 -0
  309. package/tenants/home/theme.json +6 -0
  310. package/tsconfig.base.json +20 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * URL <-> ListProductsQuery codec for URL-addressable PLP facets (brief §5).
3
+ * Filters live in the querystring so results are shareable + back-button safe.
4
+ *
5
+ * Encoding:
6
+ * ?sort=price-asc&page=2&availability=in-stock
7
+ * &opt.Color=Navy&opt.Color=Forest (repeated => OR within a facet)
8
+ * &opt.Size=M&tag=sale&priceMin=50&priceMax=200&q=wool
9
+ */
10
+ import type { ListProductsQuery } from "@tot/public-runtime";
11
+ import type { PriceBucket } from "./priceBuckets.js";
12
+
13
+ export function parseQuery(url: URL): ListProductsQuery {
14
+ const sp = url.searchParams;
15
+ const q: ListProductsQuery = {};
16
+
17
+ const sort = sp.get("sort");
18
+ if (
19
+ sort === "featured" ||
20
+ sort === "price-asc" ||
21
+ sort === "price-desc" ||
22
+ sort === "newest" ||
23
+ sort === "title-asc"
24
+ ) {
25
+ q.sort = sort;
26
+ }
27
+
28
+ // NOTE: read raw params first — Number(null) === 0, which would silently
29
+ // inject priceMin/priceMax/page = 0 and filter everything out.
30
+ const num = (key: string): number | undefined => {
31
+ const raw = sp.get(key);
32
+ if (raw == null || raw === "") return undefined;
33
+ const n = Number(raw);
34
+ return Number.isFinite(n) ? n : undefined;
35
+ };
36
+
37
+ const page = num("page");
38
+ if (page != null && page > 0) q.page = page;
39
+ const pageSize = num("pageSize");
40
+ if (pageSize != null && pageSize > 0) q.pageSize = pageSize;
41
+
42
+ const avail = sp.get("availability");
43
+ if (avail === "in-stock" || avail === "all") q.availability = avail;
44
+
45
+ const priceMin = num("priceMin");
46
+ if (priceMin != null) q.priceMin = priceMin;
47
+ const priceMax = num("priceMax");
48
+ if (priceMax != null) q.priceMax = priceMax;
49
+
50
+ const text = sp.get("q");
51
+ if (text) q.q = text;
52
+
53
+ const tags = sp.getAll("tag");
54
+ if (tags.length) q.tags = tags;
55
+
56
+ // Option facets: opt.<Name>=<value> (repeatable).
57
+ const filters: Record<string, string[]> = {};
58
+ for (const [key, value] of sp.entries()) {
59
+ if (key.startsWith("opt.")) {
60
+ const name = key.slice(4);
61
+ (filters[name] ??= []).push(value);
62
+ }
63
+ }
64
+ // tags also expressed as a facet for the facet UI.
65
+ if (tags.length) filters["tags"] = tags;
66
+ if (Object.keys(filters).length) q.filters = filters;
67
+
68
+ return q;
69
+ }
70
+
71
+ /** Build a querystring from a partial query, preserving repeated params. */
72
+ export function buildQueryString(
73
+ base: ListProductsQuery,
74
+ patch: Partial<ListProductsQuery> = {},
75
+ ): string {
76
+ const q = { ...base, ...patch };
77
+ const sp = new URLSearchParams();
78
+ if (q.sort && q.sort !== "featured") sp.set("sort", q.sort);
79
+ if (q.page && q.page > 1) sp.set("page", String(q.page));
80
+ if (q.pageSize) sp.set("pageSize", String(q.pageSize));
81
+ if (q.availability && q.availability !== "all")
82
+ sp.set("availability", q.availability);
83
+ if (q.priceMin != null) sp.set("priceMin", String(q.priceMin));
84
+ if (q.priceMax != null) sp.set("priceMax", String(q.priceMax));
85
+ if (q.q) sp.set("q", q.q);
86
+ if (q.filters) {
87
+ for (const [name, values] of Object.entries(q.filters)) {
88
+ if (name === "tags") {
89
+ values.forEach((v) => sp.append("tag", v));
90
+ } else {
91
+ values.forEach((v) => sp.append(`opt.${name}`, v));
92
+ }
93
+ }
94
+ }
95
+ const s = sp.toString();
96
+ return s ? `?${s}` : "";
97
+ }
98
+
99
+ /** Toggle a single facet value on/off, returning the new querystring (page reset). */
100
+ export function toggleFacet(
101
+ current: ListProductsQuery,
102
+ facet: string,
103
+ value: string,
104
+ ): string {
105
+ const filters: Record<string, string[]> = {};
106
+ for (const [k, v] of Object.entries(current.filters ?? {})) filters[k] = [...v];
107
+ const list = filters[facet] ?? [];
108
+ filters[facet] = list.includes(value)
109
+ ? list.filter((x) => x !== value)
110
+ : [...list, value];
111
+ if (!filters[facet].length) delete filters[facet];
112
+ const next: ListProductsQuery = { ...current, filters, page: 1 };
113
+ if (!Object.keys(filters).length) delete next.filters;
114
+ // tags facet mirrors q.tags
115
+ if (facet === "tags") {
116
+ next.tags = filters["tags"];
117
+ if (!next.tags?.length) delete next.tags;
118
+ }
119
+ return buildQueryString(next);
120
+ }
121
+
122
+ /** Is this price bucket the currently active price range? */
123
+ export function isPriceBucketActive(query: ListProductsQuery, bucket: PriceBucket): boolean {
124
+ return (
125
+ (query.priceMin ?? undefined) === bucket.min &&
126
+ (query.priceMax ?? undefined) === (bucket.max ?? undefined)
127
+ );
128
+ }
129
+
130
+ /** Toggle a price bucket on/off (single-select — a bucket replaces any prior
131
+ * price range, matching the free-text priceMin/priceMax fields it shares). */
132
+ export function togglePriceBucket(current: ListProductsQuery, bucket: PriceBucket): string {
133
+ const next: ListProductsQuery = { ...current, page: 1 };
134
+ if (isPriceBucketActive(current, bucket)) {
135
+ delete next.priceMin;
136
+ delete next.priceMax;
137
+ } else {
138
+ next.priceMin = bucket.min;
139
+ next.priceMax = bucket.max;
140
+ }
141
+ return buildQueryString(next);
142
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Wishlist / favorites — UI-only stub (brief §5: "favorites may be stubbed in
3
+ * UI only"). Persists a small product snapshot to localStorage so the heart and
4
+ * the /saved page work with zero backend. Phase 2 can swap this for an account-
5
+ * backed list without touching components (they only call these helpers + listen
6
+ * for the change event).
7
+ */
8
+
9
+ export interface SavedItem {
10
+ handle: string;
11
+ title: string;
12
+ price: number;
13
+ currency: string;
14
+ vendor?: string;
15
+ img?: string;
16
+ rehosted?: boolean;
17
+ seed: string;
18
+ }
19
+
20
+ const KEY = "tot:wishlist";
21
+ export const WISHLIST_EVENT = "tot:wishlist-changed";
22
+
23
+ export function readWishlist(): SavedItem[] {
24
+ try {
25
+ const raw = localStorage.getItem(KEY);
26
+ const arr = raw ? JSON.parse(raw) : [];
27
+ return Array.isArray(arr) ? (arr as SavedItem[]) : [];
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ function write(items: SavedItem[]): void {
34
+ try {
35
+ localStorage.setItem(KEY, JSON.stringify(items));
36
+ } catch {
37
+ /* storage disabled — degrade silently */
38
+ }
39
+ window.dispatchEvent(
40
+ new CustomEvent(WISHLIST_EVENT, { detail: { count: items.length } }),
41
+ );
42
+ }
43
+
44
+ export function isSaved(handle: string): boolean {
45
+ return readWishlist().some((i) => i.handle === handle);
46
+ }
47
+
48
+ /** Toggle membership; returns the new saved state. */
49
+ export function toggleSaved(item: SavedItem): boolean {
50
+ const list = readWishlist();
51
+ const idx = list.findIndex((i) => i.handle === item.handle);
52
+ if (idx >= 0) {
53
+ list.splice(idx, 1);
54
+ write(list);
55
+ return false;
56
+ }
57
+ write([item, ...list].slice(0, 100));
58
+ return true;
59
+ }
60
+
61
+ export function count(): number {
62
+ return readWishlist().length;
63
+ }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Tenant-resolution middleware (brief §7).
3
+ *
4
+ * Per request: Host -> TenantConfig (via TenantResolver) -> resolve theme ->
5
+ * bind a ToTClient to the tenant's data scope -> stash everything on
6
+ * Astro.locals so pages/components stay tenant-agnostic. The serialized theme
7
+ * CSS-var block is injected into <head> by Layout.astro from locals.themeStyle.
8
+ */
9
+ import { defineMiddleware } from "astro:middleware";
10
+ import { getTenantResolver } from "@/config/resolver";
11
+ import { referenceTheme } from "@/themes/reference";
12
+ import { mergeTheme, themeToCssVars } from "@/themes/schema";
13
+ import { createToTClient } from "@/lib/tot/ToTClient";
14
+ import { readEnv, readKv, readD1 } from "@/lib/env";
15
+ import { resolveTenantRouting } from "@/lib/tenantRouting";
16
+ import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
17
+ import { DEV_DRAFT_COOKIE, devDraftKey, verifyDevDraftToken } from "@/lib/dev/devDraft";
18
+ import {
19
+ isCacheablePath,
20
+ pageCacheKey,
21
+ readCacheVersion,
22
+ getCachedPage,
23
+ putCachedPage,
24
+ } from "@/lib/edgeCache";
25
+ import { STATIC_SCRIPT_HASHES, STATIC_STYLE_HASHES } from "@/lib/cspStaticHashes";
26
+ import { applySecurityHeaders } from "@/lib/securityHeaders";
27
+ import { maybeInjectPreviewReload } from "@/lib/previewReload";
28
+ import {
29
+ resolveCustomizationEnv,
30
+ buildCsp,
31
+ STOREFRONT_CSP_ALLOWLIST,
32
+ foxyCspAdditions,
33
+ CSP_REPORT_GROUP,
34
+ CSP_REPORT_PATH,
35
+ readPublishedArtifact,
36
+ themeArtifactPath,
37
+ resolveTenantFeatures,
38
+ resolveCapabilities,
39
+ capabilitiesArtifactPath,
40
+ } from "@tot/public-runtime";
41
+ import type { CapabilityConfig } from "@tot/public-runtime";
42
+ import { resolveTenantCommerce } from "@/lib/foxyCommerce";
43
+ import { evaluateDemoGate, hostInGateList } from "@/lib/demoGate";
44
+ import {
45
+ evaluateLoginGate,
46
+ parseGateMode,
47
+ isAuthPath,
48
+ sessionAdmitsHost,
49
+ PUBLIC_HOME_TENANT_ID,
50
+ } from "@/lib/auth/loginGate";
51
+ import { gatedHoldingResponse } from "@/lib/auth/gatePage";
52
+ import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
53
+ import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
54
+ import { membershipFor } from "@/lib/dashboard/tenantSelection";
55
+ import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
56
+
57
+ export const onRequest = defineMiddleware(async (context, next) => {
58
+ const { request, locals } = context;
59
+ const url = new URL(request.url);
60
+ const host =
61
+ request.headers.get("host") ?? url.host ?? "localhost";
62
+
63
+ // ---- Site maintenance kill-switch (fail-safe full close) ------------------
64
+ // MAINTENANCE_MODE=1 takes the ENTIRE site offline (every host + path) with a
65
+ // static "Coming Soon" holding page, BEFORE tenant resolution, the access
66
+ // gate, the edge cache, or any render. Not a login gate — auth paths are not
67
+ // exempt. Back online: set MAINTENANCE_MODE=0 (or unset) and redeploy.
68
+ if (isMaintenanceOn(await readEnv("MAINTENANCE_MODE"))) {
69
+ return maintenanceResponse();
70
+ }
71
+
72
+ // Demo access gate for named hosts (DEMO_GATE_HOSTS). Two modes, switched by
73
+ // DEMO_GATE_MODE:
74
+ // "password" (default) — interim Basic-Auth gate, enforced HERE (host-level,
75
+ // no tenant/session needed) so a blocked host never reaches tenant
76
+ // resolution, the edge cache, or any render.
77
+ // "login" — "Sign in with ToT" gate, enforced just below (needs the resolved
78
+ // tenant as the grant `resource` + the SESSION KV), redirecting to
79
+ // /auth/login unless a valid, resource-bound tot_session cookie is present.
80
+ const gateMode = parseGateMode(await readEnv("DEMO_GATE_MODE"));
81
+ const demoGateHosts = await readEnv("DEMO_GATE_HOSTS");
82
+ if (gateMode === "password") {
83
+ const demoGate = evaluateDemoGate(host, request.headers.get("authorization"), {
84
+ hosts: demoGateHosts,
85
+ password: await readEnv("DEMO_GATE_PASSWORD"),
86
+ realm: await readEnv("DEMO_GATE_REALM"),
87
+ });
88
+ if (demoGate) return demoGate;
89
+ }
90
+
91
+ // Host first; if the host owns no tenant, a leading /<domain>/ path segment
92
+ // selects one (DNS-free tenant selection on localhost/preview). The resolver
93
+ // is KV-backed in prod (many-tenant) and static in plain dev.
94
+ const resolver = await getTenantResolver();
95
+ const { tenant, basePath, renderPath } = await resolveTenantRouting(
96
+ host,
97
+ url.pathname,
98
+ resolver,
99
+ );
100
+ if (!tenant) {
101
+ return new Response("Unknown host", { status: 404 });
102
+ }
103
+
104
+ // Workers Cache API: backs the full-page edge cache below. Undefined in
105
+ // `astro dev` (Node) — every use is a no-op there.
106
+ const cache =
107
+ typeof caches !== "undefined" && "default" in caches
108
+ ? (caches as unknown as { default: Cache }).default
109
+ : undefined;
110
+ const kv = await readKv("TENANT_CACHE");
111
+
112
+ // Login-gate enforcement (DEMO_GATE_MODE="login"): runs before the edge cache
113
+ // so a gated page is never served to an unauthenticated viewer. The platform's
114
+ // own "self" home tenant is EXEMPT — it renders publicly (browse + sign up);
115
+ // every other tenant on a gated host is withheld behind sign-in. A valid,
116
+ // resource-bound tot_session sets locals.viewer (email + roles) for chrome;
117
+ // otherwise an unauthenticated visitor gets a "Coming soon" holding page with
118
+ // a Sign-in button (auth paths are exempt so sign-in itself is reachable). The
119
+ // grant `resource` is the tenant's appDomain — never input.
120
+ const tenantExempt = tenant.tenant_id === PUBLIC_HOME_TENANT_ID;
121
+ if (gateMode === "login" && hostInGateList(host, demoGateHosts)) {
122
+ // UNIFIED on the federated session: read the FULL session (no resource filter —
123
+ // federated sign-in's aud is the storefront app, not this tenant) and admit the
124
+ // viewer iff they hold a MEMBERSHIP for the host's tenant. One session model for
125
+ // the console + the preview gate. Deny-closed: no session / no membership → gate.
126
+ let viewerRecord = null;
127
+ if (!isAuthPath(url.pathname)) {
128
+ const sessionKv = await readKv("SESSION");
129
+ if (sessionKv) {
130
+ const sid = context.cookies.get(SESSION_COOKIE)?.value ?? null;
131
+ viewerRecord = await readSession(
132
+ new KvSessionStore(sessionKv),
133
+ sid,
134
+ Math.floor(Date.now() / 1000),
135
+ );
136
+ }
137
+ }
138
+ const nowSeconds = Math.floor(Date.now() / 1000);
139
+ const admitted = sessionAdmitsHost(viewerRecord, tenant.appDomain, nowSeconds);
140
+ const decision = evaluateLoginGate({
141
+ host,
142
+ pathname: url.pathname,
143
+ search: url.search,
144
+ mode: gateMode,
145
+ gatedHosts: demoGateHosts,
146
+ hasValidSession: admitted,
147
+ tenantExempt,
148
+ });
149
+ if (decision.action === "redirect") {
150
+ // Withhold content but offer sign-in (carrying the intended destination).
151
+ return gatedHoldingResponse(decision.location);
152
+ }
153
+ if (admitted && viewerRecord) {
154
+ // Surface the viewer's role FOR THIS host's tenant as chrome. A member sees
155
+ // their tenants[] role; a ToT-staff viewer admitted via an explicit vendor
156
+ // selection sees the staffRoles-scoped capability (distinct path).
157
+ const role = membershipFor(viewerRecord.tenants, tenant.appDomain, {
158
+ selection: viewerRecord.staffSelection,
159
+ nowSeconds,
160
+ })?.role;
161
+ locals.viewer = {
162
+ email: viewerRecord.email,
163
+ roles: role ? [role] : viewerRecord.roles,
164
+ capability: role ?? viewerRecord.capability,
165
+ };
166
+ }
167
+ }
168
+
169
+ // Preview/test detection (also reused for the customization env below). A
170
+ // production read is one with no preview host, query token, or preview cookie.
171
+ const isPreviewHost = host.toLowerCase().startsWith("preview.");
172
+ const queryToken = url.searchParams.get("__preview");
173
+ const cookieToken = context.cookies.get("tot_preview")?.value ?? null;
174
+ // Per-developer AI-edit draft (decision draft-overlay-same-origin): the signed
175
+ // tot_dev_draft cookie's mere PRESENCE opts the request out of the shared page
176
+ // cache below (a draft view must never be cached publicly); it's verified —
177
+ // signature, expiry, tenant binding — before the overlay is applied further down.
178
+ const draftCookie = context.cookies.get(DEV_DRAFT_COOKIE)?.value ?? null;
179
+
180
+ // ---- Edge page cache (D1): short-circuit production browse reads on a hit,
181
+ // BEFORE building the client / theme / rendering. Misses store the final
182
+ // response at the end of this middleware.
183
+ //
184
+ // NEVER in dev (`astro dev`): the workerd dev runtime persists the Workers Cache
185
+ // API in .wrangler/state, so a cached page would be served stale across edits AND
186
+ // restarts — silently defeating hot-reload (edits look "lost"). Dev must always
187
+ // re-render. import.meta.env.DEV statically drops this guard from the prod build.
188
+ const pageCacheEligible =
189
+ !import.meta.env.DEV &&
190
+ request.method.toUpperCase() === "GET" &&
191
+ !isPreviewHost &&
192
+ !queryToken &&
193
+ !cookieToken &&
194
+ !draftCookie &&
195
+ isCacheablePath(url.pathname, url.searchParams);
196
+ let pageKey: Request | null = null;
197
+ if (pageCacheEligible && cache && kv) {
198
+ const version = await readCacheVersion(kv, tenant.tenant_id);
199
+ pageKey = pageCacheKey(tenant.tenant_id, url.pathname, url.search, version);
200
+ const hit = await getCachedPage(cache, pageKey);
201
+ if (hit) return hit;
202
+ }
203
+
204
+ // Edge read replica: opt-in via USE_D1_CATALOG=1 (+ a CATALOG_DB binding). Off
205
+ // by default, so the bundled fixture snapshot serves reads.
206
+ const d1 =
207
+ (await readEnv("USE_D1_CATALOG")) === "1" ? await readD1("CATALOG_DB") : undefined;
208
+ const tot = await createToTClient(tenant, d1);
209
+
210
+ const proto = url.protocol === "http:" ? "http" : "https";
211
+ // Canonical base uses the tenant's canonical domain in prod; for local dev we
212
+ // keep the actual host (plus any /<domain> base path) so links resolve.
213
+ const isLocal = host.startsWith("localhost") || host.startsWith("127.0.0.1");
214
+ const canonicalBase = isLocal
215
+ ? `${proto}://${host}${basePath}`
216
+ : `https://${tenant.canonicalDomain}`;
217
+
218
+ // Live vs test customization (D4). Production hosts always serve "live" and
219
+ // ignore any token; a "preview." host is auth-gated by a signed, tenant-bound,
220
+ // short-TTL preview token (?__preview= on first hit, then the tot_preview
221
+ // cookie). Test is never indexable (X-Robots-Tag below).
222
+ const previewToken = queryToken ?? cookieToken;
223
+ const previewSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
224
+ const nowSeconds = Math.floor(Date.now() / 1000);
225
+ const customization = await resolveCustomizationEnv({
226
+ isPreviewHost,
227
+ tenantId: tenant.tenant_id,
228
+ token: previewToken,
229
+ secret: previewSecret,
230
+ nowSeconds,
231
+ });
232
+ if (customization.reject) {
233
+ return new Response(customization.reject.message, {
234
+ status: customization.reject.status,
235
+ });
236
+ }
237
+ // Persist a query-supplied token as a cookie so later navigations stay in test.
238
+ if (customization.env === "test" && queryToken) {
239
+ context.cookies.set("tot_preview", queryToken, {
240
+ httpOnly: true,
241
+ secure: true,
242
+ sameSite: "lax",
243
+ path: "/",
244
+ maxAge: 3600,
245
+ });
246
+ }
247
+
248
+ // Apply a published theme override for the resolved environment (store->app).
249
+ // The live domain serves `live`; a gated preview serves `test`. When nothing
250
+ // is published the build-time tenant theme is used unchanged.
251
+ //
252
+ // Resolution is versioned-aware inside readPublishedArtifact: env maps to a
253
+ // publish channel (live->live, test->preview); if the tenant has a channel
254
+ // pointer, the artifact is resolved through the immutable site version
255
+ // (channel -> versionId -> manifest -> content). Tenants with no channel
256
+ // pointer fall back to the legacy per-path selection — no behavior change.
257
+ const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
258
+
259
+ // Verify the developer-draft cookie (signature + expiry + tenant binding) — the
260
+ // overlay below only engages for the developer who minted the draft, on THIS
261
+ // tenant. Same secret + trust model as the preview token; invalid/expired/foreign
262
+ // tokens degrade silently to the base chain (never an error page).
263
+ const draftClaims =
264
+ draftCookie && previewSecret && kv
265
+ ? await verifyDevDraftToken(previewSecret, draftCookie, nowSeconds)
266
+ : null;
267
+ const draft =
268
+ draftClaims && draftClaims.tenantId === tenant.tenant_id ? draftClaims : null;
269
+
270
+ let theme = mergeTheme(referenceTheme, tenant.theme_tokens);
271
+ const publishedTheme = await readPublishedArtifact(
272
+ kvGet,
273
+ tenant.tenant_id,
274
+ customization.env,
275
+ themeArtifactPath(tenant.tenant_id),
276
+ );
277
+ if (publishedTheme) {
278
+ try {
279
+ theme = mergeTheme(theme, JSON.parse(publishedTheme) as DeepPartial<ThemeTokens>);
280
+ } catch {
281
+ /* malformed published artifact — keep the build-time theme */
282
+ }
283
+ }
284
+ // Developer-draft theme overlay (on top of published) — only for the draft owner.
285
+ if (draft) {
286
+ const draftTheme = await kvGet(devDraftKey(tenant.tenant_id, draft.draftId, "theme.json"));
287
+ if (draftTheme) {
288
+ try {
289
+ theme = mergeTheme(theme, JSON.parse(draftTheme) as DeepPartial<ThemeTokens>);
290
+ } catch {
291
+ /* malformed draft artifact — keep the resolved theme */
292
+ }
293
+ }
294
+ }
295
+
296
+ // Per-environment capabilities (Track A/B). Config-as-code: the build-time
297
+ // TenantConfig default is overridden by the published capabilities artifact for
298
+ // THIS environment — materialized from the tenant repo's .tot/config.json per
299
+ // commit (localhost=working tree, preview=PR head, live=promoted). So a
300
+ // capability change is PR-gated and separable per environment.
301
+ let publishedCaps: Partial<CapabilityConfig> | null = null;
302
+ const publishedCapsRaw = await readPublishedArtifact(
303
+ kvGet,
304
+ tenant.tenant_id,
305
+ customization.env,
306
+ capabilitiesArtifactPath(tenant.tenant_id),
307
+ );
308
+ if (publishedCapsRaw) {
309
+ try {
310
+ publishedCaps = JSON.parse(publishedCapsRaw) as Partial<CapabilityConfig>;
311
+ } catch {
312
+ /* malformed published artifact — keep the build-time default */
313
+ }
314
+ }
315
+ const capabilities = resolveCapabilities(tenant, publishedCaps);
316
+
317
+ // Per-request CSP nonce (D2): goes on the platform's own inline tags in
318
+ // Layout.astro so a strict CSP can allow them without 'unsafe-inline'.
319
+ const cspNonce = crypto.randomUUID();
320
+
321
+ locals.tenant = tenant;
322
+ // Effective capability flags for this tenant (commerce defaults unless the
323
+ // tenant declares siteType:"marketing" / explicit features). Pages and chrome
324
+ // read these instead of hard-coding tenant identity checks.
325
+ locals.features = resolveTenantFeatures(tenant);
326
+ locals.capabilities = capabilities;
327
+ // FoxyCart cart/checkout facts (Track A) — only when the tenant has a catalog
328
+ // AND the cartCheckout capability is enabled for THIS environment. Drives the
329
+ // loader + signed add-to-cart, and the CSP additions below.
330
+ const commerce =
331
+ locals.features.catalog && capabilities.cartCheckout.enabled
332
+ ? await resolveTenantCommerce(tenant)
333
+ : null;
334
+ locals.commerce = commerce ?? undefined;
335
+ locals.tot = tot;
336
+ locals.content = createContentProvider(tenant, {
337
+ get: kvGet,
338
+ env: customization.env,
339
+ });
340
+ // Developer-draft content overlay — serves the AI-edited artifacts to the draft
341
+ // owner only; every other viewer keeps the published/base chain above.
342
+ if (draft) {
343
+ locals.content = new DevDraftContentProvider(
344
+ locals.content,
345
+ tenant.tenant_id,
346
+ draft.draftId,
347
+ kvGet,
348
+ );
349
+ }
350
+ locals.themeStyle = themeToCssVars(theme);
351
+ locals.canonicalBase = canonicalBase;
352
+ locals.customizationEnv = customization.env;
353
+ locals.cspNonce = cspNonce;
354
+ // Prefix that path-routed pages live under ("" for host-routed). Pages stay
355
+ // tenant-agnostic; the Layout uses this to keep root-absolute links inside
356
+ // the tenant prefix on the client.
357
+ locals.basePath = basePath;
358
+
359
+ // When a /<domain>/ prefix selected the tenant, rewrite to the stripped route
360
+ // so the existing file-based routes match. next(path) rewrites WITHOUT
361
+ // re-running middleware, so the locals set above are preserved.
362
+ let response = basePath
363
+ ? await next(`${renderPath}${url.search}`)
364
+ : await next();
365
+
366
+ // On-publish auto-refresh: inject the live-reload client into SHARED preview
367
+ // (`test`) HTML pages ONLY — never `live` (keeps live clean + edge-cacheable).
368
+ // It polls /__preview/version and reloads when this tenant's cache-version
369
+ // bumps on publish/reconcile. Injection happens before the CSP header is set so
370
+ // the script's per-request nonce (cspNonce) matches the enforced policy.
371
+ response = await maybeInjectPreviewReload(response, {
372
+ env: customization.env,
373
+ tenantId: tenant.tenant_id,
374
+ basePath,
375
+ nonce: cspNonce,
376
+ });
377
+
378
+ // Baseline security headers (S1) on every response — set before the page-cache
379
+ // store below so cached copies carry them too.
380
+ applySecurityHeaders(response.headers);
381
+
382
+ // Preview (test) output must never be indexed.
383
+ if (customization.noindex) {
384
+ response.headers.set("X-Robots-Tag", "noindex, nofollow");
385
+ }
386
+
387
+ // Strict CSP — now ENFORCED in every environment (Epic S2). The report-only
388
+ // rollout is complete: platform output is CSP-clean — inline styles are
389
+ // externalized (`inlineStylesheets: "never"`), Astro's static inline scripts +
390
+ // island style are hash-pinned (cspStaticHashes), our dynamic inline tags carry
391
+ // the per-request nonce, and the last inline event handler (SortSelect's
392
+ // `onchange`) is now a bundled 'self' script. Reporting endpoints stay attached
393
+ // (report-to/report-uri work in enforce mode), so we keep telemetry as tenant
394
+ // client JS arrives — those per-version tenant hashes extend `scriptSrc` per
395
+ // request (the mechanism below already supports it).
396
+ //
397
+ // DEV EXCEPTION: `astro dev` serves CSS/HMR as UN-nonced inline <style>/<script>
398
+ // (Vite injects them); the strict nonce policy blocks those, leaving the dev
399
+ // site unstyled. The BUILD is CSP-clean (styles are externalized via
400
+ // `inlineStylesheets:"never"`), so we enforce there and only REPORT in dev —
401
+ // violations are still surfaced, but styling/HMR work locally.
402
+ const cspHeader = import.meta.env.DEV
403
+ ? "Content-Security-Policy-Report-Only"
404
+ : "Content-Security-Policy";
405
+ response.headers.set(
406
+ "Reporting-Endpoints",
407
+ `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"`,
408
+ );
409
+ // FoxyCart origins (loader CDN + the store's cart/checkout host) added to the
410
+ // strict policy only when this tenant has a store — so add-to-cart, the
411
+ // sidecart iframe/XHR, and the cross-origin form POST all pass.
412
+ const foxyCsp = commerce ? foxyCspAdditions(commerce.origin) : null;
413
+ response.headers.set(
414
+ cspHeader,
415
+ buildCsp({
416
+ nonce: cspNonce,
417
+ ...STOREFRONT_CSP_ALLOWLIST,
418
+ // Pin Astro's static inline hydration scripts + island style by hash
419
+ // (they're identical every request); my dynamic inline tags use the nonce.
420
+ scriptSrc: [
421
+ ...STOREFRONT_CSP_ALLOWLIST.scriptSrc,
422
+ ...STATIC_SCRIPT_HASHES,
423
+ ...(foxyCsp?.scriptSrc ?? []),
424
+ ],
425
+ styleSrc: [
426
+ ...STOREFRONT_CSP_ALLOWLIST.styleSrc,
427
+ ...STATIC_STYLE_HASHES,
428
+ ...(foxyCsp?.styleSrc ?? []),
429
+ ],
430
+ fontSrc: [...STOREFRONT_CSP_ALLOWLIST.fontSrc, ...(foxyCsp?.fontSrc ?? [])],
431
+ connectSrc: [
432
+ ...STOREFRONT_CSP_ALLOWLIST.connectSrc,
433
+ ...(foxyCsp?.connectSrc ?? []),
434
+ ],
435
+ imgSrc: [...STOREFRONT_CSP_ALLOWLIST.imgSrc, ...(foxyCsp?.imgSrc ?? [])],
436
+ frameSrc: foxyCsp?.frameSrc ?? [],
437
+ formAction: foxyCsp?.formAction ?? [],
438
+ reportGroup: CSP_REPORT_GROUP,
439
+ reportUri: CSP_REPORT_PATH,
440
+ }),
441
+ );
442
+
443
+ // D1: store the fully-rendered, CSP-stamped response for the next reader. Only
444
+ // production (no preview token reached here) cacheable GETs have a pageKey;
445
+ // putCachedPage additionally skips non-200, non-HTML, and cookie-bearing
446
+ // responses. Also expose Cache-Control to any fronting CDN.
447
+ if (pageKey && cache && customization.env === "live") {
448
+ response.headers.set(
449
+ "Cache-Control",
450
+ "public, s-maxage=300, stale-while-revalidate=86400",
451
+ );
452
+ await putCachedPage(cache, pageKey, response);
453
+ }
454
+ return response;
455
+ });