@tokenoftrust/storefront-runner 2.0.22 → 2.0.24

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.
@@ -58,6 +58,7 @@ import preact from "@astrojs/preact";
58
58
  import tailwindcss from "@tailwindcss/vite";
59
59
  import { tenantHotReload } from "./dev-plugins/tenant-hot-reload.mjs";
60
60
  import { materializeGuard } from "./integrations/materialize-guard.mjs";
61
+ import { lifecycleHostAsset } from "./integrations/lifecycle-host-asset.mjs";
61
62
 
62
63
  // SSR on Cloudflare Workers. `output: 'server'` because every response is
63
64
  // tenant-resolved at the edge from the Host header (see src/middleware).
@@ -94,6 +95,10 @@ export default defineConfig({
94
95
  // resolves from KV/R2 at request time. The same integration becomes strict
95
96
  // only when TOT_BUILD_TENANTS names a per-tenant static publish input.
96
97
  materializeGuard(),
98
+ // The document.deferred lifecycle host, emitted as one self-contained module at a
99
+ // stable same-origin URL so BOTH document mounts load the same boot — the raw
100
+ // marketing mount has no bundler to hoist a module import through.
101
+ lifecycleHostAsset(),
97
102
  preact({ compat: true }),
98
103
  // Astro's file-based router ignores ANY path segment starting with "_" (see
99
104
  // `astro/dist/core/routing/create-manifest.js`), so a page literally named
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Emit the document.deferred lifecycle host as ONE self-contained module at a stable
3
+ * same-origin URL, so both document mounts can load the same host.
4
+ *
5
+ * WHY NOT A ROLLUP CHUNK. `Layout.astro` gets its client code through Astro's bundler,
6
+ * which hashes filenames and splits shared modules into sibling chunks. The raw marketing
7
+ * mount can use neither: it serves a tenant's own document verbatim, so it needs a URL it
8
+ * can hardcode. And a SPLIT bundle is actively unsafe here — a published static bundle
9
+ * (`scripts/publish/materialize-tenant.mjs`) copies only what the rendered HTML references
10
+ * and follows CSS `url()` but NOT JavaScript imports, so a sibling chunk would resolve on
11
+ * the Cloudflare plane and 404 on the S3/CloudFront one. That asymmetry is exactly the
12
+ * both-CDNs failure this repo treats as an invariant, and nothing downstream would report
13
+ * it: validate-bundle only checks HTML-referenced paths.
14
+ *
15
+ * So the host is bundled ONCE by esbuild with no code splitting, and this REFUSES to emit
16
+ * anything carrying an import. A single import-free file behaves identically on both
17
+ * providers by construction.
18
+ *
19
+ * It is an Astro INTEGRATION rather than a bare Vite plugin because Astro is the only
20
+ * thing that knows where the client output actually goes: every one of Astro's build
21
+ * passes — the client pass included — runs with `build.ssr === true`, so a Vite plugin
22
+ * cannot tell them apart, while `config.build.client` says it outright.
23
+ */
24
+ import { mkdir, writeFile } from "node:fs/promises";
25
+ import { createRequire } from "node:module";
26
+ import { dirname } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { LIFECYCLE_HOST_ASSET_PATH } from "@tot/public-runtime/integration/lifecycle-host-asset";
29
+
30
+ const HOST_ENTRY = fileURLToPath(
31
+ new URL(
32
+ "../../../packages/public-runtime/src/integration/lifecycle-host-entry.ts",
33
+ import.meta.url,
34
+ ),
35
+ );
36
+
37
+ /**
38
+ * Bundle the host to a single self-contained ES module.
39
+ *
40
+ * esbuild is resolved through `astro` (a direct dependency of this app, always present
41
+ * whenever this integration runs) rather than declared again here, so its version cannot
42
+ * drift from the one the build already uses.
43
+ * @returns {Promise<string>}
44
+ */
45
+ export async function bundleLifecycleHost() {
46
+ const require = createRequire(import.meta.url);
47
+ const esbuild = createRequire(require.resolve("astro/package.json"))("esbuild");
48
+ const result = await esbuild.build({
49
+ entryPoints: [HOST_ENTRY],
50
+ bundle: true,
51
+ write: false,
52
+ metafile: true,
53
+ format: "esm",
54
+ platform: "browser",
55
+ target: "es2022",
56
+ minify: true,
57
+ treeShaking: true,
58
+ legalComments: "none",
59
+ sourcemap: false,
60
+ logLevel: "silent",
61
+ });
62
+ if (result.outputFiles.length !== 1) {
63
+ throw new Error(
64
+ `The lifecycle host bundled to ${result.outputFiles.length} files; it must be one ` +
65
+ `self-contained module (a sibling chunk 404s on the S3/CloudFront plane).`,
66
+ );
67
+ }
68
+ // The metafile is authoritative about imports in a way a regex over minified code is
69
+ // not: any entry here is a file the browser would have to fetch separately.
70
+ for (const output of Object.values(result.metafile.outputs)) {
71
+ const external = (output.imports ?? []).filter((i) => i.kind !== "file-loader");
72
+ if (external.length > 0) {
73
+ throw new Error(
74
+ `The lifecycle host bundle imports ${external.map((i) => i.path).join(", ")}; it ` +
75
+ `must carry no imports, because the static publish pipeline does not follow them.`,
76
+ );
77
+ }
78
+ }
79
+ return result.outputFiles[0].text;
80
+ }
81
+
82
+ /**
83
+ * @returns {import("astro").AstroIntegration}
84
+ */
85
+ export function lifecycleHostAsset() {
86
+ /** @type {URL | null} */
87
+ let clientDir = null;
88
+
89
+ return {
90
+ name: "tot-lifecycle-host-asset",
91
+ hooks: {
92
+ "astro:config:done": ({ config }) => {
93
+ clientDir = config.build.client;
94
+ },
95
+
96
+ // Dev serves it from memory: the raw marketing mount bypasses Astro's pipeline
97
+ // entirely, so without this the host 404s in `astro dev` and `tot dev` on exactly
98
+ // the pages this asset exists to cover. Rebundled per request (esbuild is fast) so
99
+ // an edit to the runtime shows up on reload rather than being pinned to server boot.
100
+ "astro:server:setup": ({ server }) => {
101
+ server.middlewares.use((req, res, next) => {
102
+ const path = (req.url ?? "").split("?")[0];
103
+ if (path !== `/${LIFECYCLE_HOST_ASSET_PATH}`) return next();
104
+ bundleLifecycleHost().then(
105
+ (code) => {
106
+ res.setHeader("content-type", "text/javascript; charset=utf-8");
107
+ res.setHeader("cache-control", "no-store");
108
+ res.end(code);
109
+ },
110
+ (error) => next(error),
111
+ );
112
+ });
113
+ },
114
+
115
+ "astro:build:done": async ({ logger }) => {
116
+ if (!clientDir) throw new Error("the lifecycle host has no resolved client output dir");
117
+ const code = await bundleLifecycleHost();
118
+ const target = fileURLToPath(new URL(LIFECYCLE_HOST_ASSET_PATH, clientDir));
119
+ await mkdir(dirname(target), { recursive: true });
120
+ await writeFile(target, code, "utf8");
121
+ logger.info(`lifecycle host → /${LIFECYCLE_HOST_ASSET_PATH} (${code.length} bytes)`);
122
+ },
123
+ },
124
+ };
125
+ }
@@ -618,16 +618,14 @@ const footerWidgets = await renderWidgetSlot(
618
618
  entry in lib/cspStaticHashes.ts. */}
619
619
  <script is:inline nonce={cspNonce} set:html={INTERACTION_TRACKING_BOOT_JS}></script>
620
620
 
621
- {/* document.deferred global-entry boundary (ext-kernel-04) — the last phase,
622
- the renderer-owned mount point for the deferred integration entry. */}
621
+ {/* document.deferred global-entry boundary (ext-kernel-04) — the last phase, the
622
+ renderer-owned mount point for the deferred integration entry. This region also
623
+ CARRIES the lifecycle host tag (ext-kernel-05), which initializes the plan's
624
+ integrations exactly once per document, emits the governed page view, wires the
625
+ reserved completed-navigation event, and disposes on teardown; a no-op for an
626
+ empty plan. It ships with the phase markup rather than as a per-mount addition,
627
+ so the same bytes reach the raw marketing mount — which serves a tenant's
628
+ document verbatim and has no bundler to hoist a module import through. */}
623
629
  <Fragment set:html={integrationPhases.bodyDeferred} />
624
-
625
- {/* document.deferred lifecycle boot (ext-kernel-05) — initializes the plan's
626
- integrations exactly once per document, wires the reserved completed-
627
- navigation event, and disposes on teardown; a no-op for an empty plan. */}
628
- <script>
629
- import { bootIntegrationLifecycleFromDocument } from "@tot/public-runtime";
630
- bootIntegrationLifecycleFromDocument();
631
- </script>
632
630
  </body>
633
631
  </html>
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Viewer ship-grant resolver (security-critical) — resolve a signed-in developer
3
+ * VIEWER's REAL, owner-revocable ship capability over a tenant, LIVE from the MCP
4
+ * subject-capability store. This is the near-side read of the Step-1 tot-mcp seam:
5
+ *
6
+ * GET `${origin}/api/internal/dev-access/subject-capability?subject=<email>&tenant=<appDomain>&app=storefront`
7
+ * Auth: header `x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>` (NOT a bearer)
8
+ * → 200 { capability: "ship-on-behalf" | "preview-access" }
9
+ * · an ABSENT / REVOKED grant answers `preview-access`
10
+ * · a store fault answers 502
11
+ *
12
+ * WHY it exists (the security constraint): a normally-invited developer's COARSE
13
+ * role is `preview-access`, NOT ship — the coarse "developer" role collapses to
14
+ * member. ship-on-behalf is a SEPARATE, explicit, OWNER-REVOCABLE grant. So the
15
+ * viewer/browser ship path must NEVER map the coarse role → ship and must NEVER
16
+ * trust a client-supplied `x-tot-capability` header; it resolves the REAL grant
17
+ * here, LIVE (no cache that could outlive a revoke).
18
+ *
19
+ * FAIL-CLOSED, TOTAL, NEVER THROWS: an unconfigured seam (no URL/secret), a missing
20
+ * subject/tenant, any non-2xx (INCLUDING 502), an unparseable body, an unexpected
21
+ * capability value, or a transport/timeout fault ALL resolve to `preview-access` —
22
+ * the deny default. Only an explicit `200 { capability: "ship-on-behalf" }` grants
23
+ * ship. This mirrors `dev/previewStatus.ts` + `grants/developerInvites.ts`'s
24
+ * `recordDeveloperAppBinding` transport (same MCP-origin + service-secret seam);
25
+ * it keeps its OWN small transport rather than importing another cluster's copy so
26
+ * the two stay decoupled (the same reason previewStatus duplicates its transport).
27
+ *
28
+ * The MCP HOST is the ORIGIN of `MCP_APP_BINDING_URL` — the same tier the invite
29
+ * app-binding WRITE (`recordDeveloperAppBinding`) targets, so this READ and that
30
+ * WRITE always agree on which subject-capability store they speak to.
31
+ */
32
+ import { readEnv } from "@/lib/env";
33
+ import type { Capability } from "./capability.js";
34
+
35
+ /** Minimal GET transport (mirrors `dev/previewStatus.ts` — decoupled by design). */
36
+ export interface ViewerShipGrantGetTransport {
37
+ get(
38
+ url: string,
39
+ headers: Record<string, string>,
40
+ ): Promise<{ status: number; json(): Promise<unknown> }>;
41
+ }
42
+
43
+ /** Default transport: JSON `fetch` GET. */
44
+ export class FetchShipGrantTransport implements ViewerShipGrantGetTransport {
45
+ async get(url: string, headers: Record<string, string>) {
46
+ // Bounded: this GET runs during admin-page SSR (AdminPublishTab frontmatter, on
47
+ // every admin tab), so an unbounded hang stalls the whole document stream. A
48
+ // timeout aborts to the documented deny-default (the resolver maps any transport/
49
+ // timeout fault to `preview-access`) — never a render-blocking wait on a slow MCP.
50
+ const res = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(2500) });
51
+ return { status: res.status, json: () => res.json() };
52
+ }
53
+ }
54
+
55
+ /** Injectable deps so the resolver is unit-testable without a live MCP. */
56
+ export interface ViewerShipGrantDeps {
57
+ transport?: ViewerShipGrantGetTransport;
58
+ }
59
+
60
+ /** The Step-1 subject-capability reader path (mounted OUTSIDE the session guard). */
61
+ const SUBJECT_CAPABILITY_PATH = "/api/internal/dev-access/subject-capability";
62
+
63
+ /** The viewer's blog-contribute capability — a NEW, blog-scoped projection kept OUT
64
+ * of the forge `Capability` union (which stays exactly two rungs). `ship-on-behalf`
65
+ * supersets contribute, so a ship grant also yields `"blog-contribute"` here. */
66
+ export type ViewerContributeCapability = "preview-access" | "blog-contribute";
67
+
68
+ /** The raw recognized capability read off the subject-capability seam. The private
69
+ * transport whitelists BOTH grant literals; the public wrappers narrow it. */
70
+ type RawViewerCapability = "preview-access" | "blog-contribute" | "ship-on-behalf";
71
+
72
+ /**
73
+ * ONE live subject-capability lookup, returning the raw whitelisted capability.
74
+ * FAIL-CLOSED, TOTAL, NEVER THROWS: an unconfigured seam (no URL/secret), a missing
75
+ * subject/tenant, any non-2xx (INCLUDING 502), an unparseable body, an unexpected
76
+ * capability value, or a transport/timeout fault ALL resolve to `"preview-access"` —
77
+ * the deny default. Only an explicit `200 { capability: "ship-on-behalf" }` or
78
+ * `200 { capability: "blog-contribute" }` returns a grant literal.
79
+ */
80
+ async function resolveRawViewerCapability(
81
+ email: string | null | undefined,
82
+ appDomain: string | null | undefined,
83
+ deps: ViewerShipGrantDeps = {},
84
+ ): Promise<RawViewerCapability> {
85
+ const subject = (email ?? "").trim().toLowerCase();
86
+ const tenant = (appDomain ?? "").trim();
87
+ if (!subject || !tenant) return "preview-access";
88
+
89
+ const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
90
+ const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
91
+ // Unconfigured seam → deny (the MCP route is only mounted once the secret exists).
92
+ if (!bindingUrl || !secret) return "preview-access";
93
+
94
+ let endpoint: string;
95
+ try {
96
+ endpoint = `${new URL(bindingUrl).origin}${SUBJECT_CAPABILITY_PATH}`;
97
+ } catch {
98
+ return "preview-access";
99
+ }
100
+ const full =
101
+ `${endpoint}?subject=${encodeURIComponent(subject)}` +
102
+ `&tenant=${encodeURIComponent(tenant)}&app=storefront`;
103
+
104
+ const transport = deps.transport ?? new FetchShipGrantTransport();
105
+ let res: { status: number; json(): Promise<unknown> };
106
+ try {
107
+ res = await transport.get(full, { "x-mcp-service-secret": secret });
108
+ } catch {
109
+ // Transport / timeout fault — fail closed.
110
+ return "preview-access";
111
+ }
112
+ // Any non-2xx (incl. 502 store fault, 401 unmounted, 404) → deny.
113
+ if (res.status < 200 || res.status >= 300) return "preview-access";
114
+
115
+ let payload: unknown;
116
+ try {
117
+ payload = await res.json();
118
+ } catch {
119
+ return "preview-access";
120
+ }
121
+ const capability = (payload as Record<string, unknown> | null)?.capability;
122
+ // Whitelist: ONLY the explicit grant literals admit; anything else is deny-default.
123
+ if (capability === "ship-on-behalf") return "ship-on-behalf";
124
+ if (capability === "blog-contribute") return "blog-contribute";
125
+ return "preview-access";
126
+ }
127
+
128
+ /**
129
+ * Resolve the viewer's LIVE ship capability over `appDomain`. Returns the granted
130
+ * `Capability` — `"ship-on-behalf"` ONLY on an explicit `200 { capability:
131
+ * "ship-on-behalf" }`; EVERYTHING else (unconfigured, absent, 502, timeout,
132
+ * malformed, unexpected value) fail-closes to `"preview-access"`. Never throws.
133
+ *
134
+ * @param email the signed-in viewer's email (the subject-capability store key)
135
+ * @param appDomain the resolved tenant the capability is scoped to (server-derived)
136
+ */
137
+ export async function resolveViewerShipCapability(
138
+ email: string | null | undefined,
139
+ appDomain: string | null | undefined,
140
+ deps: ViewerShipGrantDeps = {},
141
+ ): Promise<Capability> {
142
+ const raw = await resolveRawViewerCapability(email, appDomain, deps);
143
+ return raw === "ship-on-behalf" ? "ship-on-behalf" : "preview-access";
144
+ }
145
+
146
+ /**
147
+ * Resolve the viewer's LIVE blog-contribute capability over `appDomain`, from the
148
+ * SAME subject-capability seam the ship resolver reads. Returns `"blog-contribute"`
149
+ * ONLY on an explicit `200 { capability: "blog-contribute" }` OR `200 { capability:
150
+ * "ship-on-behalf" }` (ship supersets contribute); EVERYTHING else (unconfigured,
151
+ * absent, 502, timeout, malformed, unexpected value) fail-closes to
152
+ * `"preview-access"`. Never throws. Mirrors `resolveViewerShipCapability` exactly.
153
+ *
154
+ * @param email the signed-in viewer's email (the subject-capability store key)
155
+ * @param appDomain the resolved tenant the capability is scoped to (server-derived)
156
+ */
157
+ export async function resolveViewerContributeCapability(
158
+ email: string | null | undefined,
159
+ appDomain: string | null | undefined,
160
+ deps: ViewerShipGrantDeps = {},
161
+ ): Promise<ViewerContributeCapability> {
162
+ const raw = await resolveRawViewerCapability(email, appDomain, deps);
163
+ return raw === "blog-contribute" || raw === "ship-on-behalf"
164
+ ? "blog-contribute"
165
+ : "preview-access";
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.22",
3
+ "version": "2.0.24",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -9,7 +9,8 @@
9
9
  "types": "./src/index.ts",
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
- "./blog-visibility": "./src/blog-visibility.mjs"
12
+ "./blog-visibility": "./src/blog-visibility.mjs",
13
+ "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs"
13
14
  },
