@tokenoftrust/storefront-runner 2.0.23 → 2.0.25

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 (27) hide show
  1. package/apps/storefront/astro.config.mjs +5 -0
  2. package/apps/storefront/integrations/lifecycle-host-asset.mjs +125 -0
  3. package/apps/storefront/src/layouts/Layout.astro +8 -10
  4. package/apps/storefront/src/lib/integration/bakedManagedInstalls.ts +56 -0
  5. package/apps/storefront/src/lib/integration/managedInstallDeclarations.ts +11 -3
  6. package/apps/storefront/src/lib/integration/stageDestinationIds.ts +103 -0
  7. package/apps/storefront/src/middleware/renderChain.ts +15 -1
  8. package/package.json +1 -1
  9. package/packages/public-runtime/package.json +2 -1
  10. package/packages/public-runtime/schemas/managed-analytics/v1/adapter-definition.schema.json +2 -1
  11. package/packages/public-runtime/schemas/managed-analytics/v1/create-integration-install-request.schema.json +79 -0
  12. package/packages/public-runtime/schemas/managed-analytics/v1/create-migration-draft-request.schema.json +79 -0
  13. package/packages/public-runtime/schemas/managed-analytics/v1/integration-revision.schema.json +79 -0
  14. package/packages/public-runtime/schemas/managed-analytics/v1/migration-draft.schema.json +79 -0
  15. package/packages/public-runtime/schemas/managed-analytics/v1/revise-integration-install-request.schema.json +79 -0
  16. package/packages/public-runtime/schemas/managed-analytics/v1/update-migration-draft-request.schema.json +79 -0
  17. package/packages/public-runtime/src/gtm-migration/draft-generator.ts +5 -2
  18. package/packages/public-runtime/src/integration/baked-managed-installs.ts +188 -0
  19. package/packages/public-runtime/src/integration/index.ts +1 -0
  20. package/packages/public-runtime/src/integration/lifecycle-boot.ts +72 -0
  21. package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +3 -0
  22. package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -0
  23. package/packages/public-runtime/src/integration/lifecycle-host-entry.ts +16 -0
  24. package/packages/public-runtime/src/integration/lifecycle-runtime.ts +59 -0
  25. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +18 -1
  26. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +48 -3
  27. package/packages/public-runtime/src/integration/render-phases.ts +30 -2
@@ -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
 
@@ -28,6 +28,22 @@ const PUBLIC_IDENTIFIER = { ...SAFE_CONFIG_STRING, minLength: 1, maxLength: 128,
28
28
  const FINDING_CODE = { ...SAFE_CONFIG_STRING, pattern: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" } as const;
29
29
  const REDACTED_PATH = { ...SAFE_CONFIG_STRING, pattern: "^(?:\\$|[A-Za-z][A-Za-z0-9]*)(?:\\.[A-Za-z][A-Za-z0-9]*|\\[[0-9]+\\])*$" } as const;
30
30
 
31
+ /**
32
+ * A per-stage destination map — the one non-primitive configuration value, and closed:
33
+ * only the declared stages, only safe strings. `local` is absent because the local dev
34
+ * loop ships no integrations, so a value there could never fire.
35
+ */
36
+ const stageDestinationMap = {
37
+ type: "object",
38
+ additionalProperties: false,
39
+ minProperties: 1,
40
+ properties: {
41
+ candidate: SAFE_NON_EMPTY_STRING,
42
+ staged: SAFE_NON_EMPTY_STRING,
43
+ live: SAFE_NON_EMPTY_STRING,
44
+ },
45
+ } as const;
46
+
31
47
  const primitiveConfiguration = {
32
48
  type: "object",
33
49
  propertyNames: SAFE_CONFIG_KEY,
@@ -36,6 +52,7 @@ const primitiveConfiguration = {
36
52
  SAFE_CONFIG_STRING,
37
53
  { type: "boolean" },
38
54
  { type: "integer" },
55
+ stageDestinationMap,
39
56
  ],
40
57
  },
41
58
  } as const;
@@ -64,7 +81,7 @@ export const ADAPTER_DEFINITION_V1_JSON_SCHEMA = {
64
81
  key: SAFE_CONFIG_KEY,
65
82
  label: SAFE_NON_EMPTY_STRING,
66
83
  description: SAFE_NON_EMPTY_STRING,
67
- kind: { type: "string", enum: ["string", "boolean", "integer"] },
84
+ kind: { type: "string", enum: ["string", "boolean", "integer", "stageMap"] },
68
85
  required: { type: "boolean" },
69
86
  },
70
87
  },
