@tokenoftrust/storefront-runner 2.0.41 → 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.
Files changed (147) 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 +2 -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/index.ts +8 -0
  95. package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
  96. package/packages/public-runtime/src/integration/consent-config.ts +9 -0
  97. package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
  98. package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
  99. package/packages/public-runtime/src/integration/index.ts +1 -0
  100. package/packages/public-runtime/src/integration/lifecycle-boot.ts +30 -2
  101. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
  102. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
  103. package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
  104. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +9 -0
  105. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +27 -3
  106. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +13 -0
  107. package/packages/public-runtime/src/integration/native-ga4.ts +11 -0
  108. package/packages/public-runtime/src/integration/platform-adapters.ts +5 -1
  109. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  110. package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
  111. package/packages/public-runtime/src/publication-redirects.mjs +379 -0
  112. package/packages/public-runtime/src/publication-space.ts +24 -3
  113. package/packages/public-runtime/src/static-bundle.ts +145 -26
  114. package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
  115. package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
  116. package/packages/public-runtime/src/tenant.ts +59 -1
  117. package/pnpm-lock.runner.yaml +12 -0
  118. package/pnpm-workspace.yaml +4 -0
  119. package/scripts/dev/git-status.mjs +1 -1
  120. package/scripts/dev/publish.mjs +6 -0
  121. package/scripts/lib/tenant-asset-copy.mjs +4 -2
  122. package/scripts/tenant/materialize-tenant-content.mjs +1 -0
  123. package/scripts/tenant/validate.mjs +171 -8
  124. package/apps/storefront/src/lib/cfImage.ts +0 -55
  125. package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
  126. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  127. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  128. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  129. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  130. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  131. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  132. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  133. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  134. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  135. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  136. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  137. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  138. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  139. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  140. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  141. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  142. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  143. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  144. package/tenants/home/public/js/storefront.js +0 -129
  145. package/tenants/home/public/logo-wordmark.png +0 -0
  146. package/tenants/home/public/pages/storefront.css +0 -235
  147. package/tenants/home/theme.json +0 -6
@@ -42,6 +42,7 @@
42
42
  */
43
43
  import { assetContentType } from "./tenant-assets.js";
44
44
  import { formatHtmlCacheControl } from "./html-cache-policy.js";
45
+ import { NATIVE_GA4_SANDBOX_ASSET_PATH } from "./integration/native-ga4-sandbox-asset.mjs";
45
46
 
46
47
  // ---------------------------------------------------------------------------
47
48
  // R2 seam — same structural subset tenant-assets.ts uses (no workers-types dep)
@@ -167,6 +168,24 @@ export interface StaticBundleManifest {
167
168
  htmlCacheControl?: string;
168
169
  /** T6 classification: relPath -> {hash, kind}. Absent on a pre-T6 manifest — every lookup then falls back to the old versions/<digest>/<rel> key (see `resolveStaticBundleObjectKey`). */
169
170
  files?: Record<string, StaticBundleFileEntry>;
171
+ /** Exact, version-bound whole-site redirects materialized from content/redirects.json. */
172
+ redirects?: Array<{ from: string; to: string }>;
173
+ }
174
+
175
+ function isManifestRedirectPath(value: string): boolean {
176
+ return value.startsWith("/") && !value.startsWith("//") &&
177
+ !value.includes("\\") && !value.includes("?") && !value.includes("#") &&
178
+ !value.split("/").includes("..");
179
+ }
180
+
181
+ function isManifestRedirectDestination(value: string): boolean {
182
+ if (isManifestRedirectPath(value)) return true;
183
+ try {
184
+ const url = new URL(value);
185
+ return url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash;
186
+ } catch {
187
+ return false;
188
+ }
170
189
  }
171
190
 
172
191
  function parseManifest(raw: string): StaticBundleManifest | null {
@@ -193,8 +212,20 @@ function parseManifest(raw: string): StaticBundleManifest | null {
193
212
  }
194
213
  }
195
214
  }
