@tokenoftrust/storefront-runner 0.1.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 (337) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +40 -0
  3. package/apps/storefront/.dev.vars.example +41 -0
  4. package/apps/storefront/.env.example +24 -0
  5. package/apps/storefront/astro.config.mjs +245 -0
  6. package/apps/storefront/env.d.ts +155 -0
  7. package/apps/storefront/lib/ensure-preview-reconcile-secret.sh +31 -0
  8. package/apps/storefront/lib/resolve-cf-token.sh +48 -0
  9. package/apps/storefront/lib/resolve-tot-credentials.sh +69 -0
  10. package/apps/storefront/migrations/0000_baseline.sql +54 -0
  11. package/apps/storefront/migrations/0001_products_fts.sql +9 -0
  12. package/apps/storefront/migrations/README.md +54 -0
  13. package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
  14. package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
  15. package/apps/storefront/migrations/meta/_journal.json +20 -0
  16. package/apps/storefront/package.json +41 -0
  17. package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
  18. package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
  19. package/apps/storefront/public/brand/northwind-og.svg +13 -0
  20. package/apps/storefront/public/favicon.svg +5 -0
  21. package/apps/storefront/public/js/dashboard-team.js +147 -0
  22. package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
  23. package/apps/storefront/src/components/Badge.astro +34 -0
  24. package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
  25. package/apps/storefront/src/components/CollectionCard.astro +56 -0
  26. package/apps/storefront/src/components/EmptyState.astro +35 -0
  27. package/apps/storefront/src/components/Img.astro +68 -0
  28. package/apps/storefront/src/components/ProductCard.astro +228 -0
  29. package/apps/storefront/src/components/Section.astro +39 -0
  30. package/apps/storefront/src/components/Seo.astro +70 -0
  31. package/apps/storefront/src/components/WidgetFrame.astro +34 -0
  32. package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
  33. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
  34. package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
  35. package/apps/storefront/src/components/chrome/NavDropdown.astro +116 -0
  36. package/apps/storefront/src/components/chrome/SiteFooter.astro +181 -0
  37. package/apps/storefront/src/components/chrome/SiteHeader.astro +181 -0
  38. package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
  39. package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
  40. package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
  41. package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
  42. package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
  43. package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
  44. package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
  45. package/apps/storefront/src/components/home/Hero.astro +164 -0
  46. package/apps/storefront/src/components/islands/AgeGate.tsx +105 -0
  47. package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
  48. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
  49. package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
  50. package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
  51. package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
  52. package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
  53. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
  54. package/apps/storefront/src/components/islands/VariantSelector.tsx +378 -0
  55. package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
  56. package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
  57. package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
  58. package/apps/storefront/src/components/marketing/Faq.astro +37 -0
  59. package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
  60. package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
  61. package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
  62. package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
  63. package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
  64. package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
  65. package/apps/storefront/src/components/marketing/Steps.astro +36 -0
  66. package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
  67. package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
  68. package/apps/storefront/src/components/nav/Footer.astro +148 -0
  69. package/apps/storefront/src/components/nav/Header.astro +190 -0
  70. package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
  71. package/apps/storefront/src/components/plp/FacetSidebar.astro +146 -0
  72. package/apps/storefront/src/components/plp/Pagination.astro +99 -0
  73. package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
  74. package/apps/storefront/src/config/devTenants.ts +16 -0
  75. package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
  76. package/apps/storefront/src/config/resolver.ts +46 -0
  77. package/apps/storefront/src/config/tenants.ts +193 -0
  78. package/apps/storefront/src/layouts/Layout.astro +303 -0
  79. package/apps/storefront/src/lib/analytics/index.ts +47 -0
  80. package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
  81. package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
  82. package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
  83. package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
  84. package/apps/storefront/src/lib/auth/loginGate.ts +107 -0
  85. package/apps/storefront/src/lib/auth/route.ts +146 -0
  86. package/apps/storefront/src/lib/auth/session.ts +203 -0
  87. package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
  88. package/apps/storefront/src/lib/basePath.ts +41 -0
  89. package/apps/storefront/src/lib/cfImage.ts +55 -0
  90. package/apps/storefront/src/lib/chrome/model.ts +65 -0
  91. package/apps/storefront/src/lib/colors.ts +97 -0
  92. package/apps/storefront/src/lib/content-edit/client.ts +230 -0
  93. package/apps/storefront/src/lib/content-edit/index.ts +14 -0
  94. package/apps/storefront/src/lib/content-edit/route.ts +25 -0
  95. package/apps/storefront/src/lib/content-edit/types.ts +19 -0
  96. package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
  97. package/apps/storefront/src/lib/d1/catalog.ts +289 -0
  98. package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
  99. package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
  100. package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
  101. package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
  102. package/apps/storefront/src/lib/demoGate.ts +113 -0
  103. package/apps/storefront/src/lib/dev/ai-widget-client.js +127 -0
  104. package/apps/storefront/src/lib/dev/cliSignInCode.ts +132 -0
  105. package/apps/storefront/src/lib/diag/redact.ts +20 -0
  106. package/apps/storefront/src/lib/edgeCache.ts +128 -0
  107. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
  108. package/apps/storefront/src/lib/env.ts +60 -0
  109. package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
  110. package/apps/storefront/src/lib/format.ts +29 -0
  111. package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
  112. package/apps/storefront/src/lib/imageAttrs.ts +55 -0
  113. package/apps/storefront/src/lib/jsonld.ts +136 -0
  114. package/apps/storefront/src/lib/maintenance.ts +71 -0
  115. package/apps/storefront/src/lib/memoryKv.ts +32 -0
  116. package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
  117. package/apps/storefront/src/lib/placeholder.ts +99 -0
  118. package/apps/storefront/src/lib/previewReload.ts +125 -0
  119. package/apps/storefront/src/lib/rawMarketingHtml.ts +133 -0
  120. package/apps/storefront/src/lib/search/index.ts +74 -0
  121. package/apps/storefront/src/lib/sections.ts +129 -0
  122. package/apps/storefront/src/lib/securityHeaders.ts +31 -0
  123. package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
  124. package/apps/storefront/src/lib/storyblok/provider.ts +524 -0
  125. package/apps/storefront/src/lib/tenantRouting.ts +91 -0
  126. package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
  127. package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
  128. package/apps/storefront/src/lib/tot/query.ts +187 -0
  129. package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
  130. package/apps/storefront/src/lib/url.ts +119 -0
  131. package/apps/storefront/src/lib/wishlist.ts +63 -0
  132. package/apps/storefront/src/middleware/index.ts +413 -0
  133. package/apps/storefront/src/pages/404.astro +61 -0
  134. package/apps/storefront/src/pages/[...slug].astro +90 -0
  135. package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
  136. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
  137. package/apps/storefront/src/pages/api/auth/send.ts +85 -0
  138. package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
  139. package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
  140. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +64 -0
  141. package/apps/storefront/src/pages/api/csp-report.ts +25 -0
  142. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
  143. package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
  144. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +44 -0
  145. package/apps/storefront/src/pages/api/newsletter.ts +104 -0
  146. package/apps/storefront/src/pages/api/request-access.ts +172 -0
  147. package/apps/storefront/src/pages/auth/login.astro +149 -0
  148. package/apps/storefront/src/pages/auth/magic.astro +105 -0
  149. package/apps/storefront/src/pages/capabilities.astro +292 -0
  150. package/apps/storefront/src/pages/collections/[handle].astro +154 -0
  151. package/apps/storefront/src/pages/collections/index.astro +59 -0
  152. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
  153. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
  154. package/apps/storefront/src/pages/dashboard/index.astro +160 -0
  155. package/apps/storefront/src/pages/dev.astro +653 -0
  156. package/apps/storefront/src/pages/index.astro +290 -0
  157. package/apps/storefront/src/pages/llms.txt.ts +92 -0
  158. package/apps/storefront/src/pages/products/[handle].astro +443 -0
  159. package/apps/storefront/src/pages/robots.txt.ts +24 -0
  160. package/apps/storefront/src/pages/saved.astro +35 -0
  161. package/apps/storefront/src/pages/search.astro +150 -0
  162. package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
  163. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +646 -0
  164. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +85 -0
  165. package/apps/storefront/src/pages/style-guide/index.astro +94 -0
  166. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
  167. package/apps/storefront/src/styles/global.css +277 -0
  168. package/apps/storefront/src/themes/reference.ts +61 -0
  169. package/apps/storefront/src/themes/schema.ts +111 -0
  170. package/apps/storefront/src/themes/tenants/index.ts +51 -0
  171. package/apps/storefront/tsconfig.json +16 -0
  172. package/apps/storefront/vitest.config.ts +22 -0
  173. package/apps/storefront/wrangler.toml +175 -0
  174. package/package.json +23 -0
  175. package/packages/public-runtime/LICENSE +58 -0
  176. package/packages/public-runtime/package.json +22 -0
  177. package/packages/public-runtime/src/binary-path.ts +68 -0
  178. package/packages/public-runtime/src/capabilities.ts +89 -0
  179. package/packages/public-runtime/src/catalog-api.ts +81 -0
  180. package/packages/public-runtime/src/catalog-d1.ts +192 -0
  181. package/packages/public-runtime/src/csp.ts +109 -0
  182. package/packages/public-runtime/src/customization-preview.ts +197 -0
  183. package/packages/public-runtime/src/customization-reconcile.ts +63 -0
  184. package/packages/public-runtime/src/customization-runtime.ts +157 -0
  185. package/packages/public-runtime/src/customization-scripts.ts +64 -0
  186. package/packages/public-runtime/src/customization-versioning.ts +95 -0
  187. package/packages/public-runtime/src/foxy.ts +184 -0
  188. package/packages/public-runtime/src/index.ts +30 -0
  189. package/packages/public-runtime/src/product.ts +159 -0
  190. package/packages/public-runtime/src/search.ts +45 -0
  191. package/packages/public-runtime/src/tenant-assets.ts +444 -0
  192. package/packages/public-runtime/src/tenant.ts +243 -0
  193. package/packages/public-runtime/tsconfig.json +8 -0
  194. package/packages/public-runtime/vitest.config.ts +8 -0
  195. package/pnpm-workspace.yaml +7 -0
  196. package/scripts/build/copy-tenant-assets.mjs +69 -0
  197. package/scripts/tot-dev.mjs +439 -0
  198. package/tenants/bigdvapor/content/blog.json +52 -0
  199. package/tenants/bigdvapor/content/chrome.html +149 -0
  200. package/tenants/bigdvapor/content/chrome.json +47 -0
  201. package/tenants/bigdvapor/content/home.html +463 -0
  202. package/tenants/bigdvapor/content/home.json +73 -0
  203. package/tenants/bigdvapor/content/pages/about.json +5 -0
  204. package/tenants/bigdvapor/content/pages/privacy.json +6 -0
  205. package/tenants/bigdvapor/content/pages/shipping-returns.json +6 -0
  206. package/tenants/bigdvapor/content/pages-html/blogs/news.html +86 -0
  207. package/tenants/bigdvapor/content/pages-html/pages/about-us.html +106 -0
  208. package/tenants/bigdvapor/content/pages-html/pages/contact-us.html +61 -0
  209. package/tenants/bigdvapor/content/pages-html/pages/privacy-policy.html +72 -0
  210. package/tenants/bigdvapor/content/pages-html/pages/shipping-returns.html +172 -0
  211. package/tenants/bigdvapor/content/themes/bigd-navy.json +109 -0
  212. package/tenants/bigdvapor/public/img/brands/adjust-vape-disposable.png +0 -0
  213. package/tenants/bigdvapor/public/img/brands/again-vape-disposable.png +0 -0
  214. package/tenants/bigdvapor/public/img/brands/al-fakher-vape-disposable.png +0 -0
  215. package/tenants/bigdvapor/public/img/brands/arro-vape-disposable.png +0 -0
  216. package/tenants/bigdvapor/public/img/brands/cookies-vape-disposable.png +0 -0
  217. package/tenants/bigdvapor/public/img/brands/core-vape-disposable.png +0 -0
  218. package/tenants/bigdvapor/public/img/brands/cyclone-vape-disposable.png +0 -0
  219. package/tenants/bigdvapor/public/img/brands/dinner-lady-vape-disposable.png +0 -0
  220. package/tenants/bigdvapor/public/img/brands/extre-bar-vape-disposable.png +0 -0
  221. package/tenants/bigdvapor/public/img/brands/fifty-bar-vape-disposable.jpg +0 -0
  222. package/tenants/bigdvapor/public/img/brands/firerose-vape-disposable.jpg +0 -0
  223. package/tenants/bigdvapor/public/img/brands/flavor-beast-vape-disposable.jpg +0 -0
  224. package/tenants/bigdvapor/public/img/brands/foger-vape-disposable.png +0 -0
  225. package/tenants/bigdvapor/public/img/brands/kk-energy-vape-disposable.png +0 -0
  226. package/tenants/bigdvapor/public/img/brands/kumi-vape-disposable.png +0 -0
  227. package/tenants/bigdvapor/public/img/brands/lost-vape-dispoable.png +0 -0
  228. package/tenants/bigdvapor/public/img/brands/melo-labs-vape-disposable.png +0 -0
  229. package/tenants/bigdvapor/public/img/brands/mixo-vape-disposable.webp +0 -0
  230. package/tenants/bigdvapor/public/img/brands/one-tank-vape-disposable.jpg +0 -0
  231. package/tenants/bigdvapor/public/img/brands/oxbar-vape-disposable.webp +0 -0
  232. package/tenants/bigdvapor/public/img/brands/pillow-talk-vape-disposable.jpg +0 -0
  233. package/tenants/bigdvapor/public/img/brands/pod-king-vape-disposable.jpg +0 -0
  234. package/tenants/bigdvapor/public/img/brands/raz-vape-disposable.png +0 -0
  235. package/tenants/bigdvapor/public/img/hero-texas.jpg +0 -0
  236. package/tenants/bigdvapor/public/img/og.jpg +0 -0
  237. package/tenants/bigdvapor/public/img/pages/AdobeStock_202106312_web.jpg +0 -0
  238. package/tenants/bigdvapor/public/img/pages/CREW_web.jpg +0 -0
  239. package/tenants/bigdvapor/public/img/pages/Hero-Image.jpg +0 -0
  240. package/tenants/bigdvapor/public/img/tiles/adjust.png +0 -0
  241. package/tenants/bigdvapor/public/img/tiles/again.png +0 -0
  242. package/tenants/bigdvapor/public/img/tiles/al-fakher.png +0 -0
  243. package/tenants/bigdvapor/public/img/tiles/arro.png +0 -0
  244. package/tenants/bigdvapor/public/img/tiles/cookies.png +0 -0
  245. package/tenants/bigdvapor/public/img/tiles/core.png +0 -0
  246. package/tenants/bigdvapor/public/img/tiles/cyclone.png +0 -0
  247. package/tenants/bigdvapor/public/img/tiles/disposables.svg +23 -0
  248. package/tenants/bigdvapor/public/logo-wordmark.png +0 -0
  249. package/tenants/bigdvapor/public/pages/home.css +118 -0
  250. package/tenants/bigdvapor/public/pages/mkt.css +185 -0
  251. package/tenants/bigdvapor/public/pages/page.css +148 -0
  252. package/tenants/bigdvapor/public/themes/bigd-navy.css +73 -0
  253. package/tenants/bigdvapor/theme.json +12 -0
  254. package/tenants/giantvapes/content/chrome.html +152 -0
  255. package/tenants/giantvapes/content/chrome.json +94 -0
  256. package/tenants/giantvapes/content/home.html +194 -0
  257. package/tenants/giantvapes/content/home.json +50 -0
  258. package/tenants/giantvapes/content/pages/about.json +10 -0
  259. package/tenants/giantvapes/content/pages/privacy.json +6 -0
  260. package/tenants/giantvapes/content/pages/shipping-returns.json +6 -0
  261. package/tenants/giantvapes/content/pages-html/blogs/news.html +68 -0
  262. package/tenants/giantvapes/content/pages-html/pages/about-us.html +95 -0
  263. package/tenants/giantvapes/content/pages-html/pages/contact-us.html +68 -0
  264. package/tenants/giantvapes/content/pages-html/pages/privacy-policy.html +65 -0
  265. package/tenants/giantvapes/content/pages-html/pages/shipping-returns.html +77 -0
  266. package/tenants/giantvapes/content/themes/giant-navy.json +73 -0
  267. package/tenants/giantvapes/public/img/hero-suicide-bunny.jpg +0 -0
  268. package/tenants/giantvapes/public/img/hero.webp +0 -0
  269. package/tenants/giantvapes/public/img/og.jpg +0 -0
  270. package/tenants/giantvapes/public/logo-wordmark.png +0 -0
  271. package/tenants/giantvapes/public/pages/home.css +120 -0
  272. package/tenants/giantvapes/public/pages/mkt.css +185 -0
  273. package/tenants/giantvapes/public/pages/page.css +155 -0
  274. package/tenants/giantvapes/public/themes/giant-navy.css +76 -0
  275. package/tenants/giantvapes/theme.json +39 -0
  276. package/tenants/home/content/chrome.json +11 -0
  277. package/tenants/home/content/home.html +304 -0
  278. package/tenants/home/content/home.json +13 -0
  279. package/tenants/home/public/js/storefront.js +210 -0
  280. package/tenants/home/public/logo-wordmark.png +0 -0
  281. package/tenants/home/public/pages/storefront.css +212 -0
  282. package/tenants/home/theme.json +6 -0
  283. package/tenants/tokenoftrust/.tot/config.json +9 -0
  284. package/tenants/tokenoftrust/content/chrome.json +55 -0
  285. package/tenants/tokenoftrust/content/home.html +723 -0
  286. package/tenants/tokenoftrust/content/home.json +233 -0
  287. package/tenants/tokenoftrust/content/pages-html/careers.html +231 -0
  288. package/tenants/tokenoftrust/content/pages-html/company.html +275 -0
  289. package/tenants/tokenoftrust/content/pages-html/contact/contact-sales.html +188 -0
  290. package/tenants/tokenoftrust/content/pages-html/contact/startup-apply.html +206 -0
  291. package/tenants/tokenoftrust/content/pages-html/contact.html +178 -0
  292. package/tenants/tokenoftrust/content/pages-html/industries/adult-content-age-verification.html +286 -0
  293. package/tenants/tokenoftrust/content/pages-html/industries/cannabis.html +277 -0
  294. package/tenants/tokenoftrust/content/pages-html/pact-act-pricing.html +289 -0
  295. package/tenants/tokenoftrust/content/pages-html/pricing.html +483 -0
  296. package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-estimation.html +278 -0
  297. package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-gate.html +279 -0
  298. package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-verification.html +265 -0
  299. package/tenants/tokenoftrust/content/pages-html/product/age-assurance.html +173 -0
  300. package/tenants/tokenoftrust/content/pages-html/product/compliance/aml.html +214 -0
  301. package/tenants/tokenoftrust/content/pages-html/product/compliance/pact-act.html +226 -0
  302. package/tenants/tokenoftrust/content/pages-html/product/fraud-prevention.html +254 -0
  303. package/tenants/tokenoftrust/content/pages-html/product/social-profile-verification.html +212 -0
  304. package/tenants/tokenoftrust/content/pages-html/product/tax/excise-tax.html +213 -0
  305. package/tenants/tokenoftrust/content/pages-html/product/verification/aml.html +259 -0
  306. package/tenants/tokenoftrust/content/pages-html/product/verification/biometric-selfie.html +305 -0
  307. package/tenants/tokenoftrust/content/pages-html/product/verification/coverage.html +254 -0
  308. package/tenants/tokenoftrust/content/pages-html/product/verification/document-verification.html +306 -0
  309. package/tenants/tokenoftrust/content/pages-html/product/verification/electronic-identity-verification.html +304 -0
  310. package/tenants/tokenoftrust/content/pages-html/product/verification/government-id-verification.html +308 -0
  311. package/tenants/tokenoftrust/content/pages-html/product/verification.html +183 -0
  312. package/tenants/tokenoftrust/content/pages-html/product.html +228 -0
  313. package/tenants/tokenoftrust/content/pages-html/resources/integrations/bigcommerce-integration.html +220 -0
  314. package/tenants/tokenoftrust/content/pages-html/resources/integrations/shopify-integration.html +221 -0
  315. package/tenants/tokenoftrust/content/pages-html/resources/integrations/wordpress.html +220 -0
  316. package/tenants/tokenoftrust/content/pages-html/resources/integrations.html +172 -0
  317. package/tenants/tokenoftrust/content/pages-html/resources/refer-a-friend-program.html +317 -0
  318. package/tenants/tokenoftrust/content/pages-html/resources.html +177 -0
  319. package/tenants/tokenoftrust/content/pages-html/reviews.html +273 -0
  320. package/tenants/tokenoftrust/content/pages-html/solutions.html +292 -0
  321. package/tenants/tokenoftrust/content/pages-html/vlp-alcohol.html +289 -0
  322. package/tenants/tokenoftrust/content/pages-html/vlp-cigars-and-tobacco.html +286 -0
  323. package/tenants/tokenoftrust/content/pages-html/vlp-firearm-accessories.html +267 -0
  324. package/tenants/tokenoftrust/content/pages-html/vlp-peptides.html +273 -0
  325. package/tenants/tokenoftrust/content/themes/tot-navy.json +88 -0
  326. package/tenants/tokenoftrust/public/favicon.png +0 -0
  327. package/tenants/tokenoftrust/public/logo-icon.png +0 -0
  328. package/tenants/tokenoftrust/public/logo-wordmark.png +0 -0
  329. package/tenants/tokenoftrust/public/pages/company.css +108 -0
  330. package/tenants/tokenoftrust/public/pages/home.css +457 -0
  331. package/tenants/tokenoftrust/public/pages/mkt.css +482 -0
  332. package/tenants/tokenoftrust/public/pages/pricing.css +214 -0
  333. package/tenants/tokenoftrust/public/pages/solutions.css +66 -0
  334. package/tenants/tokenoftrust/public/themes/tot-navy-1.0.0.css +136 -0
  335. package/tenants/tokenoftrust/public/themes/tot-navy.css +358 -0
  336. package/tenants/tokenoftrust/theme.json +31 -0
  337. package/tsconfig.base.json +20 -0
