@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,68 @@
1
+ /**
2
+ * GET /api/dev/diff?path= — Phase 2 (cp-diff) of the hosted /dev cockpit
3
+ * (workstream tot-lime-cloud-dev-cockpit, decision
4
+ * architecture-environment-aware-cockpit). Diffs the developer's draft
5
+ * overlay against published for one path, returning the SAME shape as the
6
+ * local loop's `/__tot/diff` (scripts/dev/git-status.mjs handleDiff —
7
+ * {ok,path,status,shown,before,after}) so dev.astro's existing diff UI
8
+ * (doDiff/renderDiff) needs no client-side change.
9
+ *
10
+ * No git in the Worker — "status" derives from draft-present-vs-published
11
+ * instead of `git status`: "unchanged" (no draft, or draft equals
12
+ * published), "modified" (draft differs from an existing published value),
13
+ * "added" (a draft exists for a path with no published counterpart yet —
14
+ * e.g. a brand-new pages-html/<slug>.html the developer hasn't published).
15
+ * Both sides are the raw-HTML/JSON writable surface, always text — no binary
16
+ * degrade case exists here (unlike the local loop's arbitrary file tree).
17
+ */
18
+ import type { APIContext } from "astro";
19
+ import { json } from "@/lib/grants/route.js";
20
+ import {
21
+ resolveCockpitContext,
22
+ isCockpitContext,
23
+ isWritablePath,
24
+ readPublishedPath,
25
+ resolveDraftState,
26
+ } from "@/lib/dev/hostedCockpit";
27
+
28
+ export const prerender = false;
29
+
30
+ const MAX_DIFF_BYTES = 512 * 1024;
31
+
32
+ export async function GET(context: APIContext): Promise<Response> {
33
+ const ctx = await resolveCockpitContext(context);
34
+ if (!isCockpitContext(ctx)) return ctx;
35
+
36
+ const path = new URL(context.request.url).searchParams.get("path");
37
+ if (!path) return json({ ok: false, status: "no_path", message: "path query param is required" }, 400);
38
+ if (!isWritablePath(path)) {
39
+ return json({ ok: false, status: "bad_path", message: `Not available on the hosted panel: ${path}` }, 400);
40
+ }
41
+
42
+ const [published, { draftContent }] = await Promise.all([
43
+ readPublishedPath(ctx, path),
44
+ resolveDraftState(context, ctx, path),
45
+ ]);
46
+
47
+ if (published === null && draftContent === null) {
48
+ return json({ ok: false, status: "not_found", message: `No such file: ${path}` }, 404);
49
+ }
50
+
51
+ const status =
52
+ draftContent === null
53
+ ? "unchanged"
54
+ : published === null
55
+ ? "added"
56
+ : draftContent === published
57
+ ? "unchanged"
58
+ : "modified";
59
+
60
+ const before = published ?? "";
61
+ const after = draftContent ?? published ?? "";
62
+ const size = new TextEncoder().encode(before + after).length;
63
+ if (size > MAX_DIFF_BYTES) {
64
+ return json({ ok: true, path, status, shown: false, reason: "too_big" });
65
+ }
66
+
67
+ return json({ ok: true, path, status, shown: true, before, after });
68
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * GET /api/dev/file?path= — Phase 1 (cp-file) of the hosted /dev cockpit
3
+ * (workstream tot-lime-cloud-dev-cockpit, decision
4
+ * architecture-environment-aware-cockpit). Reads one file off the same
5
+ * synthesized tree cp-tree (/api/dev/tree) lists, mirroring the local loop's
6
+ * `/__tot/file` response contract (scripts/dev/file-browser.mjs readFile/
7
+ * handleFile) so dev.astro's filesScript viewer needs no client-side change.
8
+ *
9
+ * `path` is one of the three shapes cp-tree ever lists — content/home.html,
10
+ * content/pages-html/<slug>.html, or theme.json (scripts/dev/safe-path.mjs
11
+ * WRITE_ALLOWLIST) — so this route is a closed switch on those shapes, not a
12
+ * general path resolver; anything else is a clean 400, never a traversal risk.
13
+ *
14
+ * Reads the developer's OWN draft overlay first (readEffectivePath), falling
15
+ * back to published — so re-opening a file after a cp-write save shows the
16
+ * edit, and the `mtimeMs` it hands back is the SAME contentVersion() cp-write
17
+ * checks on the next save (the C6 stale-write guard).
18
+ *
19
+ * `writable:true` on all three is intentional now that cp-write exists: this
20
+ * is genuinely the editable surface.
21
+ */
22
+ import type { APIContext } from "astro";
23
+ import { json } from "@/lib/grants/route.js";
24
+ import {
25
+ resolveCockpitContext,
26
+ isCockpitContext,
27
+ isWritablePath,
28
+ languageForPath,
29
+ readEffectivePath,
30
+ contentVersion,
31
+ } from "@/lib/dev/hostedCockpit";
32
+
33
+ export const prerender = false;
34
+
35
+ const MAX_FILE_BYTES = 512 * 1024;
36
+
37
+ export async function GET(context: APIContext): Promise<Response> {
38
+ const ctx = await resolveCockpitContext(context);
39
+ if (!isCockpitContext(ctx)) return ctx;
40
+
41
+ const path = new URL(context.request.url).searchParams.get("path");
42
+ if (!path) return json({ ok: false, status: "no_path", message: "path query param is required" }, 400);
43
+ if (!isWritablePath(path)) {
44
+ return json({ ok: false, status: "bad_path", message: `Not available on the hosted panel: ${path}` }, 400);
45
+ }
46
+
47
+ const { content } = await readEffectivePath(context, ctx, path);
48
+ if (content === null) return json({ ok: false, status: "not_found", message: `No such file: ${path}` }, 404);
49
+
50
+ const language = languageForPath(path);
51
+ const mtimeMs = contentVersion(content);
52
+ const size = new TextEncoder().encode(content).length;
53
+ if (size > MAX_FILE_BYTES) {
54
+ return json({ ok: true, status: "ok", path, tooBig: true, size, language, content: null, mtimeMs, writable: true });
55
+ }
56
+ return json({ ok: true, status: "ok", path, size, language, content, mtimeMs, writable: true });
57
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * POST /api/dev/locate — Phase 2 (cp-locate) of the hosted /dev cockpit
3
+ * (workstream tot-lime-cloud-dev-cockpit, decision
4
+ * architecture-environment-aware-cockpit). Click-to-source in the Worker:
5
+ * given a clicked preview URL + captured DOM path, resolve the source
6
+ * line:col in the developer's raw-HTML content, using the exact SAME pure
7
+ * parse5 walk as the local loop (scripts/dev/locate.mjs `locate()`) and the
8
+ * same URL -> content-path mapping (scripts/dev/locate-handler.mjs
9
+ * `resolvePageSource`), both imported directly (dependency-free besides
10
+ * parse5, safe in the Worker).
11
+ *
12
+ * Honors decision click-to-source-anchors-at-content-region from the
13
+ * local-loop board: a click resolves against the raw content region only; the
14
+ * storefront's own chrome (header/nav/footer, not in tenant source) refuses
15
+ * explicitly with `not_in_content`, never a coincidental miss.
16
+ *
17
+ * Unlike the local route, this reads the developer's DRAFT overlay first
18
+ * (readEffectivePath) — correct, since that's what their preview iframe is
19
+ * actually showing once they've saved an edit (cp-write). No open-in-editor
20
+ * equivalent: the jump lands in the in-cockpit editor via the existing
21
+ * tot:locate postMessage handler (c8Script), already wired and unchanged.
22
+ *
23
+ * Only `locate()` itself is imported from scripts/dev/locate.mjs (pure,
24
+ * parse5-only — Workers-safe). The URL->content-path mapping
25
+ * (resolvePageSource in scripts/dev/locate-handler.mjs) is reimplemented
26
+ * here rather than imported, because that module also pulls in node:fs +
27
+ * node:child_process (open-in-editor) which have no place in a Worker
28
+ * bundle; isWritablePath (hostedCockpit.ts) already IS this route's write
29
+ * allowlist, so the two definitions can't drift.
30
+ *
31
+ * NOTE (scope): this ships the server contract only. The client picker
32
+ * widget (src/lib/dev/ai-widget-client.js) that lets a developer click an
33
+ * element in the preview has no hosted injection path yet (dev-only today,
34
+ * see rawMarketingHtml.ts) — tracked separately as cp-locate-widget-injection
35
+ * so that security-sensitive gating gets its own review rather than riding
36
+ * in here.
37
+ */
38
+ import type { APIContext } from "astro";
39
+ import { json } from "@/lib/grants/route.js";
40
+ import { locate } from "../../../../../../scripts/dev/locate.mjs";
41
+ import {
42
+ resolveCockpitContext,
43
+ isCockpitContext,
44
+ readEffectivePath,
45
+ isWritablePath,
46
+ } from "@/lib/dev/hostedCockpit";
47
+
48
+ export const prerender = false;
49
+
50
+ interface LocateBody {
51
+ path?: unknown;
52
+ domPath?: unknown;
53
+ }
54
+
55
+ /**
56
+ * Map a clicked preview URL to its raw-HTML content source, root-relative.
57
+ * Mirrors scripts/dev/locate-handler.mjs's resolvePageSource exactly (see
58
+ * that module's header for the mapping rules); duplicated rather than
59
+ * imported for the node:fs-free-Worker-bundle reason above.
60
+ */
61
+ function resolvePreviewPageSource(pathname: string, appDomain: string): string | null {
62
+ let clean: string;
63
+ try {
64
+ clean = decodeURIComponent(pathname.split("?")[0]!.split("#")[0]!);
65
+ } catch {
66
+ return null;
67
+ }
68
+ const segments = clean.split("/").filter(Boolean);
69
+ if (segments.length && segments[0] === appDomain) segments.shift();
70
+ if (segments.some((s) => s === "..")) return null;
71
+ const rel = segments.length === 0 ? "content/home.html" : `content/pages-html/${segments.join("/")}.html`;
72
+ return isWritablePath(rel) ? rel : null;
73
+ }
74
+
75
+ function isDomPath(domPath: unknown): domPath is Array<{ tag: string; index: number }> {
76
+ return (
77
+ Array.isArray(domPath) &&
78
+ domPath.length > 0 &&
79
+ domPath.every(
80
+ (s) => s && typeof s === "object" && typeof (s as any).tag === "string" && Number.isFinite(Number((s as any).index)),
81
+ )
82
+ );
83
+ }
84
+
85
+ export async function POST(context: APIContext): Promise<Response> {
86
+ const ctx = await resolveCockpitContext(context);
87
+ if (!isCockpitContext(ctx)) return ctx;
88
+
89
+ let body: LocateBody;
90
+ try {
91
+ body = (await context.request.json()) as LocateBody;
92
+ } catch {
93
+ return json({ ok: false, status: "bad_json", message: "invalid JSON body" }, 400);
94
+ }
95
+
96
+ if (!isDomPath(body.domPath)) {
97
+ return json({ ok: false, status: "bad_dom_path", message: "domPath must be a non-empty array of {tag,index}" }, 400);
98
+ }
99
+
100
+ const rel = typeof body.path === "string" ? resolvePreviewPageSource(body.path, ctx.tenant.appDomain) : null;
101
+ if (!rel) {
102
+ return json({
103
+ ok: false,
104
+ status: "not_raw_html",
105
+ message: "This page isn’t a raw-HTML content page — click-to-source is for content/*.html tenants.",
106
+ });
107
+ }
108
+
109
+ const { content } = await readEffectivePath(context, ctx, rel);
110
+ if (content === null) {
111
+ return json({ ok: false, status: "not_raw_html", message: "No raw-HTML source backs this page." });
112
+ }
113
+
114
+ const found = locate(content, body.domPath);
115
+ if (typeof (found as { line?: number }).line === "number") {
116
+ const { line, col } = found as { line: number; col: number };
117
+ return json({ ok: true, path: rel, line, col });
118
+ }
119
+ return json({
120
+ ok: false,
121
+ status: "not_in_content",
122
+ message:
123
+ "That element is part of the storefront frame (header, nav, or footer), not your content source. Click something inside your page content.",
124
+ });
125
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * POST /api/dev/publish — Phase 3 (cp-publish) of the hosted /dev cockpit
3
+ * (workstream tot-lime-cloud-dev-cockpit). Publishes the developer's current
4
+ * draft content for one file into the tenant's TEST channel — the moment the
5
+ * private draft overlay (cp-write) becomes something OTHER viewers can see
6
+ * (still not live).
7
+ *
8
+ * SCOPE (see decision cp-publish-scope-correction): the card's literal plan
9
+ * ("wire to api/preview/promote.ts") doesn't fit this surface — that route
10
+ * needs a server-to-server bearer secret + a CDP-driven compliance
11
+ * GateReport, and operates on the Gitea-fed preview/live channel, not the
12
+ * dev-draft: overlay. Instead this uses the lower-level primitive the
13
+ * reconcile pipeline itself is built on — `publishToTest`
14
+ * (private-controlplane/customization-versioning.ts) over `KVVersionStore` —
15
+ * writing straight into the SAME published-artifact store
16
+ * `readPublishedArtifact`/`PublishedContentProvider` read. Content-addressed
17
+ * + idempotent by construction (re-publishing identical bytes is a no-op).
18
+ *
19
+ * Deliberately test-only: promoting test -> live is the real external-publish
20
+ * action and isn't reachable from this surface (see decision + the dispatched
21
+ * cp-publish-go-live follow-up). "Publish" here closes write -> preview ->
22
+ * diff -> publish-to-test, honestly, without faking or bypassing the
23
+ * existing compliance gate.
24
+ */
25
+ import type { APIContext } from "astro";
26
+ import { json } from "@/lib/grants/route.js";
27
+ import { readKv } from "@/lib/env";
28
+ import {
29
+ publishToTest,
30
+ CustomizationError,
31
+ KVVersionStore,
32
+ type CustomizationManifest,
33
+ } from "@tot/private-controlplane";
34
+ import { contentArtifactPath, themeArtifactPath } from "@tot/public-runtime";
35
+ import {
36
+ resolveCockpitContext,
37
+ isCockpitContext,
38
+ isWritablePath,
39
+ pageSlugForPath,
40
+ resolveDraftState,
41
+ } from "@/lib/dev/hostedCockpit";
42
+
43
+ export const prerender = false;
44
+
45
+ // The developer's editable raw-HTML/theme surface, in ARTIFACT-path shape
46
+ // (tenants/<id>/...) — what publishToTest's isEditable check and the
47
+ // published-artifact store both key on. Not the tenant's real
48
+ // declarative-customization manifest (out of scope here): this is
49
+ // defense-in-depth on top of the isWritablePath gate already enforced below.
50
+ const PUBLISH_MANIFEST: CustomizationManifest = {
51
+ version: 1,
52
+ classes: {
53
+ "platform-owned": [],
54
+ "extension-point": [],
55
+ "tenant-owned": ["tenants/<id>/content/home.html", "tenants/<id>/content/pages-html/**", "tenants/<id>/theme.json"],
56
+ },
57
+ immutableCompliance: [],
58
+ };
59
+
60
+ /**
61
+ * Map this route's short content path (content/home.html, theme.json, ...)
62
+ * to the artifact path the published-artifact store actually keys on
63
+ * (contentArtifactPath/themeArtifactPath — the SAME mapping
64
+ * PublishedContentProvider's getHomeHtml/getPageHtml use internally via
65
+ * `publishedRaw`). Only ever called after isWritablePath(path) passed, so
66
+ * every branch here has a defined result.
67
+ */
68
+ function artifactPathFor(tenantId: string, path: string): string {
69
+ if (path === "theme.json") return themeArtifactPath(tenantId);
70
+ if (path === "content/home.html") return contentArtifactPath(tenantId, "home.html");
71
+ return contentArtifactPath(tenantId, `pages-html/${pageSlugForPath(path)}.html`);
72
+ }
73
+
74
+ interface PublishBody {
75
+ path?: unknown;
76
+ }
77
+
78
+ export async function POST(context: APIContext): Promise<Response> {
79
+ const ctx = await resolveCockpitContext(context);
80
+ if (!isCockpitContext(ctx)) return ctx;
81
+
82
+ let body: PublishBody;
83
+ try {
84
+ body = (await context.request.json()) as PublishBody;
85
+ } catch {
86
+ return json({ ok: false, status: "bad_content", message: "invalid JSON body" }, 400);
87
+ }
88
+
89
+ const path = typeof body.path === "string" ? body.path : "";
90
+ if (!path) return json({ ok: false, status: "no_path", message: "path is required" }, 400);
91
+ if (!isWritablePath(path)) {
92
+ return json({ ok: false, status: "not_writable", message: `not in the write allowlist: ${path}` }, 403);
93
+ }
94
+
95
+ const { draftContent } = await resolveDraftState(context, ctx, path);
96
+ if (draftContent === null) {
97
+ return json(
98
+ { ok: false, status: "no_draft", message: "You have no unpublished edit for this file — nothing to publish." },
99
+ 409,
100
+ );
101
+ }
102
+
103
+ const kv = await readKv("TENANT_CACHE");
104
+ if (!kv) {
105
+ // No KV bound (e.g. bare local build) — accept but report unpersisted,
106
+ // matching cp-write's degraded-mode contract rather than a broken call.
107
+ return json({ ok: true, status: "ok", path, applied: false });
108
+ }
109
+
110
+ const store = new KVVersionStore(kv);
111
+ const artifactPath = artifactPathFor(ctx.tenant.tenant_id, path);
112
+ try {
113
+ const result = await publishToTest(store, PUBLISH_MANIFEST, ctx.tenant.tenant_id, artifactPath, draftContent);
114
+ return json({
115
+ ok: true,
116
+ status: "ok",
117
+ path,
118
+ applied: true,
119
+ versionId: result.artifact.versionId,
120
+ channel: "test",
121
+ });
122
+ } catch (err) {
123
+ if (err instanceof CustomizationError) {
124
+ return json({ ok: false, status: err.code, message: err.message }, 409);
125
+ }
126
+ throw err;
127
+ }
128
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * GET /api/dev/tree — Phase 1 (cp-tree) of the hosted /dev cockpit
3
+ * (workstream tot-lime-cloud-dev-cockpit, decision
4
+ * architecture-environment-aware-cockpit). Lists the signed-in developer's OWN
5
+ * tenant content so the hosted cockpit's Files tab renders the same tree shape
6
+ * the local loop's `/__tot/tree` does (see scripts/dev/file-browser.mjs
7
+ * buildTree/handleTree — this route mirrors its {ok,tree} response contract,
8
+ * consumed unchanged by dev.astro's filesScript via the cp-env apiBase resolver).
9
+ *
10
+ * Unlike the local tree (an arbitrary on-disk directory walk), the hosted
11
+ * Worker has no filesystem — the tree here is SYNTHESIZED from exactly the
12
+ * raw-HTML surface the content provider chain understands + the write
13
+ * allowlist (scripts/dev/safe-path.mjs WRITE_ALLOWLIST): content/home.html,
14
+ * content/pages-html/<slug>.html, and theme.json. A tenant on structured
15
+ * (Storyblok/JSON) content simply has an emptier tree — nothing to browse/edit
16
+ * on this raw-HTML surface yet.
17
+ */
18
+ import type { APIContext } from "astro";
19
+ import { json } from "@/lib/grants/route.js";
20
+ import { readPublishedArtifact, themeArtifactPath } from "@tot/public-runtime";
21
+ import { resolveCockpitContext, isCockpitContext } from "@/lib/dev/hostedCockpit";
22
+
23
+ export const prerender = false;
24
+
25
+ type TreeNode =
26
+ | { name: string; path: string; type: "file"; language: string | null }
27
+ | { name: string; path: string; type: "dir"; children: TreeNode[] };
28
+
29
+ export async function GET(context: APIContext): Promise<Response> {
30
+ const ctx = await resolveCockpitContext(context);
31
+ if (!isCockpitContext(ctx)) return ctx;
32
+ const { tenant, provider, kvGet } = ctx;
33
+
34
+ const [homeHtml, pageSlugs, themeRaw] = await Promise.all([
35
+ provider.getHomeHtml(),
36
+ provider.listPageHtmlSlugs(),
37
+ readPublishedArtifact(kvGet, tenant.tenant_id, "live", themeArtifactPath(tenant.tenant_id)),
38
+ ]);
39
+
40
+ const contentChildren: TreeNode[] = [];
41
+ if (homeHtml !== null) {
42
+ contentChildren.push({ name: "home.html", path: "content/home.html", type: "file", language: "html" });
43
+ }
44
+ if (pageSlugs.length > 0) {
45
+ contentChildren.push({
46
+ name: "pages-html",
47
+ path: "content/pages-html",
48
+ type: "dir",
49
+ children: pageSlugs
50
+ .slice()
51
+ .sort((a, b) => a.localeCompare(b))
52
+ .map((slug) => ({
53
+ name: `${slug}.html`,
54
+ path: `content/pages-html/${slug}.html`,
55
+ type: "file" as const,
56
+ language: "html",
57
+ })),
58
+ });
59
+ }
60
+
61
+ const tree: TreeNode[] = [];
62
+ if (contentChildren.length > 0) {
63
+ tree.push({ name: "content", path: "content", type: "dir", children: contentChildren });
64
+ }
65
+ if (themeRaw !== null) {
66
+ tree.push({ name: "theme.json", path: "theme.json", type: "file", language: "json" });
67
+ }
68
+
69
+ return json({ ok: true, tenantId: tenant.tenant_id, tree });
70
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * POST /api/dev/write — Phase 2 (cp-write) of the hosted /dev cockpit
3
+ * (workstream tot-lime-cloud-dev-cockpit, decision
4
+ * architecture-environment-aware-cockpit). The heart of cloud editing: a
5
+ * direct text edit (not an AI prompt) written into the developer's PRIVATE
6
+ * draft overlay, reusing /api/content-edit/ai-edit.ts's exact materialization
7
+ * mechanism (dev-draft: KV keys + signed tot_dev_draft cookie the middleware
8
+ * overlay reads) — so the /dev live preview reflects the edit and no other
9
+ * viewer/channel is touched. Same request/response contract as the local
10
+ * loop's `/__tot/write` (scripts/dev/file-writer.mjs handleWrite), including
11
+ * the C6 baseVersion 409 conflict guard — but "version" here is
12
+ * contentVersion() (a content hash), not a filesystem mtime, since the Worker
13
+ * has none; cp-file hands back the same hash so the two agree.
14
+ *
15
+ * Deviates from file-writer.mjs in one place: a theme.json write must parse as
16
+ * JSON before it lands (mirrors ai-edit.ts's guard) — this draft overlay feeds
17
+ * the SAME live preview theme.json ai-edit writes, and a malformed draft would
18
+ * degrade that preview for the developer, unlike a plain on-disk write.
19
+ *
20
+ * When KV or the signing secret is unavailable (e.g. no CUSTOMIZATION_PREVIEW_SECRET
21
+ * configured for this env), the edit is accepted but not persisted —
22
+ * `applied:false`, exactly ai-edit's degraded-mode contract — never a broken call.
23
+ */
24
+ import type { APIContext } from "astro";
25
+ import { json } from "@/lib/grants/route.js";
26
+ import { DEV_DRAFT_TTL_SECONDS, devDraftKey } from "@/lib/dev/devDraft";
27
+ import {
28
+ resolveCockpitContext,
29
+ isCockpitContext,
30
+ isWritablePath,
31
+ readPublishedPath,
32
+ resolveDraftState,
33
+ contentVersion,
34
+ setDraftCookie,
35
+ } from "@/lib/dev/hostedCockpit";
36
+
37
+ export const prerender = false;
38
+
39
+ const MAX_WRITE_BYTES = 2 * 1024 * 1024;
40
+
41
+ interface WriteBody {
42
+ path?: unknown;
43
+ content?: unknown;
44
+ baseVersion?: unknown;
45
+ }
46
+
47
+ export async function POST(context: APIContext): Promise<Response> {
48
+ const ctx = await resolveCockpitContext(context);
49
+ if (!isCockpitContext(ctx)) return ctx;
50
+
51
+ let body: WriteBody;
52
+ try {
53
+ body = (await context.request.json()) as WriteBody;
54
+ } catch {
55
+ return json({ ok: false, status: "bad_content", message: "invalid JSON body" }, 400);
56
+ }
57
+
58
+ const path = typeof body.path === "string" ? body.path : "";
59
+ if (!path) return json({ ok: false, status: "no_path", message: "path is required" }, 400);
60
+ if (!isWritablePath(path)) {
61
+ return json({ ok: false, status: "not_writable", message: `not in the write allowlist: ${path}` }, 403);
62
+ }
63
+
64
+ const content = body.content;
65
+ if (typeof content !== "string") {
66
+ return json({ ok: false, status: "bad_content", message: "content must be a string" }, 400);
67
+ }
68
+ if (new TextEncoder().encode(content).length > MAX_WRITE_BYTES) {
69
+ return json({ ok: false, status: "too_large", message: "content exceeds the write size limit" }, 413);
70
+ }
71
+ if (path === "theme.json") {
72
+ try {
73
+ JSON.parse(content);
74
+ } catch {
75
+ return json({ ok: false, status: "bad_content", message: "theme.json must be valid JSON" }, 400);
76
+ }
77
+ }
78
+
79
+ // Stale-write guard (C6): compare the caller's baseVersion against the
80
+ // developer's CURRENT value for this path (their draft if one exists, else
81
+ // published) — omit baseVersion to force-save, exactly the local contract.
82
+ const { draftId, draftContent } = await resolveDraftState(context, ctx, path);
83
+ const currentContent = draftContent !== null ? draftContent : await readPublishedPath(ctx, path);
84
+ const currentVersion = contentVersion(currentContent);
85
+ const baseVersion = typeof body.baseVersion === "number" ? body.baseVersion : undefined;
86
+ if (baseVersion !== undefined && currentVersion !== baseVersion) {
87
+ return json(
88
+ {
89
+ ok: false,
90
+ status: "conflict",
91
+ current: currentVersion,
92
+ message:
93
+ currentVersion === null
94
+ ? `${path} was deleted since you opened it`
95
+ : `${path} changed since you opened it`,
96
+ },
97
+ 409,
98
+ );
99
+ }
100
+
101
+ const bytes = new TextEncoder().encode(content).length;
102
+ const mtimeMs = contentVersion(content);
103
+
104
+ if (!ctx.kvPut || !ctx.draftSecret) {
105
+ // Nothing to persist against (e.g. bare local build with no KV/secret) —
106
+ // report success without pretending the draft landed, like ai-edit.
107
+ return json({ ok: true, status: "ok", path, bytes, mtimeMs, applied: false });
108
+ }
109
+
110
+ await ctx.kvPut(devDraftKey(ctx.tenant.tenant_id, draftId, path), content, {
111
+ expirationTtl: DEV_DRAFT_TTL_SECONDS,
112
+ });
113
+ await setDraftCookie(context, ctx, draftId);
114
+
115
+ return json({ ok: true, status: "ok", path, bytes, mtimeMs, applied: true });
116
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Newsletter signup endpoint.
3
+ *
4
+ * ASSUMPTION (Phase 1): there is no ESP integration yet. We persist the email to
5
+ * the TENANT_CACHE KV namespace when the Cloudflare runtime binding is present
6
+ * (key `newsletter:<email>`), and otherwise just log. Wiring a real ESP
7
+ * (Klaviyo/Mailchimp) is a documented Phase 2 swap at the marked spot below.
8
+ *
9
+ * Robustness: a signup must NEVER 500. All failures degrade to a friendly
10
+ * thank-you so the visitor is never shown an error for handing us their email.
11
+ */
12
+ import type { APIContext } from "astro";
13
+ import { readKv } from "@/lib/env";
14
+
15
+ // Minimal, permissive email shape check — we are not the source of truth for
16
+ // deliverability; the ESP validates properly downstream.
17
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
18
+
19
+ function thankYou(message: string, status = 200): Response {
20
+ const html = `<!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="utf-8" />
24
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
25
+ <meta name="robots" content="noindex" />
26
+ <title>Thank you</title>
27
+ <style>
28
+ :root { color-scheme: light dark; }
29
+ body { font-family: ui-serif, Georgia, serif; margin: 0; min-height: 100vh;
30
+ display: grid; place-items: center; padding: 2rem; text-align: center;
31
+ background: #f4f1ea; color: #2f3a34; }
32
+ .card { max-width: 30rem; }
33
+ h1 { font-size: clamp(1.6rem, 4vw, 2.2rem); margin: 0 0 0.75rem; }
34
+ p { color: #5a655e; line-height: 1.6; }
35
+ a { color: #2f3a34; font-weight: 600; }
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <div class="card">
40
+ <h1>You're on the list.</h1>
41
+ <p>${message}</p>
42
+ <p><a href="/">Back to the shop →</a></p>
43
+ </div>
44
+ </body>
45
+ </html>`;
46
+ return new Response(html, {
47
+ status,
48
+ headers: { "content-type": "text/html; charset=utf-8" },
49
+ });
50
+ }
51
+
52
+ export async function POST(context: APIContext): Promise<Response> {
53
+ // Off for marketing tenants — they capture leads via external CTAs.
54
+ if (!context.locals.features.newsletter) {
55
+ return new Response(null, { status: 404 });
56
+ }
57
+ try {
58
+ const form = await context.request.formData();
59
+ const email = String(form.get("email") ?? "").trim().toLowerCase();
60
+ const source = String(form.get("source") ?? "unknown").trim();
61
+
62
+ if (!EMAIL_RE.test(email)) {
63
+ return thankYou(
64
+ "That address didn't look quite right — head back and try again.",
65
+ 200,
66
+ );
67
+ }
68
+
69
+ // Persist. Phase 1: KV stub. Phase 2: replace with an ESP API call here.
70
+ const kv = await readKv("TENANT_CACHE");
71
+ if (kv) {
72
+ await kv.put(
73
+ `newsletter:${email}`,
74
+ JSON.stringify({ email, source, at: new Date().toISOString() }),
75
+ );
76
+ } else {
77
+ // No KV binding (local/dev): log and move on.
78
+ console.log(`[newsletter] signup ${email} (source: ${source})`);
79
+ }
80
+
81
+ // Redirect back where they came from (or home) with a success flag, so the
82
+ // page can show its own confirmation in-context. Falls back to the
83
+ // thank-you page if there's no usable referer.
84
+ const referer = context.request.headers.get("referer");
85
+ if (referer) {
86
+ try {
87
+ const back = new URL(referer);
88
+ back.searchParams.set("subscribed", "1");
89
+ return Response.redirect(back.toString(), 303);
90
+ } catch {
91
+ // fall through to thank-you
92
+ }
93
+ }
94
+ return thankYou("We'll send word when something worth knowing lands.");
95
+ } catch {
96
+ // Never 500 on a signup.
97
+ return thankYou("We'll send word when something worth knowing lands.");
98
+ }
99
+ }
100
+
101
+ export async function GET(context: APIContext): Promise<Response> {
102
+ // Nothing to render here directly — bounce back to the shop.
103
+ return context.redirect("/", 303);
104
+ }