@tokenoftrust/storefront-runner 2.2.75 → 2.2.77

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-ZxqYN/PbpT0W68E87+IwdGfcYJuAVXNPNVxVb3VX0hw='",
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.77",
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
@@ -73,6 +74,8 @@ export const NO_SESSION_ID = -1 as const;
73
74
  export const VISITOR_CONTINUITY_STORAGE_KEY = "tot.analytics-continuity.v1" as const;
74
75
  export const VISITOR_ID_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000;
75
76
  export const SESSION_INACTIVITY_MS = 30 * 60 * 1000;
77
+ /** Ceiling on the per-visitor session count; a 90-day id cannot plausibly reach it. */
78
+ export const MAX_VISITOR_SESSION_COUNT = 100_000;
76
79
  export const AMPLITUDE_HTTP_EVENT_TYPES = Object.freeze([
77
80
  "storefront.page.viewed.v1",
78
81
  "storefront.page.viewed.v2",
@@ -171,9 +174,20 @@ export interface VisitorContinuityScope {
171
174
  siteSurface: string;
172
175
  }
173
176
 
177
+ /**
178
+ * How the current session relates to this document: `first_visit` when this document
179
+ * minted the visitor, `new_session` when it started a new session for a known visitor,
180
+ * otherwise `continuing`. Recorded per continuity instance (one per document), so the
181
+ * first adapter to read the visitor does not hide the arrival from the others.
182
+ */
183
+ export type VisitorSessionState = "first_visit" | "new_session" | "continuing";
184
+
174
185
  export interface VisitorContinuityIds {
175
186
  deviceId: string;
176
187
  sessionId: number;
188
+ sessionState: VisitorSessionState;
189
+ /** 1-based count of this visitor's sessions, current one included (GA4's session number). */
190
+ sessionCount: number;
177
191
  }
178
192
 
179
193
  export interface VisitorContinuity {
@@ -190,14 +204,17 @@ export interface CreateVisitorContinuityOptions {
190
204
  }
191
205
 
192
206
  interface VisitorRecord {
193
- schemaVersion: 1;
207
+ schemaVersion: 2;
194
208
  deviceId: string;
195
209
  createdAt: number;
196
210
  sessionStartedAt: number;
197
211
  lastActivityAt: number;
212
+ sessionCount: number;
198
213
  }
199
214
 
200
- const VISITOR_RECORD_KEYS = "createdAt,deviceId,lastActivityAt,schemaVersion,sessionStartedAt";
215
+ const VISITOR_RECORD_KEYS = "createdAt,deviceId,lastActivityAt,schemaVersion,sessionCount,sessionStartedAt";
216
+ /** The exact schemaVersion-1 shape, written before the session number existed; upgraded in place. */
217
+ const VISITOR_RECORD_V1_KEYS = "createdAt,deviceId,lastActivityAt,schemaVersion,sessionStartedAt";
201
218
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
202
219
 
203
220
  export function visitorContinuityStorageKey(scope: VisitorContinuityScope): string {
@@ -205,22 +222,33 @@ export function visitorContinuityStorageKey(scope: VisitorContinuityScope): stri
205
222
  return `${VISITOR_CONTINUITY_STORAGE_KEY}:${encodeURIComponent(contextKey)}`;
206
223
  }
207
224
 
208
- /** A stored record that is malformed, from the future, or past its fixed lifetime is not a visitor. */
225
+ /**
226
+ * A stored record that is malformed, from the future, or past its fixed lifetime is not a
227
+ * visitor. A valid record in the exact schemaVersion-1 shape keeps its id and session and
228
+ * becomes schemaVersion 2 at session number 1 (the caller writes it back); every other
229
+ * shape is still refused, so an upgraded visitor is not counted twice.
230
+ */
209
231
  function liveVisitorRecord(raw: string | null, now: number): VisitorRecord | null {
210
232
  if (!raw) return null;
211
233
  let parsed: unknown;
212
234
  try { parsed = JSON.parse(raw); } catch { return null; }
213
- if (!isPlainObject(parsed) || Object.keys(parsed).sort().join(",") !== VISITOR_RECORD_KEYS) return null;
235
+ if (!isPlainObject(parsed)) return null;
236
+ const keys = Object.keys(parsed).sort().join(",");
214
237
  const { schemaVersion, deviceId, createdAt, sessionStartedAt, lastActivityAt } = parsed;
238
+ let sessionCount = parsed["sessionCount"];
239
+ if (keys === VISITOR_RECORD_V1_KEYS && schemaVersion === 1) sessionCount = 1;
240
+ else if (keys !== VISITOR_RECORD_KEYS || schemaVersion !== 2) return null;
215
241
  if (
216
- schemaVersion !== 1 || typeof deviceId !== "string" || !UUID_PATTERN.test(deviceId) ||
242
+ typeof deviceId !== "string" || !UUID_PATTERN.test(deviceId) ||
243
+ typeof sessionCount !== "number" || !Number.isSafeInteger(sessionCount) ||
244
+ sessionCount < 1 || sessionCount > MAX_VISITOR_SESSION_COUNT ||
217
245
  typeof createdAt !== "number" || typeof sessionStartedAt !== "number" ||
218
246
  typeof lastActivityAt !== "number" ||
219
247
  !Number.isFinite(createdAt) || !Number.isFinite(sessionStartedAt) || !Number.isFinite(lastActivityAt) ||
220
248
  createdAt > now || now - createdAt >= VISITOR_ID_LIFETIME_MS ||
221
249
  sessionStartedAt < createdAt || lastActivityAt < sessionStartedAt
222
250
  ) return null;
223
- return { schemaVersion, deviceId, createdAt, sessionStartedAt, lastActivityAt };
251
+ return { schemaVersion: 2, deviceId, createdAt, sessionStartedAt, lastActivityAt, sessionCount };
224
252
  }
225
253
 
226
254
  function defaultRandomUUID(): string {
@@ -235,6 +263,11 @@ function defaultRandomUUID(): string {
235
263
  export function createVisitorContinuity(options: CreateVisitorContinuityOptions = {}): VisitorContinuity {
236
264
  const randomUUID = options.randomUUID ?? defaultRandomUUID;
237
265
  const memory = new Map<string, string>();
266
+ const startedHere = new Map<string, { sessionId: number; sessionState: VisitorSessionState }>();
267
+ const stateOf = (key: string, sessionId: number): VisitorSessionState => {
268
+ const started = startedHere.get(key);
269
+ return started && started.sessionId === sessionId ? started.sessionState : "continuing";
270
+ };
238
271
  const storage = (): VisitorContinuityStorage | undefined => {
239
272
  try { return options.storage?.(); } catch { return undefined; }
240
273
  };
@@ -255,21 +288,30 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
255
288
  try {
256
289
  const existing = liveVisitorRecord(store.getItem(key), now);
257
290
  let record: VisitorRecord | null = existing;
291
+ let started: VisitorSessionState | null = null;
258
292
  if (!record) {
259
293
  const deviceId = mint();
260
294
  if (deviceId) {
261
- record = { schemaVersion: 1, deviceId, createdAt: now, sessionStartedAt: now, lastActivityAt: now };
295
+ record = { schemaVersion: 2, deviceId, createdAt: now, sessionStartedAt: now, lastActivityAt: now, sessionCount: 1 };
296
+ started = "first_visit";
262
297
  }
263
298
  } else {
264
- const sessionStartedAt = now - record.lastActivityAt > SESSION_INACTIVITY_MS
265
- ? now
266
- : record.sessionStartedAt;
267
- record = { ...record, sessionStartedAt, lastActivityAt: Math.max(now, record.lastActivityAt) };
299
+ const rolled = now - record.lastActivityAt > SESSION_INACTIVITY_MS;
300
+ const sessionStartedAt = rolled ? now : record.sessionStartedAt;
301
+ if (rolled) started = "new_session";
302
+ const sessionCount = rolled ? Math.min(record.sessionCount + 1, MAX_VISITOR_SESSION_COUNT) : record.sessionCount;
303
+ record = { ...record, sessionStartedAt, sessionCount, lastActivityAt: Math.max(now, record.lastActivityAt) };
268
304
  }
269
305
  if (record) {
270
306
  store.setItem(key, JSON.stringify(record));
271
307
  memory.delete(key);
272
- return { deviceId: record.deviceId, sessionId: record.sessionStartedAt };
308
+ if (started) startedHere.set(key, { sessionId: record.sessionStartedAt, sessionState: started });
309
+ return {
310
+ deviceId: record.deviceId,
311
+ sessionId: record.sessionStartedAt,
312
+ sessionState: stateOf(key, record.sessionStartedAt),
313
+ sessionCount: record.sessionCount,
314
+ };
273
315
  }
274
316
  } catch {
275
317
  // Blocked or full storage: fall through to this document's memory-only visitor.
@@ -278,15 +320,16 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
278
320
  let deviceId = memory.get(key);
279
321
  if (!deviceId) {
280
322
  const minted = mint();
281
- if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID };
323
+ if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 };
282
324
  deviceId = minted;
283
325
  memory.set(key, deviceId);
284
326
  }
285
- return { deviceId, sessionId: NO_SESSION_ID };
327
+ return { deviceId, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 };
286
328
  },
287
329
  clear(scope) {
288
330
  const key = visitorContinuityStorageKey(scope);
289
331
  memory.delete(key);
332
+ startedHere.delete(key);
290
333
  try { storage()?.removeItem(key); } catch { /* the early consent boot clears on the next load */ }
291
334
  },
292
335
  };
@@ -456,7 +499,7 @@ function createAmplitudeHandler(input: AmplitudeHandlerInput): (event: CommerceE
456
499
  if (!mapped || input.dedupe.seenOrRemember(mapped.insert_id)) return;
457
500
  let identity: VisitorContinuityIds;
458
501
  try { identity = input.continuity.readOrMint(scope, mapped.time); }
459
- catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID }; }
502
+ catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 }; }
460
503
  const body = JSON.stringify({
461
504
  api_key: input.destinationId,
462
505
  events: [{ ...mapped, device_id: identity.deviceId, session_id: identity.sessionId }],
@@ -10,6 +10,12 @@ 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
+ /** Ceiling on the session number the parent may claim (its own record caps at the same value). */
16
+ const MAX_SESSION_COUNT = 100_000;
17
+ /** gtag's own session-inactivity window, in seconds. */
18
+ const GTAG_SESSION_TIMEOUT_S = 30 * 60;
13
19
  const APPROVED_ACTIONS: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
14
20
 
15
21
  function plainRecord(value: unknown): Record<string, unknown> | null {
@@ -59,6 +65,95 @@ function validGovernedProperties(value: unknown): value is Record<string, string
59
65
  !resemblesManagedAnalyticsProtectedValue(item));
60
66
  }
61
67
 
68
+ interface FrameVisitor {
69
+ clientId: string;
70
+ session?: { idSeconds: number; state: "first_visit" | "new_session" | "continuing"; count: number };
71
+ }
72
+
73
+ /**
74
+ * The visitor crosses the same hostile boundary: a lowercase UUID and, all together or
75
+ * not at all, a positive integer epoch-ms session start, its closed state, and a capped
76
+ * positive session number (1 exactly on a first visit, at least 2 on a new session).
77
+ * Anything else refuses the whole configure rather than falling back to an id gtag would
78
+ * invent.
79
+ */
80
+ function parseVisitor(value: unknown): FrameVisitor | null {
81
+ const record = plainRecord(value);
82
+ if (!record || !exactKeys(record, ["clientId", "sessionId", "sessionState", "sessionCount"], ["clientId"])) return null;
83
+ const { clientId, sessionId, sessionState, sessionCount } = record;
84
+ if (typeof clientId !== "string" || !CLIENT_ID.test(clientId)) return null;
85
+ if (sessionId === undefined && sessionState === undefined && sessionCount === undefined) return { clientId };
86
+ if (
87
+ typeof sessionId !== "number" || !Number.isSafeInteger(sessionId) || sessionId <= 0 ||
88
+ !SESSION_STATES.has(sessionState) ||
89
+ typeof sessionCount !== "number" || !Number.isSafeInteger(sessionCount) ||
90
+ sessionCount < 1 || sessionCount > MAX_SESSION_COUNT ||
91
+ (sessionState === "first_visit" && sessionCount !== 1) ||
92
+ (sessionState === "new_session" && sessionCount < 2)
93
+ ) return null;
94
+ return {
95
+ clientId,
96
+ session: {
97
+ idSeconds: Math.floor(sessionId / 1000),
98
+ state: sessionState as "first_visit" | "new_session" | "continuing",
99
+ count: sessionCount,
100
+ },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * gtag keeps its visitor and session in `_ga` / `_ga_<container>` cookies and ignores
106
+ * `client_storage: 'none'` (measured 2026-10-06 against the live gtag.js). This frame's
107
+ * origin is shared by every hosted tenant, so a cookie written here would link one
108
+ * browser across tenants. The frame's `document.cookie` is therefore replaced, before
109
+ * gtag loads, with a jar that lives only as long as this document.
110
+ */
111
+ function installMemoryCookieJar(doc: Document): Map<string, string> {
112
+ const jar = new Map<string, string>();
113
+ Object.defineProperty(doc, "cookie", {
114
+ configurable: false,
115
+ enumerable: true,
116
+ get: () => Array.from(jar, ([name, value]) => `${name}=${value}`).join("; "),
117
+ set: (raw: unknown) => {
118
+ const [pair = "", ...attributes] = String(raw).split(";");
119
+ const split = pair.indexOf("=");
120
+ if (split <= 0) return;
121
+ const name = pair.slice(0, split).trim();
122
+ const expired = attributes.some((attribute) => {
123
+ const [key = "", value = ""] = attribute.split("=").map((part) => part.trim());
124
+ if (key.toLowerCase() === "max-age") return Number(value) <= 0;
125
+ if (key.toLowerCase() === "expires") return Date.parse(value) <= Date.now();
126
+ return false;
127
+ });
128
+ if (expired) jar.delete(name);
129
+ else jar.set(name, pair.slice(split + 1).trim());
130
+ },
131
+ });
132
+ return jar;
133
+ }
134
+
135
+ /**
136
+ * Seed gtag's session cookie so it reports the parent's session: `continuing` gets a
137
+ * live session (no `session_start`, no `first_visit`) at session number `count`;
138
+ * `new_session` gets an expired one at `count - 1`, which gtag increments as it starts
139
+ * the session for a returning visitor; `first_visit` gets none (gtag reports 1). The
140
+ * GS2 layout is gtag's own and undocumented (`o` is its session counter, measured
141
+ * 2026-10-06); if gtag stops parsing it, collection still carries the parent's
142
+ * `client_id` and session id and only the new-session / first-visit flags overcount.
143
+ */
144
+ function seedSessionCookies(jar: Map<string, string>, ids: readonly string[], visitor: FrameVisitor | null): void {
145
+ jar.clear();
146
+ const session = visitor?.session;
147
+ if (!session || session.state === "first_visit") return;
148
+ const nowSeconds = Math.floor(Date.now() / 1000);
149
+ const lastSeen = session.state === "continuing" ? nowSeconds : session.idSeconds - GTAG_SESSION_TIMEOUT_S - 1;
150
+ const sessionStart = session.state === "continuing" ? session.idSeconds : lastSeen;
151
+ const sessionNumber = session.state === "continuing" ? session.count : session.count - 1;
152
+ for (const id of ids) {
153
+ jar.set(`_ga_${id.slice("G-".length)}`, `GS2.1.s${sessionStart}$o${sessionNumber}$g0$t${lastSeen}$j0$l0$h0`);
154
+ }
155
+ }
156
+
62
157
  function validCampaign(value: unknown): value is Record<string, string> {
63
158
  const record = plainRecord(value);
64
159
  if (!record || !exactKeys(record, ["source", "medium", "campaign", "term", "content"], [])) return false;
@@ -105,6 +200,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
105
200
  let lastPageLocation = "";
106
201
  let tagLoaded = false;
107
202
 
203
+ const cookieJar = installMemoryCookieJar(doc);
108
204
  const dataLayer: unknown[] = [];
109
205
  const gtag = function(..._args: unknown[]): void {
110
206
  dataLayer.push(arguments);
@@ -114,13 +210,20 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
114
210
 
115
211
  const configure = (message: Record<string, unknown>): void => {
116
212
  if (
117
- !exactKeys(message, ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties"]) ||
213
+ !exactKeys(
214
+ message,
215
+ ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties", "visitor"],
216
+ ["schemaVersion", "type", "destinationIds", "canonicalOrigin", "analyticsConsent", "governedProperties"],
217
+ ) ||
118
218
  message["schemaVersion"] !== 1 || message["type"] !== "configure" ||
119
219
  message["analyticsConsent"] !== "granted" ||
120
220
  !validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"]) ||
121
221
  !validGovernedProperties(message["governedProperties"])
122
222
  ) return;
223
+ const visitor = message["visitor"] === undefined ? null : parseVisitor(message["visitor"]);
224
+ if (message["visitor"] !== undefined && !visitor) return;
123
225
  const ids = message["destinationIds"];
226
+ seedSessionCookies(cookieJar, ids, visitor);
124
227
  canonicalOrigin = message["canonicalOrigin"];
125
228
  governedProperties = { ...message["governedProperties"] };
126
229
  currentDestinations = [...ids];
@@ -149,6 +252,8 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
149
252
  allow_ad_personalization_signals: false,
150
253
  restricted_data_processing: true,
151
254
  ignore_referrer: true,
255
+ ...(visitor ? { client_id: visitor.clientId } : {}),
256
+ ...(visitor?.session ? { session_id: visitor.session.idSeconds } : {}),
152
257
  });
153
258
  }
154
259
  if (doc.querySelector('script[data-tot-ga4="1"]') === null) {
@@ -214,6 +319,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
214
319
  active = false;
215
320
  currentDestinations = [];
216
321
  lastPageLocation = "";
322
+ cookieJar.clear();
217
323
  }
218
324
  return;
219
325
  }
@@ -223,6 +329,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
223
329
  disposed = true;
224
330
  currentDestinations = [];
225
331
  lastPageLocation = "";
332
+ cookieJar.clear();
226
333
  port.close();
227
334
  }
228
335
  return;
@@ -17,6 +17,22 @@ 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 the other session fields) when origin storage is unavailable. */
32
+ sessionId?: number;
33
+ sessionState?: "first_visit" | "new_session" | "continuing";
34
+ /** 1-based session number, GA4's `sct`. */
35
+ sessionCount?: number;
20
36
  }
