@tokenoftrust/storefront-runner 2.0.40 → 2.0.42
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/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
- package/apps/storefront/package.json +1 -0
- package/apps/storefront/platform-reconcile-tenants.json +3 -0
- package/apps/storefront/public/favicon.ico +0 -0
- package/apps/storefront/public/platform/google-measurement/v2/entry.mjs +1 -1
- package/apps/storefront/public/shared/commerce-chrome.css +147 -16
- package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
- package/apps/storefront/src/components/Img.astro +6 -5
- package/apps/storefront/src/components/ProductCard.astro +6 -4
- package/apps/storefront/src/components/Seo.astro +2 -1
- package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
- package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
- package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
- package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
- package/apps/storefront/src/components/home/Hero.astro +156 -4
- package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
- package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
- package/apps/storefront/src/components/marketing/Faq.astro +9 -2
- package/apps/storefront/src/config/devTenantSeed.ts +1 -1
- package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
- package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
- package/apps/storefront/src/config/tenants.ts +22 -0
- package/apps/storefront/src/layouts/Layout.astro +69 -44
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
- package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
- package/apps/storefront/src/lib/asyncCss.ts +23 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
- package/apps/storefront/src/lib/basePath.ts +70 -9
- package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
- package/apps/storefront/src/lib/chrome/model.ts +4 -0
- package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
- package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
- package/apps/storefront/src/lib/diag/capture.ts +161 -0
- package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
- package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
- package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
- package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
- package/apps/storefront/src/lib/diag/reference.ts +148 -0
- package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
- package/apps/storefront/src/lib/diag/report.ts +109 -0
- package/apps/storefront/src/lib/diag/session.ts +93 -0
- package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
- package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
- package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
- package/apps/storefront/src/lib/imageAttrs.ts +9 -6
- package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
- package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
- package/apps/storefront/src/lib/jsonScript.ts +13 -0
- package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
- package/apps/storefront/src/lib/previewReload.ts +53 -9
- package/apps/storefront/src/lib/rawChrome.ts +148 -19
- package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
- package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
- package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
- package/apps/storefront/src/lib/tenantRouting.ts +15 -4
- package/apps/storefront/src/lib/tot/syncState.ts +21 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
- package/apps/storefront/src/middleware/controlPlane.ts +9 -5
- package/apps/storefront/src/middleware/index.ts +13 -1
- package/apps/storefront/src/middleware/renderChain.ts +54 -11
- package/apps/storefront/src/middleware/routeBranch.ts +58 -12
- package/apps/storefront/src/middleware/staticBundle.ts +37 -3
- package/apps/storefront/src/pages/[...slug].astro +6 -2
- package/apps/storefront/src/pages/api/request-access.ts +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +11 -1
- package/apps/storefront/src/pages/cart.ts +23 -0
- package/apps/storefront/src/pages/index.astro +64 -85
- package/apps/storefront/src/pages/products/[handle].astro +8 -6
- package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
- package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
- package/apps/storefront/src/styles/fonts.css +7 -0
- package/apps/storefront/src/styles/global.css +397 -3
- package/package.json +4 -1
- package/packages/cli/src/visual-parity.mjs +321 -0
- package/packages/public-runtime/package.json +2 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/chrome.ts +44 -7
- package/packages/public-runtime/src/csp.ts +12 -1
- package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
- package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
- package/packages/public-runtime/src/extension-contract.ts +20 -6
- package/packages/public-runtime/src/index.ts +8 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
- package/packages/public-runtime/src/integration/consent-config.ts +9 -0
- package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
- package/packages/public-runtime/src/integration/csp-compiler.ts +6 -3
- package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +34 -6
- package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
- package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +35 -1
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +45 -12
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +28 -7
- package/packages/public-runtime/src/integration/native-ga4.ts +18 -2
- package/packages/public-runtime/src/integration/platform-adapters.ts +14 -3
- package/packages/public-runtime/src/integration/render-phases.ts +28 -2
- package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
- package/packages/public-runtime/src/publication-redirects.mjs +379 -0
- package/packages/public-runtime/src/publication-space.ts +24 -3
- package/packages/public-runtime/src/static-bundle.ts +145 -26
- package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
- package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
- package/packages/public-runtime/src/tenant.ts +59 -1
- package/pnpm-lock.runner.yaml +12 -0
- package/pnpm-workspace.yaml +4 -0
- package/scripts/dev/git-status.mjs +1 -1
- package/scripts/dev/publish.mjs +6 -0
- package/scripts/lib/tenant-asset-copy.mjs +4 -2
- package/scripts/tenant/materialize-tenant-content.mjs +1 -0
- package/scripts/tenant/validate.mjs +171 -8
- package/apps/storefront/src/lib/cfImage.ts +0 -55
- package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/js/storefront.js +0 -129
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +0 -235
- 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
|
|
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
|
-
|
|
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
|
|
41
|
-
//
|
|
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:
|
|
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 =
|
|
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(
|
|
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
|
|
76
|
-
*
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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:
|
|
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 /
|
|
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 + "/
|
|
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
|
-
|
|
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) { /*
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
}
|