@@ -28,9 +28,30 @@ export type IntegrationRevisionStatus = (typeof INTEGRATION_REVISION_STATUSES)[n
28
28
  export const ADAPTER_AVAILABILITY = ["available", "deprecated"] as const;
29
29
  export type AdapterAvailability = (typeof ADAPTER_AVAILABILITY)[number];
30
30
 
31
- export const ADAPTER_CONFIG_VALUE_KINDS = ["string", "boolean", "integer"] as const;
31
+ export const ADAPTER_CONFIG_VALUE_KINDS = ["string", "boolean", "integer", "stageMap"] as const;
32
32
  export type AdapterConfigValueKind = (typeof ADAPTER_CONFIG_VALUE_KINDS)[number];
33
- export type AdapterConfigValue = string | boolean | number;
33
+
34
+ /**
35
+ * The stages a merchant may address a destination at. `local` is deliberately absent:
36
+ * the local dev loop ships no integrations and grants only essential consent, so a value
37
+ * there could never fire and offering one would imply otherwise.
38
+ */
39
+ export const ADAPTER_CONFIG_STAGES = ["candidate", "staged", "live"] as const;
40
+ export type AdapterConfigStage = (typeof ADAPTER_CONFIG_STAGES)[number];
41
+
42
+ /**
43
+ * A per-stage destination map — the one non-primitive configuration value.
44
+ *
45
+ * Its shape is CLOSED: keys come from {@link ADAPTER_CONFIG_STAGES} and every value is a
46
+ * plain string held to the same executable-content and PII checks as any other
47
+ * configuration value. So the property that makes this contract safe is intact — a
48
+ * merchant still cannot supply markup, selectors, secrets, or arbitrary structure — while
49
+ * a destination can differ per stage, which is what makes the devbook's own rule (a
50
+ * non-live stage must not reuse the live property) satisfiable through the console at all.
51
+ */
52
+ export type AdapterConfigStageMap = Partial<Record<AdapterConfigStage, string>>;
53
+
54
+ export type AdapterConfigValue = string | boolean | number | AdapterConfigStageMap;
34
55
  /** Public identifiers are opaque handles: never emails, names, or user-provided labels. */
35
56
  export type OpaquePublicId = string;
36
57
  /** Stable, redacted machine code; migration findings never contain source text or PII. */
@@ -535,12 +556,36 @@ export function validateManagedAnalyticsSafePayload(input: unknown): ContractRes
535
556
  return v.ok ? prepared : { ok: false, violations: v.items };
536
557
  }
537
558
 
559
+ /** A plain object, i.e. a candidate per-stage map rather than a primitive. */
560
+ function isStageMap(value: unknown): boolean {
561
+ return typeof value === "object" && value !== null && !Array.isArray(value);
562
+ }
563
+
538
564
  function validateConfig(input: unknown, v: Violations, path: string): void {
539
565
  const obj = object(input, v, path);
540
566
  if (!obj) return;
541
567
  for (const [key, value] of Object.entries(obj)) {
542
568
  if (!/^[a-z][a-zA-Z0-9]{0,63}$/.test(key)) v.add(pathAt(path, key), "format", "Configuration keys must be lower camel-case identifiers.");
543
- if (!(typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isInteger(value)))) v.add(pathAt(path, key), "type", "Configuration values must be strings, booleans, or integers.");
569
+ // A per-stage map is the one permitted non-primitive, and it is CLOSED: stage keys
570
+ // only, string values only, each held to the same executable-content check below. So
571
+ // no arbitrary structure or depth can enter configuration through it.
572
+ if (isStageMap(value)) {
573
+ const entries = Object.entries(value as Record<string, unknown>);
574
+ if (entries.length === 0) v.add(pathAt(path, key), "empty", "A per-stage map must name at least one stage.");
575
+ for (const [stage, id] of entries) {
576
+ const stagePath = pathAt(pathAt(path, key), stage);
577
+ if (!(ADAPTER_CONFIG_STAGES as readonly string[]).includes(stage)) {
578
+ v.add(stagePath, "unknown_enum", `Stage must be one of: ${ADAPTER_CONFIG_STAGES.join(", ")}.`);
579
+ }
580
+ if (typeof id !== "string") {
581
+ v.add(stagePath, "type", "A per-stage value must be a string.");
582
+ } else if (EXECUTABLE_OR_INSTRUCTION_VALUE.test(id)) {
583
+ v.add(stagePath, "executable_value", "Configuration cannot contain markup, JavaScript, templates, selectors, or storage instructions.");
584
+ }
585
+ }
586
+ continue;
587
+ }
588
+ if (!(typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isInteger(value)))) v.add(pathAt(path, key), "type", "Configuration values must be strings, booleans, integers, or a per-stage map of strings.");
544
589
  if (typeof value === "string" && EXECUTABLE_OR_INSTRUCTION_VALUE.test(value)) v.add(pathAt(path, key), "executable_value", "Configuration cannot contain markup, JavaScript, templates, selectors, or storage instructions.");
545
590
  }
546
591
  const safe = validateManagedAnalyticsSafePayload(obj);
@@ -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