@tokenoftrust/storefront-runner 2.2.75 → 2.2.76

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.
@@ -19,7 +19,7 @@ export const STATIC_SCRIPT_HASHES: readonly string[] = [
19
19
  "'sha256-YicfxUFBkKrQapZ/tzZKlaKTdNHgwVfv7y2Wa0NC6/k='",
20
20
  "'sha256-eIXWvAmxkr251LJZkjniEK5LcPF3NkapbJepohwYRIc='",
21
21
  "'sha256-elMLh/6Oubj8u9FRLwZLyRa6TqKikGs75mf0jfWcoxI='",
22
- "'sha256-Rl3sizqw4AmEkfQgkgajKo4uJC7JkyVCYiPJY/DJbIE='",
22
+ "'sha256-n9b6Y8jO/N+uwfItpBlKcnLoRaKnidYcvDkuixl+DwY='",
23
23
  ];
24
24
 
25
25
  export const STATIC_STYLE_HASHES: readonly string[] = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.75",
3
+ "version": "2.2.76",
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",
@@ -140,7 +140,7 @@ export interface BootLifecycleOptions {
140
140
  */
141
141
  scheduleGa4Sandbox?: NativeGa4SandboxScheduler;
142
142
  /**
143
- * The persistent store behind the Amplitude visitor id and session; defaults to the
143
+ * The persistent store behind the GA4 and Amplitude visitor id and session; defaults to the
144
144
  * window's origin storage. Read lazily and only while analytics is eligible.
145
145
  */
146
146
  visitorStorage?: () => VisitorContinuityStorage | undefined;
@@ -17,7 +17,8 @@
17
17
  * Storage is injected by the host and never touched by name here; when it is
18
18
  * unavailable the id lives in memory for this document only and the event carries
19
19
  * no session (`NO_SESSION_ID`), overcounting visitors rather than failing the event.
20
- * There is no `user_id`.
20
+ * There is no `user_id`. The same store backs the GA4 adapter's `client_id` and session
21
+ * (`native-ga4.ts`), so both destinations count the same visitors and sessions.
21
22
  *
22
23
  * Page views also carry channel ATTRIBUTION (UTM tokens + the referrer's origin)
23
24
  * and the page LOCATION (`page_hostname` / `page_url`, from the server-stamped
@@ -171,9 +172,18 @@ export interface VisitorContinuityScope {
171
172
  siteSurface: string;
172
173
  }
173
174
 
175
+ /**
176
+ * How the current session relates to this document: `first_visit` when this document
177
+ * minted the visitor, `new_session` when it started a new session for a known visitor,
178
+ * otherwise `continuing`. Recorded per continuity instance (one per document), so the
179
+ * first adapter to read the visitor does not hide the arrival from the others.
180
+ */
181
+ export type VisitorSessionState = "first_visit" | "new_session" | "continuing";
182
+
174
183
  export interface VisitorContinuityIds {
175
184
  deviceId: string;
176
185
  sessionId: number;
186
+ sessionState: VisitorSessionState;
177
187
  }
178
188
 
179
189
  export interface VisitorContinuity {
@@ -235,6 +245,11 @@ function defaultRandomUUID(): string {
235
245
  export function createVisitorContinuity(options: CreateVisitorContinuityOptions = {}): VisitorContinuity {
236
246
  const randomUUID = options.randomUUID ?? defaultRandomUUID;
237
247
  const memory = new Map<string, string>();
248
+ const startedHere = new Map<string, { sessionId: number; sessionState: VisitorSessionState }>();
249
+ const stateOf = (key: string, sessionId: number): VisitorSessionState => {
250
+ const started = startedHere.get(key);
251
+ return started && started.sessionId === sessionId ? started.sessionState : "continuing";
252
+ };
238
253
  const storage = (): VisitorContinuityStorage | undefined => {
239
254
  try { return options.storage?.(); } catch { return undefined; }
240
255
  };
@@ -255,21 +270,28 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
255
270
  try {
256
271
  const existing = liveVisitorRecord(store.getItem(key), now);
257
272
  let record: VisitorRecord | null = existing;
273
+ let started: VisitorSessionState | null = null;
258
274
  if (!record) {
259
275
  const deviceId = mint();
260
276
  if (deviceId) {
261
277
  record = { schemaVersion: 1, deviceId, createdAt: now, sessionStartedAt: now, lastActivityAt: now };
278
+ started = "first_visit";
262
279
  }
263
280
  } else {
264
- const sessionStartedAt = now - record.lastActivityAt > SESSION_INACTIVITY_MS
265
- ? now
266
- : record.sessionStartedAt;
281
+ const rolled = now - record.lastActivityAt > SESSION_INACTIVITY_MS;
282
+ const sessionStartedAt = rolled ? now : record.sessionStartedAt;
283
+ if (rolled) started = "new_session";
267
284
  record = { ...record, sessionStartedAt, lastActivityAt: Math.max(now, record.lastActivityAt) };
268
285
  }
269
286
  if (record) {
270
287
  store.setItem(key, JSON.stringify(record));
271
288
  memory.delete(key);
272
- return { deviceId: record.deviceId, sessionId: record.sessionStartedAt };
289
+ if (started) startedHere.set(key, { sessionId: record.sessionStartedAt, sessionState: started });
290
+ return {
291
+ deviceId: record.deviceId,
292
+ sessionId: record.sessionStartedAt,
293
+ sessionState: stateOf(key, record.sessionStartedAt),
294
+ };
273
295
  }
274
296
  } catch {
275
297
  // Blocked or full storage: fall through to this document's memory-only visitor.
@@ -278,15 +300,16 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
278
300
  let deviceId = memory.get(key);
279
301
  if (!deviceId) {
280
302
  const minted = mint();
281
- if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID };
303
+ if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit" };
282
304
  deviceId = minted;
283
305
  memory.set(key, deviceId);
284
306
  }
285
- return { deviceId, sessionId: NO_SESSION_ID };
307
+ return { deviceId, sessionId: NO_SESSION_ID, sessionState: "first_visit" };
286
308
  },
287
309
  clear(scope) {
288
310
  const key = visitorContinuityStorageKey(scope);
289
311
  memory.delete(key);
312
+ startedHere.delete(key);
290
313
  try { storage()?.removeItem(key); } catch { /* the early consent boot clears on the next load */ }
291
314
  },
292
315
  };
@@ -456,7 +479,7 @@ function createAmplitudeHandler(input: AmplitudeHandlerInput): (event: CommerceE
456
479
  if (!mapped || input.dedupe.seenOrRemember(mapped.insert_id)) return;
457
480
  let identity: VisitorContinuityIds;
458
481
  try { identity = input.continuity.readOrMint(scope, mapped.time); }
459
- catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID }; }
482
+ catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit" }; }
460
483
  const body = JSON.stringify({
461
484
  api_key: input.destinationId,
462
485
  events: [{ ...mapped, device_id: identity.deviceId, session_id: identity.sessionId }],
@@ -10,6 +10,10 @@ import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-val
10
10
  const MEASUREMENT_ID = /^G-[A-Z0-9]{4,20}$/;
11
11
  const CAMPAIGN_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
12
12
  const GOVERNED_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
13
+ const CLIENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
14
+ const SESSION_STATES: ReadonlySet<unknown> = new Set(["first_visit", "new_session", "continuing"]);
15
+ /** gtag's own session-inactivity window, in seconds. */
16
+ const GTAG_SESSION_TIMEOUT_S = 30 * 60;
13
17
  const APPROVED_ACTIONS: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
14
18
 
15
19
  function plainRecord(value: unknown): Record<string, unknown> | null {
@@ -59,6 +63,82 @@ function validGovernedProperties(value: unknown): value is Record<string, string
59
63
  !resemblesManagedAnalyticsProtectedValue(item));
60
64
  }
61
65
 
66
+ interface FrameVisitor {
67
+ clientId: string;
68
+ session?: { idSeconds: number; state: "first_visit" | "new_session" | "continuing" };
69
+ }
70
+
71
+ /**
72
+ * The visitor crosses the same hostile boundary: a lowercase UUID and, together or not
73
+ * at all, a positive integer epoch-ms session start plus its closed state. Anything else
74
+ * refuses the whole configure rather than falling back to an id gtag would invent.
75
+ */
76
+ function parseVisitor(value: unknown): FrameVisitor | null {
77
+ const record = plainRecord(value);
78
+ if (!record || !exactKeys(record, ["clientId", "sessionId", "sessionState"], ["clientId"])) return null;
79
+ const { clientId, sessionId, sessionState } = record;
80
+ if (typeof clientId !== "string" || !CLIENT_ID.test(clientId)) return null;
81
+ if (sessionId === undefined && sessionState === undefined) return { clientId };
82
+ if (
83
+ typeof sessionId !== "number" || !Number.isSafeInteger(sessionId) || sessionId <= 0 ||
84
+ !SESSION_STATES.has(sessionState)
85
+ ) return null;
86
+ return {
87
+ clientId,
88
+ session: { idSeconds: Math.floor(sessionId / 1000), state: sessionState as "first_visit" | "new_session" | "continuing" },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * gtag keeps its visitor and session in `_ga` / `_ga_<container>` cookies and ignores
94
+ * `client_storage: 'none'` (measured 2026-10-06 against the live gtag.js). This frame's
95
+ * origin is shared by every hosted tenant, so a cookie written here would link one
96
+ * browser across tenants. The frame's `document.cookie` is therefore replaced, before
97
+ * gtag loads, with a jar that lives only as long as this document.
98
+ */
99
+ function installMemoryCookieJar(doc: Document): Map<string, string> {
100
+ const jar = new Map<string, string>();
101
+ Object.defineProperty(doc, "cookie", {
102
+ configurable: false,
103
+ enumerable: true,
104
+ get: () => Array.from(jar, ([name, value]) => `${name}=${value}`).join("; "),
105
+ set: (raw: unknown) => {
106
+ const [pair = "", ...attributes] = String(raw).split(";");
107
+ const split = pair.indexOf("=");
108
+ if (split <= 0) return;
109
+ const name = pair.slice(0, split).trim();
110
+ const expired = attributes.some((attribute) => {
111
+ const [key = "", value = ""] = attribute.split("=").map((part) => part.trim());
112
+ if (key.toLowerCase() === "max-age") return Number(value) <= 0;
113
+ if (key.toLowerCase() === "expires") return Date.parse(value) <= Date.now();
114
+ return false;
115
+ });
116
+ if (expired) jar.delete(name);
117
+ else jar.set(name, pair.slice(split + 1).trim());
118
+ },
119
+ });
120
+ return jar;
121
+ }
122
+
123
+ /**
124
+ * Seed gtag's session cookie so it reports the parent's session: `continuing` gets a
125
+ * live session (no `session_start`, no `first_visit`); `new_session` gets an expired one,
126
+ * so gtag starts a session for a returning visitor; `first_visit` gets none. The field
127
+ * layout is gtag's own; if gtag stops parsing it, collection still carries the parent's
128
+ * `client_id` and session id and only the new-session / first-visit flags overcount.
129
+ */
130
+ function seedSessionCookies(jar: Map<string, string>, ids: readonly string[], visitor: FrameVisitor | null): void {
131
+ jar.clear();
132
+ const session = visitor?.session;
133
+ if (!session || session.state === "first_visit") return;
134
+ const nowSeconds = Math.floor(Date.now() / 1000);
135
+ const lastSeen = session.state === "continuing" ? nowSeconds : session.idSeconds - GTAG_SESSION_TIMEOUT_S - 1;
136
+ const sessionStart = session.state === "continuing" ? session.idSeconds : lastSeen;
137
+ for (const id of ids) {
138
+ jar.set(`_ga_${id.slice("G-".length)}`, `GS2.1.s${sessionStart}$o1$g0$t${lastSeen}$j0$l0$h0`);
139
+ }
140
+ }
141
+
62
142
  function validCampaign(value: unknown): value is Record<string, string> {
63
143
  const record = plainRecord(value);
64
144
  if (!record || !exactKeys(record, ["source", "medium", "campaign", "term", "content"], [])) return false;
@@ -105,6 +185,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
105
185
  let lastPageLocation = "";
106
186
  let tagLoaded = false;
107
187
 
188
+ const cookieJar = installMemoryCookieJar(doc);
108
189
  const dataLayer: unknown[] = [];
109
190
  const gtag = function(..._args: unknown[]): void {
110
191
  dataLayer.push(arguments);
@@ -114,13 +195,20 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
114
195
 
115
196
  const configure = (message: Record<string, unknown>): void => {
116
197
  if (
117
- !exactKeys(message, ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties"]) ||
198
+ !exactKeys(
199
+ message,
200
+ ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties", "visitor"],
201
+ ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties"],
202
+ ) ||
118
203
  message["schemaVersion"] !== 1 || message["type"] !== "configure" ||
119
204
  message["analyticsConsent"] !== "granted" ||
120
205
  !validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"]) ||
121
206
  !validGovernedProperties(message["governedProperties"])
122
207
  ) return;
208
+ const visitor = message["visitor"] === undefined ? null : parseVisitor(message["visitor"]);
209
+ if (message["visitor"] !== undefined && !visitor) return;
123
210
  const ids = message["destinationIds"];
211
+ seedSessionCookies(cookieJar, ids, visitor);
124
212
  canonicalOrigin = message["canonicalOrigin"];
125
213
  governedProperties = { ...message["governedProperties"] };
126
214
  currentDestinations = [...ids];
@@ -149,6 +237,8 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
149
237
  allow_ad_personalization_signals: false,
150
238
  restricted_data_processing: true,
151
239
  ignore_referrer: true,
240
+ ...(visitor ? { client_id: visitor.clientId } : {}),
241
+ ...(visitor?.session ? { session_id: visitor.session.idSeconds } : {}),
152
242
  });
153
243
  }
154
244
  if (doc.querySelector('script[data-tot-ga4="1"]') === null) {
@@ -214,6 +304,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
214
304
  active = false;
215
305
  currentDestinations = [];
216
306
  lastPageLocation = "";
307
+ cookieJar.clear();
217
308
  }
218
309
  return;
219
310
  }
@@ -223,6 +314,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
223
314
  disposed = true;
224
315
  currentDestinations = [];
225
316
  lastPageLocation = "";
317
+ cookieJar.clear();
226
318
  port.close();
227
319
  }
228
320
  return;
@@ -17,6 +17,20 @@ export interface NativeGa4SandboxConfig {
17
17
  * life, so a per-event copy would only widen the hostile boundary.
18
18
  */
19
19
  governedProperties: GovernedEventProperties;
20
+ /**
21
+ * The parent's visitor continuity (shared with the Amplitude adapter). gtag keeps no
22
+ * state on the frame origin, which every hosted tenant shares, so the visitor and
23
+ * session arrive here instead. Absent when the browser cannot mint an id.
24
+ */
25
+ visitor?: NativeGa4SandboxVisitor;
26
+ }
27
+
28
+ export interface NativeGa4SandboxVisitor {
29
+ /** Lowercase random UUID, the GA4 `client_id`. */
30
+ clientId: string;
31
+ /** Session start in epoch ms; absent (with `sessionState`) when origin storage is unavailable. */
32
+ sessionId?: number;
33
+ sessionState?: "first_visit" | "new_session" | "continuing";
20
34
  }
21
35
 
22
36
  export interface NativeGa4PageViewCommand {
@@ -5,6 +5,14 @@
5
5
  * lifecycle subscription. It never loads Google code into the regulated parent.
6
6
  * Instead, a closed command crosses one private MessagePort into the reviewed,
7
7
  * isolated-origin GA4 sandbox after consent and logical-event dedupe have passed.
8
+ *
9
+ * VISITOR CONTINUITY. The GA4 `client_id` and session come from the same
10
+ * `VisitorContinuity` store as the Amplitude adapter's `device_id`/`session_id`
11
+ * (`native-amplitude.ts`), so both destinations count one visitor per browser, tenant,
12
+ * environment and site surface. They are read or minted only on the eligible bind path
13
+ * and cross into the frame in the `configure` message; the frame keeps gtag's cookies in
14
+ * memory, never on its origin (see `native-ga4-sandbox-entry.ts`). An update into
15
+ * ineligibility clears the visitor, so a later grant configures a fresh one.
8
16
  */
9
17
  import type { IntegrationLifecycleContext, IntegrationLifecycleModule, LifecycleUpdateReason } from "./lifecycle.js";
10
18
  import type { CommerceEventType } from "./events.js";
@@ -17,6 +25,12 @@ import {
17
25
  } from "./platform-adapters.js";
18
26
  import { INTEGRATION_ENVIRONMENTS, type IntegrationEnvironment } from "./environment.js";
19
27
  import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-value-safety.js";
28
+ import {
29
+ createVisitorContinuity,
30
+ NO_SESSION_ID,
31
+ type VisitorContinuity,
32
+ type VisitorContinuityScope,
33
+ } from "./native-amplitude.js";
20
34
  import {
21
35
  buildGovernedEventProperties,
22
36
  type GovernedEventProperties,
@@ -24,6 +38,7 @@ import {
24
38
  import type {
25
39
  NativeGa4SandboxCommand,
26
40
  NativeGa4SandboxConfig,
41
+ NativeGa4SandboxVisitor,
27
42
  } from "./native-ga4-sandbox.js";
28
43
  import {
29
44
  NATIVE_GA4_SANDBOX_ASSET_URL,
@@ -69,9 +84,14 @@ export type NativeGa4Observer = (observation: NativeGa4Observation) => void;
69
84
  export interface NativeGa4ModuleOptions {
70
85
  transport: NativeGa4Transport | NativeGa4TransportSend;
71
86
  observe?: NativeGa4Observer;
87
+ /** The host's visitor store, shared with Amplitude; defaults to memory-only (one visitor per document, no session). */
88
+ continuity?: VisitorContinuity;
89
+ /** Epoch-millisecond clock for session activity; defaults to the wall clock. */
90
+ now?: () => number;
72
91
  }
73
92
 
74
93
  interface ActiveGa4Config {
94
+ scope: VisitorContinuityScope;
75
95
  destinationIds: readonly string[];
76
96
  environment: IntegrationEnvironment;
77
97
  siteSurface: IntegrationLifecycleContext["environment"]["siteSurface"];
@@ -109,8 +129,10 @@ function configuredFor(ctx: IntegrationLifecycleContext): ActiveGa4Config | null
109
129
  // Unlabellable traffic is not collected: without a provable tenant and stage
110
130
  // every number downstream is unattributable, so this joins the other gates.
111
131
  const governedProperties = buildGovernedEventProperties(ctx.environment, ctx.tenantPublicId);
112
- if (!governedProperties) return null;
132
+ const scope = continuityScope(ctx);
133
+ if (!governedProperties || !scope) return null;
113
134
  return {
135
+ scope,
114
136
  destinationIds,
115
137
  environment: ctx.environment.environment,
116
138
  siteSurface: ctx.environment.siteSurface,
@@ -119,6 +141,29 @@ function configuredFor(ctx: IntegrationLifecycleContext): ActiveGa4Config | null
119
141
  };
120
142
  }
121
143
 
144
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
145
+
146
+ /** The consent broker's context triple; null when the context cannot name one. */
147
+ function continuityScope(ctx: IntegrationLifecycleContext): VisitorContinuityScope | null {
148
+ const { tenantPublicId } = ctx;
149
+ const { environment, siteSurface } = ctx.environment;
150
+ if (
151
+ typeof tenantPublicId !== "string" || tenantPublicId.length === 0 ||
152
+ typeof environment !== "string" || typeof siteSurface !== "string"
153
+ ) return null;
154
+ return { tenantPublicId, environment, siteSurface };
155
+ }
156
+
157
+ /** A visitor the frame accepts; a browser that cannot mint an id sends none and gtag keeps a per-document one. */
158
+ function frameVisitor(continuity: VisitorContinuity, scope: VisitorContinuityScope, now: number): NativeGa4SandboxVisitor | undefined {
159
+ const ids = continuity.readOrMint(scope, now);
160
+ if (!UUID_PATTERN.test(ids.deviceId)) return undefined;
161
+ if (ids.sessionId === NO_SESSION_ID || !Number.isSafeInteger(ids.sessionId) || ids.sessionId <= 0) {
162
+ return { clientId: ids.deviceId };
163
+ }
164
+ return { clientId: ids.deviceId, sessionId: ids.sessionId, sessionState: ids.sessionState };
165
+ }
166
+
122
167
  function hasAnalyticsConsent(ctx: IntegrationLifecycleContext): boolean {
123
168
  const consent = parseConsentSnapshot(ctx.consent);
124
169
  return consent !== null && isPurposeEligible(consent, "analytics");
@@ -194,6 +239,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
194
239
  let currentPageLocation: string | null = null;
195
240
  const delivered = new Set<string>();
196
241
  const deliveredOrder: string[] = [];
242
+ const continuity = options.continuity ?? createVisitorContinuity();
243
+ const now = options.now ?? (() => Date.now());
197
244
 
198
245
  const transport = typeof options.transport === "function" ? undefined : options.transport;
199
246
  const send = typeof options.transport === "function" ? options.transport : options.transport.send.bind(options.transport);
@@ -221,11 +268,17 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
221
268
  }
222
269
  };
223
270
 
224
- const apply = (ctx: IntegrationLifecycleContext): void => {
271
+ /** `clearIfIneligible`: an update that loses analytics eligibility forgets the visitor. */
272
+ const apply = (ctx: IntegrationLifecycleContext, clearIfIneligible: boolean): void => {
225
273
  if (!stop() || disposed) return;
226
274
  const next = configuredFor(ctx);
227
275
  if (!next) {
228
- observeSafely(options.observe, hasAnalyticsConsent(ctx) ? "configuration_invalid" : "consent_ineligible");
276
+ const consented = hasAnalyticsConsent(ctx);
277
+ if (!consented && clearIfIneligible) {
278
+ const scope = continuityScope(ctx);
279
+ try { if (scope) continuity.clear(scope); } catch { /* the early consent boot clears on the next load */ }
280
+ }
281
+ observeSafely(options.observe, consented ? "configuration_invalid" : "consent_ineligible");
229
282
  return;
230
283
  }
231
284
  if (!ctx.subscribe) {
@@ -233,12 +286,14 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
233
286
  return;
234
287
  }
235
288
  try {
236
- const configuration = {
289
+ const visitor = frameVisitor(continuity, next.scope, now());
290
+ const configuration: NativeGa4SandboxConfig = {
237
291
  schemaVersion: 1 as const,
238
292
  destinationIds: [...next.destinationIds],
239
293
  canonicalOrigin: next.canonicalOrigin,
240
294
  analyticsConsent: "granted" as const,
241
295
  governedProperties: { ...next.governedProperties },
296
+ ...(visitor ? { visitor } : {}),
242
297
  };
243
298
  runTransportEffect(transport?.configure ? () => transport.configure!(configuration) : undefined);
244
299
  } catch {
@@ -264,6 +319,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
264
319
  return;
265
320
  }
266
321
  if (command.type === "page_view") currentPageLocation = `${current.canonicalOrigin}${command.path}`;
322
+ // Record activity so the shared session rolls on inactivity, not on page-load age.
323
+ try { continuity.readOrMint(current.scope, now()); } catch { /* activity is best-effort */ }
267
324
  delivered.add(event.id);
268
325
  deliveredOrder.push(event.id);
269
326
  if (deliveredOrder.length > 1_024) {
@@ -284,8 +341,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
284
341
  };
285
342
 
286
343
  return {
287
- init: apply,
288
- update(_reason: LifecycleUpdateReason, ctx) { return apply(ctx); },
344
+ init(ctx) { return apply(ctx, false); },
345
+ update(_reason: LifecycleUpdateReason, ctx) { return apply(ctx, true); },
289
346
  dispose() {
290
347
  disposed = true;
291
348
  stop();
@@ -41,7 +41,7 @@ export interface PlatformAdapterEntryMarker {
41
41
  export interface PlatformAdapterRuntimeHost {
42
42
  fetch?: typeof fetch;
43
43
  nativeGa4Transport?: NativeGa4Transport;
44
- /** The host's persistent visitor store for Amplitude; absent keeps visitors memory-only. */
44
+ /** The host's persistent visitor store, shared by GA4 and Amplitude; absent keeps visitors memory-only. */
45
45
  visitorContinuity?: VisitorContinuity;
46
46
  }
47
47
 
@@ -74,9 +74,9 @@ const AMPLITUDE_MARKER: PlatformAdapterEntryMarker = Object.freeze({
74
74
  export const PLATFORM_ADAPTER_RUNTIME_REGISTRY: readonly PlatformAdapterRuntimeRegistration[] = Object.freeze([
75
75
  Object.freeze({
76
76
  marker: GA4_MARKER,
77
- create: ({ nativeGa4Transport }: PlatformAdapterRuntimeHost) => {
77
+ create: ({ nativeGa4Transport, visitorContinuity }: PlatformAdapterRuntimeHost) => {
78
78
  if (!nativeGa4Transport) throw new Error("platform_adapter_ga4_sandbox_unavailable");
79
- return createNativeGa4Module({ transport: nativeGa4Transport });
79
+ return createNativeGa4Module({ transport: nativeGa4Transport, continuity: visitorContinuity });
80
80
  },
81
81
  }),
82
82
  Object.freeze({
@@ -154,9 +154,8 @@ export function makePlatformAdapterRuntimeLoader(
154
154
  nativeGa4Transport: registration.marker.appId === GOOGLE_MEASUREMENT_ADAPTER.adapterId
155
155
  ? options.nativeGa4Transport?.()
156
156
  : undefined,
157
- visitorContinuity: registration.marker.appId === AMPLITUDE_HTTP_ADAPTER.adapterId
158
- ? options.visitorContinuity
159
- : undefined,
157
+ // One store for both destinations, so they agree on visitors and sessions.
158
+ visitorContinuity: options.visitorContinuity,
160
159
  });
161
160
  };
162
161
  }