14
15
  "scripts": {
15
16
  "typecheck": "tsc --noEmit",
@@ -24,6 +24,13 @@
24
24
  * the renderer-inlined default-deny consent snapshot and holds a non-essential entry
25
25
  * back until its declared purposes are eligible. With an environment-only plan there
26
26
  * are no integrations, so the runtime boots inert and ready.
27
+ *
28
+ * The binding also constructs this document's managed-analytics bridge over the same
29
+ * commerce broker the adapters subscribe to, and hands the lifecycle the bootstrap
30
+ * page-view input built from the host document (path + minimized attribution). An
31
+ * adapter's subscription is receive-only, so this is what an active analytics
32
+ * integration actually receives — without it every adapter loads, subscribes, and then
33
+ * waits on a broker nothing emits into.
27
34
  */
28
35
  import { IntegrationLifecycle, type IntegrationModuleLoader } from "./lifecycle-runtime.js";
29
36
  import type { LifecycleObserver } from "./lifecycle-runtime.js";
@@ -36,6 +43,8 @@ import type { NavigationCompletedEvent, NavigationKind } from "./events.js";
36
43
  import { CommerceEventBroker } from "./commerce-broker.js";
37
44
  import { validateBrowserEnvironment, BROWSER_ENVIRONMENT_GLOBAL, type BrowserEnvironment } from "./environment.js";
38
45
  import { makePlatformAdapterRuntimeLoader } from "./platform-adapter-runtime.js";
46
+ import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
47
+ import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
39
48
 
40
49
  /** The window property holding the single per-document lifecycle (idempotent boot guard). */
41
50
  export const LIFECYCLE_SINGLETON_GLOBAL = "__TOT_INTEGRATION_LIFECYCLE__";
@@ -54,6 +63,12 @@ export interface LifecycleWindow {
54
63
  location: { pathname: string; search: string; hash: string; href: string };
55
64
  addEventListener(type: string, listener: (event: unknown) => void): void;
56
65
  fetch?: typeof fetch;
66
+ /**
67
+ * Read ONLY for the referrer the managed page view minimizes to a bare origin. The
68
+ * analytics data allowlist accepts no other document state — no title, no DOM, no
69
+ * globals — so this is deliberately the narrowest slice that supports attribution.
70
+ */
71
+ document?: { referrer?: string };
57
72
  [key: string]: unknown;
58
73
  }
59
74
 
@@ -83,6 +98,14 @@ export interface BootLifecycleOptions {
83
98
  * consent-filtered exactly like the platform path. Injectable for tests.
84
99
  */
85
100
  commerceBroker?: CommerceEventBroker;
101
+ /**
102
+ * The managed-analytics bridge that emits this document's governed page view into the
103
+ * commerce broker; defaults to one constructed over the resolved broker. Injectable so
104
+ * a test can assert the emission without a DOM.
105
+ */
106
+ managedAnalyticsBridge?: ManagedAnalyticsEventBrokerBridge;
107
+ /** Epoch-millisecond source for the page view's correlation expiry; defaults to the wall clock. */
108
+ now?: () => number;
86
109
  /** Host-owned fetch capability for reviewed first-party platform adapters. */
87
110
  fetchCapability?: typeof fetch;
88
111
  /** Entry import capability; injectable for tests and non-browser hosts. */
@@ -178,6 +201,47 @@ function makeDocumentCommerceBroker(
178
201
  });
179
202
  }
