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