@tokenoftrust/storefront-runner 2.0.41 → 2.0.43

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 (148) hide show
  1. package/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
  2. package/apps/storefront/package.json +1 -0
  3. package/apps/storefront/platform-reconcile-tenants.json +3 -0
  4. package/apps/storefront/public/favicon.ico +0 -0
  5. package/apps/storefront/public/shared/commerce-chrome.css +147 -16
  6. package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
  7. package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
  8. package/apps/storefront/src/components/Img.astro +6 -5
  9. package/apps/storefront/src/components/ProductCard.astro +6 -4
  10. package/apps/storefront/src/components/Seo.astro +2 -1
  11. package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
  12. package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
  13. package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
  14. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
  15. package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
  16. package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
  17. package/apps/storefront/src/components/home/Hero.astro +156 -4
  18. package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
  19. package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
  20. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
  21. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
  22. package/apps/storefront/src/components/marketing/Faq.astro +9 -2
  23. package/apps/storefront/src/config/devTenantSeed.ts +1 -1
  24. package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
  25. package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
  26. package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
  27. package/apps/storefront/src/config/tenants.ts +22 -0
  28. package/apps/storefront/src/layouts/Layout.astro +69 -44
  29. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
  31. package/apps/storefront/src/lib/asyncCss.ts +23 -0
  32. package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
  33. package/apps/storefront/src/lib/basePath.ts +70 -9
  34. package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
  35. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  36. package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
  37. package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
  38. package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
  39. package/apps/storefront/src/lib/diag/capture.ts +161 -0
  40. package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
  41. package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
  42. package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
  43. package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
  44. package/apps/storefront/src/lib/diag/reference.ts +148 -0
  45. package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
  46. package/apps/storefront/src/lib/diag/report.ts +109 -0
  47. package/apps/storefront/src/lib/diag/session.ts +93 -0
  48. package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
  49. package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
  50. package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
  51. package/apps/storefront/src/lib/imageAttrs.ts +9 -6
  52. package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
  53. package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
  54. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
  55. package/apps/storefront/src/lib/jsonScript.ts +13 -0
  56. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
  57. package/apps/storefront/src/lib/previewReload.ts +53 -9
  58. package/apps/storefront/src/lib/rawChrome.ts +148 -19
  59. package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
  60. package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
  61. package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
  62. package/apps/storefront/src/lib/tenantRouting.ts +15 -4
  63. package/apps/storefront/src/lib/tot/syncState.ts +21 -0
  64. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
  65. package/apps/storefront/src/middleware/controlPlane.ts +9 -5
  66. package/apps/storefront/src/middleware/index.ts +13 -1
  67. package/apps/storefront/src/middleware/renderChain.ts +54 -11
  68. package/apps/storefront/src/middleware/routeBranch.ts +58 -12
  69. package/apps/storefront/src/middleware/staticBundle.ts +37 -3
  70. package/apps/storefront/src/pages/[...slug].astro +6 -2
  71. package/apps/storefront/src/pages/api/request-access.ts +5 -4
  72. package/apps/storefront/src/pages/blog/[slug].astro +11 -1
  73. package/apps/storefront/src/pages/cart.ts +23 -0
  74. package/apps/storefront/src/pages/index.astro +64 -85
  75. package/apps/storefront/src/pages/products/[handle].astro +8 -6
  76. package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
  77. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
  78. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
  79. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
  80. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
  81. package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
  82. package/apps/storefront/src/styles/fonts.css +7 -0
  83. package/apps/storefront/src/styles/global.css +397 -3
  84. package/package.json +4 -1
  85. package/packages/cli/src/visual-parity.mjs +321 -0
  86. package/packages/public-runtime/package.json +7 -0
  87. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  88. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  89. package/packages/public-runtime/src/chrome.ts +44 -7
  90. package/packages/public-runtime/src/csp.ts +12 -1
  91. package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
  92. package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
  93. package/packages/public-runtime/src/extension-contract.ts +20 -6
  94. package/packages/public-runtime/src/image-dependencies.ts +1438 -0
  95. package/packages/public-runtime/src/index.ts +8 -0
  96. package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
  97. package/packages/public-runtime/src/integration/consent-config.ts +9 -0
  98. package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
  99. package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
  100. package/packages/public-runtime/src/integration/index.ts +1 -0
  101. package/packages/public-runtime/src/integration/lifecycle-boot.ts +30 -2
  102. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
  103. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
  104. package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
  105. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +9 -0
  106. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +27 -3
  107. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +13 -0
  108. package/packages/public-runtime/src/integration/native-ga4.ts +11 -0
  109. package/packages/public-runtime/src/integration/platform-adapters.ts +5 -1
  110. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  111. package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
  112. package/packages/public-runtime/src/publication-redirects.mjs +379 -0
  113. package/packages/public-runtime/src/publication-space.ts +24 -3
  114. package/packages/public-runtime/src/static-bundle.ts +145 -26
  115. package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
  116. package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
  117. package/packages/public-runtime/src/tenant.ts +59 -1
  118. package/pnpm-lock.runner.yaml +19 -0
  119. package/pnpm-workspace.yaml +4 -0
  120. package/scripts/dev/git-status.mjs +1 -1
  121. package/scripts/dev/publish.mjs +6 -0
  122. package/scripts/lib/tenant-asset-copy.mjs +4 -2
  123. package/scripts/tenant/materialize-tenant-content.mjs +1 -0
  124. package/scripts/tenant/validate.mjs +171 -8
  125. package/apps/storefront/src/lib/cfImage.ts +0 -55
  126. package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
  127. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  128. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  129. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  130. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  131. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  132. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  133. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  134. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  135. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  136. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  137. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  138. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  139. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  140. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  141. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  142. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  143. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  144. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  145. package/tenants/home/public/js/storefront.js +0 -129
  146. package/tenants/home/public/logo-wordmark.png +0 -0
  147. package/tenants/home/public/pages/storefront.css +0 -235
  148. package/tenants/home/theme.json +0 -6
