@tokenoftrust/storefront-runner 2.2.76 → 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-n9b6Y8jO/N+uwfItpBlKcnLoRaKnidYcvDkuixl+DwY='",
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.76",
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",
@@ -74,6 +74,8 @@ export const NO_SESSION_ID = -1 as const;
74
74
  export const VISITOR_CONTINUITY_STORAGE_KEY = "tot.analytics-continuity.v1" as const;
75
75
  export const VISITOR_ID_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000;
76
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;
77
79
  export const AMPLITUDE_HTTP_EVENT_TYPES = Object.freeze([
78
80
  "storefront.page.viewed.v1",
79
81
  "storefront.page.viewed.v2",
@@ -184,6 +186,8 @@ export interface VisitorContinuityIds {
184
186
  deviceId: string;
185
187
  sessionId: number;
186
188
  sessionState: VisitorSessionState;
189
+ /** 1-based count of this visitor's sessions, current one included (GA4's session number). */
190
+ sessionCount: number;
187
191
  }
188
192
 
189
193
  export interface VisitorContinuity {
@@ -200,14 +204,17 @@ export interface CreateVisitorContinuityOptions {
200
204
  }
201
205
 
202
206
  interface VisitorRecord {
203
- schemaVersion: 1;
207
+ schemaVersion: 2;
204
208
  deviceId: string;
205
209
  createdAt: number;
206
210
  sessionStartedAt: number;
207
211
  lastActivityAt: number;
212
+ sessionCount: number;
208
213
  }
209
214
 
210
- 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";
211
218
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
212
219
 
213
220
  export function visitorContinuityStorageKey(scope: VisitorContinuityScope): string {
@@ -215,22 +222,33 @@ export function visitorContinuityStorageKey(scope: VisitorContinuityScope): stri
215
222
  return `${VISITOR_CONTINUITY_STORAGE_KEY}:${encodeURIComponent(contextKey)}`;
216
223
  }
217
224
 
218
- /** 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
+ */
219
231
  function liveVisitorRecord(raw: string | null, now: number): VisitorRecord | null {
220
232
  if (!raw) return null;
221
233
  let parsed: unknown;
222
234
  try { parsed = JSON.parse(raw); } catch { return null; }
223
- 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(",");
224
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;
225
241
  if (
226
- 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 ||
227
245
  typeof createdAt !== "number" || typeof sessionStartedAt !== "number" ||
228
246
  typeof lastActivityAt !== "number" ||
229
247
  !Number.isFinite(createdAt) || !Number.isFinite(sessionStartedAt) || !Number.isFinite(lastActivityAt) ||
230
248
  createdAt > now || now - createdAt >= VISITOR_ID_LIFETIME_MS ||
231
249
  sessionStartedAt < createdAt || lastActivityAt < sessionStartedAt
232
250
  ) return null;
233
- return { schemaVersion, deviceId, createdAt, sessionStartedAt, lastActivityAt };
251
+ return { schemaVersion: 2, deviceId, createdAt, sessionStartedAt, lastActivityAt, sessionCount };
234
252
  }
235
253
 
236
254
  function defaultRandomUUID(): string {
@@ -274,14 +292,15 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
274
292
  if (!record) {
275
293
  const deviceId = mint();
276
294
  if (deviceId) {
277
- record = { schemaVersion: 1, deviceId, createdAt: now, sessionStartedAt: now, lastActivityAt: now };
295
+ record = { schemaVersion: 2, deviceId, createdAt: now, sessionStartedAt: now, lastActivityAt: now, sessionCount: 1 };
278
296
  started = "first_visit";
279
297
  }
280
298
  } else {
281
299
  const rolled = now - record.lastActivityAt > SESSION_INACTIVITY_MS;
282
300
  const sessionStartedAt = rolled ? now : record.sessionStartedAt;
283
301
  if (rolled) started = "new_session";
284
- record = { ...record, sessionStartedAt, lastActivityAt: Math.max(now, record.lastActivityAt) };
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) };
285
304
  }
286
305
  if (record) {
287
306
  store.setItem(key, JSON.stringify(record));
@@ -291,6 +310,7 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
291
310
  deviceId: record.deviceId,
292
311
  sessionId: record.sessionStartedAt,
293
312
  sessionState: stateOf(key, record.sessionStartedAt),
313
+ sessionCount: record.sessionCount,
294
314
  };
295
315
  }
296
316
  } catch {
@@ -300,11 +320,11 @@ export function createVisitorContinuity(options: CreateVisitorContinuityOptions
300
320
  let deviceId = memory.get(key);
301
321
  if (!deviceId) {
302
322
  const minted = mint();
303
- if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit" };
323
+ if (!minted) return { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 };
304
324
  deviceId = minted;
305
325
  memory.set(key, deviceId);
306
326
  }
307
- return { deviceId, sessionId: NO_SESSION_ID, sessionState: "first_visit" };
327
+ return { deviceId, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 };
308
328
  },
309
329
  clear(scope) {
310
330
  const key = visitorContinuityStorageKey(scope);
@@ -479,7 +499,7 @@ function createAmplitudeHandler(input: AmplitudeHandlerInput): (event: CommerceE
479
499
  if (!mapped || input.dedupe.seenOrRemember(mapped.insert_id)) return;
480
500
  let identity: VisitorContinuityIds;
481
501
  try { identity = input.continuity.readOrMint(scope, mapped.time); }
482
- catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit" }; }
502
+ catch { identity = { deviceId: ANONYMOUS_DEVICE_ID, sessionId: NO_SESSION_ID, sessionState: "first_visit", sessionCount: 1 }; }
483
503
  const body = JSON.stringify({
484
504
  api_key: input.destinationId,
485
505
  events: [{ ...mapped, device_id: identity.deviceId, session_id: identity.sessionId }],
@@ -12,6 +12,8 @@ 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
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
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;
15
17
  /** gtag's own session-inactivity window, in seconds. */
16
18
  const GTAG_SESSION_TIMEOUT_S = 30 * 60;
17
19
  const APPROVED_ACTIONS: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
@@ -65,27 +67,37 @@ function validGovernedProperties(value: unknown): value is Record<string, string
65
67
 
66
68
  interface FrameVisitor {
67
69
  clientId: string;
68
- session?: { idSeconds: number; state: "first_visit" | "new_session" | "continuing" };
70
+ session?: { idSeconds: number; state: "first_visit" | "new_session" | "continuing"; count: number };
69
71
  }
70
72
 
71
73
  /**
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.
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.
75
79
  */
76
80
  function parseVisitor(value: unknown): FrameVisitor | null {
77
81
  const record = plainRecord(value);
78
- if (!record || !exactKeys(record, ["clientId", "sessionId", "sessionState"], ["clientId"])) return null;
79
- const { clientId, sessionId, sessionState } = record;
82
+ if (!record || !exactKeys(record, ["clientId", "sessionId", "sessionState", "sessionCount"], ["clientId"])) return null;
83
+ const { clientId, sessionId, sessionState, sessionCount } = record;
80
84
  if (typeof clientId !== "string" || !CLIENT_ID.test(clientId)) return null;
81
- if (sessionId === undefined && sessionState === undefined) return { clientId };
85
+ if (sessionId === undefined && sessionState === undefined && sessionCount === undefined) return { clientId };
82
86
  if (
83
87
  typeof sessionId !== "number" || !Number.isSafeInteger(sessionId) || sessionId <= 0 ||
84
- !SESSION_STATES.has(sessionState)
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)
85
93
  ) return null;
86
94
  return {
87
95
  clientId,
88
- session: { idSeconds: Math.floor(sessionId / 1000), state: sessionState as "first_visit" | "new_session" | "continuing" },
96
+ session: {
97
+ idSeconds: Math.floor(sessionId / 1000),
98
+ state: sessionState as "first_visit" | "new_session" | "continuing",
99
+ count: sessionCount,
100
+ },
89
101
  };
90
102
  }
91
103
 
@@ -122,9 +134,11 @@ function installMemoryCookieJar(doc: Document): Map<string, string> {
122
134
 
123
135
  /**
124
136
  * 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
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
128
142
  * `client_id` and session id and only the new-session / first-visit flags overcount.
129
143
  */
130
144
  function seedSessionCookies(jar: Map<string, string>, ids: readonly string[], visitor: FrameVisitor | null): void {
@@ -134,8 +148,9 @@ function seedSessionCookies(jar: Map<string, string>, ids: readonly string[], vi
134
148
  const nowSeconds = Math.floor(Date.now() / 1000);
135
149
  const lastSeen = session.state === "continuing" ? nowSeconds : session.idSeconds - GTAG_SESSION_TIMEOUT_S - 1;
136
150
  const sessionStart = session.state === "continuing" ? session.idSeconds : lastSeen;
151
+ const sessionNumber = session.state === "continuing" ? session.count : session.count - 1;
137
152
  for (const id of ids) {
138
- jar.set(`_ga_${id.slice("G-".length)}`, `GS2.1.s${sessionStart}$o1$g0$t${lastSeen}$j0$l0$h0`);
153
+ jar.set(`_ga_${id.slice("G-".length)}`, `GS2.1.s${sessionStart}$o${sessionNumber}$g0$t${lastSeen}$j0$l0$h0`);
139
154
  }
140
155
  }
141
156
 
@@ -28,9 +28,11 @@ export interface NativeGa4SandboxConfig {
28
28
  export interface NativeGa4SandboxVisitor {
29
29
  /** Lowercase random UUID, the GA4 `client_id`. */
30
30
  clientId: string;
31
- /** Session start in epoch ms; absent (with `sessionState`) when origin storage is unavailable. */
31
+ /** Session start in epoch ms; absent (with the other session fields) when origin storage is unavailable. */
32
32
  sessionId?: number;
33
33
  sessionState?: "first_visit" | "new_session" | "continuing";
34
+ /** 1-based session number, GA4's `sct`. */
35
+ sessionCount?: number;
34
36
  }
35
37
 
36
38
  export interface NativeGa4PageViewCommand {
@@ -161,7 +161,12 @@ function frameVisitor(continuity: VisitorContinuity, scope: VisitorContinuitySco
161
161
  if (ids.sessionId === NO_SESSION_ID || !Number.isSafeInteger(ids.sessionId) || ids.sessionId <= 0) {
162
162
  return { clientId: ids.deviceId };
163
163
  }
164
- return { clientId: ids.deviceId, sessionId: ids.sessionId, sessionState: ids.sessionState };
164
+ return {
165
+ clientId: ids.deviceId,
166
+ sessionId: ids.sessionId,
167
+ sessionState: ids.sessionState,
168
+ sessionCount: ids.sessionCount,
169
+ };
165
170
  }
166
171
 
167
172
  function hasAnalyticsConsent(ctx: IntegrationLifecycleContext): boolean {
@@ -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
+ }