180
203
 
204
+ /**
205
+ * How long the bootstrap page view's attribution thread stays valid. It matches the
206
+ * correlation TTL the cart-handoff seam uses, so one visit's page view and any later
207
+ * commerce event it is threaded to expire together.
208
+ */
209
+ const PAGE_VIEW_CORRELATION_TTL_MS = 60 * 60 * 1000;
210
+
211
+ /**
212
+ * Build this document's bootstrap page-view input from the host document.
213
+ *
214
+ * The data allowlist is the whole surface: the route PATH (never the query string, where
215
+ * preview/candidate wrappers and tokens live) plus attribution already minimized to a
216
+ * referrer ORIGIN and the five standard UTM tokens. Title, DOM state, and browser globals
217
+ * are not accepted by this seam and are not read.
218
+ *
219
+ * `logicalId` and the correlation id are derived from the plan revision rather than
220
+ * randomized: one traditional document is counted exactly once, each document load builds
221
+ * its own broker, and the broker's dedupe key is per-document — so a stable, opaque,
222
+ * non-identifying value is both sufficient and deterministically testable.
223
+ */
224
+ function documentPageView(
225
+ win: LifecycleWindow,
226
+ descriptor: IntegrationBootDescriptor,
227
+ now: () => number,
228
+ ): ManagedPageViewInput {
229
+ const attribution = minimizePageAttribution({
230
+ referrerUrl: win.document?.referrer ?? null,
231
+ routeUrl: win.location.href,
232
+ });
233
+ const id = `pageview-${descriptor.planRevision}`;
234
+ return {
235
+ logicalId: id,
236
+ path: win.location.pathname,
237
+ correlation: {
238
+ id,
239
+ expiresAt: new Date(now() + PAGE_VIEW_CORRELATION_TTL_MS).toISOString(),
240
+ },
241
+ ...(attribution ? { attribution } : {}),
242
+ };
243
+ }
244
+
181
245
  function readDescriptor(win: LifecycleWindow): IntegrationBootDescriptor | null {
182
246
  const descriptor = win[INTEGRATION_PLAN_GLOBAL];
183
247
  if (!descriptor || typeof descriptor !== "object") return null;
@@ -259,6 +323,9 @@ export function bootIntegrationLifecycle(
259
323
  const commerceBroker =
260
324
  options.commerceBroker ?? makeDocumentCommerceBroker(descriptor, getConsentSnapshot);
261
325
 
326
+ // The managed-analytics bridge over the SAME broker the adapters subscribe to — the
327
+ // only seam that emits this document's governed page view. The lifecycle owns when it
328
+ // fires (after every adapter has subscribed, and re-attempted on a consent grant).
262
329
  const lifecycle = new IntegrationLifecycle({
263
330
  descriptor,
264
331
  loadModule,
@@ -268,6 +335,11 @@ export function bootIntegrationLifecycle(
268
335
  environment,
269
336
  getConsentSnapshot,
270
337
  commerceBroker,
338
+ managedAnalytics: {
339
+ bridge: options.managedAnalyticsBridge ??
340
+ new ManagedAnalyticsEventBrokerBridge({ broker: commerceBroker }),
341
+ pageView: () => documentPageView(win, descriptor, options.now ?? Date.now),
342
+ },
271
343
  });
272
344
  win[LIFECYCLE_SINGLETON_GLOBAL] = lifecycle;
273
345
 
@@ -0,0 +1,3 @@
1
+ /** Types for the dual-consumer (`.ts` engine + `.mjs` build plugin) host-asset path. */
2
+ export declare const LIFECYCLE_HOST_ASSET_PATH: string;
3
+ export declare const LIFECYCLE_HOST_ASSET_URL: string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The stable public URL of the document.deferred lifecycle host bundle.
3
+ *
4
+ * It lives in a `.mjs` module because it has TWO consumers that cannot share a `.ts`
5
+ * one: the render engine (`render-phases.ts`) that emits the `<script src>`, and the
6
+ * build plugin (`apps/storefront/build-plugins/lifecycle-host-asset.mjs`) that produces
7
+ * the file at that exact path. One source of truth, so the tag and the artifact cannot
8
+ * disagree — a mismatch would leave every static tenant page with plan data and no host,
9
+ * which fails silently in exactly the way it is not allowed to.
10
+ *
11
+ * The path is version-pinned (`/v1/`) like a platform adapter entry, so a future host
12
+ * with a different boot contract ships alongside rather than replacing this one.
13
+ */
14
+ export const LIFECYCLE_HOST_ASSET_PATH = "platform/lifecycle/v1/boot.mjs";
15
+
16
+ /** The root-absolute, same-origin URL the served markup references. */
17
+ export const LIFECYCLE_HOST_ASSET_URL = `/${LIFECYCLE_HOST_ASSET_PATH}`;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The document.deferred lifecycle HOST — the side-effecting entry both document mounts
3
+ * load, bundled to the stable same-origin URL in `./lifecycle-host-asset.mjs`.
4
+ *
5
+ * It is deliberately the whole module: importing it BOOTS. That is what makes it usable
6
+ * from a plain `<script type="module" src>` tag, which in turn is what lets the raw
7
+ * marketing mount host the lifecycle at all — it serves a tenant's own document verbatim
8
+ * and never passes through a bundler, so it cannot host a module-graph import the way
9
+ * `Layout.astro` can.
10
+ *
11
+ * NOT exported from the package barrel: `index.ts` re-exports its modules, and a
12
+ * self-invoking module there would boot the lifecycle on any server-side import.
13
+ */
14
+ import { bootIntegrationLifecycleFromDocument } from "./lifecycle-boot.js";
15
+
16
+ bootIntegrationLifecycleFromDocument();
@@ -25,6 +25,8 @@ import type { CommerceEventBroker } from "./commerce-broker.js";
25
25
  import type { IntegrationBootDescriptor, BootDescriptorIntegration } from "./render-phases.js";
26
26
  import type { NavigationCompletedEvent, NavigationKind } from "./events.js";
27
27
  import { validateNavigationCompletedEvent } from "./events.js";
28
+ import type { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
29
+ import type { ManagedPageViewInput } from "./managed-analytics.js";
28
30
  import { COMMERCE_EVENT_STOREFRONT_SCHEMA_VERSION, ENVIRONMENT_SCHEMA_VERSION } from "./version.js";
29
31
  import type { BrowserEnvironment } from "./environment.js";
30
32
  import type { ConsentSnapshot } from "./consent-broker.js";
@@ -125,6 +127,26 @@ export interface IntegrationLifecycleOptions {
125
127
  * shape a DOM-free unit test uses when it wires no broker.
126
128
  */
127
129
  commerceBroker?: CommerceEventBroker;
130
+ /**
131
+ * The document's managed-analytics bridge and the bootstrap page-view input for THIS
132
+ * document. Wiring it is what makes an active analytics adapter actually RECEIVE
133
+ * something: the bridge is the only seam that emits `storefront.page.viewed.v2` into
134
+ * {@link commerceBroker}, and an adapter's subscription is receive-only, so with no
135
+ * bridge every adapter initializes, subscribes, and then waits on a broker nothing
136
+ * ever emits into.
137
+ *
138
+ * The runtime owns the ORDERING, because the broker delivers to the subscribers
139
+ * registered at emit time and never replays: the bootstrap page view is emitted at the
140
+ * END of {@link init}, once every eligible integration has initialized (and so
141
+ * subscribed). `pageView` is a factory rather than a value because it is built from the
142
+ * host's DOM (path, referrer, UTM), which this DOM-free core must not read itself.
143
+ *
144
+ * Absent ⇒ no page view (the shape a DOM-free unit test uses when it wires no bridge).
145
+ */
146
+ managedAnalytics?: {
147
+ bridge: ManagedAnalyticsEventBrokerBridge;
148
+ pageView: () => ManagedPageViewInput;
149
+ };
128
150
  /** ISO-8601 timestamp source for the navigation event; defaults to wall clock. */
129
151
  now?: () => string;
130
152
  /** Opaque per-event id source; defaults to a monotonic, non-secret local counter. */
@@ -208,6 +230,34 @@ export class IntegrationLifecycle {
208
230
  }
209
231
  // A dispose that raced in during an await leaves us terminal; otherwise we are live.
210
232
  if (this.state === "initializing") this.state = "initialized";
233
+ // Last, so every adapter that just subscribed is registered when this emits: the
234
+ // broker has no replay, so a page view emitted before their `init` reaches nobody.
235
+ if (this.state === "initialized") this.emitBootstrapPageView();
236
+ }
237
+
238
+ /**
239
+ * Emit this document's bootstrap-owned page view through the managed-analytics bridge
240
+ * (`storefront.page.viewed.v2`), exactly once.
241
+ *
242
+ * Consent is a PRECONDITION here rather than something the bridge is left to discover:
243
+ * the bridge counts a document once and is then terminal for `initialize`, so calling
244
+ * it while `analytics` is ineligible would burn the document's only page view on a
245
+ * default-deny snapshot and lose it permanently. Instead the attempt is held and
246
+ * retried from {@link update}, so a visitor who grants analytics after load still gets
247
+ * counted for the document they are on.
248
+ *
249
+ * Analytics never breaks the page: a throwing bridge is swallowed exactly like a
250
+ * throwing integration.
251
+ */
252
+ private emitBootstrapPageView(): void {
253
+ const managed = this.opts.managedAnalytics;
254
+ if (!managed || managed.bridge.isInitialized || managed.bridge.isDisposed) return;
255
+ if (this.opts.commerceBroker?.isConsentEligible("analytics") === false) return;
256
+ try {
257
+ managed.bridge.initialize(managed.pageView());
258
+ } catch {
259
+ // A page view is never worth a broken document.
260
+ }
211
261
  }
212
262
 
213
263
  /** Whether the consent gate (when injected) permits loading this integration now. */
@@ -319,6 +369,10 @@ export class IntegrationLifecycle {
319
369
  this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
320
370
  }
321
371
  }
372
+ // A consent grant is the case that matters: it both loads a previously-gated adapter
373
+ // (above) and makes the held-back bootstrap page view emittable, so the document the
374
+ // visitor consented ON is still counted rather than only its successors.
375
+ this.emitBootstrapPageView();
322
376
  }
323
377
 
324
378
  /**
@@ -340,6 +394,11 @@ export class IntegrationLifecycle {
340
394
  }
341
395
  this.active.length = 0;
342
396
  this.deferred = []; // gated integrations never loaded — nothing to dispose.
397
+ try {
398
+ this.opts.managedAnalytics?.bridge.dispose();
399
+ } catch {
400
+ // Teardown is best-effort; a throwing bridge cannot leave the document undisposed.
401
+ }
343
402
  this.state = "disposed";
344
403
  }
345
404
 
@@ -9,7 +9,9 @@
9
9
  * `bodyStart` (body.start fallback), and `bodyDeferred` (document.deferred global
10
10
  * entry). Both document mounts — `Layout.astro` and `rawHtmlResponse()` — splice
11
11
  * the SAME strings, so their per-phase markup is identical by construction (golden
12
- * parity is a property of calling one function, not of keeping two in sync).
12
+ * parity is a property of calling one function, not of keeping two in sync). The
13
+ * `document.deferred` region includes the lifecycle HOST tag, so "the plan is
14
+ * published" and "the plan runs" cannot come apart between mounts.
13
15
  *
14
16
  * Nonce discipline (ADR 0018): the engine stamps the {@link INTEGRATION_NONCE_PLACEHOLDER}
15
17
  * sentinel, never a live nonce, onto its inline `<script>`s. The renderer replaces
@@ -37,6 +39,10 @@ import {
37
39
  serializeForInlineScript,
38
40
  } from "./environment.js";
39
41
  import { defaultConsentSnapshot, type ConsentSnapshot } from "./consent-broker.js";
42
+ import { LIFECYCLE_HOST_ASSET_URL } from "./lifecycle-host-asset.mjs";
43
+
44
+ /** Re-exported so consumers and tests read the host URL from the engine that emits it. */
45
+ export { LIFECYCLE_HOST_ASSET_PATH, LIFECYCLE_HOST_ASSET_URL } from "./lifecycle-host-asset.mjs";
40
46
 
41
47
  /**
42
48
  * The sentinel a renderer stamps in place of a live CSP nonce. It is a valid
@@ -163,11 +169,33 @@ export function renderIntegrationPhases(
163
169
  phaseBoundary("body.start") + `<div data-tot-integration-root hidden></div>`;
164
170
 
165
171
  const bodyDeferred =
166
- phaseBoundary("document.deferred") + `<div data-tot-integration-deferred hidden></div>`;
172
+ phaseBoundary("document.deferred") +
173
+ `<div data-tot-integration-deferred hidden></div>` +
174
+ lifecycleHostScript();
167
175
 
168
176
  return { head, bodyStart, bodyDeferred };
169
177
  }
170
178
 
179
+ /**
180
+ * The document.deferred lifecycle HOST tag — what actually boots the plan.
181
+ *
182
+ * It belongs to the ENGINE, not to a mount. The engine's promise is that both mounts
183
+ * emit identical per-phase markup because they call one function; a host installed by
184
+ * only one of them breaks that promise in the way that matters most, because the mount
185
+ * that lacks it still publishes `__TOT_INTEGRATION_PLAN__`. A page then advertises a
186
+ * complete, enabled integration plan and runs none of it — indistinguishable, from every
187
+ * surface that reports configuration, from a working install.
188
+ *
189
+ * Carries no nonce by design: it is a same-origin `src` reference, admitted by
190
+ * `script-src 'self'` (see `csp.ts`), so it needs neither a nonce nor a content hash —
191
+ * the same treatment the static `/js/*.js` behaviors get. That also keeps it correct
192
+ * under the raw mount, which stamps nonces onto the tenant's own inline tags BEFORE
193
+ * splicing engine bytes.
194
+ */
195
+ function lifecycleHostScript(): string {
196
+ return `<script type="module" src="${LIFECYCLE_HOST_ASSET_URL}"></script>`;
197
+ }
198
+
171
199
  /**
172
200
  * Replace every {@link INTEGRATION_NONCE_PLACEHOLDER} in `text` (an HTML body or a
173
201
  * CSP header value) with one fresh per-response `nonce`. Idempotent on text with no