@@ -0,0 +1,107 @@
1
+ import {
2
+ isSafeRehostedAssetPathForTenant,
3
+ isSafeStorefrontHref,
4
+ } from "@tot/public-runtime";
5
+ import { withBase } from "@/lib/basePath";
6
+ import type {
7
+ CommerceTrustStripBlock,
8
+ EditorialBlock,
9
+ FaqBlock,
10
+ HomeBlock,
11
+ MarketingBlock,
12
+ MembershipBandBlock,
13
+ NewsletterBlock,
14
+ PromoTilesBlock,
15
+ } from "@/lib/storyblok/content-model";
16
+
17
+ export type CommerceStaticResolved =
18
+ | { kind: "promo_tiles"; block: PromoTilesBlock }
19
+ | { kind: "trust_strip"; block: CommerceTrustStripBlock }
20
+ | { kind: "membership_band"; block: MembershipBandBlock }
21
+ | { kind: "editorial"; block: EditorialBlock }
22
+ | { kind: "newsletter"; block: NewsletterBlock }
23
+ | { kind: "faq"; block: FaqBlock };
24
+
25
+ /** Place static commerce blocks without coupling them to a delivery provider. */
26
+ export function resolveCommerceStaticBlock(
27
+ block: HomeBlock | MarketingBlock,
28
+ ): CommerceStaticResolved | null {
29
+ switch (block.component) {
30
+ case "promo_tiles":
31
+ return block.tiles.length ? { kind: "promo_tiles", block } : null;
32
+ case "trust_strip":
33
+ return { kind: "trust_strip", block };
34
+ case "membership_band":
35
+ return { kind: "membership_band", block };
36
+ case "editorial":
37
+ return { kind: "editorial", block };
38
+ case "newsletter":
39
+ return { kind: "newsletter", block };
40
+ case "faq":
41
+ return { kind: "faq", block };
42
+ default:
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function promoTilesView(
48
+ block: PromoTilesBlock,
49
+ basePath: string,
50
+ tenantId: string,
51
+ ) {
52
+ const presentation = block.presentation ?? "overlay";
53
+ const isOverlay = presentation === "overlay";
54
+ const isLogoStrip = presentation === "logo-strip";
55
+ const isPortrait = presentation === "portrait-cards";
56
+ const gridClass = isLogoStrip
57
+ ? "grid grid-cols-3 items-center gap-3 sm:grid-cols-5"
58
+ : isPortrait
59
+ ? "promo-portrait-rail"
60
+ : presentation === "editorial-cards"
61
+ ? "grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
62
+ : `grid gap-5 ${block.tiles.length >= 3 ? "sm:grid-cols-3" : "sm:grid-cols-2"}`;
63
+
64
+ return {
65
+ presentation,
66
+ isOverlay,
67
+ isLogoStrip,
68
+ isPortrait,
69
+ eyebrow: block.eyebrow ?? (isOverlay ? "Featured" : undefined),
70
+ gridClass,
71
+ tiles: block.tiles
72
+ .filter((tile) => isSafeStorefrontHref(tile.href))
73
+ .map((tile) => ({
74
+ ...tile,
75
+ href: withBase(basePath, tile.href),
76
+ image: isSafeRehostedAssetPathForTenant(tile.image, tenantId) ? tile.image : undefined,
77
+ imageAlt: tile.imageAlt ?? tile.label,
78
+ imageWidth: isLogoStrip ? 160 : 800,
79
+ imageHeight: isLogoStrip ? 80 : isPortrait ? 1000 : 450,
80
+ imageClass: [
81
+ "transition-transform duration-300 group-hover:scale-[1.02]",
82
+ isOverlay && "absolute inset-0 h-full w-full object-cover group-hover:scale-105",
83
+ isLogoStrip && "h-10 w-20 object-contain",
84
+ isPortrait && "aspect-[4/5] w-full rounded-[var(--radius-md)] object-cover",
85
+ presentation === "editorial-cards" && "aspect-[16/9] w-full rounded-[var(--radius-md)] object-cover",
86
+ ].filter(Boolean),
87
+ })),
88
+ cta:
89
+ block.ctaLabel && isSafeStorefrontHref(block.ctaHref)
90
+ ? { label: block.ctaLabel, href: withBase(basePath, block.ctaHref) }
91
+ : null,
92
+ };
93
+ }
94
+
95
+ export function editorialMediaView(block: EditorialBlock, tenantId: string) {
96
+ if (
97
+ isSafeRehostedAssetPathForTenant(block.image, tenantId) &&
98
+ typeof block.imageAlt === "string"
99
+ ) {
100
+ return { kind: "image" as const, src: block.image, alt: block.imageAlt };
101
+ }
102
+ return {
103
+ kind: "placeholder" as const,
104
+ seed: block.imageSeed ?? block.title,
105
+ label: block.title,
106
+ };
107
+ }
@@ -4,12 +4,13 @@
4
4
  * Picks the `src` + responsive `srcset` for a catalog image, factored out of
5
5
  * Img.astro so the branching is unit-testable without rendering the component:
6
6
  * - unusable/missing URL -> deterministic placeholder data URI
7
- * - rehosted (R2) -> Cloudflare Image Resizing (/cdn-cgi/image, format=auto)
7
+ * - rehosted asset -> shared CDN URL as-is (Cloudflare + CloudFront safe)
8
8
  * - referenced Shopify CDN -> Shopify's native ?width= scheme
9
9
  * - anything else usable -> the URL as-is, no srcset
10
10
  */
11
11
  import { placeholderDataUri, isUsableImageUrl } from "./placeholder.js";
12
- import { cfImageUrl, cfImageSrcset, CF_IMAGE_WIDTHS } from "./cfImage.js";
12
+
13
+ const RESPONSIVE_IMAGE_WIDTHS = [320, 480, 640, 960, 1280, 1600] as const;
13
14
 
14
15
  export interface ResolveImageInput {
15
16
  src?: string;
@@ -37,15 +38,17 @@ export function resolveImage(input: ResolveImageInput): ResolvedImage {
37
38
  }
38
39
  const url = src!;
39
40
 
40
- // Rehosted (R2) images go through Cloudflare Image Resizing — true resizing +
41
- // format=auto (AVIF/WebP).
41
+ // Rehosted assets live behind the shared asset CDN. Serve that URL directly:
42
+ // `/cdn-cgi/image` exists only on zones configured for Cloudflare Image
43
+ // Resizing and is not a CloudFront contract. The original URL is the one
44
+ // delivery shape both first-class storefront providers can serve.
42
45
  if (rehosted) {
43
- return { src: cfImageUrl(url, { width }), srcset: cfImageSrcset(url, width) };
46
+ return { src: url };
44
47
  }
45
48
 
46
49
  // Referenced Shopify CDN URLs already resize via ?width=; keep that scheme.
47
50
  if (isShopifyCdnUrl(url)) {
48
- const srcset = CF_IMAGE_WIDTHS.filter((w) => w <= width * 2)
51
+ const srcset = RESPONSIVE_IMAGE_WIDTHS.filter((w) => w <= width * 2)
49
52
  .map((w) => `${url}${url.includes("?") ? "&" : "?"}width=${w} ${w}w`)
50
53
  .join(", ");
51
54
  return { src: url, srcset };
@@ -15,6 +15,28 @@ const CSP_HEADERS = [
15
15
  "content-security-policy-report-only",
16
16
  ] as const;
17
17
 
18
+ /**
19
+ * Stamp every inline script/style in a dynamic HTML response. Astro emits a few
20
+ * inline runtime tags after component rendering (island bootstrap/style), and
21
+ * JSON-LD plus path-prefix helpers are also inline even though they do not come
22
+ * from the integration renderer. They cannot carry the template placeholder at
23
+ * authoring time, so the final response boundary adds the live nonce here.
24
+ * External scripts keep their normal `src`-based CSP admission.
25
+ */
26
+ export function stampInlineTagNonces(html: string, nonce: string): string {
27
+ return stampNonce(html, nonce).replace(
28
+ /<(script|style)\b([^>]*)>/gi,
29
+ (tag, name: string, attrs: string) => {
30
+ if (/\bnonce\s*=\s*(["'])\s*\1/i.test(attrs)) {
31
+ return tag.replace(/\bnonce\s*=\s*(["'])\s*\1/i, `nonce="${nonce}"`);
32
+ }
33
+ if (/\bnonce\s*=/i.test(attrs)) return tag;
34
+ if (name.toLowerCase() === "script" && /\bsrc\s*=/i.test(attrs)) return tag;
35
+ return `<${name} nonce="${nonce}"${attrs}>`;
36
+ },
37
+ );
38
+ }
39
+
18
40
  /** A fresh, non-guessable per-response nonce. */
19
41
  export function freshNonce(): string {
20
42
  return crypto.randomUUID();
@@ -41,7 +63,7 @@ export async function stampResponseNonce(
41
63
  if (!contentType.includes("text/html")) return response;
42
64
 
43
65
  const body = await response.text();
44
- return new Response(stampNonce(body, nonce), {
66
+ return new Response(stampInlineTagNonces(body, nonce), {
45
67
  status: response.status,
46
68
  statusText: response.statusText,
47
69
  headers: response.headers,
@@ -15,6 +15,42 @@ export interface IntegrationRenderState {
15
15
  compiledCsp?: CompiledIntegrationCsp;
16
16
  }
17
17
 
18
+ function declarationAppId(declaration: IntegrationDeclarationInput): string | undefined {
19
+ const id = declaration?.manifest?.app?.id;
20
+ return typeof id === "string" && id.length > 0 ? id : undefined;
21
+ }
22
+
23
+ function hasDuplicateAppIds(declarations: readonly IntegrationDeclarationInput[]): boolean {
24
+ const seen = new Set<string>();
25
+ for (const declaration of declarations) {
26
+ const id = declarationAppId(declaration);
27
+ if (!id) continue;
28
+ if (seen.has(id)) return true;
29
+ seen.add(id);
30
+ }
31
+ return false;
32
+ }
33
+
34
+ /**
35
+ * Compose tenant-tree declarations with the control plane's current managed set.
36
+ * A managed declaration is authoritative for the same app because it represents
37
+ * the platform-owned installation lifecycle. Duplicate app ids within either
38
+ * authority remain untouched so the shared resolver refuses that malformed source.
39
+ */
40
+ export function composeIntegrationDeclarations(
41
+ tenantDeclarations: readonly IntegrationDeclarationInput[],
42
+ managedDeclarations: readonly IntegrationDeclarationInput[],
43
+ ): IntegrationDeclarationInput[] {
44
+ if (hasDuplicateAppIds(tenantDeclarations) || hasDuplicateAppIds(managedDeclarations)) {
45
+ return [...tenantDeclarations, ...managedDeclarations];
46
+ }
47
+ const managedAppIds = new Set(managedDeclarations.map(declarationAppId).filter((id): id is string => !!id));
48
+ return [
49
+ ...tenantDeclarations.filter((declaration) => !managedAppIds.has(declarationAppId(declaration) ?? "")),
50
+ ...managedDeclarations,
51
+ ];
52
+ }
53
+
18
54
  /**
19
55
  * Resolve the plan and CSP as one fail-closed unit. A document either receives
20
56
  * the reviewed plan with the CSP compiled from that exact plan, or an
@@ -72,16 +72,19 @@ export interface TenantAnalyticsMaterialization {
72
72
  * declaration is environment-INDEPENDENT in content — its per-stage ids are all baked
73
73
  * into `publicConfig` and the client picks the current stage at render time — so the
74
74
  * environment here only satisfies {@link buildPlatformAdapterDeclaration}'s validation
75
- * and its (discarded) plan resolution. `canonicalHost` is a fixed placeholder, never the
76
- * tenant id, so a clone id (which is not a legal hostname) can never fail construction.
75
+ * and its plan-derived CSP requirements. The caller must supply the tenant's real
76
+ * canonical host so those requirements authorize the same-site adapter frame that the
77
+ * browser runtime will construct.
77
78
  */
78
- const CONSTRUCTION_ENVIRONMENT: BrowserEnvironment = {
79
- schemaVersion: ENVIRONMENT_SCHEMA_VERSION,
80
- siteSurface: "storefront",
81
- environment: "live",
82
- canonicalHost: "tenant-analytics.invalid",
83
- revision: "tenant-analytics-declaration",
84
- };
79
+ function constructionEnvironment(canonicalHost: string): BrowserEnvironment {
80
+ return {
81
+ schemaVersion: ENVIRONMENT_SCHEMA_VERSION,
82
+ siteSurface: "storefront",
83
+ environment: "live",
84
+ canonicalHost,
85
+ revision: "tenant-analytics-declaration",
86
+ };
87
+ }
85
88
 
86
89
  const EMPTY: readonly IntegrationDeclarationInput[] = Object.freeze([]);
87
90
 
@@ -133,8 +136,9 @@ function readDestination(
133
136
  export function materializeTenantAnalyticsDeclarations(input: {
134
137
  raw: unknown;
135
138
  tenantPublicId: string;
139
+ canonicalHost: string;
136
140
  }): TenantAnalyticsMaterialization {
137
- const { raw, tenantPublicId } = input;
141
+ const { raw, tenantPublicId, canonicalHost } = input;
138
142
  // An undeclared tenant (no file) is the zero-cost norm, not an error.
139
143
  if (raw === undefined || raw === null) {
140
144
  return { ok: true, declarations: EMPTY, violations: [] };
@@ -167,7 +171,7 @@ export function materializeTenantAnalyticsDeclarations(input: {
167
171
  adapterId: destination.adapter,
168
172
  ids: destination.ids,
169
173
  tenantPublicId,
170
- environment: CONSTRUCTION_ENVIRONMENT,
174
+ environment: constructionEnvironment(canonicalHost),
171
175
  });
172
176
  if (!built.ok) {
173
177
  for (const violation of built.violations) v.items.push(reanchor(violation, index));
@@ -193,6 +197,7 @@ export function materializeTenantAnalyticsDeclarations(input: {
193
197
  export function loadTenantAnalyticsDeclarations(
194
198
  raw: unknown,
195
199
  tenantPublicId: string,
200
+ canonicalHost: string,
196
201
  ): readonly IntegrationDeclarationInput[] {
197
- return materializeTenantAnalyticsDeclarations({ raw, tenantPublicId }).declarations;
202
+ return materializeTenantAnalyticsDeclarations({ raw, tenantPublicId, canonicalHost }).declarations;
198
203
  }
@@ -0,0 +1,13 @@
1
+ /** Serialize JSON for an HTML script data block without allowing tag breakout. */
2
+ export function safeJsonScriptContent(value: unknown): string {
3
+ const serialized = JSON.stringify(value);
4
+ if (serialized === undefined) {
5
+ throw new TypeError("script data must be JSON-serializable");
6
+ }
7
+ return serialized
8
+ .replace(/</g, "\\u003c")
9
+ .replace(/>/g, "\\u003e")
10
+ .replace(/&/g, "\\u0026")
11
+ .replace(/\u2028/g, "\\u2028")
12
+ .replace(/\u2029/g, "\\u2029");
13
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Private response marker for the in-process pinned-route rewrite boundary.
3
+ *
4
+ * `context.rewrite()` runs the rewritten request through middleware and returns
5
+ * a complete response: preview helpers, security headers, CSP, and the live
6
+ * nonce have already been applied. The outer render chain must pass those bytes
7
+ * through instead of decorating and nonce-stamping them a second time.
8
+ *
9
+ * The marker exists only while the inner response unwinds through the outer
10
+ * middleware pass. `takePinnedRouteRewriteResponse` removes it before the
11
+ * response reaches a client.
12
+ */
13
+ export const PINNED_ROUTE_REWRITE_COMPLETE_HEADER =
14
+ "x-tot-internal-pinned-route-rewrite-complete";
15
+
16
+ export function markPinnedRouteRewriteComplete(response: Response): Response {
17
+ response.headers.set(PINNED_ROUTE_REWRITE_COMPLETE_HEADER, "1");
18
+ return response;
19
+ }
20
+
21
+ export function takePinnedRouteRewriteResponse(response: Response): Response | null {
22
+ if (response.headers.get(PINNED_ROUTE_REWRITE_COMPLETE_HEADER) !== "1") return null;
23
+
24
+ const headers = new Headers(response.headers);
25
+ headers.delete(PINNED_ROUTE_REWRITE_COMPLETE_HEADER);
26
+ return new Response(response.body, {
27
+ status: response.status,
28
+ statusText: response.statusText,
29
+ headers,
30
+ });
31
+ }
@@ -4,7 +4,7 @@
4
4
  * shared `test`-env preview that others review.
5
5
  *
6
6
  * A tiny live-reload client is injected (by src/middleware) into `test`-env HTML
7
- * pages ONLY — never `live`. It polls `GET /__preview/version?tenant=<id>` (the
7
+ * pages ONLY — never `live`. It polls `GET /api/preview/version?tenant=<id>` (the
8
8
  * per-tenant edge cache-version integer, which bumps on every reconcile/publish;
9
9
  * see lib/edgeCache.ts + api/preview/reconcile.ts) and reloads the page when the
10
10
  * version changes. Poll-based v1; an SSE / Durable-Object PUSH is the later
@@ -40,25 +40,55 @@ export function buildPreviewReloadScript(opts: {
40
40
  // page used resolves it (path-routed previews carry a /<domain> prefix).
41
41
  return `(() => {
42
42
  const TENANT = ${tenant}, BASE = ${base}, POLL_MS = ${pollMs};
43
- const url = BASE + "/__preview/version?tenant=" + encodeURIComponent(TENANT);
43
+ const url = BASE + "/api/preview/version?tenant=" + encodeURIComponent(TENANT);
44
44
  let known = null;
45
+ let timer = null;
46
+ // A hidden tab cannot see a reload, so polling it is pure waste — and an
47
+ // unconditional once-a-second fetch leaves the tab in perpetual network
48
+ // activity, which reads to a reviewer as a preview that never finishes
49
+ // loading. Poll only while the page is actually being looked at, and check
50
+ // once immediately on re-show so nothing is missed while it was away.
51
+ // A STRUCTURAL failure — the endpoint answering with something that is not the
52
+ // version JSON — is not transient and must not be retried at the live cadence.
53
+ // The login gate answers an API path with the "Coming soon" holding page using a
54
+ // 200 and an HTML body, so res.ok is true and only the json() parse fails, which
55
+ // at this seam is indistinguishable from a healthy idle preview. Decay the poll
56
+ // and say so once, so a dead signal looks dead instead of leaving the tab
57
+ // fetching once a second for as long as it stays open.
58
+ let backoff = 0;
59
+ const schedule = () => {
60
+ if (timer !== null) { clearTimeout(timer); timer = null; }
61
+ if (document.hidden) return;
62
+ timer = setTimeout(tick, POLL_MS * Math.pow(2, backoff < 6 ? backoff : 6));
63
+ };
45
64
  const tick = async () => {
65
+ timer = null;
46
66
  try {
47
67
  const res = await fetch(url, { headers: { accept: "application/json" }, cache: "no-store" });
48
- if (res.ok) {
68
+ const type = res.headers.get("content-type") || "";
69
+ if (!res.ok || type.indexOf("application/json") === -1) {
70
+ if (backoff === 0) {
71
+ console.error("[tot] preview auto-refresh is not working: " + url + " answered " +
72
+ res.status + " " + (type || "(no content-type)") + " instead of the version JSON." +
73
+ " This page will not refresh when you publish.");
74
+ }
75
+ backoff = backoff + 1;
76
+ } else {
49
77
  const v = (await res.json()).version;
78
+ backoff = 0;
50
79
  if (known === null) { known = v; }
51
80
  // TODO(theme-hot-swap): when the change is theme-only, swap the CSS custom
52
81
  // properties in place (no reload). Needs the reconcile to report a
53
82
  // theme-only delta; gated behind this same version signal until then.
54
83
  else if (v !== known) { location.reload(); return; }
55
84
  }
56
- } catch (_e) { /* transient — keep polling */ }
57
- setTimeout(tick, POLL_MS);
85
+ } catch (_e) { /* offline / aborted — genuinely transient, keep the cadence */ }
86
+ schedule();
58
87
  };
88
+ document.addEventListener("visibilitychange", schedule);
59
89
  // TODO(push): replace this poll with an SSE / Durable-Object stream so the
60
90
  // reload is instant instead of within POLL_MS of a publish.
61
- setTimeout(tick, POLL_MS);
91
+ schedule();
62
92
  })();`;
63
93
  }
64
94
 
@@ -81,6 +111,13 @@ export function buildCspViolationBannerScript(): string {
81
111
  if (window.__totCspBanner) return; window.__totCspBanner = true;
82
112
  const seen = new Set();
83
113
  let box = null;
114
+ const isStyleGuide = /^\\/_style-guide(?:\\/|$)/.test(window.location.pathname || "");
115
+ const isPreviewInspectionStyle = (directive, uri) =>
116
+ directive === "style-src-elem" &&
117
+ (uri === "inline" || uri === "(inline)") &&
118
+ (window.navigator.userAgent.includes("CodexBrowser") ||
119
+ new URLSearchParams(window.location.search).has("__preview") ||
120
+ isStyleGuide);
84
121
  const ensureBox = () => {
85
122
  if (box) return box;
86
123
  box = document.createElement("div");
@@ -101,15 +138,22 @@ export function buildCspViolationBannerScript(): string {
101
138
  (document.body || document.documentElement).appendChild(box);
102
139
  return box;
103
140
  };
104
- document.addEventListener("securitypolicyviolation", (e) => {
105
- const directive = e.effectiveDirective || e.violatedDirective || "";
106
- const uri = e.blockedURI || "(inline)";
141
+ const recordViolation = (directive, uri) => {
142
+ // Preview inspection tooling injects its own nonce-less overlay stylesheet.
143
+ // Automated CSP validation remains authoritative for page resources; keep
144
+ // every other violation visible, and keep normal public routes diagnostic.
145
+ if (isPreviewInspectionStyle(directive, uri)) return;
107
146
  const key = directive + "|" + uri;
108
147
  if (seen.has(key)) return; seen.add(key);
109
148
  const li = document.createElement("li");
110
149
  // textContent only (never raw-HTML assignment) so the URI can't inject markup.
111
150
  li.textContent = uri + " — blocked by " + directive;
112
151
  ensureBox().__list.appendChild(li);
152
+ };
153
+ document.addEventListener("securitypolicyviolation", (e) => {
154
+ const directive = e.effectiveDirective || e.violatedDirective || "";
155
+ const uri = e.blockedURI || "(inline)";
156
+ recordViolation(directive, uri);
113
157
  });
114
158
  })();`;
115
159
  }