@@ -0,0 +1,524 @@
1
+ /**
2
+ * ContentProvider — the Storyblok seam (brief §7 CMS).
3
+ *
4
+ * Two implementations behind one interface:
5
+ * - StoryblokContentProvider — calls the Storyblok delivery API for a tenant's
6
+ * child space, maps stories -> our content model.
7
+ * - LocalContentProvider — reads src/content/pilot/*.json, so the site
8
+ * renders fully OFFLINE with no Storyblok token.
9
+ *
10
+ * Selection: if the tenant has a usable delivery token AND the Storyblok client
11
+ * is configured, use Storyblok; otherwise fall back to local. Phase 1 ships with
12
+ * a placeholder token, so the LOCAL provider is what actually renders. The
13
+ * mapping layer is the documented swap point — Storyblok story JSON shares the
14
+ * same `component` discriminators as our blocks, so the parent-space schemas map
15
+ * 1:1. See DECISIONS.md.
16
+ */
17
+ import type {
18
+ EditorialPage,
19
+ HomeContent,
20
+ SiteChrome,
21
+ } from "./content-model.js";
22
+ import type { CustomizationEnv, KvGet, TenantConfig } from "@tot/public-runtime";
23
+ import { contentArtifactPath, readPublishedArtifact } from "@tot/public-runtime";
24
+
25
+ export interface ContentProvider {
26
+ getHome(): Promise<HomeContent>;
27
+ /**
28
+ * A marketing tenant's self-authored home document (raw HTML), or null. When
29
+ * present the homepage route serves it VERBATIM (bypassing the block Layout) —
30
+ * the "vendor injects their own HTML" path. Full `<!doctype html>` document;
31
+ * the route nonces its inline <style>/<script> so it still satisfies CSP.
32
+ */
33
+ getHomeHtml(): Promise<string | null>;
34
+ /**
35
+ * A marketing tenant's self-authored SUBPAGE document (raw HTML) for `slug`,
36
+ * or null. Same verbatim-serve path as getHomeHtml — the catch-all route
37
+ * (`[...slug].astro`) serves it bypassing the block Layout and nonces its
38
+ * inline tags. Files live at `content/<tenant>/pages-html/<slug>.html`.
39
+ */
40
+ getPageHtml(slug: string): Promise<string | null>;
41
+ /** Slugs of this tenant's raw subpages (for the sitemap). */
42
+ listPageHtmlSlugs(): Promise<string[]>;
43
+ /**
44
+ * The tenant's shared-chrome WRAPPER document (raw HTML), or null. A full
45
+ * document containing the CHROME_BODY_MARKER where a body-only page fragment is
46
+ * spliced in (see lib/rawMarketingHtml.ts). Lets raw pages stop duplicating
47
+ * chrome: chrome lives once here, pages become fragments. File:
48
+ * `content/<tenant>/chrome.html`. Tenant-owned (no cross-tenant fallback).
49
+ */
50
+ getChromeHtml(): Promise<string | null>;
51
+ getChrome(): Promise<SiteChrome>;
52
+ getPage(slug: string): Promise<EditorialPage | null>;
53
+ listPages(): Promise<EditorialPage[]>;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // LocalContentProvider — static JSON bundled at build time.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ // Per-tenant local content lives under the colocated repo-root layout
61
+ // tenants/<tenant_id>/content/. Vite globs bundle every tenant's JSON at build
62
+ // time; a file missing for a tenant falls back to the default scope, so a
63
+ // partially-adopted tenant still renders. Depth: from src/lib/storyblok/ the
64
+ // repo root is five levels up (storyblok→lib→src→storefront→apps→root).
65
+ const homeGlobs = import.meta.glob<HomeContent>("../../../../../tenants/*/content/home.json", {
66
+ import: "default",
67
+ });
68
+ const chromeGlobs = import.meta.glob<SiteChrome>("../../../../../tenants/*/content/chrome.json", {
69
+ import: "default",
70
+ });
71
+ const pageGlobs = import.meta.glob<EditorialPage>("../../../../../tenants/*/content/pages/*.json", {
72
+ import: "default",
73
+ });
74
+ // Raw self-authored home documents (marketing tenants). `?raw` bundles the file
75
+ // as a string; a tenant with no home.html simply has no entry here.
76
+ const homeHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/home.html", {
77
+ query: "?raw",
78
+ import: "default",
79
+ });
80
+ // Raw self-authored SUBPAGE documents: tenants/<tenant>/content/pages-html/<slug>.html
81
+ // (e.g. pages-html/pricing.html -> /pricing/). `**` supports nested slugs.
82
+ const pageHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/pages-html/**/*.html", {
83
+ query: "?raw",
84
+ import: "default",
85
+ });
86
+ // Shared-chrome wrapper document (marketing tenants that author body-only page
87
+ // fragments). One per tenant; a tenant without it keeps serving full documents.
88
+ const chromeHtmlGlobs = import.meta.glob<string>("../../../../../tenants/*/content/chrome.html", {
89
+ query: "?raw",
90
+ import: "default",
91
+ });
92
+
93
+ const DEFAULT_SCOPE = "home";
94
+
95
+ function pickContent<T>(
96
+ globs: Record<string, () => Promise<T>>,
97
+ scope: string,
98
+ ): (() => Promise<T>) | null {
99
+ const seg = `/tenants/${scope}/content/`;
100
+ const key = Object.keys(globs).find((k) => k.includes(seg));
101
+ return (key ? globs[key] : null) ?? null;
102
+ }
103
+
104
+ function pickPages(scope: string): Array<() => Promise<EditorialPage>> {
105
+ const seg = `/tenants/${scope}/content/pages/`;
106
+ return Object.keys(pageGlobs)
107
+ .filter((k) => k.includes(seg))
108
+ .map((k) => pageGlobs[k]!);
109
+ }
110
+
111
+ /** Normalize a route slug to the file stem: strip surrounding slashes + .html. */
112
+ function normalizeRawPageSlug(slug: string): string {
113
+ return slug.replace(/^\/+|\/+$/g, "").replace(/\.html$/i, "");
114
+ }
115
+
116
+ /** The subpage slug a glob key encodes for `scope`, or null if it's not one. */
117
+ function rawPageSlugForKey(scope: string, key: string): string | null {
118
+ const seg = `/tenants/${scope}/content/pages-html/`;
119
+ const start = key.indexOf(seg);
120
+ if (start < 0 || !key.endsWith(".html")) return null;
121
+ return key.slice(start + seg.length, -".html".length);
122
+ }
123
+
124
+ function pickPageHtml(scope: string, slug: string): (() => Promise<string>) | null {
125
+ const wanted = normalizeRawPageSlug(slug);
126
+ if (!wanted) return null;
127
+ const key = Object.keys(pageHtmlGlobs).find(
128
+ (k) => rawPageSlugForKey(scope, k) === wanted,
129
+ );
130
+ return key ? pageHtmlGlobs[key]! : null;
131
+ }
132
+
133
+ /**
134
+ * DEV hot-reload for raw tenant HTML. The `?raw` import.meta.glob above bundles
135
+ * each file's contents at server boot, so an edit to tenants/<id>/content/*.html
136
+ * would NOT preview until a full rebuild — the "magic on save" gap. In dev we read
137
+ * the file from disk at REQUEST time instead, so an edit shows on the next request.
138
+ *
139
+ * Prod: import.meta.env.DEV is statically false, so this whole branch tree-shakes
140
+ * out and the bundled glob is used — node:fs never enters the Worker. If fs is
141
+ * unavailable (e.g. the workerd dev runtime) or the file is missing, we return null
142
+ * and the caller falls back to the glob, so behavior is never worse than before.
143
+ */
144
+ async function readRawDevFile(scope: string, relative: string): Promise<string | null> {
145
+ if (!import.meta.env.DEV) return null;
146
+ try {
147
+ const [{ readFile }, { fileURLToPath }, { resolve, dirname }] = await Promise.all([
148
+ import("node:fs/promises"),
149
+ import("node:url"),
150
+ import("node:path"),
151
+ ]);
152
+ // provider.ts lives at apps/storefront/src/lib/storyblok/ — the repo root is five
153
+ // levels up, mirroring the import.meta.glob prefix ("../../../../../tenants/...").
154
+ const here = dirname(fileURLToPath(import.meta.url));
155
+ const abs = resolve(here, "../../../../..", "tenants", scope, "content", relative);
156
+ return await readFile(abs, "utf8");
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ export class LocalContentProvider implements ContentProvider {
163
+ constructor(private readonly scope: string = DEFAULT_SCOPE) {}
164
+
165
+ async getHome(): Promise<HomeContent> {
166
+ const loader = pickContent(homeGlobs, this.scope) ?? pickContent(homeGlobs, DEFAULT_SCOPE);
167
+ if (!loader) throw new Error("no home content");
168
+ return loader();
169
+ }
170
+
171
+ async getHomeHtml(): Promise<string | null> {
172
+ // DEV: serve fresh from disk so an edit previews on save (see readRawDevFile).
173
+ const dev = await readRawDevFile(this.scope, "home.html");
174
+ if (dev !== null) return dev;
175
+ // Tenant-owned only (no cross-tenant fallback): a raw home document is a
176
+ // whole-page identity, never inherited from the default scope.
177
+ const loader = pickContent(homeHtmlGlobs, this.scope);
178
+ return loader ? loader() : null;
179
+ }
180
+
181
+ async getPageHtml(slug: string): Promise<string | null> {
182
+ // DEV: serve fresh from disk so a subpage edit previews on save.
183
+ const dev = await readRawDevFile(this.scope, `pages-html/${normalizeRawPageSlug(slug)}.html`);
184
+ if (dev !== null) return dev;
185
+ // Tenant-owned only, like getHomeHtml — raw subpages are whole-page identity.
186
+ const loader = pickPageHtml(this.scope, slug);
187
+ return loader ? loader() : null;
188
+ }
189
+
190
+ async listPageHtmlSlugs(): Promise<string[]> {
191
+ return Object.keys(pageHtmlGlobs)
192
+ .map((k) => rawPageSlugForKey(this.scope, k))
193
+ .filter((slug): slug is string => !!slug)
194
+ .sort((a, b) => a.localeCompare(b));
195
+ }
196
+
197
+ async getChromeHtml(): Promise<string | null> {
198
+ // DEV: serve fresh from disk so a chrome edit previews on save.
199
+ const dev = await readRawDevFile(this.scope, "chrome.html");
200
+ if (dev !== null) return dev;
201
+ // Tenant-owned only (no cross-tenant fallback) — chrome is whole-page identity.
202
+ const loader = pickContent(chromeHtmlGlobs, this.scope);
203
+ return loader ? loader() : null;
204
+ }
205
+
206
+ async getChrome(): Promise<SiteChrome> {
207
+ const defL = pickContent(chromeGlobs, DEFAULT_SCOPE);
208
+ const def: SiteChrome = defL
209
+ ? await defL()
210
+ : { nav: [], footer: { columns: [], social: [] } };
211
+ const ownL = pickContent(chromeGlobs, this.scope);
212
+ if (!ownL) return def;
213
+ const c = await ownL();
214
+ if (this.scope === DEFAULT_SCOPE) return c;
215
+ // Per-field fallback fills only STRUCTURAL scaffolding (nav/footer) so a
216
+ // partially-adopted tenant still renders. Promotional/identity/legal fields
217
+ // (announcement, headerCtas, disclaimers) are tenant-OWNED and never inherit
218
+ // from the default scope — otherwise e.g. a marketing tenant would show the
219
+ // default store's shipping banner or nicotine disclaimers.
220
+ return {
221
+ nav: c.nav?.length ? c.nav : def.nav,
222
+ footer:
223
+ c.footer && (c.footer.columns?.length || c.footer.social?.length)
224
+ ? c.footer
225
+ : def.footer,
226
+ headerCtas: c.headerCtas,
227
+ announcement: c.announcement,
228
+ disclaimer: c.disclaimer,
229
+ disclaimers: c.disclaimers,
230
+ };
231
+ }
232
+
233
+ async getPage(slug: string): Promise<EditorialPage | null> {
234
+ return (await this.listPages()).find((p) => p.slug === slug) ?? null;
235
+ }
236
+
237
+ async listPages(): Promise<EditorialPage[]> {
238
+ // Editorial pages are NOT cross-tenant: a distinct tenant with no pages of
239
+ // its own returns none rather than inheriting the default scope's pages.
240
+ // (Serving e.g. pilot's privacy/legal copy under another brand would be a
241
+ // content/compliance leak; marketing tenants keep legal pages external.)
242
+ const loaders = pickPages(this.scope);
243
+ return Promise.all(loaders.map((l) => l()));
244
+ }
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // StoryblokContentProvider — live delivery API, mapping stories -> our model.
249
+ //
250
+ // Every method tries the Storyblok delivery API and falls back to
251
+ // LocalContentProvider on ANY failure (no token, network error, non-2xx, or a
252
+ // story whose shape doesn't map) — so the site never breaks. The map* fns assume
253
+ // the Storyblok components mirror content-model.ts (same `component`
254
+ // discriminators / shared parent-space schema, per DECISIONS.md). If your space
255
+ // uses different field names, adjust the map* fns — that's the single seam.
256
+ //
257
+ // Config via env: STORYBLOK_REGION (eu|us|ap|ca, default eu),
258
+ // STORYBLOK_VERSION (published|draft, default published).
259
+ // ---------------------------------------------------------------------------
260
+
261
+ const STORYBLOK_REGION_BASES: Record<string, string> = {
262
+ eu: "https://api.storyblok.com",
263
+ us: "https://api-us.storyblok.com",
264
+ ap: "https://api-ap.storyblok.com",
265
+ ca: "https://api-ca.storyblok.com",
266
+ };
267
+
268
+ function storyblokBase(): string {
269
+ const region = (import.meta.env.STORYBLOK_REGION ?? "eu").toLowerCase();
270
+ return STORYBLOK_REGION_BASES[region] ?? STORYBLOK_REGION_BASES.eu!;
271
+ }
272
+
273
+ function storyblokVersion(): "published" | "draft" {
274
+ return import.meta.env.STORYBLOK_VERSION === "draft" ? "draft" : "published";
275
+ }
276
+
277
+ interface StoryblokStory {
278
+ slug: string;
279
+ full_slug: string;
280
+ name: string;
281
+ content: Record<string, any>;
282
+ }
283
+
284
+ // Cap Storyblok latency so a slow/unreachable CMS can't hang the Worker — on
285
+ // timeout (or any error) the caller falls back to LocalContentProvider. The
286
+ // storefront must always render. (Epic S4 / S3.)
287
+ const STORYBLOK_TIMEOUT_MS = 1500;
288
+
289
+ async function fetchStoryblokJson<T>(url: string): Promise<T | null> {
290
+ try {
291
+ const res = await fetch(url, { signal: AbortSignal.timeout(STORYBLOK_TIMEOUT_MS) });
292
+ if (!res.ok) return null;
293
+ return (await res.json()) as T;
294
+ } catch {
295
+ // Network error, non-2xx already handled, or AbortSignal timeout.
296
+ return null;
297
+ }
298
+ }
299
+
300
+ async function fetchStory(token: string, slug: string): Promise<StoryblokStory | null> {
301
+ const url =
302
+ `${storyblokBase()}/v2/cdn/stories/${slug}` +
303
+ `?token=${encodeURIComponent(token)}&version=${storyblokVersion()}`;
304
+ const json = await fetchStoryblokJson<{ story?: StoryblokStory }>(url);
305
+ return json?.story ?? null;
306
+ }
307
+
308
+ async function fetchStories(token: string, startsWith: string): Promise<StoryblokStory[]> {
309
+ const url =
310
+ `${storyblokBase()}/v2/cdn/stories` +
311
+ `?starts_with=${encodeURIComponent(startsWith)}` +
312
+ `&token=${encodeURIComponent(token)}&version=${storyblokVersion()}&per_page=100`;
313
+ const json = await fetchStoryblokJson<{ stories?: StoryblokStory[] }>(url);
314
+ return json?.stories ?? [];
315
+ }
316
+
317
+ function mapHome(content: Record<string, any>): HomeContent {
318
+ // Storyblok nests the block list under `body` (array of bloks, each with its
319
+ // own `component`); our HomeBlock union uses the same discriminator.
320
+ const blocks = Array.isArray(content.body)
321
+ ? content.body
322
+ : Array.isArray(content.blocks)
323
+ ? content.blocks
324
+ : [];
325
+ if (blocks.length === 0) throw new Error("storyblok home: no blocks");
326
+ return {
327
+ seoTitle: content.seoTitle ?? content.seo_title,
328
+ seoDescription: content.seoDescription ?? content.seo_description,
329
+ blocks: blocks as HomeContent["blocks"],
330
+ };
331
+ }
332
+
333
+ function mapPage(content: Record<string, any>, slug: string): EditorialPage {
334
+ // Expect a rendered-HTML field (text/markdown component). A raw richtext object
335
+ // would need a resolver — we throw so the local fallback serves it instead.
336
+ const body_html = content.body_html ?? content.body;
337
+ if (typeof body_html !== "string") throw new Error(`storyblok page ${slug}: body not HTML`);
338
+ return {
339
+ slug: slug.replace(/^pages\//, ""),
340
+ title: content.title ?? slug,
341
+ seoTitle: content.seoTitle ?? content.seo_title,
342
+ seoDescription: content.seoDescription ?? content.seo_description,
343
+ body_html,
344
+ eyebrow: content.eyebrow,
345
+ intro: content.intro,
346
+ isPolicy: !!content.isPolicy,
347
+ };
348
+ }
349
+
350
+ export class StoryblokContentProvider implements ContentProvider {
351
+ constructor(
352
+ private readonly token: string,
353
+ private readonly fallback: ContentProvider = new LocalContentProvider(),
354
+ ) {}
355
+
356
+ async getHome(): Promise<HomeContent> {
357
+ try {
358
+ const story = await fetchStory(this.token, "home");
359
+ if (story) return mapHome(story.content);
360
+ } catch {
361
+ /* fall back */
362
+ }
363
+ return this.fallback.getHome();
364
+ }
365
+
366
+ getHomeHtml(): Promise<string | null> {
367
+ // Raw home documents live in tenant content files, not Storyblok — delegate.
368
+ return this.fallback.getHomeHtml();
369
+ }
370
+
371
+ getPageHtml(slug: string): Promise<string | null> {
372
+ return this.fallback.getPageHtml(slug);
373
+ }
374
+
375
+ listPageHtmlSlugs(): Promise<string[]> {
376
+ return this.fallback.listPageHtmlSlugs();
377
+ }
378
+
379
+ getChromeHtml(): Promise<string | null> {
380
+ // The chrome wrapper lives in tenant content files, not Storyblok — delegate.
381
+ return this.fallback.getChromeHtml();
382
+ }
383
+
384
+ async getChrome(): Promise<SiteChrome> {
385
+ // Editorial chrome (nav/footer/tagline) is authored in Storyblok; the
386
+ // announcement + legal disclaimers are MIGRATION-sourced (written to local
387
+ // chrome.json), so overlay those from the fallback regardless.
388
+ const local = await this.fallback.getChrome();
389
+ try {
390
+ const story = await fetchStory(this.token, "chrome");
391
+ const c = story?.content;
392
+ if (c && Array.isArray(c.nav)) {
393
+ return {
394
+ nav: c.nav,
395
+ footer: c.footer ?? local.footer,
396
+ announcement: local.announcement ?? c.announcement,
397
+ disclaimer: local.disclaimer,
398
+ disclaimers: local.disclaimers,
399
+ };
400
+ }
401
+ } catch {
402
+ /* fall back */
403
+ }
404
+ return local;
405
+ }
406
+
407
+ async getPage(slug: string): Promise<EditorialPage | null> {
408
+ try {
409
+ const story = await fetchStory(this.token, `pages/${slug}`);
410
+ if (story) return mapPage(story.content, story.full_slug ?? slug);
411
+ } catch {
412
+ /* fall back */
413
+ }
414
+ return this.fallback.getPage(slug);
415
+ }
416
+
417
+ async listPages(): Promise<EditorialPage[]> {
418
+ try {
419
+ const stories = await fetchStories(this.token, "pages/");
420
+ const pages = stories
421
+ .map((s) => {
422
+ try {
423
+ return mapPage(s.content, s.full_slug ?? s.slug);
424
+ } catch {
425
+ return null;
426
+ }
427
+ })
428
+ .filter((p): p is EditorialPage => p !== null);
429
+ if (pages.length > 0) return pages;
430
+ } catch {
431
+ /* fall back */
432
+ }
433
+ return this.fallback.listPages();
434
+ }
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // PublishedContentProvider — overlays published customization content for the
439
+ // resolved environment (store->app wiring). For each content artifact it checks
440
+ // the customization store ({live,test} pointer -> versioned JSON); if present it
441
+ // serves that, otherwise it delegates to the base provider (Storyblok/local).
442
+ // listPages() stays from the base (per-page overrides flow through getPage).
443
+ // ---------------------------------------------------------------------------
444
+
445
+ export interface ContentCustomization {
446
+ get: KvGet;
447
+ env: CustomizationEnv;
448
+ }
449
+
450
+ export class PublishedContentProvider implements ContentProvider {
451
+ constructor(
452
+ private readonly base: ContentProvider,
453
+ private readonly tenantId: string,
454
+ private readonly cust: ContentCustomization,
455
+ ) {}
456
+
457
+ /** Raw published-artifact string for a tenant file, or null to fall back to base. */
458
+ private publishedRaw(relative: string): Promise<string | null> {
459
+ return readPublishedArtifact(
460
+ this.cust.get,
461
+ this.tenantId,
462
+ this.cust.env,
463
+ contentArtifactPath(this.tenantId, relative),
464
+ );
465
+ }
466
+
467
+ private async published<T>(relative: string): Promise<T | null> {
468
+ const raw = await this.publishedRaw(relative);
469
+ if (!raw) return null;
470
+ try {
471
+ return JSON.parse(raw) as T;
472
+ } catch {
473
+ return null;
474
+ }
475
+ }
476
+
477
+ async getHome(): Promise<HomeContent> {
478
+ return (await this.published<HomeContent>("home.json")) ?? this.base.getHome();
479
+ }
480
+ // Raw whole-page documents (T0 marketing tenants) publish via KV too, so a
481
+ // vendor edit previews / goes live with NO Worker rebuild. Artifact path mirrors
482
+ // the source file: content/<tenant>/home.html and .../pages-html/<slug>.html.
483
+ async getHomeHtml(): Promise<string | null> {
484
+ return (await this.publishedRaw("home.html")) ?? this.base.getHomeHtml();
485
+ }
486
+ async getPageHtml(slug: string): Promise<string | null> {
487
+ const rel = `pages-html/${normalizeRawPageSlug(slug)}.html`;
488
+ return (await this.publishedRaw(rel)) ?? this.base.getPageHtml(slug);
489
+ }
490
+ listPageHtmlSlugs(): Promise<string[]> {
491
+ return this.base.listPageHtmlSlugs();
492
+ }
493
+ getChromeHtml(): Promise<string | null> {
494
+ // Raw chrome wrapper is bundled tenant content (like home/page HTML) — delegate.
495
+ return this.base.getChromeHtml();
496
+ }
497
+ async getChrome(): Promise<SiteChrome> {
498
+ return (await this.published<SiteChrome>("chrome.json")) ?? this.base.getChrome();
499
+ }
500
+ async getPage(slug: string): Promise<EditorialPage | null> {
501
+ return (await this.published<EditorialPage>(`pages/${slug}.json`)) ?? this.base.getPage(slug);
502
+ }
503
+ listPages(): Promise<EditorialPage[]> {
504
+ return this.base.listPages();
505
+ }
506
+ }
507
+
508
+ const PLACEHOLDER_TOKEN_RE = /PLACEHOLDER|^$/i;
509
+
510
+ export function createContentProvider(
511
+ tenant: TenantConfig,
512
+ customization?: ContentCustomization,
513
+ ): ContentProvider {
514
+ // Content dirs are keyed by tenant_id (the adopter writes content/<tenant>/).
515
+ const local = new LocalContentProvider(tenant.tenant_id);
516
+ const token = tenant.storyblok_space_token;
517
+ const usable = token && !PLACEHOLDER_TOKEN_RE.test(token);
518
+ const base: ContentProvider = usable
519
+ ? new StoryblokContentProvider(token, local)
520
+ : local;
521
+ return customization
522
+ ? new PublishedContentProvider(base, tenant.tenant_id, customization)
523
+ : base;
524
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Tenant routing resolution: Host first, then a `/<domain>/*` path-prefix
3
+ * fallback for hosts that don't resolve to a tenant on their own.
4
+ *
5
+ * Precedence (host-first — matches the prod contract where a custom hostname
6
+ * authoritatively owns a tenant; the path form is a DNS-free escape hatch for
7
+ * localhost/preview):
8
+ *
9
+ * 1. Host strictly claims a tenant -> use it, no base path.
10
+ * 2. Host is unknown AND the first path
11
+ * segment looks like a hostname that
12
+ * strictly claims a tenant -> use it; the segment becomes the
13
+ * base path and is stripped from
14
+ * the rendered route.
15
+ * 3. Otherwise -> registry default (resolveByHost).
16
+ *
17
+ * Example: on localhost (which claims no tenant), GET
18
+ * /store2.tokenoftrust.com/products/foo
19
+ * resolves tenant "store2", basePath "/store2.tokenoftrust.com", and renders
20
+ * the route "/products/foo". A bare localhost/products falls through to the
21
+ * default tenant because "products" isn't a hostname.
22
+ */
23
+ import type { TenantConfig, TenantResolver } from "@tot/public-runtime";
24
+
25
+ export interface TenantRouting {
26
+ tenant: TenantConfig | null;
27
+ /** "" when host-resolved; "/<domain>" when path-resolved. */
28
+ basePath: string;
29
+ /** Route to render with the tenant prefix stripped (leading slash, no query). */
30
+ renderPath: string;
31
+ }
32
+
33
+ /**
34
+ * A path segment is treated as a tenant-domain candidate only if it looks like
35
+ * a hostname (has a dot, no "@" or other path-ish junk). Real first segments
36
+ * — products, collections, saved, search — never contain a dot, so there is no
37
+ * collision with genuine routes.
38
+ */
39
+ export function looksLikeHost(segment: string): boolean {
40
+ return (
41
+ segment.length > 0 &&
42
+ segment.includes(".") &&
43
+ !segment.startsWith(".") &&
44
+ !segment.endsWith(".") &&
45
+ !segment.includes("@") &&
46
+ !segment.includes(" ")
47
+ );
48
+ }
49
+
50
+ /** Normalize a path: always leading slash, never a trailing slash (except "/"). */
51
+ function normalizePath(path: string): string {
52
+ const withSlash = path.startsWith("/") ? path : `/${path}`;
53
+ const trimmed = withSlash.replace(/\/+$/, "");
54
+ return trimmed === "" ? "/" : trimmed;
55
+ }
56
+
57
+ export async function resolveTenantRouting(
58
+ host: string,
59
+ pathname: string,
60
+ resolver: TenantResolver,
61
+ ): Promise<TenantRouting> {
62
+ // 1. Host wins when it explicitly owns a tenant.
63
+ const byHost = await resolver.resolveStrict(host);
64
+ if (byHost) {
65
+ return { tenant: byHost, basePath: "", renderPath: normalizePath(pathname) };
66
+ }
67
+
68
+ // 2. Unknown host -> try a /<domain>/* path prefix.
69
+ const segments = pathname.replace(/^\/+/, "").split("/");
70
+ const firstRaw = segments[0] ?? "";
71
+ let first = firstRaw;
72
+ try {
73
+ first = decodeURIComponent(firstRaw);
74
+ } catch {
75
+ /* leave as-is if it isn't valid percent-encoding */
76
+ }
77
+ if (looksLikeHost(first)) {
78
+ const byPath = await resolver.resolveStrict(first);
79
+ if (byPath) {
80
+ return {
81
+ tenant: byPath,
82
+ basePath: `/${first}`,
83
+ renderPath: normalizePath(segments.slice(1).join("/")),
84
+ };
85
+ }
86
+ }
87
+
88
+ // 3. Fall back to the registry default for this (unknown) host.
89
+ const fallback = await resolver.resolveByHost(host);
90
+ return { tenant: fallback, basePath: "", renderPath: normalizePath(pathname) };
91
+ }