@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,321 @@
1
+ /**
2
+ * Read-only closure checks for the optional visual-parity migration module.
3
+ * The tenant checkout remains portable: this reads only content/, public/, and
4
+ * .tot/visual-parity.json, never a preview host or a delivery provider.
5
+ */
6
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
8
+ import { extname, join, relative } from "node:path";
9
+
10
+ const ERROR = "error";
11
+ // Public routes implemented by the renderer. Authentication and cart URLs are
12
+ // deliberately absent: they are flow-specific, not portable parity targets.
13
+ const PLATFORM_ROUTES = ["/collections", "/collections/", "/products/", "/search", "/saved", "/account", "/account/", "/blog", "/blog/"];
14
+ const ASSET_EXTENSIONS = new Set([".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"]);
15
+
16
+ const finding = (rule, file, message, fix) => ({ level: ERROR, rule, file, message, fix });
17
+ const walk = (dir) => !existsSync(dir) ? [] : readdirSync(dir).flatMap((name) => {
18
+ const path = join(dir, name);
19
+ return statSync(path).isDirectory() ? walk(path) : [path];
20
+ });
21
+ const pointer = (parts) => parts.length ? `/${parts.map((part) => String(part).replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}` : "/";
22
+ const stripQuery = (value) => value.split(/[?#]/, 1)[0];
23
+ const normalizeRoute = (value) => {
24
+ const path = stripQuery(value).replace(/\/+/g, "/").replace(/\/$/, "") || "/";
25
+ return path.startsWith("/") ? path : `/${path}`;
26
+ };
27
+ const hasDotSegment = (value) => stripQuery(value).split("/").some((part) => part === "." || part === ".." || /^%2e(?:%2e)?$/i.test(part));
28
+ const safeLocator = (value) => {
29
+ try {
30
+ const url = new URL(value.startsWith("//") ? `https:${value}` : value);
31
+ return url.origin;
32
+ } catch {
33
+ if (value.startsWith("/")) return normalizeRoute(value);
34
+ return "[redacted authored value]";
35
+ }
36
+ };
37
+
38
+ function parseConfig(tenantDir) {
39
+ const path = join(tenantDir, ".tot", "visual-parity.json");
40
+ if (!existsSync(path)) return { value: {}, findings: [] };
41
+ try {
42
+ const value = JSON.parse(readFileSync(path, "utf8"));
43
+ if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("must be a JSON object");
44
+ const findings = [];
45
+ if (value.externalNavigationAllowlist !== undefined && !Array.isArray(value.externalNavigationAllowlist)) {
46
+ findings.push(finding("parity-config-shape", ".tot/visual-parity.json", "externalNavigationAllowlist must be an array of HTTPS origins or URLs", "replace it with an explicit reviewed array, for example [\"https://partner.example\"]"));
47
+ }
48
+ for (const [index, entry] of (Array.isArray(value.externalNavigationAllowlist) ? value.externalNavigationAllowlist : []).entries()) {
49
+ try {
50
+ const url = new URL(entry);
51
+ if (typeof entry !== "string" || url.protocol !== "https:" || url.username || url.password || /[?#]/.test(entry)) throw new Error("invalid");
52
+ } catch {
53
+ findings.push(finding("parity-allowlist-entry", `.tot/visual-parity.json/externalNavigationAllowlist/${index}`, "allowlist entries must be HTTPS origins or exact path prefixes without credentials, query, or fragment", "use https://partner.example for an origin or https://partner.example/returns for that path boundary"));
54
+ }
55
+ }
56
+ return { value, findings };
57
+ } catch {
58
+ return { value: {}, findings: [finding("parity-config-parse", ".tot/visual-parity.json", "invalid JSON", "fix .tot/visual-parity.json before closing visual parity")] };
59
+ }
60
+ }
61
+
62
+ function tenantFromConfig(tenantDir) {
63
+ try {
64
+ const value = JSON.parse(readFileSync(join(tenantDir, ".tot", "config.json"), "utf8"));
65
+ return typeof value?.tenant === "string" ? value.tenant : null;
66
+ } catch { return null; }
67
+ }
68
+
69
+ function allowedNavigation(value, allowlist) {
70
+ try {
71
+ const url = new URL(value);
72
+ return allowlist.some((entry) => {
73
+ try {
74
+ const allowed = new URL(entry);
75
+ if (allowed.protocol !== "https:" || allowed.username || allowed.password) return false;
76
+ if (allowed.pathname === "/") return url.origin === allowed.origin;
77
+ return url.origin === allowed.origin && (url.pathname === allowed.pathname || url.pathname.startsWith(`${allowed.pathname.replace(/\/$/, "")}/`));
78
+ } catch { return false; }
79
+ });
80
+ } catch { return false; }
81
+ }
82
+
83
+ function assetType(bytes) {
84
+ const ascii = bytes.subarray(0, 256).toString("utf8").trimStart().toLowerCase();
85
+ if (pngValid(bytes)) return ".png";
86
+ if (bytes.subarray(0, 3).toString("ascii") === "GIF") return ".gif";
87
+ if (jpegValid(bytes)) return ".jpg";
88
+ if (bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return ".webp";
89
+ if (bytes.subarray(4, 8).toString("ascii") === "ftyp" && bytes.subarray(8, 12).toString("ascii").toLowerCase().includes("avif")) return ".avif";
90
+ if (bytes.subarray(0, 4).toString("ascii") === "wOFF") return ".woff";
91
+ if (bytes.subarray(0, 4).toString("ascii") === "wOF2") return ".woff2";
92
+ if (icoValid(bytes)) return ".ico";
93
+ if (ascii.startsWith("<svg") || ascii.startsWith("<?xml") && ascii.includes("<svg")) return ".svg";
94
+ return null;
95
+ }
96
+ function pngValid(bytes) {
97
+ if (bytes.length < 45 || !bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return false;
98
+ let pos = 8, sawIhdr = false;
99
+ let sawIdat = false;
100
+ while (pos + 12 <= bytes.length) {
101
+ const length = bytes.readUInt32BE(pos); const type = bytes.subarray(pos + 4, pos + 8).toString("ascii");
102
+ if (length > bytes.length - pos - 12) return false;
103
+ if (type === "IHDR") { if (sawIhdr || length !== 13 || bytes.readUInt32BE(pos + 8) === 0 || bytes.readUInt32BE(pos + 12) === 0) return false; sawIhdr = true; }
104
+ if (type === "IDAT" && length > 0) sawIdat = true;
105
+ pos += 12 + length; if (type === "IEND") return sawIhdr && sawIdat && length === 0 && pos === bytes.length;
106
+ } return false;
107
+ }
108
+ function jpegValid(bytes) {
109
+ if (bytes.length < 10 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
110
+ let pos = 2, sof = false;
111
+ while (pos < bytes.length) {
112
+ if (bytes[pos++] !== 0xff) return false; while (bytes[pos] === 0xff) pos++;
113
+ const marker = bytes[pos++]; if (marker === 0xd9) return sof && pos === bytes.length;
114
+ if (marker === 0xda) { if (pos + 2 > bytes.length) return false; const length = bytes.readUInt16BE(pos); const scan = pos + length; return sof && scan < bytes.length - 2 && bytes[bytes.length - 2] === 0xff && bytes[bytes.length - 1] === 0xd9; }
115
+ if (marker === 0x01 || marker >= 0xd0 && marker <= 0xd7) continue;
116
+ if (pos + 2 > bytes.length) return false; const length = bytes.readUInt16BE(pos); if (length < 2 || pos + length > bytes.length) return false;
117
+ if (marker >= 0xc0 && marker <= 0xc3 && length >= 8) { if (bytes.readUInt16BE(pos + 3) === 0 || bytes.readUInt16BE(pos + 5) === 0) return false; sof = true; }
118
+ pos += length;
119
+ } return false;
120
+ }
121
+ function icoValid(bytes) {
122
+ if (bytes.length < 22 || bytes.readUInt16LE(0) !== 0 || bytes.readUInt16LE(2) !== 1) return false;
123
+ const count = bytes.readUInt16LE(4); if (!count || bytes.length < 6 + count * 16) return false;
124
+ for (let i = 0; i < count; i++) { const at = 6 + i * 16, size = bytes.readUInt32LE(at + 8), offset = bytes.readUInt32LE(at + 12); if (size && offset >= 6 + count * 16 && offset + size <= bytes.length) { const payload = bytes.subarray(offset, offset + size); if (pngValid(payload) || payload.length >= 40 && payload.readInt32LE(0) >= 40 && payload.readInt32LE(4) > 0 && payload.readInt32LE(8) > 0) return true; } }
125
+ return false;
126
+ }
127
+
128
+ function checkAsset(value, at, file, context) {
129
+ const { findings, tenantId, publicDir } = context;
130
+ if (!value || value.startsWith("#")) return;
131
+ if (/^data:/i.test(value)) {
132
+ findings.push(finding("parity-data-asset", `${file}${at}`, "embedded data assets are not portable parity inputs", "copy the asset into public/ and use a tenant-local path"));
133
+ return;
134
+ }
135
+ if (/^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith("//")) {
136
+ findings.push(finding("parity-external-asset", `${file}${at}`, `asset source ${safeLocator(value)} is off-host`, "copy the asset into public/ and reference it with /tenants/<tenant>/..."));
137
+ return;
138
+ }
139
+ const path = stripQuery(value);
140
+ let rel;
141
+ if (path.startsWith("/tenants/")) {
142
+ const prefix = `/tenants/${tenantId}/`;
143
+ if (!tenantId || !path.startsWith(prefix)) {
144
+ findings.push(finding("parity-cross-tenant-asset", `${file}${at}`, "asset does not belong to this tenant", "copy the asset into this tenant's public/ directory"));
145
+ return;
146
+ }
147
+ rel = path.slice(prefix.length);
148
+ } else if (path.startsWith("/")) {
149
+ findings.push(finding("parity-unowned-asset", `${file}${at}`, "asset is not tenant-local", "use a tenant-local asset committed under public/"));
150
+ return;
151
+ } else rel = path;
152
+ if (!rel || hasDotSegment(rel)) {
153
+ findings.push(finding("parity-asset-path", `${file}${at}`, "asset path escapes or does not name public/", "use a direct tenant-local path under public/"));
154
+ return;
155
+ }
156
+ const asset = join(publicDir, rel);
157
+ if (!existsSync(asset)) {
158
+ findings.push(finding("parity-asset-missing", `${file}${at}`, "tenant-local asset is absent from public/", "add the referenced asset under public/ or correct the reference"));
159
+ return;
160
+ }
161
+ const extension = extname(rel).toLowerCase();
162
+ if (!ASSET_EXTENSIONS.has(extension)) {
163
+ findings.push(finding("parity-asset-extension", `${file}${at}`, "asset has an unsupported extension", "re-export favicon/assets as PNG or SVG; font containers require platform-managed validation"));
164
+ return;
165
+ }
166
+ if (statSync(asset).isDirectory()) {
167
+ findings.push(finding("parity-asset-directory", `${file}${at}`, "asset names a directory", "reference a file under public/"));
168
+ return;
169
+ }
170
+ if (ASSET_EXTENSIONS.has(extension)) {
171
+ const actual = assetType(readFileSync(asset));
172
+ const compatible = actual === extension || actual === ".jpg" && extension === ".jpeg";
173
+ if (!actual) findings.push(finding("parity-asset-undecodable", `${file}${at}`, `asset does not pass bounded ${extension} structural integrity checks`, "replace it with a structurally valid file of the declared type"));
174
+ else if (!compatible) findings.push(finding("parity-asset-type", `${file}${at}`, `asset bytes do not match the ${extension} extension`, "export the asset in the declared format"));
175
+ else if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"].includes(extension)) context.decodeAssets.push({ asset, file: `${file}${at}` });
176
+ }
177
+ }
178
+
179
+ function decodeImages(entries) {
180
+ if (!entries.length) return new Set();
181
+ const source = "const{createRequire}=require('node:module');const sharp=createRequire(process.argv[1])('sharp');(async()=>{let a=JSON.parse(process.argv[2]);let o=[];for(const p of a){try{let r=await sharp(p,{failOn:'error'}).raw().toBuffer({resolveWithObject:true});o.push(r.info.width>0&&r.info.height>0)}catch{o.push(false)}}process.stdout.write(JSON.stringify(o))})().catch(()=>process.exit(1));";
182
+ const result = spawnSync(process.execPath, ["-e", source, import.meta.url, JSON.stringify(entries.map((entry) => entry.asset))], { encoding: "utf8", timeout: 15000 });
183
+ try { if (result.status !== 0) throw new Error(); const output = JSON.parse(result.stdout); return new Set(entries.flatMap((_, index) => output[index] ? [] : [index])); } catch { return new Set(entries.map((_, index) => index)); }
184
+ }
185
+
186
+ function inspectHtml(html, file, at, context) {
187
+ const attr = /\b(href|src)\s*=\s*(?:(["'])(.*?)\2|([^\s>]+))/gi;
188
+ let index = 0;
189
+ for (const match of html.matchAll(attr)) {
190
+ const value = (match[3] ?? match[4] ?? "").trim();
191
+ inspectReference(match[1].toLowerCase(), value, `${at}/html-attribute-${index++}`, file, context);
192
+ }
193
+ }
194
+ function inspectReference(kind, value, at, file, context) {
195
+ if (kind === "src") return checkAsset(value, at, file, context);
196
+ if (!value || value.startsWith("#") || /^(mailto:|tel:)/i.test(value)) return;
197
+ if (/^(javascript|vbscript|data):/i.test(value)) {
198
+ context.findings.push(finding("parity-executable-link", `${file}${at}`, "link uses an executable scheme", "replace it with a local route or a reviewed HTTPS destination"));
199
+ return;
200
+ }
201
+ if (/^https?:\/\//i.test(value) || value.startsWith("//")) {
202
+ if (!allowedNavigation(value.startsWith("//") ? `https:${value}` : value, context.allowlist)) context.findings.push(finding("parity-external-navigation", `${file}${at}`, `external navigation ${safeLocator(value)} is not on the reviewed allowlist`, "add its HTTPS origin or URL to .tot/visual-parity.json only after human review"));
203
+ return;
204
+ }
205
+ if (!value.startsWith("/")) {
206
+ context.findings.push(finding("parity-relative-link", `${file}${at}`, "relative link has no stable paired route", "use a root-absolute local route"));
207
+ return;
208
+ }
209
+ if (hasDotSegment(value)) {
210
+ context.findings.push(finding("parity-route-path", `${file}${at}`, "local route contains a dot segment", "use a canonical root-absolute route"));
211
+ return;
212
+ }
213
+ const route = normalizeRoute(value);
214
+ if (!context.routes.has(route) && !PLATFORM_ROUTES.some((platform) => platform.endsWith("/") ? route.startsWith(platform) : route === platform)) context.findings.push(finding("parity-route-missing", `${file}${at}`, "local route is not declared by a page slug or platform", "add the matching content/pages JSON record or correct the link"));
215
+ }
216
+
217
+ function inspectValue(value, file, parts, context) {
218
+ if (Array.isArray(value)) return value.forEach((item, index) => inspectValue(item, file, [...parts, index], context));
219
+ if (!value || typeof value !== "object") return;
220
+ for (const [key, child] of Object.entries(value)) {
221
+ const next = [...parts, key];
222
+ if (key === "body_html" && typeof child === "string") inspectHtml(child, file, pointer(next), context);
223
+ if ((key === "href" || key === "ctaHref") && typeof child === "string") inspectReference("href", child, pointer(next), file, context);
224
+ if ((key === "src" || key === "image" || key === "icon" || key === "logo" || key === "logoDark" || key === "favicon") && typeof child === "string") checkAsset(child, pointer(next), file, context);
225
+ if (typeof child === "string" && /(?:css|style|stylesheet)$/i.test(key)) {
226
+ for (const match of child.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gi)) checkAsset(match[2].trim(), `${pointer(next)}/css`, file, context);
227
+ }
228
+ inspectValue(child, file, next, context);
229
+ }
230
+ }
231
+
232
+ function pageShape(value, file) {
233
+ const findings = [];
234
+ const required = [["slug", "string"], ["title", "string"]];
235
+ for (const [key, type] of required) {
236
+ const valid = type === "array" ? Array.isArray(value[key]) : typeof value[key] === type;
237
+ if (!valid || (type === "string" && value[key].trim() === "")) findings.push(finding("parity-page-field", `${file}/${key}`, `${key} must be a non-empty ${type}`, `set ${key} to the page value expected by the renderer`));
238
+ }
239
+ if (value.isPolicy !== undefined && typeof value.isPolicy !== "boolean") findings.push(finding("parity-page-field", `${file}/isPolicy`, "isPolicy must be a boolean when present", "set isPolicy to true or false"));
240
+ if (value.sections !== undefined && !Array.isArray(value.sections)) findings.push(finding("parity-page-field", `${file}/sections`, "sections must be an array when present", "set sections to an array of renderer-supported sections"));
241
+ if (Array.isArray(value.sections)) value.sections.forEach((section, index) => {
242
+ const at = `${file}/sections/${index}`;
243
+ if (!section || typeof section !== "object" || Array.isArray(section)) return findings.push(finding("parity-section-shape", at, "section must be an object", "use a renderer-supported prose section"));
244
+ const string = (v) => typeof v === "string" && v.trim() !== "";
245
+ const rich = (v) => typeof v === "string" ? v.trim() !== "" : Array.isArray(v) && v.length > 0 && v.every((run) => run && typeof run === "object" && !Array.isArray(run) && ((run.kind === "text" && string(run.text) && (run.strong === undefined || typeof run.strong === "boolean") && (run.em === undefined || typeof run.em === "boolean")) || (run.kind === "link" && string(run.text) && string(run.href) && (run.external === undefined || typeof run.external === "boolean"))));
246
+ switch (section.kind) {
247
+ case "heading": if (!string(section.text) || section.level !== undefined && section.level !== 2 && section.level !== 3 || section.id !== undefined && !string(section.id)) findings.push(finding("parity-section-shape", at, "heading requires string text, optional id, and level 2 or 3", "supply a valid heading")); break;
248
+ case "paragraph": if (!rich(section.text)) findings.push(finding("parity-section-shape", at, "paragraph requires RichText", "supply a valid string or inline-run array")); break;
249
+ case "list": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !rich(item)) || section.ordered !== undefined && typeof section.ordered !== "boolean") findings.push(finding("parity-section-shape", at, "list requires RichText items and optional boolean ordered", "supply valid list items")); break;
250
+ case "definitions": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !item || !string(item.term) || !rich(item.description))) findings.push(finding("parity-section-shape", at, "definitions require string terms and RichText descriptions", "supply valid definition items")); break;
251
+ case "callout": if (!rich(section.body) || !["info", "warning", "legal", "success"].includes(section.tone) || section.title !== undefined && !string(section.title)) findings.push(finding("parity-section-shape", at, "callout requires supported tone, RichText body, and optional string title", "supply a valid callout")); break;
252
+ case "contact": if (section.heading !== undefined && !string(section.heading) || section.email !== undefined && !string(section.email) || section.phone !== undefined && !string(section.phone) || section.address !== undefined && (!Array.isArray(section.address) || section.address.some((line) => !string(line))) || !string(section.email) && !string(section.phone) && !(Array.isArray(section.address) && section.address.length)) findings.push(finding("parity-section-shape", at, "contact requires string members and email, phone, or address", "supply valid contact details")); break;
253
+ case "needs_review": if (!string(section.reason) || section.severity !== undefined && !["blocker", "note"].includes(section.severity) || section.owner !== undefined && !string(section.owner) || section.href !== undefined && !string(section.href)) findings.push(finding("parity-section-shape", at, "needs_review requires reason and valid optional severity/owner/href", "supply a valid needs_review section")); break;
254
+ default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, or needs_review"));
255
+ }
256
+ });
257
+ const bodyHtml = typeof value.body_html === "string" && value.body_html.trim() !== "";
258
+ const sections = Array.isArray(value.sections) && value.sections.length > 0;
259
+ if (!bodyHtml && !sections) findings.push(finding("parity-page-body", file, "page needs non-empty body_html or non-empty sections", "add rendered body_html or one or more sections"));
260
+ return findings;
261
+ }
262
+
263
+ function readStructured(path, file, findings) {
264
+ try { return JSON.parse(readFileSync(path, "utf8")); }
265
+ catch { findings.push(finding("parity-content-parse", file, "invalid JSON", "repair the JSON before visual-parity closure")); return null; }
266
+ }
267
+
268
+ /** @typedef {{level:string, rule:string, file:string, message:string, fix:string}} ParityFinding */
269
+ /** @typedef {{asset:string, file:string}} DecodeAsset */
270
+ /** @typedef {{tenantId?: string}} VisualParityOptions */
271
+ /** Validate the portable content closure required before human visual-parity review.
272
+ * @param {string} tenantDir
273
+ * @param {VisualParityOptions} [options]
274
+ */
275
+ export function validateVisualParityClosure(tenantDir, options = {}) {
276
+ const { tenantId } = options;
277
+ /** @type {ParityFinding[]} */
278
+ const findings = [];
279
+ const { value: config, findings: configFindings } = parseConfig(tenantDir);
280
+ findings.push(...configFindings);
281
+ const pagesDir = join(tenantDir, "content", "pages");
282
+ const files = walk(pagesDir).filter((file) => file.endsWith(".json")).sort();
283
+ if (files.length === 0) findings.push(finding("parity-pages-missing", "content/pages/", "no page JSON files define the visual-parity route scope", "add the paired authored page records under content/pages/ before closing parity"));
284
+ const pages = [];
285
+ for (const path of files) {
286
+ const file = relative(tenantDir, path);
287
+ try {
288
+ const value = JSON.parse(readFileSync(path, "utf8"));
289
+ if (!value || Array.isArray(value) || typeof value !== "object") {
290
+ findings.push(finding("parity-page-shape", file, "page JSON must be an object", "write a page object with a non-empty slug")); continue;
291
+ }
292
+ findings.push(...pageShape(value, file));
293
+ if (typeof value.slug !== "string" || !value.slug.trim()) continue;
294
+ pages.push({ file, value });
295
+ } catch { findings.push(finding("parity-page-parse", file, "invalid JSON", "repair the JSON before visual-parity closure")); }
296
+ }
297
+ const routes = new Set(["/"]);
298
+ for (const page of pages) routes.add(normalizeRoute(page.value.slug));
299
+ /** @type {{findings:ParityFinding[], tenantId:string|null, publicDir:string, routes:Set<string>, allowlist:string[], decodeAssets:DecodeAsset[]}} */
300
+ const context = { findings, tenantId: tenantId ?? tenantFromConfig(tenantDir), publicDir: join(tenantDir, "public"), routes, allowlist: Array.isArray(config.externalNavigationAllowlist) ? config.externalNavigationAllowlist : [], decodeAssets: [] };
301
+ if (!context.tenantId) findings.push(finding("parity-tenant-id", ".tot/config.json", "tenant id is required to verify /tenants/<tenant>/ assets", "set tenant in .tot/config.json or pass a resolved tenant id"));
302
+ for (const page of pages) inspectValue(page.value, page.file, [], context);
303
+ for (const name of ["home.json", "chrome.json"]) {
304
+ const path = join(tenantDir, "content", name);
305
+ if (existsSync(path)) {
306
+ const value = readStructured(path, `content/${name}`, findings);
307
+ if (value != null) inspectValue(value, `content/${name}`, [], context);
308
+ }
309
+ }
310
+ const themePath = join(tenantDir, "theme.json");
311
+ if (existsSync(themePath)) {
312
+ const value = readStructured(themePath, "theme.json", findings);
313
+ if (value != null) inspectValue(value, "theme.json", [], context);
314
+ }
315
+ for (const index of decodeImages(context.decodeAssets)) {
316
+ const entry = context.decodeAssets[index];
317
+ if (entry) findings.push(finding("parity-asset-decode", entry.file, "image decoder could not produce nonzero pixels", "replace the image with a decodable file"));
318
+ }
319
+ findings.sort((a, b) => `${a.file}\0${a.rule}`.localeCompare(`${b.file}\0${b.rule}`));
320
+ return { ok: findings.length === 0, profile: "visual-parity", findings };
321
+ }
@@ -10,9 +10,16 @@
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
12
  "./blog-visibility": "./src/blog-visibility.mjs",
13
+ "./publication-redirects": "./src/publication-redirects.mjs",
14
+ "./image-dependencies": "./src/image-dependencies.ts",
15
+ "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
13
16
  "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
14
17
  "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs"
15
18
  },
19
+ "dependencies": {
20
+ "lightningcss": "^1.32.0",
21
+ "parse5": "^7.2.1"
22
+ },
16
23
  "scripts": {
17
24
  "typecheck": "tsc --noEmit",
18
25
  "build": "tsc",
@@ -182,7 +182,7 @@
182
182
  "mountPath": {
183
183
  "allOf": [
184
184
  { "$ref": "#/$defs/path" },
185
- { "type": "string", "pattern": "^/(?!(?:\\.well-known|__evidence|__preview|_astro|_style-guide|admin|api|apps|assets|auth|brand|capabilities|cockpit|collections|dashboard|dev|favicon\\.svg|health|js|llms\\.txt|platform|preview|products|robots\\.txt|saved|search|shared|sitemap\\.xml|style-guide|tenants)(?:/|$))" }
185
+ { "type": "string", "pattern": "^/(?!(?:\\.[wW][eE][lL][lL]-[kK][nN][oO][wW][nN]|__[eE][vV][iI][dD][eE][nN][cC][eE]|__[pP][rR][eE][vV][iI][eE][wW]|_[aA]|_[aA][sS][tT][rR][oO]|_[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[aA][dD][mM][iI][nN]|[aA][pP][iI]|[aA][pP][pP][sS]|[aA][sS][sS][eE][tT][sS]|[aA][uU][tT][hH]|[bB][rR][aA][nN][dD]|[cC][aA][pP][aA][bB][iI][lL][iI][tT][iI][eE][sS]|[cC][oO][cC][kK][pP][iI][tT]|[cC][oO][lL][lL][eE][cC][tT][iI][oO][nN][sS]|[dD][aA][sS][hH][bB][oO][aA][rR][dD]|[dD][eE][vV]|[fF][aA][vV][iI][cC][oO][nN]\\.[sS][vV][gG]|[fF][eE][eE][dD]\\.[jJ][sS][oO][nN]|[hH][eE][aA][lL][tT][hH]|[jJ][sS]|[lL][lL][mM][sS]\\.[tT][xX][tT]|[pP][lL][aA][tT][fF][oO][rR][mM]|[pP][rR][eE][vV][iI][eE][wW]|[pP][rR][oO][dD][uU][cC][tT][sS]|[pP][uU][bB][lL][iI][cC][aA][tT][iI][oO][nN]-[dD][iI][sS][cC][oO][vV][eE][rR][yY]|[rR][oO][bB][oO][tT][sS]\\.[tT][xX][tT]|[sS][aA][vV][eE][dD]|[sS][eE][aA][rR][cC][hH]|[sS][eE][aA][rR][cC][hH]-[iI][nN][dD][eE][xX]\\.[jJ][sS][oO][nN]|[sS][hH][aA][rR][eE][dD]|[sS][iI][tT][eE][mM][aA][pP]\\.[xX][mM][lL]|[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[tT][eE][nN][aA][nN][tT][sS])(?:/|$))" }
186
186
  ]
187
187
  },
188
188
  "aliasPath": {
@@ -67,7 +67,7 @@
67
67
  "mountPath": {
68
68
  "allOf": [
69
69
  { "$ref": "#/$defs/normalizedPath" },
70
- { "type": "string", "pattern": "^/(?!(?:\\.well-known|__evidence|__preview|_astro|_style-guide|admin|api|apps|assets|auth|brand|capabilities|cockpit|collections|dashboard|dev|favicon\\.svg|health|js|llms\\.txt|platform|preview|products|robots\\.txt|saved|search|shared|sitemap\\.xml|style-guide|tenants)(?:/|$))" }
70
+ { "type": "string", "pattern": "^/(?!(?:\\.[wW][eE][lL][lL]-[kK][nN][oO][wW][nN]|__[eE][vV][iI][dD][eE][nN][cC][eE]|__[pP][rR][eE][vV][iI][eE][wW]|_[aA]|_[aA][sS][tT][rR][oO]|_[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[aA][dD][mM][iI][nN]|[aA][pP][iI]|[aA][pP][pP][sS]|[aA][sS][sS][eE][tT][sS]|[aA][uU][tT][hH]|[bB][rR][aA][nN][dD]|[cC][aA][pP][aA][bB][iI][lL][iI][tT][iI][eE][sS]|[cC][oO][cC][kK][pP][iI][tT]|[cC][oO][lL][lL][eE][cC][tT][iI][oO][nN][sS]|[dD][aA][sS][hH][bB][oO][aA][rR][dD]|[dD][eE][vV]|[fF][aA][vV][iI][cC][oO][nN]\\.[sS][vV][gG]|[fF][eE][eE][dD]\\.[jJ][sS][oO][nN]|[hH][eE][aA][lL][tT][hH]|[jJ][sS]|[lL][lL][mM][sS]\\.[tT][xX][tT]|[pP][lL][aA][tT][fF][oO][rR][mM]|[pP][rR][eE][vV][iI][eE][wW]|[pP][rR][oO][dD][uU][cC][tT][sS]|[pP][uU][bB][lL][iI][cC][aA][tT][iI][oO][nN]-[dD][iI][sS][cC][oO][vV][eE][rR][yY]|[rR][oO][bB][oO][tT][sS]\\.[tT][xX][tT]|[sS][aA][vV][eE][dD]|[sS][eE][aA][rR][cC][hH]|[sS][eE][aA][rR][cC][hH]-[iI][nN][dD][eE][xX]\\.[jJ][sS][oO][nN]|[sS][hH][aA][rR][eE][dD]|[sS][iI][tT][eE][mM][aA][pP]\\.[xX][mM][lL]|[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[tT][eE][nN][aA][nN][tT][sS])(?:/|$))" }
71
71
  ]
72
72
  },
73
73
  "aliasPath": {
@@ -180,10 +180,16 @@ export interface ChromeAnnouncement {
180
180
  * components/islands/IsolatedAgeGate.tsx uses for component pages — so tenant
181
181
  * CSS/JS can't reach the dialog.
182
182
  */
183
+ export const CHROME_AGE_GATE_VARIANTS = ["default", "compact-modal"] as const;
184
+ export type ChromeAgeGateVariant = (typeof CHROME_AGE_GATE_VARIANTS)[number];
185
+
183
186
  export interface ChromeAgeGate {
187
+ /** Raw-chrome fallback; component storefront enforcement uses TenantConfig.compliance.minAge. */
184
188
  minAge: number;
185
- /** localStorage key remembering a pass (defaults to a stable derived key). */
189
+ /** Raw-chrome fallback persistence key; the component storefront derives its own scoped key. */
186
190
  storageKey?: string;
191
+ /** Component and raw-chrome visual presentation. */
192
+ variant?: ChromeAgeGateVariant;
187
193
  title?: string;
188
194
  lede?: string;
189
195
  yesLabel?: string;
@@ -195,6 +201,16 @@ export interface ChromeAgeGate {
195
201
  mark?: { text: string; className?: string }[];
196
202
  }
197
203
 
204
+ /** Platform-owned persistence scope for a tenant's enforced age affirmation. */
205
+ export function deriveAgeGateStorageKey(tenantId: string, minAge: number): string {
206
+ return `tot:age-affirmed:${encodeURIComponent(tenantId)}:${minAge}`;
207
+ }
208
+
209
+ /** Resolve the enforcement-owned age token inside governed presentation copy. */
210
+ export function resolveAgeGateCopy(value: string | undefined, minAge: number): string | undefined {
211
+ return value?.replaceAll("{{minAge}}", String(minAge));
212
+ }
213
+
198
214
  /** Footer brand block: identity + tagline/contact + optional inline newsletter. */
199
215
  export interface ChromeFooterBrand {
200
216
  logo?: ChromeLogo;
@@ -228,14 +244,12 @@ export interface ChromeHead {
228
244
  // existing members.
229
245
  // ---------------------------------------------------------------------------
230
246
 
231
- /** The only two header content shapes: a full header, or a minimal one
232
- * (e.g. checkout/policy pages — brand + a reduced action set, no full nav). */
233
- export const HEADER_VARIANTS = ["primary", "minimal"] as const;
247
+ /** Header content shapes: full, minimal, or source-parity compact commerce chrome. */
248
+ export const HEADER_VARIANTS = ["primary", "minimal", "compact-centered"] as const;
234
249
  export type HeaderVariant = (typeof HEADER_VARIANTS)[number];
235
250
 
236
- /** Footer content shapes. One member today; the type is an extensible string
237
- * union (not a boolean) so a future variant is an additive change. */
238
- export const FOOTER_VARIANTS = ["default"] as const;
251
+ /** Footer content shapes. The string union keeps presentation explicit and validated. */
252
+ export const FOOTER_VARIANTS = ["default", "light-collapsible"] as const;
239
253
  export type FooterVariant = (typeof FOOTER_VARIANTS)[number];
240
254
 
241
255
  export function isHeaderVariant(v: unknown): v is HeaderVariant {
@@ -503,6 +517,29 @@ function validateAgeGate(v: unknown, path: string, errors: string[]): v is Chrom
503
517
  if (typeof v.minAge !== "number" || !Number.isFinite(v.minAge)) {
504
518
  errors.push(`${path}.minAge: required number`);
505
519
  }
520
+ checkOptionalString(v.storageKey, `${path}.storageKey`, errors);
521
+ if (
522
+ v.variant !== undefined &&
523
+ !CHROME_AGE_GATE_VARIANTS.includes(v.variant as ChromeAgeGateVariant)
524
+ ) {
525
+ errors.push(`${path}.variant: must be one of ${CHROME_AGE_GATE_VARIANTS.join(", ")}`);
526
+ }
527
+ for (const field of ["title", "lede", "yesLabel", "noLabel", "denyText", "fineText"] as const) {
528
+ checkOptionalString(v[field], `${path}.${field}`, errors);
529
+ }
530
+ if (v.mark !== undefined) {
531
+ if (!Array.isArray(v.mark)) {
532
+ errors.push(`${path}.mark: must be an array`);
533
+ } else {
534
+ v.mark.forEach((part, i) => {
535
+ if (!isObject(part) || !isNonEmptyString(part.text)) {
536
+ errors.push(`${path}.mark[${i}].text: required non-empty string`);
537
+ return;
538
+ }
539
+ checkOptionalString(part.className, `${path}.mark[${i}].className`, errors);
540
+ });
541
+ }
542
+ }
506
543
  return errors.length === before;
507
544
  }
508
545
 
@@ -26,6 +26,17 @@ export interface CspOptions {
26
26
  connectSrc?: readonly string[];
27
27
  /** Sandboxed-widget origins. */
28
28
  frameSrc?: readonly string[];
29
+ /**
30
+ * Extra origins allowed to frame this response, beyond `'self'`. Used only for
31
+ * the native GA4 sandbox's paired apex/www alias — the sandbox mounts on the
32
+ * OTHER of the tenant's apex/www hosts (see resolveNativeGa4SandboxUrl), so that
33
+ * host must be allowed to frame it back; `frame-src` alone only grants the
34
+ * reverse direction (who the page may frame), not this one. Must be computed
35
+ * from the platform's own request context, never from tenant/adapter-declared
36
+ * config — csp-compiler.ts deliberately keeps integration-plane additions off
37
+ * this directive.
38
+ */
39
+ frameAncestors?: readonly string[];
29
40
  /**
30
41
  * Extra `form-action` targets. Default is `'self'`; add-to-cart forms POST
31
42
  * cross-origin to the checkout store's `/cart`, so that origin is added here.
@@ -43,7 +54,7 @@ export function buildCsp(opts: CspOptions): string {
43
54
  "default-src": ["'self'"],
44
55
  "base-uri": ["'self'"],
45
56
  "object-src": ["'none'"],
46
- "frame-ancestors": ["'self'"],
57
+ "frame-ancestors": ["'self'", ...(opts.frameAncestors ?? [])],
47
58
  "form-action": ["'self'", ...(opts.formAction ?? [])],
48
59
  "script-src": ["'self'", nonce, ...(opts.scriptSrc ?? [])],
49
60
  "style-src": ["'self'", nonce, ...(opts.styleSrc ?? [])],
@@ -0,0 +1,19 @@
1
+ export declare const BLOCK_PALETTE_CURRENT_CONTRACT: "block-palette@6";
2
+ export declare const BLOCK_PALETTE_SUPPORTED_CONTRACTS: readonly [
3
+ "block-palette@1",
4
+ "block-palette@2",
5
+ "block-palette@3",
6
+ "block-palette@4",
7
+ "block-palette@5",
8
+ "block-palette@6",
9
+ ];
10
+ export declare const FEATURED_PRODUCT_PRESENTATIONS: readonly ["grid", "carousel"];
11
+ export declare const PROMO_TILE_PRESENTATIONS: readonly [
12
+ "overlay",
13
+ "portrait-cards",
14
+ "editorial-cards",
15
+ "logo-strip",
16
+ ];
17
+ export declare function isSafeRehostedAssetPath(value: unknown): value is string;
18
+ export declare function isSafeRehostedAssetPathForTenant(value: unknown, tenantId: unknown): value is string;
19
+ export declare function isSafeStorefrontHref(value: unknown): value is string;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Runtime values for the storefront block-palette contract.
3
+ *
4
+ * Plain JavaScript keeps one dependency-free authority consumable by the edge
5
+ * runtime and repository Node tooling. The separately published CLI mirrors
6
+ * these values and pins that mirror with a parity test.
7
+ */
8
+ export const BLOCK_PALETTE_CURRENT_CONTRACT = "block-palette@6";
9
+
10
+ export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
11
+ "block-palette@1",
12
+ "block-palette@2",
13
+ "block-palette@3",
14
+ "block-palette@4",
15
+ "block-palette@5",
16
+ BLOCK_PALETTE_CURRENT_CONTRACT,
17
+ ]);
18
+
19
+ export const FEATURED_PRODUCT_PRESENTATIONS = Object.freeze(["grid", "carousel"]);
20
+
21
+ export const PROMO_TILE_PRESENTATIONS = Object.freeze([
22
+ "overlay",
23
+ "portrait-cards",
24
+ "editorial-cards",
25
+ "logo-strip",
26
+ ]);
27
+
28
+ /** True only for static assets materialized into a governed serving namespace. */
29
+ export function isSafeRehostedAssetPath(value) {
30
+ if (typeof value !== "string" || !value.startsWith("/")) return false;
31
+ if (value.includes("\\") || value.includes("?") || value.includes("#")) return false;
32
+ let decoded;
33
+ try {
34
+ decoded = decodeURIComponent(value);
35
+ } catch {
36
+ return false;
37
+ }
38
+ if (decoded !== value || decoded.includes("..") || decoded.includes("//")) return false;
39
+ return /^\/(?:tenants\/[^/]+|shared)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value);
40
+ }
41
+
42
+ /** Bind a tenant asset reference to the tenant currently being rendered. */
43
+ export function isSafeRehostedAssetPathForTenant(value, tenantId) {
44
+ if (!isSafeRehostedAssetPath(value) || typeof tenantId !== "string" || !tenantId) return false;
45
+ return value.startsWith("/shared/") || value.startsWith(`/tenants/${tenantId}/`);
46
+ }
47
+
48
+ /** Navigation authored in a commerce block stays on the current storefront. */
49
+ export function isSafeStorefrontHref(value) {
50
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return false;
51
+ if (value.includes("\\") || value.includes("?") || value.includes("#") || /[\u0000-\u001f\u007f]/.test(value)) return false;
52
+ try {
53
+ const decoded = decodeURIComponent(value);
54
+ if (decoded !== value || decoded.includes("\\") || /[\u0000-\u001f\u007f]/.test(decoded)) return false;
55
+ const segments = value.split("/");
56
+ return !segments.some((segment, index) =>
57
+ index > 0 && (segment === "." || segment === ".." || (segment === "" && index < segments.length - 1))
58
+ );
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
@@ -6,14 +6,25 @@
6
6
  * used by docs, validators, CLI checks, and the storefront app itself.
7
7
  */
8
8
 
9
- export const BLOCK_PALETTE_CURRENT_CONTRACT = "block-palette@4" as const;
10
- export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = [
11
- "block-palette@1",
12
- "block-palette@2",
13
- "block-palette@3",
9
+ import {
10
+ BLOCK_PALETTE_SUPPORTED_CONTRACTS,
11
+ FEATURED_PRODUCT_PRESENTATIONS,
12
+ PROMO_TILE_PRESENTATIONS,
13
+ } from "./extension-contract-values.mjs";
14
+ export {
14
15
  BLOCK_PALETTE_CURRENT_CONTRACT,
15
- ] as const;
16
+ BLOCK_PALETTE_SUPPORTED_CONTRACTS,
17
+ FEATURED_PRODUCT_PRESENTATIONS,
18
+ PROMO_TILE_PRESENTATIONS,
19
+ isSafeRehostedAssetPath,
20
+ isSafeRehostedAssetPathForTenant,
21
+ isSafeStorefrontHref,
22
+ } from "./extension-contract-values.mjs";
23
+
24
+ export type PromoTilePresentation =
25
+ (typeof PROMO_TILE_PRESENTATIONS)[number];
16
26
 
27
+ /** The versioned contract accepted by the platform block palette. */
17
28
  export type BlockPaletteContract =
18
29
  (typeof BLOCK_PALETTE_SUPPORTED_CONTRACTS)[number];
19
30
 
@@ -27,6 +38,9 @@ export const FEATURED_PRODUCT_SOURCE_KEYS = [
27
38
  export type FeaturedProductSourceKey =
28
39
  (typeof FEATURED_PRODUCT_SOURCE_KEYS)[number];
29
40
 
41
+ export type FeaturedProductPresentation =
42
+ (typeof FEATURED_PRODUCT_PRESENTATIONS)[number];
43
+
30
44
  export type ProductSectionSource =
31
45
  | FeaturedProductSourceKey
32
46
  | { collection: string }