@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.
Files changed (150) 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/platform/google-measurement/v2/entry.mjs +1 -1
  6. package/apps/storefront/public/shared/commerce-chrome.css +147 -16
  7. package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
  8. package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
  9. package/apps/storefront/src/components/Img.astro +6 -5
  10. package/apps/storefront/src/components/ProductCard.astro +6 -4
  11. package/apps/storefront/src/components/Seo.astro +2 -1
  12. package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
  13. package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
  14. package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
  15. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
  16. package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
  17. package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
  18. package/apps/storefront/src/components/home/Hero.astro +156 -4
  19. package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
  20. package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
  21. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
  22. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
  23. package/apps/storefront/src/components/marketing/Faq.astro +9 -2
  24. package/apps/storefront/src/config/devTenantSeed.ts +1 -1
  25. package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
  26. package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
  27. package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
  28. package/apps/storefront/src/config/tenants.ts +22 -0
  29. package/apps/storefront/src/layouts/Layout.astro +69 -44
  30. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
  31. package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
  32. package/apps/storefront/src/lib/asyncCss.ts +23 -0
  33. package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
  34. package/apps/storefront/src/lib/basePath.ts +70 -9
  35. package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
  36. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  37. package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
  38. package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
  39. package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
  40. package/apps/storefront/src/lib/diag/capture.ts +161 -0
  41. package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
  42. package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
  43. package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
  44. package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
  45. package/apps/storefront/src/lib/diag/reference.ts +148 -0
  46. package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
  47. package/apps/storefront/src/lib/diag/report.ts +109 -0
  48. package/apps/storefront/src/lib/diag/session.ts +93 -0
  49. package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
  50. package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
  51. package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
  52. package/apps/storefront/src/lib/imageAttrs.ts +9 -6
  53. package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
  54. package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
  55. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
  56. package/apps/storefront/src/lib/jsonScript.ts +13 -0
  57. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
  58. package/apps/storefront/src/lib/previewReload.ts +53 -9
  59. package/apps/storefront/src/lib/rawChrome.ts +148 -19
  60. package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
  61. package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
  62. package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
  63. package/apps/storefront/src/lib/tenantRouting.ts +15 -4
  64. package/apps/storefront/src/lib/tot/syncState.ts +21 -0
  65. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
  66. package/apps/storefront/src/middleware/controlPlane.ts +9 -5
  67. package/apps/storefront/src/middleware/index.ts +13 -1
  68. package/apps/storefront/src/middleware/renderChain.ts +54 -11
  69. package/apps/storefront/src/middleware/routeBranch.ts +58 -12
  70. package/apps/storefront/src/middleware/staticBundle.ts +37 -3
  71. package/apps/storefront/src/pages/[...slug].astro +6 -2
  72. package/apps/storefront/src/pages/api/request-access.ts +5 -4
  73. package/apps/storefront/src/pages/blog/[slug].astro +11 -1
  74. package/apps/storefront/src/pages/cart.ts +23 -0
  75. package/apps/storefront/src/pages/index.astro +64 -85
  76. package/apps/storefront/src/pages/products/[handle].astro +8 -6
  77. package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
  78. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
  79. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
  80. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
  81. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
  82. package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
  83. package/apps/storefront/src/styles/fonts.css +7 -0
  84. package/apps/storefront/src/styles/global.css +397 -3
  85. package/package.json +4 -1
  86. package/packages/cli/src/visual-parity.mjs +321 -0
  87. package/packages/public-runtime/package.json +2 -0
  88. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  89. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  90. package/packages/public-runtime/src/chrome.ts +44 -7
  91. package/packages/public-runtime/src/csp.ts +12 -1
  92. package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
  93. package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
  94. package/packages/public-runtime/src/extension-contract.ts +20 -6
  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/csp-compiler.ts +6 -3
  100. package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
  101. package/packages/public-runtime/src/integration/index.ts +1 -0
  102. package/packages/public-runtime/src/integration/lifecycle-boot.ts +34 -6
  103. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
  104. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
  105. package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
  106. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
  107. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +35 -1
  108. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +45 -12
  109. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +28 -7
  110. package/packages/public-runtime/src/integration/native-ga4.ts +18 -2
  111. package/packages/public-runtime/src/integration/platform-adapters.ts +14 -3
  112. package/packages/public-runtime/src/integration/render-phases.ts +28 -2
  113. package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
  114. package/packages/public-runtime/src/publication-redirects.mjs +379 -0
  115. package/packages/public-runtime/src/publication-space.ts +24 -3
  116. package/packages/public-runtime/src/static-bundle.ts +145 -26
  117. package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
  118. package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
  119. package/packages/public-runtime/src/tenant.ts +59 -1
  120. package/pnpm-lock.runner.yaml +12 -0
  121. package/pnpm-workspace.yaml +4 -0
  122. package/scripts/dev/git-status.mjs +1 -1
  123. package/scripts/dev/publish.mjs +6 -0
  124. package/scripts/lib/tenant-asset-copy.mjs +4 -2
  125. package/scripts/tenant/materialize-tenant-content.mjs +1 -0
  126. package/scripts/tenant/validate.mjs +171 -8
  127. package/apps/storefront/src/lib/cfImage.ts +0 -55
  128. package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
  129. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  130. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  131. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  132. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  133. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  134. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  135. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  136. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  137. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  138. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  139. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  140. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  141. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  142. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  143. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  144. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  145. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  146. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  147. package/tenants/home/public/js/storefront.js +0 -129
  148. package/tenants/home/public/logo-wordmark.png +0 -0
  149. package/tenants/home/public/pages/storefront.css +0 -235
  150. package/tenants/home/theme.json +0 -6
