@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
@@ -59,13 +59,18 @@ export async function resolveTenantRouting(
59
59
  pathname: string,
60
60
  resolver: TenantResolver,
61
61
  ): Promise<TenantRouting> {
62
- // 1. Host wins when it explicitly owns a tenant.
62
+ // 1. Host wins when it explicitly owns a tenant — UNLESS it declares
63
+ // `pathPrefixTenants`, the platform's shared preview host, which serves its
64
+ // own content at the root AND dispatches other tenants by path prefix.
65
+ // Returning early for that host turns every /<domain>/* URL into a path on
66
+ // its own site, which is how giving it a tenant of its own silently 404'd
67
+ // the dev loop, the admin preview links and the post-deploy chrome canary.
63
68
  const byHost = await resolver.resolveStrict(host);
64
- if (byHost) {
69
+ if (byHost && !byHost.pathPrefixTenants) {
65
70
  return { tenant: byHost, basePath: "", renderPath: normalizePath(pathname) };
66
71
  }
67
72
 
68
- // 2. Unknown host -> try a /<domain>/* path prefix.
73
+ // 2. Unknown host, or a declared path-prefix host -> try a /<domain>/* prefix.
69
74
  const segments = pathname.replace(/^\/+/, "").split("/");
70
75
  const firstRaw = segments[0] ?? "";
71
76
  let first = firstRaw;
@@ -85,7 +90,13 @@ export async function resolveTenantRouting(
85
90
  }
86
91
  }
87
92
 
88
- // 3. Fall back to the registry default for this (unknown) host.
93
+ // 3. A path-prefix host whose prefix named no known tenant serves its OWN
94
+ // content — that is the root case, and any other path on it.
95
+ if (byHost) {
96
+ return { tenant: byHost, basePath: "", renderPath: normalizePath(pathname) };
97
+ }
98
+
99
+ // 4. Fall back to the registry default for this (unknown) host.
89
100
  const fallback = await resolver.resolveByHost(host);
90
101
  return { tenant: fallback, basePath: "", renderPath: normalizePath(pathname) };
91
102
  }
@@ -57,6 +57,20 @@ export interface SyncStateSweepChanges {
57
57
  collections: SyncStateEntityChangeSet;
58
58
  }
59
59
 
60
+ /** Mirrors packages/migration/src/load/sync-state.ts's `ImageRehostRecord`. */
61
+ export interface SyncStateImageRehost {
62
+ computedAt: string;
63
+ mode: "rehost" | "reference";
64
+ sink: string | null;
65
+ considered: number;
66
+ rehosted: number;
67
+ alreadyRehosted: number;
68
+ referenced: number;
69
+ errors: number;
70
+ products: string[];
71
+ collections: string[];
72
+ }
73
+
60
74
  /** The on-disk shape written by the migration CLI (schema "tot.sync-state/1"). */
61
75
  export interface CatalogSyncState {
62
76
  schema: string;
@@ -74,6 +88,13 @@ export interface CatalogSyncState {
74
88
  * migrated before hsby-B2 (change detection) has no `lastChanges`.
75
89
  */
76
90
  lastChanges?: SyncStateSweepChanges;
91
+ /**
92
+ * What the most recent sync's image rehost moved. Absent when that run moved
93
+ * no imagery. Recorded separately from `lastChanges` because a rehost rewrites
94
+ * image urls without touching `source_checksum` — so the content-hash delta
95
+ * reports those records as unchanged, and this is the only account of the move.
96
+ */
97
+ lastImageRehost?: SyncStateImageRehost;
77
98
  }
78
99
 
79
100
  // Vite resolves this glob at build time and bundles every tenant's sync-state
@@ -119,7 +119,21 @@ function attrsOf(element: RewriterElement): Record<string, string> {
119
119
  * shipping no identity for this content at all.
120
120
  *
121
121
  * No-op (returns `html` unchanged) when `HTMLRewriter` isn't available in
122
- * this runtime.
122
+ * this runtime, and equally when the rewrite itself cannot complete.
123
+ *
124
+ * FAIL-OPEN, ALWAYS. This is a safety net over content the platform does not
125
+ * author, so it must never be able to take a page down. `onEndTag` registers a
126
+ * callback for an end tag the parser is not obliged to produce: `<li>`, `<p>`,
127
+ * `<td>` and friends may legally omit theirs, and `HTMLRewriter` then aborts the
128
+ * stream with `TypeError: Parser error: No end tag.` — mid-transform, so no
129
+ * guard at registration can catch it. That throw propagated out of
130
+ * `getHomeHtml`/`getPageHtml` and cost the whole route: `/` and `/sitemap.xml`
131
+ * served a zero-byte 404 for a tenant whose HTML was valid.
132
+ *
133
+ * An unstamped document loses derived click identity — the exact loss the
134
+ * absence of any stamp already represents, and what `reportTrackingChainHealth`
135
+ * exists to surface. A 404 is not a smaller loss than that, so the original HTML
136
+ * wins whenever the pass cannot finish.
123
137
  */
124
138
  export async function stampTrackingIdsAtPublish(html: string): Promise<string> {
125
139
  const HTMLRewriter = getHtmlRewriterCtor();
@@ -157,6 +171,18 @@ export async function stampTrackingIdsAtPublish(html: string): Promise<string> {
157
171
  },
158
172
  });
159
173
 
160
- const rewritten = rewriter.transform(new Response(html));
161
- return await rewritten.text();
174
+ try {
175
+ const rewritten = rewriter.transform(new Response(html));
176
+ return await rewritten.text();
177
+ } catch (error) {
178
+ // One structured line, never a throw — the same posture the raw-surface
179
+ // health report takes. The document still serves; only its derived ids are
180
+ // absent, and that absence is what the health signal already reports.
181
+ console.error(
182
+ `[tracking] publish-time id stamping skipped — the document is served unstamped: ${
183
+ error instanceof Error ? error.message : String(error)
184
+ }`,
185
+ );
186
+ return html;
187
+ }
162
188
  }