215
+ let redirects: StaticBundleManifest["redirects"];
216
+ if (m.redirects !== undefined) {
217
+ if (!Array.isArray(m.redirects)) return null;
218
+ redirects = [];
219
+ for (const value of m.redirects) {
220
+ const redirect = value as { from?: unknown; to?: unknown };
221
+ if (!redirect || typeof redirect.from !== "string" || typeof redirect.to !== "string" ||
222
+ !isManifestRedirectPath(redirect.from) ||
223
+ !isManifestRedirectDestination(redirect.to)) return null;
224
+ redirects.push({ from: redirect.from, to: redirect.to });
225
+ }
226
+ }
196
227
  const htmlCacheControl = typeof m.htmlCacheControl === "string" ? m.htmlCacheControl : undefined;
197
- return { treeDigest: m.treeDigest, pinnedNonce: m.pinnedNonce, csp, htmlCacheControl, files };
228
+ return { treeDigest: m.treeDigest, pinnedNonce: m.pinnedNonce, csp, htmlCacheControl, files, redirects };
198
229
  } catch {
199
230
  return null;
200
231
  }
@@ -260,8 +291,82 @@ function failClosed(deps: ServeStaticBundleDeps, detail: string): Response {
260
291
  });
261
292
  }
262
293
 
263
- /** Cache-Control for an `asset`-kind file — the pool key IS the content hash, so caching forever is always correct (epic §A). */
264
- const ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
294
+ /** Cache-Control for a content-addressed or hash-named object — its URL can never name different bytes. */
295
+ const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
296
+
297
+ /**
298
+ * Cache-Control for an asset served at its STABLE path (`/shared/x.css`, `/brand/logo.png`). A
299
+ * republish can put different bytes at the same path, so it revalidates — the same tier the S3
300
+ * plane stamps (`s3-publish.mjs` `CACHE_CONTROL.ASSET`). Pages fetch their sub-resources by
301
+ * content address; the stable path serves links, crawlers and runtime-computed URLs.
302
+ */
303
+ const STABLE_ASSET_CACHE_CONTROL = "public, max-age=86400, stale-while-revalidate=604800";
304
+
305
+ /** Bundle prefixes whose stable paths are still immutable: hash-named build output and path-versioned platform code. */
306
+ const IMMUTABLE_STABLE_PREFIXES = ["_astro/", "platform/"];
307
+
308
+ /**
309
+ * URL prefix of a content-addressed asset (`/_a/<sha256><ext>`) — kept in lockstep with
310
+ * `scripts/publish/lib/asset-pool.mjs` `PINNED_ASSET_PREFIX` and the CloudFront function
311
+ * (`live-pointer-rewrite.mjs` `versionIndependentAssetUri`).
312
+ */
313
+ export const PINNED_ASSET_URL_PREFIX = "/_a/";
314
+
315
+ /** URL prefix of hash-named build output, served from the version-independent named store. */
316
+ export const HASH_NAMED_URL_PREFIX = "/_astro/";
317
+
318
+ /** R2 key of a hash-named file in the named store (`asset-pool.mjs` `namedStoreKey`). */
319
+ export function staticBundleNamedStoreKey(rel: string): string {
320
+ return `assets/${rel}`;
321
+ }
322
+
323
+ /**
324
+ * The R2 key a version-independent asset URL names, or null for any other path. These URLs
325
+ * resolve WITHOUT the live pointer (ADR 0013, "Sub-resources are version-independent"): a page
326
+ * from an older version keeps fetching its own bytes after the pointer moves.
327
+ * `/_a/<sha256><ext>` → `assets/<sha256><ext>` (the content-addressed pool)
328
+ * `/_astro/<name>` → `assets/_astro/<name>` (the named store)
329
+ */
330
+ export function versionIndependentAssetKey(pathname: string): string | null {
331
+ if (/^\/_a\/[0-9a-f]{64}(\.[A-Za-z0-9]+)?$/.test(pathname)) return `assets/${pathname.slice(4)}`;
332
+ if (/^\/_astro(\/[A-Za-z0-9._@-]+)+$/.test(pathname) && !pathname.includes("/.")) {
333
+ return staticBundleNamedStoreKey(pathname.slice(1));
334
+ }
335
+ return null;
336
+ }
337
+
338
+ /**
339
+ * Serve a version-independent asset straight from R2, or return null when `pathname` is not one
340
+ * (or, for `/_astro/`, when the named store does not hold it — the Worker's own build also serves
341
+ * that prefix, so a miss belongs to the caller). A missing `/_a/` object is a 404: the address is
342
+ * exact, so there is nothing else it could mean.
343
+ */
344
+ export async function serveVersionIndependentAsset(
345
+ bucket: StaticBundleR2BucketLike,
346
+ pathname: string,
347
+ method = "GET",
348
+ ): Promise<Response | null> {
349
+ const key = versionIndependentAssetKey(pathname);
350
+ if (!key) return null;
351
+ const object = await bucket.get(key);
352
+ if (!object) {
353
+ if (pathname.startsWith(HASH_NAMED_URL_PREFIX)) return null;
354
+ return new Response("Not found", {
355
+ status: 404,
356
+ headers: { "cache-control": "no-store", "x-tot-static-bundle": "pinned-miss" },
357
+ });
358
+ }
359
+ const headers: Record<string, string> = {
360
+ "content-type": assetContentType(key),
361
+ "cache-control": IMMUTABLE_CACHE_CONTROL,
362
+ "x-tot-static-bundle": "pinned",
363
+ };
364
+ if (typeof object.size === "number") headers["content-length"] = String(object.size);
365
+ const body: BodyInit | null = method.toUpperCase() === "HEAD"
366
+ ? null
367
+ : object.body ?? (await object.arrayBuffer());
368
+ return new Response(body, { status: 200, headers });
369
+ }
265
370
 