@@ -1,18 +1,29 @@
1
1
  import type {
2
+ PublicationSpaceAliasV1,
2
3
  PublicationSpaceManifestV1,
3
4
  PublicationSpaceSourceV1,
4
5
  } from "@tot/public-runtime";
5
6
 
7
+ /**
8
+ * The root publication space for a tenant.
9
+ *
10
+ * `aliases` are the tenant's declared redirects, in the one `redirect-308` contract that carries
11
+ * them the whole way: `publicationRedirects()` lifts them into the aggregate metadata,
12
+ * `cloudfront-live-pointer.mjs` writes them into the live pointer value, and the edge function
13
+ * serves the 308. A tenant declaring none gets a `routes` object without the key at all, so its
14
+ * manifest — and therefore its digest — is unchanged by this parameter existing.
15
+ */
6
16
  export function rootPublicationSpaceConfig(
7
17
  tenant: string,
8
18
  source: PublicationSpaceSourceV1,
19
+ aliases: readonly PublicationSpaceAliasV1[] = [],
9
20
  ): PublicationSpaceManifestV1 {
10
21
  return {
11
22
  version: 1,
12
23
  id: "root",
13
24
  tenant,
14
25
  source,
15
- routes: { canonicalMount: "/" },
26
+ routes: aliases.length ? { canonicalMount: "/", aliases: [...aliases] } : { canonicalMount: "/" },
16
27
  visibility: "public",
17
28
  authorization: { policy: "public" },
18
29
  discovery: { sitemap: true, feeds: true, publicSearch: true, llms: true },
@@ -4,6 +4,11 @@ import { rootPublicationSpaceConfig } from "./publicationSpaceConfig.js";
4
4
  const ROOT_SOURCES: Readonly<Record<string, PublicationSpaceSourceV1>> = {
5
5
  home: { forge: "fixture", repo: "home", defaultBranch: "main" },
6
6
  "tokenoftrust.com": { forge: "gitea", repo: "tokenoftrust.com-main", defaultBranch: "main" },
7
+ "storefront.tokenoftrust.store": {
8
+ forge: "gitea",
9
+ repo: "storefront.tokenoftrust.store",
10
+ defaultBranch: "main",
11
+ },
7
12
  "sample-store.com": { forge: "gitea", repo: "sample-store.com", defaultBranch: "main" },
8
13
  "sample-store.e2e.test": { forge: "fixture", repo: "sample-store.e2e.test", defaultBranch: "main" },
9
14
  "verify.e2e.test": { forge: "fixture", repo: "verify.e2e.test", defaultBranch: "main" },
@@ -12,6 +12,7 @@
12
12
  * StaticTenantResolver, tenantResolver) is unchanged — only the data is generic.
13
13
  */
14
14
  import type { TenantConfig, TenantResolver } from "@tot/public-runtime";
15
+ import { tenantRendersPublicly } from "@tot/public-runtime";
15
16
  import { getTenantTheme } from "@/themes/tenants";
16
17
  import { rootPublicationSpaceConfig } from "@/config/publicationSpaceConfig";
17
18
 
@@ -96,3 +97,24 @@ export class StaticTenantResolver implements TenantResolver {
96
97
  }
97
98
 
98
99
  export const tenantResolver: TenantResolver = new StaticTenantResolver();
100
+
101
+ const COMPOSED_PLATFORM_FIELDS = ["access", "pathPrefixTenants"] as const;
102
+
103
+ /** Same contract as the real registry's withPlatformPolicy — kept in sync by hand. */
104
+ export function withPlatformPolicy(record: TenantConfig): TenantConfig {
105
+ const declared = REGISTRY.find((t) => t.tenant_id === record.tenant_id);
106
+ const out = { ...record };
107
+ for (const field of COMPOSED_PLATFORM_FIELDS) {
108
+ const value = declared?.[field];
109
+ if (value === undefined) delete out[field];
110
+ else (out[field] as unknown) = value;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** Same contract as the real registry's tenantIsDeclaredPublic — kept in sync by hand. */
116
+ export function tenantIsDeclaredPublic(tenantId: string): boolean {
117
+ return REGISTRY.some(
118
+ (t) => t.tenant_id === tenantId && tenantRendersPublicly(t),
119
+ );
120
+ }
@@ -23,9 +23,12 @@ import { referenceTheme } from "@/themes/reference";
23
23
  import { mergeTheme } from "@/themes/schema";
24
24
  import { organizationLd, websiteLd } from "@/lib/jsonld";
25
25
  import { nonceInlineTags, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
26
+ import { CHROME_BEHAVIOR_JS, sanitizeSharedChromeFragment } from "@/lib/rawChrome";
27
+ import { ASYNC_CSS_SWAP_JS } from "@/lib/asyncCss";
26
28
  import { reportTrackingChainHealth } from "@/lib/tracking/interactionHealth";
27
29
  import { resolveHeaderVariant, resolveFooterVariant } from "@/lib/chrome/variant";
28
30
  import { splitFooterLegal } from "@/lib/chrome/footerLegal";
31
+ import { bindManagedCartAnchors, managedCartHref } from "@/lib/chrome/managedCart";
29
32
  import type { HeaderVariant, FooterVariant } from "@/lib/chrome/model";
30
33
  import { renderIntegrationPhases, INTERACTION_TRACKING_BOOT_JS } from "@tot/public-runtime";
31
34
  import { readViewerSession } from "@/lib/auth/route";
@@ -127,6 +130,9 @@ const chrome = await Astro.locals.content.getChrome();
127
130
  // actionable message rather than silently falling back (lib/chrome/variant.ts).
128
131
  const resolvedHeaderVariant = resolveHeaderVariant(headerVariant, chrome.header.variant);
129
132
  const resolvedFooterVariant = resolveFooterVariant(footerVariant, chrome.footer.variant);
133
+ const footerCompliance = [chrome.footer.warning, chrome.complianceStrip]
134
+ .filter((line): line is string => Boolean(line))
135
+ .join(" ") || undefined;
130
136
 
131
137
  // SHARED CHROME: if the tenant ships content/<id>/chrome.html (the pixel-perfect
132
138
  // header/footer the raw marketing pages use), render THAT chrome around the
@@ -135,7 +141,10 @@ const resolvedFooterVariant = resolveFooterVariant(footerVariant, chrome.footer.
135
141
  // <!--PAGE_BODY-->: everything before = header, after = footer; pull its tenant
136
142
  // stylesheet links into <head>; nonce its inline tags for CSP. Tenants without a
137
143
  // chrome.html keep the token-driven Header/Footer (below).
138
- const chromeHtml = await Astro.locals.content.getChromeHtml(path);
144
+ const chromeHtml = bindManagedCartAnchors(
145
+ (await Astro.locals.content.getChromeHtml(path)) ?? "",
146
+ Astro.locals.commerce,
147
+ ) || null;
139
148
  if (chromeHtml) {
140
149
  // HEALTH: this is the sharedChrome BYPASS path — a hand-authored chrome.html
141
150
  // wins outright over the governed chrome components below, so nothing
@@ -156,13 +165,22 @@ if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
156
165
  const marker = chromeHtml.indexOf(CHROME_BODY_MARKER);
157
166
  const bodyEnd = chromeHtml.lastIndexOf("</body>");
158
167
  sharedChrome = {
159
- header: nonceInlineTags(chromeHtml.slice(bodyOpen, marker), cspNonce),
160
- footer: nonceInlineTags(chromeHtml.slice(marker + CHROME_BODY_MARKER.length, bodyEnd >= 0 ? bodyEnd : undefined), cspNonce),
168
+ header: nonceInlineTags(
169
+ sanitizeSharedChromeFragment(chromeHtml.slice(bodyOpen, marker)),
170
+ cspNonce,
171
+ ),
172
+ footer: nonceInlineTags(
173
+ sanitizeSharedChromeFragment(
174
+ chromeHtml.slice(marker + CHROME_BODY_MARKER.length, bodyEnd >= 0 ? bodyEnd : undefined),
175
+ ),
176
+ cspNonce,
177
+ ),
161
178
  css,
162
179
  };
163
180
  }
164
181
 
165
182
  const compliance = tenant.compliance;
183
+ const cartHref = managedCartHref(Astro.locals.commerce);
166
184
 
167
185
  // Site-chrome data resolution (chrome/* is the canonical, prop-driven
168
186
  // component set; this Layout is the ONE place that resolves session/feature/
@@ -310,7 +328,7 @@ const footerWidgets = await renderWidgetSlot(
310
328
  <Fragment set:html={sharedChrome.header} />
311
329
  ) : (
312
330
  <>
313
- <div class="sticky top-0 z-40">
331
+ <div class:list={[resolvedHeaderVariant !== "compact-centered" && "sticky top-0 z-40"]}>
314
332
  {chrome.header.announcement?.messages[0] && (
315
333
  <AnnouncementBar text={chrome.header.announcement.messages[0]} variant="static" />
316
334
  )}
@@ -322,10 +340,16 @@ const footerWidgets = await renderWidgetSlot(
322
340
  variant={headerDark ? "dark" : "light"}
323
341
  layout={resolvedHeaderVariant}
324
342
  announcement={chrome.header.announcement?.messages[0]}
325
- searchAction={features.productSearch ? "/search" : undefined}
343
+ searchAction={
344
+ features.productSearch ? (chrome.header.search?.action ?? "/search") : undefined
345
+ }
346
+ searchPlaceholder={chrome.header.search?.placeholder}
347
+ searchLabel={chrome.header.search?.label}
326
348
  savedItemsHref={features.savedItems ? "/saved" : undefined}
327
349
  headerCtas={chrome.header.ctas}
328
- authLink={authLink}
350
+ accountHref={chrome.header.accountHref}
351
+ authLink={chrome.header.accountHref ? undefined : authLink}
352
+ cartHref={cartHref}
329
353
  sticky={false}
330
354
  />
331
355
  </div>
@@ -339,18 +363,35 @@ const footerWidgets = await renderWidgetSlot(
339
363
  <ShippingRestrictionNotice text={compliance?.shippingRestriction} />
340
364
 
341
365
  {sharedChrome ? (
342
- <Fragment set:html={sharedChrome.footer} />
366
+ <>
367
+ <Fragment set:html={sharedChrome.footer} />
368
+ {/* Shared chrome is declarative tenant markup. Its supported behavior is
369
+ supplied from this platform-owned module, never from tenant scripts. */}
370
+ <script is:inline nonce={cspNonce} set:html={CHROME_BEHAVIOR_JS}></script>
371
+ </>
343
372
  ) : (
344
373
  <>
345
374
  <SiteFooter
346
375
  brand={{
347
- wordmark: tenant.displayName,
348
- contact: chrome.footer.brand?.text ? [chrome.footer.brand.text] : undefined,
376
+ logo: chrome.footer.brand?.logo,
377
+ wordmark:
378
+ chrome.footer.brand?.wordmark?.map((part) => part.text).join("") ??
379
+ tenant.displayName,
380
+ contact: chrome.footer.brand?.text?.split(/\r?\n/),
349
381
  }}
350
382
  variant={resolvedFooterVariant}
351
383
  columns={footerColumns}
352
- newsletter={features.newsletter ? { title: "Newsletter", placeholder: "you@example.com" } : undefined}
384
+ newsletter={
385
+ features.newsletter
386
+ ? (chrome.footer.newsletter ?? {
387
+ title: "Newsletter",
388
+ placeholder: "you@example.com",
389
+ })
390
+ : undefined
391
+ }
392
+ payments={chrome.footer.payments}
353
393
  social={chrome.footer.social}
394
+ compliance={footerCompliance}
354
395
  legal={legalLine}
355
396
  bottomLinks={bottomLinks}
356
397
  />
@@ -372,7 +413,13 @@ const footerWidgets = await renderWidgetSlot(
372
413
  ))}
373
414
 
374
415
  {capabilities.ageVerification.enabled && compliance?.minAge && (
375
- <IsolatedAgeGate client:load minAge={compliance.minAge} brand={tenant.displayName} />
416
+ <IsolatedAgeGate
417
+ client:load
418
+ minAge={compliance.minAge}
419
+ brand={tenant.displayName}
420
+ tenantId={tenant.tenant_id}
421
+ presentation={chrome.ageGate}
422
+ />
376
423
  )}
377
424
 
378
425
  {/* Global quick-view dialog (opened by any [data-quickview] card button).
@@ -451,39 +498,10 @@ const footerWidgets = await renderWidgetSlot(
451
498
  /<domain>/ path segment (locals.basePath), keep same-origin
452
499
  root-absolute links inside that prefix so navigation stays on the
453
500
  tenant. Host-routed requests have no base path and skip this entirely. */}
454
- {basePath && (
455
- <script is:inline nonce={cspNonce} define:vars={{ basePath }}>
456
- (function () {
457
- var base = basePath;
458
- if (!base) return;
459
- function fix(a) {
460
- var href = a.getAttribute("href");
461
- if (!href || href.charAt(0) !== "/" || href.charAt(1) === "/") return;
462
- // Global asset namespaces are root-served under every tenant.
463
- if (href.indexOf("/tenants/") === 0 || href.indexOf("/shared/") === 0) return;
464
- if (href === base || href.indexOf(base + "/") === 0) return;
465
- a.setAttribute("href", base + href);
466
- }
467
- function fixAll() {
468
- document.querySelectorAll('a[href^="/"]').forEach(fix);
469
- }
470
- if (document.readyState === "loading") {
471
- document.addEventListener("DOMContentLoaded", fixAll);
472
- } else {
473
- fixAll();
474
- }
475
- // Late/island-rendered anchors: fix just-in-time before navigation.
476
- document.addEventListener(
477
- "pointerdown",
478
- function (e) {
479
- var a = e.target && e.target.closest && e.target.closest('a[href^="/"]');
480
- if (a) fix(a);
481
- },
482
- true,
483
- );
484
- })();
485
- </script>
486
- )}
501
+ <script>
502
+ import { bindBasePathAnchors } from "@/lib/basePath";
503
+ bindBasePathAnchors();
504
+ </script>
487
505
 
488
506
  {/* Dev-loop theme hot-patch: tenant-hot-reload.mjs broadcasts a
489
507
  `tot:theme-vars` HMR custom event on every theme.json save instead of
@@ -573,6 +591,13 @@ const footerWidgets = await renderWidgetSlot(
573
591
  entry in lib/cspStaticHashes.ts. */}
574
592
  <script is:inline nonce={cspNonce} set:html={INTERACTION_TRACKING_BOOT_JS}></script>
575
593
 
594
+ {/* Swaps every <AsyncStylesheet>-rendered preload link to rel="stylesheet"
595
+ once fetched. ONE nonced script per document handles every such link,
596
+ however many <AsyncStylesheet> instances rendered (lib/asyncCss.ts —
597
+ the onload="" attribute trick can't carry a nonce under this CSP). A
598
+ no-op when the page has none. */}
599
+ <script is:inline nonce={cspNonce} set:html={ASYNC_CSS_SWAP_JS}></script>
600
+
576
601
  {/* document.deferred global-entry boundary (ext-kernel-04) — the last phase, the
577
602
  renderer-owned mount point for the deferred integration entry. This region also
578
603
  CARRIES the lifecycle host tag (ext-kernel-05), which initializes the plan's
@@ -60,22 +60,38 @@ function submitAuthorizationKey(
60
60
  * Persist who took `action` on `subjectId` (a `changeId` for reject/accept/build,
61
61
  * or the aggregate id for ship). Best-effort — never blocks or fails the
62
62
  * mutation it describes; a write fault is swallowed, mirroring `recordActivity`.
63
+ *
64
+ * `email: null` means the request carried no real per-person identity — the
65
+ * headless Bearer/CLI operator path, which authenticates a shared tenant-level
66
+ * secret, never a signed-in developer (the CLI holds no storefront viewer
67
+ * session). Recording that case under the tenant OWNER's email would credit
68
+ * the owner's account for an action any operator-secret holder could have
69
+ * taken — a real misattribution, not a display nicety — so it is recorded as
70
+ * a distinct `service` identity instead.
63
71
  */
64
72
  export async function recordChangeActor(
65
73
  kv: ChangeActorKv,
66
74
  tenantId: string,
67
75
  subjectId: string,
68
76
  action: ChangeActorAction,
69
- email: string,
77
+ email: string | null,
70
78
  ): Promise<void> {
71
79
  try {
72
- const record: ChangeActorRecord = {
73
- kind: "human",
74
- subject: `email:${email.trim().toLowerCase()}`,
75
- email,
76
- name: displayNameFromEmail(email),
77
- at: new Date().toISOString(),
78
- };
80
+ const record: ChangeActorRecord = email
81
+ ? {
82
+ kind: "human",
83
+ subject: `email:${email.trim().toLowerCase()}`,
84
+ email,
85
+ name: displayNameFromEmail(email),
86
+ at: new Date().toISOString(),
87
+ }
88
+ : {
89
+ kind: "service",
90
+ subject: "service:cli-operator",
91
+ email: null,
92
+ name: "CLI (operator credential)",
93
+ at: new Date().toISOString(),
94
+ };
79
95
  await kv.put(attributionKey(tenantId, subjectId, action), JSON.stringify(record));
80
96
  } catch {
81
97
  /* best-effort — the mutation it describes already succeeded */
@@ -87,6 +87,21 @@ export function actorEmailFor(context: APIContext, auth: { owner: string }): str
87
87
  return context.locals?.viewer?.email ?? auth.owner;
88
88
  }
89
89
 
90
+ /**
91
+ * The real per-person identity to CREDIT for an owner-facing "who did this"
92
+ * record — DELIBERATELY narrower than {@link actorEmailFor}. That helper's
93
+ * owner fallback exists only to give the anonymized activity hash and
94
+ * authorization checks a valid bucket/identity to reason about; crediting the
95
+ * SAME fallback to `recordChangeActor` would misattribute every action taken
96
+ * over the headless Bearer/CLI operator path (a shared tenant-level secret,
97
+ * never a signed-in developer — the CLI holds no storefront viewer session)
98
+ * to the tenant owner personally. `null` means exactly that: no real person
99
+ * to credit, not "credit the owner." See `changeActorAttribution.ts`.
100
+ */
101
+ export function attributedActorEmailFor(context: APIContext): string | null {
102
+ return context.locals?.viewer?.email ?? null;
103
+ }
104
+
90
105
  /**
91
106
  * Best-effort, deterministic display name from an email — a real name is NOT
92
107
  * captured anywhere in the current session/grant model. A placeholder good enough
@@ -0,0 +1,23 @@
1
+ /**
2
+ * CSP-safe async (non-render-blocking) CSS loading.
3
+ *
4
+ * The classic `<link rel="preload" as="style" onload="this.rel='stylesheet'">`
5
+ * trick doesn't work under this storefront's CSP (docs/architecture/async-css.md):
6
+ * `onload="…"` is an inline EVENT-HANDLER ATTRIBUTE, governed by `script-src`
7
+ * (there is no separate `script-src-attr` directive here), and an attribute
8
+ * can't carry a per-request nonce — only a `<script nonce>` TAG can. So the
9
+ * swap has to happen from a nonced `<script>`, not an attribute.
10
+ *
11
+ * Rather than emit one nonced script per stylesheet, `<AsyncStylesheet>`
12
+ * (../components/AsyncStylesheet.astro) tags its `<link rel="preload">` with
13
+ * `data-async-css`, and Layout.astro renders `ASYNC_CSS_SWAP_JS` ONCE per page
14
+ * — it flips every matching link's `rel` to `stylesheet` no matter how many
15
+ * `<AsyncStylesheet>` instances rendered. A `<noscript>` fallback covers
16
+ * no-JS. fb-1789228922973-oidvf5.
17
+ */
18
+ export const ASYNC_CSS_ATTR = "data-async-css";
19
+
20
+ export const ASYNC_CSS_SWAP_JS = `(function () {
21
+ var links = document.querySelectorAll('link[rel="preload"][as="style"][${ASYNC_CSS_ATTR}]');
22
+ for (var i = 0; i < links.length; i++) links[i].rel = 'stylesheet';
23
+ })();`;
@@ -19,14 +19,6 @@ import type { SessionRecord } from "./session.js";
19
19
 
20
20
  export type GateMode = "password" | "login";
21
21
 
22
- /**
23
- * The platform's own "self" home tenant (tenants.ts `HOME_TENANT`, registry
24
- * default). It is NEVER login-gated — `storefront.tokenoftrust.store` renders its
25
- * public home so people can browse + sign up. Every OTHER tenant on a gated host
26
- * (path-prefix or per-tenant subdomain) is gated behind sign-in.
27
- */
28
- export const PUBLIC_HOME_TENANT_ID = "home";
29
-
30
22
  /**
31
23
  * The record fields the host-admission + host-capability checks read. All optional
32
24
  * — these readers tolerate a partial record and are deny-closed on absence.
@@ -139,9 +131,27 @@ export function isAuthPath(pathname: string): boolean {
139
131
  * unauthenticated `tot` install (src/pages/api/cli/policy.ts). Kept exact (not a
140
132
  * broad `/api/cli/` prefix) so a future authed CLI endpoint isn't accidentally
141
133
  * exposed.
134
+ *
135
+ * `/api/preview/version` is the shared preview's on-publish refresh SIGNAL
136
+ * (src/pages/api/preview/version.ts): a read-only per-tenant edge cache-version
137
+ * integer, no-store and noindex, carrying nothing a viewer of the preview cannot
138
+ * already see. It must be exempt HERE rather than by relaxing
139
+ * {@link isBypassPrivilegedPath}, which excludes every `/api/` route from the
140
+ * preview-token and QA bypasses on purpose — that exclusion is the security
141
+ * boundary and stays intact.
142
+ *
143
+ * Matched in BOTH addressing forms, because the injected reload client fetches it
144
+ * under the page's basePath: a path-routed preview sends `/<tenant>/api/preview/
145
+ * version`, so a bare-only match leaves the gate answering the "Coming soon"
146
+ * holding page with a 200. The client reads `(await res.json()).version` inside a
147
+ * catch that treats every failure as transient, so an HTML body there is swallowed
148
+ * silently and the reload can never fire while the tab polls once a second forever.
142
149
  */
143
150
  export function isPublicPath(pathname: string): boolean {
144
- return pathname === "/api/cli/policy";
151
+ if (pathname === "/api/cli/policy") return true;
152
+ if (pathname === "/api/preview/version") return true;
153
+ if (/^\/[^/]+\/api\/preview\/version$/.test(pathname)) return true;
154
+ return false;
145
155
  }
146
156
 
147
157
  /**
@@ -277,8 +287,9 @@ export interface LoginGateInput {
277
287
  /** True when a valid, resource-bound session was resolved for this request. */
278
288
  hasValidSession: boolean;
279
289
  /**
280
- * True when the resolved tenant is the platform's own public home (self) —
281
- * exempt from the gate so it renders publicly. See {@link PUBLIC_HOME_TENANT_ID}.
290
+ * True when the resolved tenant declares itself publicly accessible, exempting
291
+ * it from the gate so it renders to anyone. Sourced from the tenant's own
292
+ * `access.public` declaration via `tenantRendersPublicly` — never a tenant id.
282
293
  */
283
294
  tenantExempt?: boolean;
284
295
  /**
@@ -13,20 +13,53 @@
13
13
  *
14
14
  * It is idempotent and conservative: only root-absolute single-slash paths are
15
15
  * prefixed. Protocol-relative URLs ("//cdn…"), absolute URLs ("https://…"),
16
- * fragments/queries ("#x", "?x"), and already-prefixed paths pass through. The
17
- * GLOBAL static-asset namespaces are also left untouched — they are served at root
18
- * under both routing modes and must NOT carry a tenant prefix:
19
- * - `/tenants/…` — per-tenant static assets (logo, favicon, theme/mkt css);
20
- * - `/shared/…` — platform-shipped shared assets (shared commerce-chrome css).
21
- * This mirrors both `prefixTenantLinks` (lib/rawMarketingHtml.ts) and the
22
- * defense-in-depth client script in Layout.astro.
16
+ * fragments/queries ("#x", "?x"), already-prefixed paths, and every
17
+ * {@link ROOT_SERVED_HREF_NAMESPACES} path pass through.
23
18
  */
19
+
20
+ /**
21
+ * Root-served namespaces that a tenant prefix must never be applied to.
22
+ *
23
+ * These are served at the ORIGIN ROOT under both routing modes: host-routed, the
24
+ * platform's static handler answers them before the Worker; path-routed, the same
25
+ * bytes still live at the root. A prefixed `/<tenant>/favicon.svg` is not a static
26
+ * asset, so it costs a redirect at best.
27
+ *
28
+ * `tenants` is per-tenant static assets (already tenant-scoped in the path, so a
29
+ * second prefix would double it). The rest mirror `PLATFORM_STATIC_ASSET_PREFIXES`
30
+ * in `src/middleware/routeBranch.ts` — the serving-side authority for the same set.
31
+ * They are restated here rather than imported because this module ships to the
32
+ * browser and in the published runner tree, which is assembled from a COPY LIST
33
+ * rather than a dependency graph. `rawMarketingHtml.test.ts` asserts this list
34
+ * covers that one, so the two cannot drift apart unnoticed.
35
+ *
36
+ * Consumers: {@link withBase} (and through it {@link bindBasePathAnchors}) and
37
+ * `prefixTenantLinks` (lib/rawMarketingHtml.ts).
38
+ */
39
+ export const ROOT_SERVED_HREF_NAMESPACES = [
40
+ "_a",
41
+ "_astro",
42
+ "apps",
43
+ "assets",
44
+ "brand",
45
+ "favicon.svg",
46
+ "js",
47
+ "platform",
48
+ "shared",
49
+ "tenants",
50
+ ] as const;
51
+
52
+ /** True when root-absolute `href` names a {@link ROOT_SERVED_HREF_NAMESPACES} path. */
53
+ export function isRootServedHref(href: string): boolean {
54
+ const path = href.slice(1).split(/[?#]/, 1)[0] ?? "";
55
+ return ROOT_SERVED_HREF_NAMESPACES.some((ns) => path === ns || path.startsWith(`${ns}/`));
56
+ }
57
+
24
58
  export function withBase(basePath: string, href: string): string {
25
59
  if (!basePath || !href) return href;
26
60
  // Only same-origin root-absolute paths; skip protocol-relative "//".
27
61
  if (href.charAt(0) !== "/" || href.charAt(1) === "/") return href;
28
- // Global asset namespaces are root-served under every tenant; never prefix them.
29
- if (href.startsWith("/tenants/") || href.startsWith("/shared/")) return href;
62
+ if (isRootServedHref(href)) return href;
30
63
  // Already inside the prefix.
31
64
  if (href === basePath || href.startsWith(`${basePath}/`)) return href;
32
65
  return `${basePath}${href}`;
@@ -46,3 +79,31 @@ export function clientBasePath(): string {
46
79
  export function withClientBase(href: string): string {
47
80
  return withBase(clientBasePath(), href);
48
81
  }
82
+
83
+ /**
84
+ * Keep same-origin root-absolute anchors inside the tenant prefix, so navigation on a
85
+ * path-routed page stays on the tenant. Covers anchors in raw tenant HTML and chrome,
86
+ * which never pass through {@link withBase}. A no-op when host-routed.
87
+ */
88
+ export function bindBasePathAnchors(doc: Document = document): void {
89
+ const base = doc.documentElement.dataset.basePath ?? "";
90
+ if (!base) return;
91
+ const fix = (a: Element) => {
92
+ const href = a.getAttribute("href");
93
+ if (!href) return;
94
+ const next = withBase(base, href);
95
+ if (next !== href) a.setAttribute("href", next);
96
+ };
97
+ const fixAll = () => doc.querySelectorAll('a[href^="/"]').forEach(fix);
98
+ if (doc.readyState === "loading") doc.addEventListener("DOMContentLoaded", fixAll);
99
+ else fixAll();
100
+ // Late/island-rendered anchors: fix just-in-time before navigation.
101
+ doc.addEventListener(
102
+ "pointerdown",
103
+ (e) => {
104
+ const a = e.target instanceof Element ? e.target.closest('a[href^="/"]') : null;
105
+ if (a) fix(a);
106
+ },
107
+ true,
108
+ );
109
+ }
@@ -0,0 +1,50 @@
1
+ import type { ResolvedManagedCheckout } from "@tot/public-runtime";
2
+
3
+ /** The managed checkout is the sole cart destination when it is available. */
4
+ export function managedCartHref(
5
+ commerce: ResolvedManagedCheckout | null | undefined,
6
+ ): string | undefined {
7
+ return commerce?.cartUrl;
8
+ }
9
+
10
+ const ANCHOR_RE = /<a\b[^>]*>[\s\S]*?<\/a>/gi;
11
+
12
+ function isCartAnchor(anchor: string): boolean {
13
+ const openingTag = anchor.slice(0, anchor.indexOf(">") + 1);
14
+ const classValue = openingTag.match(/\bclass=(?:"([^"]*)"|'([^']*)')/i);
15
+ const classes = (classValue?.[1] ?? classValue?.[2] ?? "").split(/\s+/);
16
+ const ariaLabel = openingTag.match(/\baria-label=(?:"([^"]*)"|'([^']*)')/i);
17
+ const label = ariaLabel?.[1] ?? ariaLabel?.[2] ?? "";
18
+ return classes.includes("cart") || /^Cart(?:\b|,)/i.test(label);
19
+ }
20
+
21
+ function escapeAttribute(value: string): string {
22
+ return value
23
+ .replaceAll("&", "&amp;")
24
+ .replaceAll('"', "&quot;")
25
+ .replaceAll("<", "&lt;")
26
+ .replaceAll(">", "&gt;");
27
+ }
28
+
29
+ /**
30
+ * Bind raw/shared chrome cart affordances to the same request-scoped checkout
31
+ * destination used by product forms. If checkout is unavailable, remove the
32
+ * affordance instead of sending shoppers to the storefront's nonexistent
33
+ * `/cart` page. Works on rendered HTML, so generated and tenant-authored shared
34
+ * chrome follow the same rule without tenant-specific branches.
35
+ */
36
+ export function bindManagedCartAnchors(
37
+ html: string,
38
+ commerce: ResolvedManagedCheckout | null | undefined,
39
+ ): string {
40
+ const href = managedCartHref(commerce);
41
+ return html.replace(ANCHOR_RE, (anchor) => {
42
+ if (!isCartAnchor(anchor)) return anchor;
43
+ if (!href) return "";
44
+ const escaped = escapeAttribute(href);
45
+ if (/\bhref=(?:"[^"]*"|'[^']*')/i.test(anchor)) {
46
+ return anchor.replace(/\bhref=(?:"[^"]*"|'[^']*')/i, `href="${escaped}"`);
47
+ }
48
+ return anchor.replace(/^<a\b/i, `<a href="${escaped}"`);
49
+ });
50
+ }
@@ -26,6 +26,10 @@ export {
26
26
  type ChromeUtilityNav,
27
27
  type ChromeAnnouncement,
28
28
  type ChromeAgeGate,
29
+ CHROME_AGE_GATE_VARIANTS,
30
+ type ChromeAgeGateVariant,
31
+ deriveAgeGateStorageKey,
32
+ resolveAgeGateCopy,
29
33
  type ChromeFooterBrand,
30
34
  type ChromeTrustMark,
31
35
  type ChromeHead,
@@ -0,0 +1,9 @@
1
+ export const NEWSLETTER_AGE_ATTESTATION = Object.freeze({
2
+ name: "age_attestation",
3
+ value: "21plus",
4
+ label: "I confirm I am 21 years of age or older and agree to receive marketing emails.",
5
+ });
6
+
7
+ export function resolveNewsletterAgeAttestation(enabled: boolean | undefined) {
8
+ return enabled ? NEWSLETTER_AGE_ATTESTATION : undefined;
9
+ }