21
37
 
22
38
  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,34 @@ 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 {
165
+ clientId: ids.deviceId,
166
+ sessionId: ids.sessionId,
167
+ sessionState: ids.sessionState,
168
+ sessionCount: ids.sessionCount,
169
+ };
170
+ }
171
+
122
172
  function hasAnalyticsConsent(ctx: IntegrationLifecycleContext): boolean {
123
173
  const consent = parseConsentSnapshot(ctx.consent);
124
174
  return consent !== null && isPurposeEligible(consent, "analytics");
@@ -194,6 +244,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
194
244
  let currentPageLocation: string | null = null;
195
245
  const delivered = new Set<string>();
196
246
  const deliveredOrder: string[] = [];
247
+ const continuity = options.continuity ?? createVisitorContinuity();
248
+ const now = options.now ?? (() => Date.now());
197
249
 
198
250
  const transport = typeof options.transport === "function" ? undefined : options.transport;
199
251
  const send = typeof options.transport === "function" ? options.transport : options.transport.send.bind(options.transport);
@@ -221,11 +273,17 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
221
273
  }
222
274
  };
223
275
 
224
- const apply = (ctx: IntegrationLifecycleContext): void => {
276
+ /** `clearIfIneligible`: an update that loses analytics eligibility forgets the visitor. */
277
+ const apply = (ctx: IntegrationLifecycleContext, clearIfIneligible: boolean): void => {
225
278
  if (!stop() || disposed) return;
226
279
  const next = configuredFor(ctx);
227
280
  if (!next) {
228
- observeSafely(options.observe, hasAnalyticsConsent(ctx) ? "configuration_invalid" : "consent_ineligible");
281
+ const consented = hasAnalyticsConsent(ctx);
282
+ if (!consented && clearIfIneligible) {
283
+ const scope = continuityScope(ctx);
284
+ try { if (scope) continuity.clear(scope); } catch { /* the early consent boot clears on the next load */ }
285
+ }
286
+ observeSafely(options.observe, consented ? "configuration_invalid" : "consent_ineligible");
229
287
  return;
230
288
  }
231
289
  if (!ctx.subscribe) {
@@ -233,12 +291,14 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
233
291
  return;
234
292
  }
235
293
  try {
236
- const configuration = {
294
+ const visitor = frameVisitor(continuity, next.scope, now());
295
+ const configuration: NativeGa4SandboxConfig = {
237
296
  schemaVersion: 1 as const,
238
297
  destinationIds: [...next.destinationIds],
239
298
  canonicalOrigin: next.canonicalOrigin,
240
299
  analyticsConsent: "granted" as const,
241
300
  governedProperties: { ...next.governedProperties },
301
+ ...(visitor ? { visitor } : {}),
242
302
  };
243
303
  runTransportEffect(transport?.configure ? () => transport.configure!(configuration) : undefined);
244
304
  } catch {
@@ -264,6 +324,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
264
324
  return;
265
325
  }
266
326
  if (command.type === "page_view") currentPageLocation = `${current.canonicalOrigin}${command.path}`;
327
+ // Record activity so the shared session rolls on inactivity, not on page-load age.
328
+ try { continuity.readOrMint(current.scope, now()); } catch { /* activity is best-effort */ }
267
329
  delivered.add(event.id);
268
330
  deliveredOrder.push(event.id);
269
331
  if (deliveredOrder.length > 1_024) {
@@ -284,8 +346,8 @@ export function createNativeGa4Module(options: NativeGa4ModuleOptions): Integrat
284
346
  };
285
347
 
286
348
  return {
287
- init: apply,
288
- update(_reason: LifecycleUpdateReason, ctx) { return apply(ctx); },
349
+ init(ctx) { return apply(ctx, false); },
350
+ update(_reason: LifecycleUpdateReason, ctx) { return apply(ctx, true); },
289
351
  dispose() {
290
352
  disposed = true;
291
353
  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
  }
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ // ga4-gs2-canary.mjs — does gtag.js still read the GA4 sandbox's seeded session cookie?
3
+ //
4
+ // The sandbox frame seeds gtag's `_ga_<container>` cookie (Google's undocumented GS2 format)
5
+ // into an in-memory jar so GA4 reports the parent's visitor and session. This loads the BUILT
6
+ // sandbox document and the CURRENT live gtag.js in headless Chromium, drives three page loads
7
+ // (first visit, continuing, new session) through the real bind/configure protocol, and asserts
8
+ // what gtag puts on /g/collect. Every collect request is ABORTED: nothing reaches Google.
9
+ //
10
+ // Needs the network (gtag.js), so it is a canary, not a CI test. Exit 0 = gtag behaves as the
11
+ // seed expects; 1 = drift (the docs say what GA4 then reports); 2 = could not run.
12
+ //
13
+ // Usage: node scripts/dev/ga4-gs2-canary.mjs
14
+ import { createRequire } from "node:module";
15
+
16
+ const storefrontRequire = createRequire(new URL("../../apps/storefront/package.json", import.meta.url));
17
+ let chromium;
18
+ let buildGoogleMeasurementSandboxHtml;
19
+ try {
20
+ ({ chromium } = storefrontRequire("@playwright/test"));
21
+ ({ buildGoogleMeasurementSandboxHtml } = await import("../../apps/storefront/integrations/google-measurement-sandbox-asset.mjs"));
22
+ } catch (error) {
23
+ console.error(`ga4-gs2-canary: cannot run — ${error instanceof Error ? error.message : String(error)}`);
24
+ process.exit(2);
25
+ }
26
+
27
+ const PARENT = "https://shop.canary.test";
28
+ const FRAME = "https://www.shop.canary.test/platform/google-measurement/v2/sandbox.html";
29
+ const MEASUREMENT_ID = "G-CANARY0001";
30
+ const CLIENT_ID = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d";
31
+ const FIRST_SESSION_MS = Date.now() - 5 * 60 * 1000;
32
+ const SECOND_SESSION_MS = Date.now();
33
+ const seconds = (ms) => String(Math.floor(ms / 1000));
34
+
35
+ const LOADS = [
36
+ {
37
+ name: "first visit",
38
+ visitor: { clientId: CLIENT_ID, sessionId: FIRST_SESSION_MS, sessionState: "first_visit", sessionCount: 1 },
39
+ expect: { cid: CLIENT_ID, sid: seconds(FIRST_SESSION_MS), sct: "1", _ss: "1", _fv: "1" },
40
+ },
41
+ {
42
+ name: "next page, same session",
43
+ visitor: { clientId: CLIENT_ID, sessionId: FIRST_SESSION_MS, sessionState: "continuing", sessionCount: 1 },
44
+ expect: { cid: CLIENT_ID, sid: seconds(FIRST_SESSION_MS), sct: "1", _ss: null, _fv: null },
45
+ },
46
+ {
47
+ name: "return, new session",
48
+ visitor: { clientId: CLIENT_ID, sessionId: SECOND_SESSION_MS, sessionState: "new_session", sessionCount: 2 },
49
+ expect: { cid: CLIENT_ID, sid: seconds(SECOND_SESSION_MS), sct: "2", _ss: "1", _fv: null },
50
+ },
51
+ ];
52
+
53
+ const parentPage = (visitor) => `<!doctype html><body><script>
54
+ const frame = document.createElement("iframe");
55
+ frame.setAttribute("sandbox", "allow-scripts allow-same-origin");
56
+ frame.src = ${JSON.stringify(FRAME)};
57
+ frame.addEventListener("load", () => {
58
+ const channel = new MessageChannel();
59
+ frame.contentWindow.postMessage({ protocol: "tot.ga4-sandbox/1", type: "bind", parentOrigin: location.origin }, new URL(frame.src).origin, [channel.port2]);
60
+ channel.port1.postMessage({ schemaVersion: 1, type: "configure", destinationIds: [${JSON.stringify(MEASUREMENT_ID)}],
61
+ canonicalOrigin: location.origin, analyticsConsent: "granted",
62
+ governedProperties: { tenant: "canary", environment: "live", site_surface: "storefront" },
63
+ visitor: ${JSON.stringify(visitor)} });
64
+ channel.port1.postMessage({ schemaVersion: 1, type: "page_view", path: "/canary" });
65
+ });
66
+ document.body.append(frame);
67
+ </script></body>`;
68
+
69
+ const html = await buildGoogleMeasurementSandboxHtml();
70
+ const browser = await chromium.launch();
71
+ const failures = [];
72
+ let gtagLoaded = false;
73
+ try {
74
+ const context = await browser.newContext();
75
+ const pageViews = new Map();
76
+ let current = 0;
77
+ await context.route("**/*", async (route) => {
78
+ const request = route.request();
79
+ const url = new URL(request.url());
80
+ if (request.url().startsWith(FRAME)) return route.fulfill({ status: 200, contentType: "text/html", body: html });
81
+ if (url.origin === PARENT) return route.fulfill({ status: 200, contentType: "text/html", body: parentPage(LOADS[current].visitor) });
82
+ if (url.host === "www.googletagmanager.com" && url.pathname === "/gtag/js") {
83
+ gtagLoaded = true;
84
+ return route.continue();
85
+ }
86
+ if (url.pathname.endsWith("/g/collect")) {
87
+ for (const line of [url.search.slice(1), ...(request.postData() ?? "").split("\n")].filter(Boolean)) {
88
+ const params = new URLSearchParams(url.search);
89
+ for (const [key, value] of new URLSearchParams(line)) params.set(key, value);
90
+ if (params.get("en") === "page_view" && !pageViews.has(current)) {
91
+ pageViews.set(current, Object.fromEntries(["cid", "sid", "sct", "_ss", "_fv"].map((key) => [key, params.get(key)])));
92
+ }
93
+ }
94
+ }
95
+ return route.abort();
96
+ });
97
+ const page = await context.newPage();
98
+ for (current = 0; current < LOADS.length; current += 1) {
99
+ await page.goto(`${PARENT}/?load=${current}`);
100
+ for (let waited = 0; !pageViews.has(current) && waited < 15_000; waited += 250) await page.waitForTimeout(250);
101
+ await page.goto("about:blank");
102
+ await page.waitForTimeout(500);
103
+ }
104
+ const cookies = await context.cookies([FRAME, PARENT]);
105
+
106
+ if (!gtagLoaded) {
107
+ console.error("ga4-gs2-canary: cannot run — gtag.js never loaded (network?)");
108
+ process.exitCode = 2;
109
+ } else {
110
+ LOADS.forEach((load, index) => {
111
+ const actual = pageViews.get(index);
112
+ if (!actual) return failures.push(`${load.name}: no page_view reached /g/collect`);
113
+ for (const [key, value] of Object.entries(load.expect)) {
114
+ if (actual[key] !== value) failures.push(`${load.name}: ${key}=${actual[key]} (expected ${value})`);
115
+ }
116
+ console.log(`${load.name}: ${JSON.stringify({ ...actual, cid: actual.cid === CLIENT_ID ? "<seeded>" : actual.cid })}`);
117
+ });
118
+ if (cookies.length > 0) failures.push(`cookies written on the frame or parent origin: ${cookies.map((c) => c.name).join(", ")}`);
119
+ if (failures.length > 0) {
120
+ console.error("ga4-gs2-canary: DRIFT — gtag no longer honours the sandbox seed as expected:");
121
+ for (const failure of failures) console.error(` - ${failure}`);
122
+ console.error("See docs/architecture/analytics-activation.md (GA4) for what GA4 reports in this state.");
123
+ process.exitCode = 1;
124
+ } else {
125
+ console.log("ga4-gs2-canary: OK — gtag reads the GS2 seed; cid, sid, sct, _ss and _fv match, no cookies written.");
126
+ }
127
+ }
128
+ } finally {
129
+ await browser.close();
130
+ }