@@ -12,6 +12,7 @@ import { fromD1, type D1Like } from "@/lib/d1/catalog";
12
12
  import { readCurrentTenantConsentConfig } from "@/lib/apps/consentConfigRead";
13
13
  import { loadActiveManagedInstallDeclarations } from "@/lib/integration/managedInstallDeclarations";
14
14
  import { buildTenantConsentBroker, verifyPreviewToken } from "@tot/public-runtime";
15
+ import { tenantIsDeclaredPublic } from "@/config/tenants";
15
16
  import type {
16
17
  ConsentSnapshot,
17
18
  IntegrationDeclarationInput,
@@ -26,7 +27,6 @@ import {
26
27
  isAdminPath,
27
28
  sessionAdmitsHost,
28
29
  sessionHostCapability,
29
- PUBLIC_HOME_TENANT_ID,
30
30
  type GateMode,
31
31
  } from "@/lib/auth/loginGate";
32
32
  import { resolveAdminLanding, isAdminSelectPath } from "@/lib/auth/adminLanding";
@@ -64,15 +64,19 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
64
64
  const { request, locals } = context;
65
65
 
66
66
  // Login-gate enforcement (DEMO_GATE_MODE="login"): runs before the render so a
67
- // gated page is never served to an unauthenticated viewer. The platform's own
68
- // "self" home tenant is EXEMPT — it renders publicly (browse + sign up); every
69
- // other tenant on a gated host is withheld behind sign-in. A valid,
67
+ // gated page is never served to an unauthenticated viewer. A tenant that
68
+ // DECLARES `access.public` is EXEMPT — it renders publicly (browse + sign up);
69
+ // every other tenant on a gated host is withheld behind sign-in. A valid,
70
70
  // resource-bound tot_session sets locals.viewer (email + roles) for chrome;
71
71
  // otherwise an unauthenticated visitor gets a "Coming soon" holding page with
72
72
  // a Sign-in button (auth paths are exempt so sign-in itself is reachable). The
73
73
  // grant `resource` is the tenant's appDomain — never input.
74
74
  if (input.hostLoginGated && !input.internalContentGrant) {
75
- const tenantExempt = tenant.tenant_id === PUBLIC_HOME_TENANT_ID;
75
+ // From the platform registry by id, NOT off `tenant` — that object resolves
76
+ // from TENANT_CACHE KV first, so a record written before this field existed
77
+ // would silently gate a page the platform declares public, and a KV write
78
+ // must never be able to make a store public. See tenantIsDeclaredPublic.
79
+ const tenantExempt = tenantIsDeclaredPublic(tenant.tenant_id);
76
80
  // UNIFIED on the federated session: read the FULL session (no resource filter —
77
81
  // federated sign-in's aud is the storefront app, not this tenant) and admit the
78
82
  // viewer iff they hold a MEMBERSHIP for the host's tenant. One session model for
@@ -36,6 +36,7 @@ import { HEALTH_PATH, healthResponse } from "@/lib/health";
36
36
  import { observeDeployVersion } from "@/lib/activity/deployVersion";
37
37
  import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
38
38
  import {
39
+ platformStaticRootLocationV1,
39
40
  publicationRedirectLocationV1,
40
41
  resolveMiddlewareBranchV1,
41
42
  } from "./routeBranch.js";
@@ -245,7 +246,18 @@ export const onRequest = defineMiddleware(async (context, next) => {
245
246
  hasManagedInstalls: false,
246
247
  });
247
248
  if (bundled) return bundled;
248
- const asset = basePath ? await next(`${renderPath}${url.search}`) : await next();
249
+ const rootLocation = platformStaticRootLocationV1({
250
+ origin: url.origin,
251
+ basePath,
252
+ renderPath,
253
+ search: url.search,
254
+ });
255
+ if (rootLocation) {
256
+ const response = new Response(null, { status: 308, headers: { location: rootLocation } });
257
+ applySecurityHeaders(response.headers);
258
+ return response;
259
+ }
260
+ const asset = await next();
249
261
  applySecurityHeaders(asset.headers);
250
262
  return asset;
251
263
  }
@@ -51,6 +51,8 @@ import {
51
51
  resolveEnvironmentOnlyPlan,
52
52
  INTEGRATION_NONCE_PLACEHOLDER,
53
53
  defaultConsentSnapshot,
54
+ defaultAnalyticsConsentConfig,
55
+ buildTenantConsentBroker,
54
56
  exactPublicationTimestamp,
55
57
  parseConsentSnapshot,
56
58
  CONSENT_SNAPSHOT_INPUT_HEADER,
@@ -58,6 +60,7 @@ import {
58
60
  CONSENT_SNAPSHOT_HMAC_SECRET_BINDING,
59
61
  MATERIALIZE_MODE_BINDING,
60
62
  verifyConsentSnapshotSig,
63
+ resolveNativeGa4SandboxUrl,
61
64
  } from "@tot/public-runtime";
62
65
  import { readBakedManagedInstalls } from "@/lib/integration/bakedManagedInstalls";
63
66
  import type {
@@ -86,7 +89,11 @@ import {
86
89
  cspHeaderName,
87
90
  } from "@/lib/integration/browserEnvironment";
88
91
  import { freshNonce, stampResponseNonce } from "@/lib/integration/nonceStamp";
89
- import { resolveIntegrationRenderState } from "@/lib/integration/renderIntegrationState";
92
+ import { takePinnedRouteRewriteResponse } from "@/lib/pinnedRouteRewriteBoundary";
93
+ import {
94
+ composeIntegrationDeclarations,
95
+ resolveIntegrationRenderState,
96
+ } from "@/lib/integration/renderIntegrationState";
90
97
  import { recordActivity } from "@/lib/activity/recordActivity";
91
98
  import { middlewareRewriteTargetV1 } from "./routeBranch.js";
92
99
  import {
@@ -119,8 +126,8 @@ export interface RenderChainInput {
119
126
  managedIntegrationDeclarations: readonly IntegrationDeclarationInput[];
120
127
  /**
121
128
  * Reads the tenant's saved consent revision (D1). `null` on the public branch,
122
- * which falls back to the fail-closed essential-only default — the posture the
123
- * baked-snapshot path already documents for the public/materialize render.
129
+ * which falls back to the configured-analytics platform default when no saved
130
+ * tenant override exists.
124
131
  */
125
132
  readTenantConsentSnapshot: (() => Promise<ConsentSnapshot | null>) | null;
126
133
  /**
@@ -270,10 +277,10 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
270
277
  // is byte-identical to the non-integration form) and the environment-only plan is
271
278
  // resolved below exactly as before — zero cost.
272
279
  let resolvedBrowserEnvironment: BrowserEnvironment | null = null;
273
- const integrationDeclarations = [
274
- ...loadTenantIntegrationDeclarations(tenant),
275
- ...managedIntegrationDeclarations,
276
- ];
280
+ const integrationDeclarations = composeIntegrationDeclarations(
281
+ loadTenantIntegrationDeclarations(tenant),
282
+ managedIntegrationDeclarations,
283
+ );
277
284
  locals.integrationDeclarations = integrationDeclarations;
278
285
  if (integrationDeclarations.length > 0) {
279
286
  const declFeatures = resolveTenantFeatures(tenant);
@@ -304,8 +311,10 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
304
311
 
305
312
  // ---- Consent snapshot: the tenant's ACTUAL initial consent state, not a
306
313
  // hardcoded default. Both document mounts (Layout.astro, rawHtmlResponse) inline
307
- // THIS value; an unresolved tenant stays on the fail-closed essential-only default.
314
+ // THIS value. Configured analytics defaults on unless an authoritative saved or
315
+ // baked tenant consent snapshot overrides it.
308
316
  locals.consentSnapshot = defaultConsentSnapshot();
317
+ let tenantConsentResolved = false;
309
318
  // BAKED-SNAPSHOT INPUT (ADR 0013), honored ONLY on the materialize render, ONLY
310
319
  // under a valid tenant-bound signature. The go-live bake pipeline resolves the
311
320
  // tenant's REAL snapshot from D1 ONCE on the Worker, signs it, and provides
@@ -338,9 +347,20 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
338
347
  }
339
348
  if (bakedConsent) {
340
349
  locals.consentSnapshot = bakedConsent;
350
+ tenantConsentResolved = true;
341
351
  } else if (input.readTenantConsentSnapshot) {
342
352
  const saved = await input.readTenantConsentSnapshot();
343
- if (saved) locals.consentSnapshot = saved;
353
+ if (saved) {
354
+ locals.consentSnapshot = saved;
355
+ tenantConsentResolved = true;
356
+ }
357
+ }
358
+ if (
359
+ !tenantConsentResolved &&
360
+ integrationDeclarations.some((declaration) =>
361
+ declaration.manifest.browser.consent.purposes.includes("analytics"))
362
+ ) {
363
+ locals.consentSnapshot = buildTenantConsentBroker(defaultAnalyticsConsentConfig()).getSnapshot();
344
364
  }
345
365
 
346
366
  // ---- Edge page cache: short-circuit production browse reads on a hit,
@@ -523,7 +543,10 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
523
543
  // matching nonce/CSP pair.
524
544
  const cspNonce = INTEGRATION_NONCE_PLACEHOLDER;
525
545
 
526
- locals.tenant = tenant;
546
+ // Components read non-CSS brand choices (for example hero/header variants)
547
+ // from the tenant object. Expose the same effective theme that produced the
548
+ // CSS variables so a reconciled theme.json controls both surfaces.
549
+ locals.tenant = { ...tenant, theme_tokens: theme };
527
550
  // One immutable clock for every visibility consumer in this render. Selected
528
551
  // Worker content uses its audited pointer transition; materialization uses the
529
552
  // signed build input; an ordinary legacy request has no clock and schedules stay hidden.
@@ -621,14 +644,25 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
621
644
  basePath,
622
645
  renderPath,
623
646
  search: url.search,
647
+ // Astro re-runs this middleware for its error route, against the ORIGINAL
648
+ // request. Rewriting there discards the route it already chose.
649
+ routePattern: context.routePattern,
624
650
  });
625
651
  let response = rewriteTarget !== undefined
626
652
  ? await next(rewriteTarget)
627
653
  : await next();
628
654
 
655
+ // A pinned generic-route fallback uses `context.rewrite()`, which runs the
656
+ // rewritten route through this middleware and hands back a fully decorated,
657
+ // CSP-stamped response. Preserve that inner response verbatim. Running the
658
+ // outer decoration path again would inject preview helpers twice and replace
659
+ // the CSP nonce without replacing the already-live nonce in the document.
660
+ const pinnedRewriteResponse = takePinnedRouteRewriteResponse(response);
661
+ if (pinnedRewriteResponse) return pinnedRewriteResponse;
662
+
629
663
  // On-publish auto-refresh: inject the live-reload client into SHARED preview
630
664
  // (`test`) HTML pages ONLY — never `live` (keeps live clean + edge-cacheable).
631
- // It polls /__preview/version and reloads when this tenant's cache-version
665
+ // It polls /api/preview/version and reloads when this tenant's cache-version
632
666
  // bumps on publish/reconcile. Injection happens before the CSP header is set so
633
667
  // the script's per-request nonce (cspNonce) matches the enforced policy.
634
668
  response = await maybeInjectPreviewReload(response, {
@@ -717,6 +751,15 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
717
751
  ...widgetCsp.frameSrc,
718
752
  ...embedCsp.frameSrc,
719
753
  ],
754
+ // The native GA4 sandbox mounts on this tenant's OTHER apex/www host (see
755
+ // resolveNativeGa4SandboxUrl) so gtag.js runs on a real, non-opaque origin.
756
+ // frame-src (above) lets this page frame that sandbox; frame-ancestors is
757
+ // evaluated by the FRAMED document, so the sandbox origin must separately be
758
+ // allowed to frame this one back, or the site's own strict `frame-ancestors
759
+ // 'self'` blocks its own sandbox (apex and www are different origins).
760
+ // Derived from this request's own hostname — never a hardcoded tenant literal
761
+ // — so it's correct for every custom-domain tenant, not just one (ADR 0032).
762
+ frameAncestors: [new URL(resolveNativeGa4SandboxUrl(url.href)).origin],
720
763
  formAction: checkoutCsp?.formAction ?? [],
721
764
  reportGroup: CSP_REPORT_GROUP,
722
765
  reportUri: CSP_REPORT_PATH,
@@ -15,10 +15,9 @@
15
15
  * - {@link PLATFORM_STATIC_ASSET_PREFIXES} — built and public/ bytes. Identical
16
16
  * for every visitor, no tenant state at all. Serving them through origin
17
17
  * compute is exactly what ADR 0013 exists to prevent.
18
- * - {@link PLATFORM_PUBLIC_DISCOVERY_PATHS} — robots/sitemap/llms. Anonymous-
19
- * identical, but rendered from the tenant's own catalog + content, so they
20
- * are not raw bytes; they are ordinary Tier 0 public output that happens to
21
- * live on a reserved mount.
18
+ * - {@link PLATFORM_PUBLIC_DISCOVERY_PATHS} — anonymous-identical discovery
19
+ * output. Production serves materialized bytes from content-addressed
20
+ * storage; local and preview rendering keeps the same Tier 0 route shape.
22
21
  * - {@link PLATFORM_PUBLIC_APP_ROUTE_PREFIXES} — the app's own catalog browse
23
22
  * pages. Reserved so a tenant mount cannot claim them, which is a different
24
23
  * question from who may read them: they are the storefront's most public
@@ -27,6 +26,7 @@
27
26
  * Contract: docs/architecture/decisions/0013-public-serving-contract.md
28
27
  */
29
28
  import {
29
+ PUBLICATION_DISCOVERY_OUTPUT_PATHS_V1,
30
30
  resolvePublicationRoutePlanV1,
31
31
  type PublicationRoutePlanV1,
32
32
  type PublicationRouteRedirectPlanV1,
@@ -50,6 +50,7 @@ export type MiddlewareBranchV1 = "static-passthrough" | "public-render" | "contr
50
50
  * front of bytes that never vary.
51
51
  */
52
52
  export const PLATFORM_STATIC_ASSET_PREFIXES = [
53
+ "/_a",
53
54
  "/_astro",
54
55
  "/apps",
55
56
  "/assets",
@@ -61,15 +62,13 @@ export const PLATFORM_STATIC_ASSET_PREFIXES = [
61
62
  ] as const;
62
63
 
63
64
  /**
64
- * Per-tenant discovery documents. Every one is generated from `locals.tot` +
65
- * `locals.content` (see src/pages/robots.txt.ts, sitemap.xml.ts, llms.txt.ts),
66
- * so they need the public render chain — they are not static bytes and cannot be
67
- * passed through.
65
+ * Per-tenant public discovery documents. Production publication materializes
66
+ * these routes into content-addressed storage; local and preview rendering use
67
+ * the public render chain. They never enter the authorization control plane.
68
68
  */
69
69
  export const PLATFORM_PUBLIC_DISCOVERY_PATHS = [
70
- "/llms.txt",
70
+ ...PUBLICATION_DISCOVERY_OUTPUT_PATHS_V1,
71
71
  "/robots.txt",
72
- "/sitemap.xml",
73
72
  ] as const;
74
73
 
75
74
  /**
@@ -105,7 +104,8 @@ export function isPlatformStaticAssetPath(renderPath: string): boolean {
105
104
  }
106
105
 
107
106
  export function isPlatformPublicDiscoveryPath(renderPath: string): boolean {
108
- return PLATFORM_PUBLIC_DISCOVERY_PATHS.some((entry) => matchesPathOrPrefix(renderPath, entry));
107
+ const caseFolded = renderPath.toLowerCase();
108
+ return PLATFORM_PUBLIC_DISCOVERY_PATHS.some((entry) => caseFolded === entry);
109
109
  }
110
110
 
111
111
  export function isPlatformPublicAppRoutePath(renderPath: string): boolean {
@@ -142,18 +142,64 @@ export interface ResolvedMiddlewareBranchV1 {
142
142
  publication: PublicationRoutePlanV1;
143
143
  }
144
144
 
145
- /** Astro rewrite target for tenant-prefix stripping or canonical render aliases. */
145
+ /**
146
+ * Astro route patterns the error pipeline pins. When Astro renders one of these it
147
+ * has ALREADY chosen the route, and re-runs this middleware against the ORIGINAL
148
+ * request — same URL, so the same tenant prefix, so the same rewrite target.
149
+ * Rewriting then throws that choice away and sends the render back to the
150
+ * catch-all, which 404s again with a null body. The error page never renders.
151
+ */
152
+ const ASTRO_ERROR_ROUTE_PATTERNS: readonly string[] = ["/404", "/500"];
153
+
154
+ /** True when Astro is rendering its error route, not the request's own route. */
155
+ export function isAstroErrorRouteV1(routePattern: string | undefined): boolean {
156
+ return routePattern !== undefined && ASTRO_ERROR_ROUTE_PATTERNS.includes(routePattern);
157
+ }
158
+
159
+ /**
160
+ * Astro rewrite target for tenant-prefix stripping or canonical render aliases.
161
+ *
162
+ * Returns `undefined` — do not rewrite — while the error route is rendering.
163
+ * Astro's error handler builds a state pinned to `/404` but carrying the ORIGINAL
164
+ * pathname, then re-runs middleware over it; a prefix rewrite there re-dispatches
165
+ * to the tenant path, which resolves to the catch-all and answers 404 with a null
166
+ * body. The merged result is a zero-byte 404: every path-routed 404 on this
167
+ * deployment, while the same 404 host-routed returns the full page.
168
+ */
146
169
  export function middlewareRewriteTargetV1(input: {
147
170
  requestPath: string;
148
171
  basePath: string;
149
172
  renderPath: string;
150
173
  search: string;
174
+ /** `context.routePattern` — the route Astro is currently rendering. */
175
+ routePattern?: string;
151
176
  }): string | undefined {
177
+ if (isAstroErrorRouteV1(input.routePattern)) return undefined;
152
178
  return input.basePath !== "" || input.renderPath !== input.requestPath
153
179
  ? `${input.renderPath}${input.search}`
154
180
  : undefined;
155
181
  }
156
182
 
183
+ /**
184
+ * Where a path-routed platform static request belongs: its root-served URL.
185
+ *
186
+ * Platform bytes live at the origin root under every routing mode, and the
187
+ * platform's static handler answers them only there. `/<tenant>/shared/x.css` is
188
+ * not a file, and an Astro rewrite to `/shared/x.css` does not reach the static
189
+ * handler either: rewrites dispatch through the ROUTER, whose only match is the
190
+ * `[...slug]` catch-all. So the prefixed request is redirected to the root, which
191
+ * is correct on every substrate. Returns `undefined` when host-routed.
192
+ */
193
+ export function platformStaticRootLocationV1(input: {
194
+ origin: string;
195
+ basePath: string;
196
+ renderPath: string;
197
+ search: string;
198
+ }): string | undefined {
199
+ if (!input.basePath) return undefined;
200
+ return `${input.origin}${input.renderPath}${input.search}`;
201
+ }
202
+
157
203
  /** Absolute 308 location for host- and path-routed publication aliases. */
158
204
  export function publicationRedirectLocationV1(input: {
159
205
  origin: string;
@@ -9,7 +9,12 @@
9
9
  */
10
10
  import { readR2 } from "@/lib/runtimeEnv";
11
11
  import { getCachedPage, putCachedPage, staticBundleCacheKey } from "@/lib/edgeCache";
12
- import { resolveChannelDigest, serveStaticBundle } from "@tot/public-runtime";
12
+ import {
13
+ resolveChannelDigest,
14
+ serveStaticBundle,
15
+ serveVersionIndependentAsset,
16
+ versionIndependentAssetKey,
17
+ } from "@tot/public-runtime";
13
18
  import type { StaticBundleR2BucketLike, TenantConfig } from "@tot/public-runtime";
14
19
 
15
20
  export interface StaticBundleAttemptInput {
@@ -46,11 +51,38 @@ export async function tryServeStaticBundle(
46
51
  input: StaticBundleAttemptInput,
47
52
  ): Promise<Response | null> {
48
53
  const { request, url, tenant, cache, kv } = input;
54
+ const method = request.method.toUpperCase();
55
+
56
+ // Version-independent assets (`/_a/<sha256>`, `/_astro/<name>`) are exact addresses: the same
57
+ // bytes for every viewer, preview or not, whatever the live pointer says. A page from an
58
+ // older version — or a preview of one — keeps resolving its own sub-resources.
59
+ if ((method === "GET" || method === "HEAD") && versionIndependentAssetKey(url.pathname)) {
60
+ const bucket = await readR2("STATIC_BUNDLES");
61
+ if (bucket) {
62
+ const pinnedKey = cache && method === "GET"
63
+ ? staticBundleCacheKey(tenant.tenant_id, "pinned", url.pathname, "")
64
+ : null;
65
+ if (pinnedKey && cache) {
66
+ const hit = await getCachedPage(cache, pinnedKey);
67
+ if (hit) return hit;
68
+ }
69
+ const pinned = await serveVersionIndependentAsset(
70
+ bucket as unknown as StaticBundleR2BucketLike,
71
+ url.pathname,
72
+ method,
73
+ );
74
+ if (pinned) {
75
+ if (pinnedKey && cache && pinned.ok) await putCachedPage(cache, pinnedKey, pinned);
76
+ return pinned;
77
+ }
78
+ }
79
+ }
80
+
49
81
  const eligible =
50
82
  tenant.staticPublish != null &&
51
83
  tenant.staticPublish.enabled !== false &&
52
84
  input.basePath === "" &&
53
- request.method.toUpperCase() === "GET" &&
85
+ (method === "GET" || method === "HEAD") &&
54
86
  !input.isPreviewHost &&
55
87
  !input.hasPreviewToken &&
56
88
  !input.hasCandidatePin &&
@@ -80,7 +112,7 @@ export async function tryServeStaticBundle(
80
112
  return null;
81
113
  }
82
114
 
83
- const bundleCacheKey = cache
115
+ const bundleCacheKey = cache && method === "GET"
84
116
  ? staticBundleCacheKey(tenant.tenant_id, digest, url.pathname, url.search)
85
117
  : null;
86
118
  if (bundleCacheKey && cache) {
@@ -98,6 +130,8 @@ export async function tryServeStaticBundle(
98
130
  },
99
131
  },
100
132
  url.pathname,
133
+ url.search,
134
+ method,
101
135
  );
102
136
  // `null` = the manifest proves this path is outside the bundle's static surface
103
137
  // (an app surface like /admin) — the caller owns it.
@@ -11,7 +11,8 @@ import ProseSections from "@/components/content/ProseSections.astro";
11
11
  import ProseToc from "@/components/content/ProseToc.astro";
12
12
  import { breadcrumbLd } from "@/lib/jsonld";
13
13
  import { truncate } from "@/lib/format";
14
- import { rawHtmlPolicy, rawHtmlResponse } from "@/lib/rawMarketingHtml";
14
+ import { rawHtmlPolicy, rawHtmlResponse, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml";
15
+ import { markPinnedRouteRewriteComplete } from "@/lib/pinnedRouteRewriteBoundary";
15
16
 
16
17
  const { tenant, canonicalBase, content, basePath } = Astro.locals;
17
18
  const slug = (Astro.params.slug ?? "").replace(/^\/+|\/+$/g, "");
@@ -32,7 +33,9 @@ if (
32
33
  // dir, so the pages live under `/style-guide/…` and we rewrite the underscored URL
33
34
  // onto them. These are noindex + sitemap-excluded and never reskin the live site.
34
35
  if (slug === "_style-guide" || slug.startsWith("_style-guide/")) {
35
- return Astro.rewrite("/style-guide/" + slug.slice("_style-guide".length + 1));
36
+ return markPinnedRouteRewriteComplete(
37
+ await Astro.rewrite("/style-guide/" + slug.slice("_style-guide".length + 1)),
38
+ );
36
39
  }
37
40
 
38
41
  // Raw tenant-authored marketing subpage (vendor-injected HTML) — serve the
@@ -50,6 +53,7 @@ if (rawHtml && rawHtmlPolicy(tenant).allowed) {
50
53
  chromeWrapper,
51
54
  plan: Astro.locals.integrationPlan,
52
55
  consentSnapshot: Astro.locals.consentSnapshot,
56
+ canonicalUrl: rawPageCanonicalUrl(canonicalBase, slug),
53
57
  });
54
58
  }
55
59
 
@@ -1,7 +1,7 @@
1
1
  /**
2
- * POST /api/request-access — early-access lead capture for the `home` landing page.
2
+ * POST /api/request-access — early-access lead capture for the storefront landing page.
3
3
  *
4
- * The browser form (tenants/home) POSTs here same-origin (CSP `connect-src 'self'`
4
+ * The browser form (tenants/storefront.tokenoftrust.store) POSTs here same-origin (CSP `connect-src 'self'`
5
5
  * + `form-action 'self'`). This route forwards the lead to tot20's generic
6
6
  * messaging endpoint SERVER-SIDE — the storefront's ToT credentials never reach
7
7
  * the browser. The message emails the Trello Success board so a card is created.
@@ -18,7 +18,7 @@
18
18
  * the server-side send just works and no mailto is returned.
19
19
  *
20
20
  * Deliberately NOT gated on `context.locals.features.*` — unlike newsletter.ts,
21
- * this must work on the `home` marketing tenant.
21
+ * this must work on the storefront marketing tenant.
22
22
  */
23
23
  import type { APIContext } from "astro";
24
24
  import { readEnv } from "@/lib/env";
@@ -26,7 +26,8 @@ import { readEnv } from "@/lib/env";
26
26
  export const prerender = false;
27
27
 
28
28
  // Trello Success board — a card is created from the forwarded email.
29
- // Kept in sync with the mailto: fallback in tenants/home/public/js/storefront.js.
29
+ // Kept in sync with the mailto: fallback in
30
+ // tenants/storefront.tokenoftrust.store/public/js/storefront.js.
30
31
  const BOARD_EMAIL = "darrin84+l6gajmvvoo1x4edew5qs@boards.trello.com";
31
32
 
32
33
  // Permissive email shape — tot20 / the mail system is the real deliverability authority.
@@ -11,12 +11,22 @@ import { buildAlternates } from "@/lib/seo/alternates.js";
11
11
  import { languageLabel } from "@/lib/i18n.js";
12
12
  import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
13
13
 
14
+ // These redirects also exist in the published artifact: the publication composition lifts this
15
+ // file into the space's `routes.aliases`, which reach the CDN plane through the live pointer. The
16
+ // redirect below is the pointer-served (`worker-r2-pointer`) plane's IMPLEMENTATION of those same
17
+ // declared redirects — that plane resolves routing from tenant config, so without it a renamed
18
+ // slug would redirect on the CDN plane and 404 here. The materialization rail verifies this names
19
+ // a source the composition actually materializes.
20
+ export const MATERIALIZED_REDIRECT_SOURCE = "content/blog/redirects.json";
21
+
14
22
  const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
15
23
  const slug = Astro.params.slug ?? "";
16
24
  const post = await getBlogPost(tenant.tenant_id, slug, content, publicationTimestamp);
17
25
  if (!post) {
18
26
  const redirect = await resolveBlogRedirect(tenant.tenant_id, slug, content);
19
- if (redirect) return Astro.redirect(`${basePath}/blog/${redirect}/`, 301);
27
+ // 308, matching the `redirect-308` alias contract the published plane serves, so a renamed slug
28
+ // behaves identically on both substrates.
29
+ if (redirect) return Astro.redirect(`${basePath}/blog/${redirect}/`, 308);
20
30
  return new Response(null, { status: 404 });
21
31
  }
22
32
 
@@ -0,0 +1,23 @@
1
+ import type { APIRoute } from "astro";
2
+ import { managedCartHref } from "@/lib/chrome/managedCart.js";
3
+
4
+ export const prerender = false;
5
+
6
+ /**
7
+ * Backward-compatible entry for bookmarked or tenant-authored `/cart` links.
8
+ * Chrome now links straight to managed checkout, but this route prevents stale
9
+ * links from falling through the content catch-all to a misleading storefront
10
+ * 404. Tenant resolution and preview authorization have already run in
11
+ * middleware before this redirect is selected.
12
+ */
13
+ export const GET: APIRoute = ({ locals }) => {
14
+ const location = managedCartHref(locals.commerce);
15
+ if (!location) return new Response(null, { status: 404 });
16
+ return new Response(null, {
17
+ status: 302,
18
+ headers: {
19
+ location,
20
+ "cache-control": "private, no-store",
21
+ },
22
+ });
23
+ };