266
371
  /**
267
372
  * Serve `pathname` from the digest-addressed static bundle in R2.
@@ -292,15 +397,20 @@ const ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
292
397
  * manifest's file `kind` (long-lived immutable for the shared `asset` pool —
293
398
  * safe because the key itself is the content hash; short/revalidating for a
294
399
  * `route` doc, rendered fresh per version at a STABLE, non-digest url) and,
295
- * for an HTML response, the bundle's PINNED CSP header read back from the
296
- * same manifest read — never the Worker's per-request nonce middleware, per
297
- * the ADR's CSP-consequences note.
400
+ * for a tenant HTML response, the bundle's PINNED CSP header read back from
401
+ * the same manifest read — never the Worker's per-request nonce middleware,
402
+ * per the ADR's CSP-consequences note. The platform-owned GA4 sandbox is the
403
+ * exact-path exception because its generated document owns a hash-pinned meta CSP.
298
404
  */
299
405
  export async function serveStaticBundle(
300
406
  deps: ServeStaticBundleDeps,
301
407
  pathname: string,
408
+ search = "",
409
+ method = "GET",
302
410
  ): Promise<Response | null> {
303
411
  const { bucket, digest } = deps;
412
+ const pinned = await serveVersionIndependentAsset(bucket, pathname, method);
413
+ if (pinned) return pinned;
304
414
  if (!digest) {
305
415
  return failClosed(deps, "pointer-serving tenant has no resolved live digest");
306
416
  }
@@ -315,6 +425,18 @@ export async function serveStaticBundle(
315
425
  return failClosed(deps, `missing/unreadable manifest for digest ${digest}`);
316
426
  }
317
427
 
428
+ const redirectPath = pathname === "/" || pathname.split("/").pop()?.includes(".")
429
+ ? pathname
430
+ : pathname.endsWith("/") ? pathname : `${pathname}/`;
431
+ const redirect = manifest.redirects?.find(({ from }) => from === pathname || from === redirectPath);
432
+ if (redirect) {
433
+ const query = redirect.to.startsWith("/") ? search : "";
434
+ return new Response(null, {
435
+ status: 308,
436
+ headers: { location: `${redirect.to}${query}`, "cache-control": "public, max-age=300" },
437
+ });
438
+ }
439
+
318
440
  const entry = manifest.files?.[rel];
319
441
  if (manifest.files && !entry) {
320
442
  // Provably not in this bundle's static surface — an app surface (/admin,
@@ -333,38 +455,35 @@ export async function serveStaticBundle(
333
455
  // shared DEFAULT_HTML_CACHE_POLICY string — the SAME source the S3 plane reads, so
334
456
  // both planes emit a byte-identical header.
335
457
  const routeCacheControl = manifest.htmlCacheControl ?? formatHtmlCacheControl();
336
- // Cache tier: use the manifest's `kind` classification when present.
337
- // A manifest with no `files` at all falls back to the ORIGINAL
338
- // isHtml-based heuristic — every non-html path used to be treated as a
339
- // long-cacheable asset, which this preserves exactly for an old bundle
340
- // this Worker version might still encounter mid-rollout.
341
- const cacheControl = entry
342
- ? entry.kind === "asset"
343
- ? ASSET_CACHE_CONTROL
344
- : routeCacheControl
345
- : isHtml
346
- ? routeCacheControl
347
- : ASSET_CACHE_CONTROL;
458
+ // Cache tier: route documents take the tenant's policy; an asset at its stable path
459
+ // revalidates unless its path itself is immutable (hash-named or path-versioned).
460
+ const cacheControl = isHtml || entry?.kind === "route"
461
+ ? routeCacheControl
462
+ : IMMUTABLE_STABLE_PREFIXES.some((prefix) => rel.startsWith(prefix))
463
+ ? IMMUTABLE_CACHE_CONTROL
464
+ : STABLE_ASSET_CACHE_CONTROL;
348
465
  const headers: Record<string, string> = {
349
466
  "content-type": assetContentType(rel),
350
- // Cache-key-by-digest lives in the caller's edge-cache layer for `route`
351
- // keys (versions/<digest>/<rel> is digest-scoped — a pointer flip is a
352
- // clean cache miss on the NEW digest's keys, never a stale serve of the
353
- // old one); `asset` keys are ADDITIONALLY digest-independent (content-
354
- // addressed by hash), so they're safe to cache forever downstream too.
355
- // This header governs downstream/browser caching of the STABLE URL:
467
+ // Cache-key-by-digest lives in the caller's edge-cache layer (a pointer flip
468
+ // is a clean miss on the NEW digest's keys). This header governs downstream/
469
+ // browser caching of the STABLE URL:
356
470
  "cache-control": cacheControl,
357
471
  "x-tot-static-bundle": "hit",
358
472
  "x-tot-static-bundle-digest": digest,
359
473
  };
360
474
 
361
- if (isHtml && manifest.csp) {
475
+ // The reviewed GA4 transport frame is a platform-owned security boundary with
476
+ // its own generated, hash-pinned meta CSP. Applying the tenant page CSP as a
477
+ // second policy would intersect the two and block the reviewed gtag.js origin.
478
+ if (isHtml && rel !== NATIVE_GA4_SANDBOX_ASSET_PATH && manifest.csp) {
362
479
  headers[manifest.csp.headerName] = manifest.csp.value;
363
480
  }
364
481
 
365
482
  if (typeof object.size === "number") {
366
483
  headers["content-length"] = String(object.size);
367
484
  }
368
- const body: BodyInit = object.body ?? (await object.arrayBuffer());
485
+ const body: BodyInit | null = method.toUpperCase() === "HEAD"
486
+ ? null
487
+ : object.body ?? (await object.arrayBuffer());
369
488
  return new Response(body, { status: 200, headers });
370
489
  }
@@ -0,0 +1,3 @@
1
+ /** Types for the dependency-free tenant embed-defaults data module (tenant-embed-defaults.mjs). */
2
+ export const TENANT_EMBED_DEFAULTS: Readonly<Record<string, readonly string[]>>;
3
+ export function tenantEmbedDefaults(tenantId: string): string[];
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Build-time embed opt-in defaults, keyed by tenant id.
3
+ *
4
+ * Plain JS ON PURPOSE — the same reason as embed-catalog.mjs: this data is read
5
+ * both by TypeScript (`tenants.ts`, which spreads it into each TenantConfig) AND
6
+ * by plain-`node` tooling that cannot import TS. The static-publish pipeline
7
+ * (scripts/publish/lib/tenant-csp-profile.mjs) builds a CloudFront CSP off the
8
+ * tenant's opted-in embeds, and a CloudFront policy is applied by a dependency-light
9
+ * script with no TS toolchain. Keeping the declaration HERE gives both readers ONE
10
+ * copy: `tenants.ts` is the only consumer that turns it into a TenantConfig field,
11
+ * and no second constant exists to drift from it.
12
+ *
13
+ * This is the PLATFORM default only. A tenant's effective opt-in is this unioned
14
+ * with the tenant's own published `embeds.json` and filtered to the vetted catalog
15
+ * — see `resolveEffectiveEmbeds` (csp.ts), which every reader goes through. A
16
+ * registered tenant self-serves entirely through `embeds.json` and needs no entry
17
+ * here; an entry exists only for a tenant whose opt-in predates (or is independent
18
+ * of) its own content repo.
19
+ *
20
+ * Slugs must exist in EMBED_PROVIDERS — the platform owns each provider's exact
21
+ * origins, so a tenant lists a slug and never a raw host. An unknown slug is
22
+ * dropped by `resolveEffectiveEmbeds` (fail-safe) and flagged by the embeds lint.
23
+ *
24
+ * @type {Readonly<Record<string, readonly string[]>>}
25
+ */
26
+ export const TENANT_EMBED_DEFAULTS = {
27
+ // The corporate site's /contact/contact-sales/ page embeds a Pipedrive Web Form.
28
+ "tokenoftrust.com": ["pipedrive"],
29
+ };
30
+
31
+ /**
32
+ * This tenant's build-time embed defaults, or `[]` when it declares none. Always
33
+ * returns a fresh array so a caller can't mutate the shared declaration.
34
+ *
35
+ * @param {string} tenantId
36
+ * @returns {string[]}
37
+ */
38
+ export function tenantEmbedDefaults(tenantId) {
39
+ return [...(TENANT_EMBED_DEFAULTS[tenantId] ?? [])];
40
+ }
@@ -83,6 +83,20 @@ export interface ThemeColorTokens {
83
83
  "field-border"?: string;
84
84
  /** Review/rating star fill (trust/UGC surfaces). Default: `accent`. */
85
85
  star?: string;
86
+
87
+ // --- Conversion-role aliases. These name the JOB a color does on a
88
+ // conversion surface rather than its place in the palette, so a tenant can
89
+ // move the buy button without dragging nav chrome, headings and
90
+ // ::selection (all of which read `primary`) along with it. ---
91
+ /** Primary call-to-action fill (buy button, hero CTA). Default: `primary`. */
92
+ cta?: string;
93
+ /** Hover/active shade of `cta`. Default: `primary-hover`. */
94
+ "cta-hover"?: string;
95
+ /** Readable text/icon color ON `cta`. Default: `primary-contrast`. */
96
+ "cta-contrast"?: string;
97
+ /** Trust / reassurance accent — guarantee callouts, security and compliance
98
+ * badges, proof strips. Default: `success`. */
99
+ trust?: string;
86
100
  }
87
101
 
88
102
  export interface ThemeFontFamily {
@@ -140,8 +154,12 @@ export interface ThemeBrandTokens {
140
154
  * background with the brand color used only as an accent on the headline/CTA —
141
155
  * matches merchants whose own site is a light page with color accents. The
142
156
  * brand adopter sets "light" when it captures a real hero image.
157
+ *
158
+ * "banner" renders a merchant-supplied wide hero asset edge-to-edge. It is
159
+ * intended for visual-parity migrations where the source artwork already
160
+ * contains the campaign copy and CTA treatment.
143
161
  */
144
- heroVariant?: "bold" | "light";
162
+ heroVariant?: "bold" | "light" | "banner";
145
163
  }
146
164
 
147
165
  export interface ThemeTokens {
@@ -388,6 +406,17 @@ export function siteTypeDefaults(siteType: SiteType): TenantFeatures {
388
406
  return { ...(siteType === "marketing" ? MARKETING_FEATURES : COMMERCE_FEATURES) };
389
407
  }
390
408
 
409
+ /**
410
+ * Does this tenant render publicly on a login-gated host? Deny-closed: only an
411
+ * explicit `access.public` declaration exempts a tenant from the gate, so an
412
+ * undeclared tenant stays behind sign-in.
413
+ */
414
+ export function tenantRendersPublicly(
415
+ tenant: Pick<TenantConfig, "access">,
416
+ ): boolean {
417
+ return tenant.access?.public === true;
418
+ }
419
+
391
420
  /**
392
421
  * Resolve a tenant's effective features: siteType defaults, with any explicit
393
422
  * `features` overrides layered on top (undefined entries ignored, so a tenant
@@ -434,6 +463,35 @@ export interface TenantConfig {
434
463
  siteType?: SiteType;
435
464
  /** Explicit feature overrides layered atop the siteType defaults. */
436
465
  features?: Partial<TenantFeatures>;
466
+ /**
467
+ * Public-access declaration read by the login gate. A tenant on a gated host
468
+ * (`DEMO_GATE_HOSTS`) is withheld behind sign-in unless it declares itself
469
+ * public here. Omitted == gated, so a new tenant is private by default and
470
+ * going public is an explicit, reviewable line in the tenant's config —
471
+ * never a tenant id the gate recognizes.
472
+ */
473
+ access?: {
474
+ /**
475
+ * Render publicly on a gated host: no Coming-Soon interstitial and no
476
+ * sign-in requirement, so anyone can browse and sign up.
477
+ */
478
+ public?: boolean;
479
+ };
480
+ /**
481
+ * This tenant's host ALSO dispatches other tenants under a `/<domain>/*` path
482
+ * prefix, while still serving its own content at the root. Declared by the
483
+ * platform's shared preview host, which is reached as `<host>/<tenant-domain>/…`
484
+ * by the dev loop, the admin preview links and the post-deploy chrome canary.
485
+ *
486
+ * Routing is otherwise host-first: a host owning a tenant serves only that
487
+ * tenant, so `/<domain>/*` is an ordinary path on its own site. That default is
488
+ * load-bearing — without it any merchant's hostname could serve a different
489
+ * tenant's storefront under a path — so this stays opt-in and platform-declared
490
+ * (ADR 0035), never inferred and never a tenant's to set.
491
+ *
492
+ * Omitted == the host serves only its own tenant.
493
+ */
494
+ pathPrefixTenants?: boolean;
437
495
  /**
438
496
  * Third-party embeds this tenant is allowed to render, as provider slugs from
439
497
  * the platform's `EMBED_PROVIDERS` catalog (e.g. `["pipedrive"]`) — NOT raw
@@ -7,6 +7,10 @@ settings:
7
7
  importers:
8
8
 
9
9
  .:
10
+ dependencies:
11
+ sharp:
12
+ specifier: ^0.34.5
13
+ version: 0.34.5
10
14
  devDependencies:
11
15
  typescript:
12
16
  specifier: ^5.6.3
@@ -20,6 +24,9 @@ importers:
20
24
  '@astrojs/preact':
21
25
  specifier: ^6.0.0
22
26
  version: 6.0.0(@babel/core@7.29.7)(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(preact@10.29.2)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0)
27
+ '@fontsource/anuphan':
28
+ specifier: 5.3.0
29
+ version: 5.3.0
23
30
  '@fontsource/inter':
24
31
  specifier: 5.3.0
25
32
  version: 5.3.0
@@ -1109,6 +1116,9 @@ packages:
1109
1116
  '@floating-ui/utils@0.2.11':
1110
1117
  resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
1111
1118
 
1119
+ '@fontsource/anuphan@5.3.0':
1120
+ resolution: {integrity: sha512-1vWJrY2shVTVE4Bv1BheXKQKWI4DxNSWF64++zt7wbRuYef8QV1tJItvHHxN6+1fknwanIM8RKsXiR0RuHPlhA==}
1121
+
1112
1122
  '@fontsource/inter@5.3.0':
1113
1123
  resolution: {integrity: sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==}
1114
1124
 
@@ -4524,6 +4534,8 @@ snapshots:
4524
4534
 
4525
4535
  '@floating-ui/utils@0.2.11': {}
4526
4536
 
4537
+ '@fontsource/anuphan@5.3.0': {}
4538
+
4527
4539
  '@fontsource/inter@5.3.0': {}
4528
4540
 
4529
4541
  '@fontsource/jetbrains-mono@5.3.0': {}
@@ -6,3 +6,7 @@ onlyBuiltDependencies:
6
6
  - esbuild
7
7
  - sharp
8
8
  - workerd
9
+ allowBuilds:
10
+ esbuild: true
11
+ sharp: true
12
+ workerd: true
@@ -85,7 +85,7 @@ export function classify(statusMap, relPathPosix) {
85
85
  */
86
86
  export function fileStatus(gitCwd, relPath) {
87
87
  const posix = relPath.split(sep).join("/");
88
- const res = git(gitCwd, ["status", "--porcelain", "--", posix]);
88
+ const res = git(gitCwd, ["status", "--porcelain", "--untracked-files=all", "--", posix]);
89
89
  if (!res.ok) return "unknown";
90
90
  const line = res.stdout.split("\n").find((l) => l.length >= 2);
91
91
  if (!line) return "unchanged";
@@ -117,6 +117,12 @@ export async function publishFile(env, sourceRoot, tenantId, relPath, deps = {})
117
117
  const changeId = deriveChangeId(tenantId, session.email || session.token);
118
118
 
119
119
  try {
120
+ // Bind the active tenant before the candidate call, the same way `tot submit` does.
121
+ // candidate_open confines the target repo to the tenants the session holds, so an
122
+ // unbound session refuses with a cross-tenant error that reads as a permission
123
+ // problem rather than a missing bind. An invited developer's session already holds
124
+ // this tenant, so this is the explicit form of a precondition we were relying on.
125
+ await client.callTool("client_switch", { tenant: tenantId });
120
126
  const result = await client.callTool("candidate_open", {
121
127
  repo,
122
128
  changeId,
@@ -57,13 +57,15 @@ export function copyTreeSkippingUnreadable(from, to, deps) {
57
57
  const childRel = rel ? join(rel, entry.name) : entry.name;
58
58
  const childSrc = join(src, entry.name);
59
59
  const childDst = join(dst, entry.name);
60
+ if (entry.isSymbolicLink()) {
61
+ skipped.push({ path: childRel, reason: "SYMLINK" });
62
+ continue;
63
+ }
60
64
  if (entry.isDirectory()) {
61
65
  walk(childSrc, childDst, childRel);
62
66
  continue;
63
67
  }
64
68
  try {
65
- // copyFile follows symlinks, so a DANGLING link throws here and is skipped —
66
- // which is the common shape of "referenced but missing".
67
69
  copyFile(childSrc, childDst);
68
70
  copied++;
69
71
  } catch (err) {
@@ -133,6 +133,7 @@ export function resolveTargetPath(root, tenant, repoPath) {
133
133
  function canonicalMappings(tenant) {
134
134
  return [
135
135
  { workspace: "theme.json", repo: `tenants/${tenant}/theme.json`, kind: "file" },
136
+ { workspace: "analytics.json", repo: `tenants/${tenant}/analytics.json`, kind: "file" },
136
137
  { workspace: "content/", repo: `tenants/${tenant}/content/`, kind: "tree" },
137
138
  { workspace: "public/", repo: `tenants/${tenant}/public/`, kind: "tree" },
138
139
  ];