@tokenoftrust/storefront-runner 2.0.30 → 2.0.32
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.
- package/apps/storefront/astro.config.mjs +2 -0
- package/apps/storefront/env.d.ts +0 -2
- package/apps/storefront/integrations/google-measurement-sandbox-asset.mjs +97 -0
- package/apps/storefront/public/platform/google-measurement/v2/entry.mjs +13 -0
- package/apps/storefront/src/layouts/Layout.astro +3 -48
- package/apps/storefront/src/lib/analytics/budgets.json +0 -1
- package/apps/storefront/src/lib/analytics/index.ts +1 -4
- package/apps/storefront/src/lib/analytics/webVitals.ts +42 -4
- package/apps/storefront/src/lib/integration/tenantDeclarations.ts +1 -1
- package/apps/storefront/src/lib/monitoring/manifest.ts +1 -2
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -1
- package/packages/public-runtime/src/csp.ts +2 -2
- package/packages/public-runtime/src/integration/consent-broker.ts +370 -13
- package/packages/public-runtime/src/integration/csp-compiler.ts +3 -6
- package/packages/public-runtime/src/integration/events.ts +5 -5
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +1 -2
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +28 -14
- package/packages/public-runtime/src/integration/lifecycle-host-entry.ts +2 -0
- package/packages/public-runtime/src/integration/lifecycle-runtime.ts +256 -22
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +9 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +3 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +207 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +168 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +111 -215
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +18 -9
- package/packages/public-runtime/src/integration/platform-adapters.ts +20 -9
- package/packages/public-runtime/src/integration/render-phases.ts +28 -2
- package/packages/public-runtime/src/integration/version.ts +2 -2
- package/packages/public-runtime/tests/fixtures/integration/commerce-event.example.json +1 -1
- package/packages/public-runtime/tests/fixtures/integration/commerce-order-event.example.json +1 -1
- package/packages/public-runtime/tests/fixtures/integration/integration-manifest-v2.example.json +1 -1
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/manifest.json +1 -1
- package/scripts/tenant/lint-embeds.mjs +0 -8
- package/apps/storefront/public/platform/google-measurement/v1/entry.mjs +0 -19
|
@@ -160,6 +160,13 @@ type LifecycleState = "idle" | "initializing" | "initialized" | "disposing" | "d
|
|
|
160
160
|
interface ActiveIntegration {
|
|
161
161
|
integration: BootDescriptorIntegration;
|
|
162
162
|
module: IntegrationLifecycleModule;
|
|
163
|
+
eligible: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A module whose init hook has started but has not yet settled. */
|
|
167
|
+
interface StartingIntegration extends ActiveIntegration {
|
|
168
|
+
cancelled: boolean;
|
|
169
|
+
disposed: boolean;
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
function messageOf(error: unknown): string {
|
|
@@ -195,8 +202,16 @@ export function sanitizeNavigationUrl(raw: string, keepHash: boolean): string {
|
|
|
195
202
|
export class IntegrationLifecycle {
|
|
196
203
|
private state: LifecycleState = "idle";
|
|
197
204
|
private readonly active: ActiveIntegration[] = [];
|
|
205
|
+
/** Modules visible to an interrupting consent withdrawal while init is unresolved. */
|
|
206
|
+
private readonly starting: StartingIntegration[] = [];
|
|
198
207
|
/** Integrations held back by the consent gate; re-evaluated on each `update`. */
|
|
199
208
|
private deferred: BootDescriptorIntegration[] = [];
|
|
209
|
+
/** Updates arriving during async initialization, replayed before bootstrap emission. */
|
|
210
|
+
private readonly pendingUpdates: LifecycleUpdateReason[] = [];
|
|
211
|
+
/** An atomic withdrawal/grant seen during init needs only deferred-load reconciliation. */
|
|
212
|
+
private pendingGrantReconciliation = false;
|
|
213
|
+
/** Serializes ordinary post-init updates; consent withdrawal interrupts an unresolved grant. */
|
|
214
|
+
private updateQueue: Promise<void> = Promise.resolve();
|
|
200
215
|
private eventCounter = 0;
|
|
201
216
|
private readonly opts: IntegrationLifecycleOptions;
|
|
202
217
|
|
|
@@ -220,16 +235,26 @@ export class IntegrationLifecycle {
|
|
|
220
235
|
if (this.state !== "idle") return;
|
|
221
236
|
this.state = "initializing";
|
|
222
237
|
for (const integration of this.opts.descriptor.integrations) {
|
|
238
|
+
if (this.state !== "initializing") break;
|
|
223
239
|
if (!this.eligible(integration)) {
|
|
224
240
|
// Consent precedes the loader: hold it back, do not load, and record why.
|
|
225
241
|
this.deferred.push(integration);
|
|
226
242
|
this.observe({ stage: "gate", integration, ok: false });
|
|
227
243
|
continue;
|
|
228
244
|
}
|
|
229
|
-
await this.initOne(integration);
|
|
245
|
+
const remainDeferred = await this.initOne(integration);
|
|
246
|
+
if (this.state !== "initializing") break;
|
|
247
|
+
if (remainDeferred) this.deferred.push(integration);
|
|
230
248
|
}
|
|
231
249
|
// A dispose that raced in during an await leaves us terminal; otherwise we are live.
|
|
232
250
|
if (this.state === "initializing") this.state = "initialized";
|
|
251
|
+
if (this.state === "initialized" && this.pendingGrantReconciliation) {
|
|
252
|
+
this.pendingGrantReconciliation = false;
|
|
253
|
+
await this.loadNewlyEligible();
|
|
254
|
+
}
|
|
255
|
+
if (this.state === "initialized") {
|
|
256
|
+
for (const reason of this.pendingUpdates.splice(0)) await this.update(reason);
|
|
257
|
+
}
|
|
233
258
|
// Last, so every adapter that just subscribed is registered when this emits: the
|
|
234
259
|
// broker has no replay, so a page view emitted before their `init` reaches nobody.
|
|
235
260
|
if (this.state === "initialized") this.emitBootstrapPageView();
|
|
@@ -250,6 +275,7 @@ export class IntegrationLifecycle {
|
|
|
250
275
|
* throwing integration.
|
|
251
276
|
*/
|
|
252
277
|
private emitBootstrapPageView(): void {
|
|
278
|
+
if (this.state !== "initialized") return;
|
|
253
279
|
const managed = this.opts.managedAnalytics;
|
|
254
280
|
if (!managed || managed.bridge.isInitialized || managed.bridge.isDisposed) return;
|
|
255
281
|
if (this.opts.commerceBroker?.isConsentEligible("analytics") === false) return;
|
|
@@ -265,6 +291,11 @@ export class IntegrationLifecycle {
|
|
|
265
291
|
return this.opts.isEligible ? this.opts.isEligible(integration) : true;
|
|
266
292
|
}
|
|
267
293
|
|
|
294
|
+
/** Read terminal state across async hook boundaries without relying on stale narrowing. */
|
|
295
|
+
private isTerminal(): boolean {
|
|
296
|
+
return this.state === "disposing" || this.state === "disposed";
|
|
297
|
+
}
|
|
298
|
+
|
|
268
299
|
/**
|
|
269
300
|
* Load any consent-deferred integration that has since become eligible, in the
|
|
270
301
|
* plan's resolved order. Called from `update` so a consent grant loads the
|
|
@@ -273,31 +304,179 @@ export class IntegrationLifecycle {
|
|
|
273
304
|
private async loadNewlyEligible(): Promise<void> {
|
|
274
305
|
if (this.deferred.length === 0) return;
|
|
275
306
|
const stillDeferred: BootDescriptorIntegration[] = [];
|
|
307
|
+
const candidates: BootDescriptorIntegration[] = [];
|
|
276
308
|
for (const integration of this.deferred) {
|
|
277
|
-
|
|
278
|
-
else stillDeferred.push(integration);
|
|
309
|
+
(this.eligible(integration) ? candidates : stillDeferred).push(integration);
|
|
279
310
|
}
|
|
311
|
+
// Reserve eligible entries before the first await so an interrupting consent
|
|
312
|
+
// transition cannot start a duplicate load of the same adapter.
|
|
280
313
|
this.deferred = stillDeferred;
|
|
314
|
+
for (const integration of candidates) {
|
|
315
|
+
if (this.isTerminal()) break;
|
|
316
|
+
let remainDeferred = await this.initOne(integration);
|
|
317
|
+
// A rapid withdrawal can cancel this reserved init, then a regrant can arrive
|
|
318
|
+
// before it settles. Retry with a fresh module as soon as the cancelled attempt
|
|
319
|
+
// returns; otherwise that regrant has no later update left to wake it.
|
|
320
|
+
while (
|
|
321
|
+
remainDeferred
|
|
322
|
+
&& this.state !== "disposing"
|
|
323
|
+
&& this.state !== "disposed"
|
|
324
|
+
&& this.eligible(integration)
|
|
325
|
+
) {
|
|
326
|
+
remainDeferred = await this.initOne(integration);
|
|
327
|
+
}
|
|
328
|
+
if (remainDeferred && this.state !== "disposing" && this.state !== "disposed") {
|
|
329
|
+
this.deferred.push(integration);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
281
332
|
}
|
|
282
333
|
|
|
283
|
-
|
|
334
|
+
/** Returns true when consent changed during loading and the entry remains deferred. */
|
|
335
|
+
private async initOne(integration: BootDescriptorIntegration): Promise<boolean> {
|
|
336
|
+
if (this.isTerminal()) return false;
|
|
284
337
|
let module: IntegrationLifecycleModule;
|
|
285
338
|
try {
|
|
286
339
|
module = await this.opts.loadModule(integration);
|
|
287
340
|
} catch (error) {
|
|
288
341
|
this.observe({ stage: "load", integration, ok: false, error: messageOf(error) });
|
|
289
|
-
return;
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (this.state === "disposing" || this.state === "disposed") return false; // torn down mid-load: do not init.
|
|
345
|
+
// Consent may be withdrawn while the asynchronous loader is in flight. Recheck
|
|
346
|
+
// before calling tenant/vendor code; an entry must never initialize after denial.
|
|
347
|
+
if (!this.eligible(integration)) {
|
|
348
|
+
this.observe({ stage: "gate", integration, ok: false });
|
|
349
|
+
return true;
|
|
290
350
|
}
|
|
291
|
-
|
|
351
|
+
const starting: StartingIntegration = {
|
|
352
|
+
integration,
|
|
353
|
+
module,
|
|
354
|
+
eligible: true,
|
|
355
|
+
cancelled: false,
|
|
356
|
+
disposed: false,
|
|
357
|
+
};
|
|
358
|
+
this.starting.push(starting);
|
|
359
|
+
let remainDeferred = false;
|
|
292
360
|
try {
|
|
293
361
|
await module.init(this.buildContext(integration));
|
|
294
|
-
|
|
295
|
-
|
|
362
|
+
// A consent withdrawal may arrive while vendor/sandbox init is unresolved. Its
|
|
363
|
+
// interrupt marks the module cancelled and invokes teardown immediately; never
|
|
364
|
+
// publish that late init as active, and load a fresh module on a later grant.
|
|
365
|
+
remainDeferred = starting.cancelled
|
|
366
|
+
|| this.isTerminal()
|
|
367
|
+
|| !this.eligible(integration);
|
|
368
|
+
if (!remainDeferred) {
|
|
369
|
+
this.active.push({ integration, module, eligible: true });
|
|
370
|
+
this.observe({ stage: "init", integration, ok: true });
|
|
371
|
+
}
|
|
296
372
|
} catch (error) {
|
|
373
|
+
remainDeferred = starting.cancelled
|
|
374
|
+
|| this.isTerminal()
|
|
375
|
+
|| !this.eligible(integration);
|
|
297
376
|
this.observe({ stage: "init", integration, ok: false, error: messageOf(error) });
|
|
377
|
+
} finally {
|
|
378
|
+
const index = this.starting.indexOf(starting);
|
|
379
|
+
if (index >= 0) this.starting.splice(index, 1);
|
|
380
|
+
}
|
|
381
|
+
return remainDeferred;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Interrupt a consent grant whose module init has not settled. Calling update and
|
|
386
|
+
* dispose synchronously gives the module/sandbox its immediate stop signal; returned
|
|
387
|
+
* promises are observed in the background so a hostile or stuck hook cannot delay the
|
|
388
|
+
* viewer's withdrawal. The late init is kept out of `active` by `cancelled` above.
|
|
389
|
+
*/
|
|
390
|
+
private interruptWithdrawnStarts(reason: LifecycleUpdateReason): boolean {
|
|
391
|
+
const withdrawn = this.starting.filter(({ integration }) => !this.eligible(integration));
|
|
392
|
+
if (withdrawn.length === 0) return false;
|
|
393
|
+
for (const entry of withdrawn) {
|
|
394
|
+
if (entry.cancelled) continue;
|
|
395
|
+
entry.cancelled = true;
|
|
396
|
+
if (entry.module.update) {
|
|
397
|
+
try {
|
|
398
|
+
const result = entry.module.update(reason, this.buildContext(entry.integration));
|
|
399
|
+
void Promise.resolve(result).then(
|
|
400
|
+
() => this.observe({ stage: "update", integration: entry.integration, ok: true, reason }),
|
|
401
|
+
(error) => this.observe({
|
|
402
|
+
stage: "update", integration: entry.integration, ok: false, reason, error: messageOf(error),
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
this.observe({
|
|
407
|
+
stage: "update", integration: entry.integration, ok: false, reason, error: messageOf(error),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (entry.module.dispose && !entry.disposed) {
|
|
412
|
+
entry.disposed = true;
|
|
413
|
+
try {
|
|
414
|
+
const result = entry.module.dispose();
|
|
415
|
+
void Promise.resolve(result).then(
|
|
416
|
+
() => this.observe({ stage: "dispose", integration: entry.integration, ok: true }),
|
|
417
|
+
(error) => this.observe({
|
|
418
|
+
stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error),
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
this.observe({ stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error) });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Deliver an update to a fixed set of active modules without loading deferred work. */
|
|
430
|
+
private async updateActive(
|
|
431
|
+
reason: LifecycleUpdateReason,
|
|
432
|
+
entries: readonly ActiveIntegration[],
|
|
433
|
+
): Promise<void> {
|
|
434
|
+
for (const { integration, module } of entries) {
|
|
435
|
+
if (this.state !== "initialized") return;
|
|
436
|
+
if (!module.update) continue;
|
|
437
|
+
try {
|
|
438
|
+
await module.update(reason, this.buildContext(integration));
|
|
439
|
+
this.observe({ stage: "update", integration, ok: true, reason });
|
|
440
|
+
} catch (error) {
|
|
441
|
+
this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Signal every active module synchronously during a withdrawal. Returned promises
|
|
448
|
+
* are observed in the background so one stuck adapter cannot delay the stop signal
|
|
449
|
+
* for another adapter or keep the viewer's withdrawal request pending.
|
|
450
|
+
*/
|
|
451
|
+
private interruptActiveUpdates(
|
|
452
|
+
reason: LifecycleUpdateReason,
|
|
453
|
+
entries: readonly ActiveIntegration[],
|
|
454
|
+
): void {
|
|
455
|
+
for (const { integration, module } of entries) {
|
|
456
|
+
if (!module.update) continue;
|
|
457
|
+
try {
|
|
458
|
+
const result = module.update(reason, this.buildContext(integration));
|
|
459
|
+
void Promise.resolve(result).then(
|
|
460
|
+
() => this.observe({ stage: "update", integration, ok: true, reason }),
|
|
461
|
+
(error) => this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) }),
|
|
462
|
+
);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
|
|
465
|
+
}
|
|
298
466
|
}
|
|
299
467
|
}
|
|
300
468
|
|
|
469
|
+
/** Record current eligibility and report whether this consent change withdrew an active entry. */
|
|
470
|
+
private updateActiveEligibility(): boolean {
|
|
471
|
+
let withdrew = false;
|
|
472
|
+
for (const entry of this.active) {
|
|
473
|
+
const eligible = this.eligible(entry.integration);
|
|
474
|
+
if (entry.eligible && !eligible) withdrew = true;
|
|
475
|
+
entry.eligible = eligible;
|
|
476
|
+
}
|
|
477
|
+
return withdrew;
|
|
478
|
+
}
|
|
479
|
+
|
|
301
480
|
/**
|
|
302
481
|
* Build the typed context handed to `init`/`update`: this integration's own
|
|
303
482
|
* resolved public config (never another integration's), the document's
|
|
@@ -349,26 +528,59 @@ export class IntegrationLifecycle {
|
|
|
349
528
|
|
|
350
529
|
/**
|
|
351
530
|
* Deliver an in-place `update(reason)` to every initialized integration that implements
|
|
352
|
-
* it. Used for a consent change, a config change, or a rollback — never a re-init.
|
|
353
|
-
*
|
|
531
|
+
* it. Used for a consent change, a config change, or a rollback — never a re-init. An
|
|
532
|
+
* update during async initialization is replayed before bootstrap emission; calls before
|
|
533
|
+
* initialization starts or after dispose are no-ops. One throwing `update` is isolated.
|
|
354
534
|
*/
|
|
355
535
|
async update(reason: LifecycleUpdateReason): Promise<void> {
|
|
356
536
|
if (!LIFECYCLE_UPDATE_REASONS.includes(reason)) return;
|
|
537
|
+
if (this.state !== "initializing" && this.state !== "initialized") return;
|
|
538
|
+
// Withdrawal is a safety interrupt, including while the document's initial
|
|
539
|
+
// lifecycle boot is still awaiting module.init().
|
|
540
|
+
let interruptedWithdrawal = false;
|
|
541
|
+
if (reason === "consent-changed") {
|
|
542
|
+
interruptedWithdrawal = this.interruptWithdrawnStarts(reason);
|
|
543
|
+
interruptedWithdrawal = this.updateActiveEligibility() || interruptedWithdrawal;
|
|
544
|
+
}
|
|
545
|
+
if (interruptedWithdrawal) {
|
|
546
|
+
// A mixed initial state can have earlier entries active while a later analytics
|
|
547
|
+
// entry is still starting. Stop both groups now; returning before this delivery
|
|
548
|
+
// would leave an active sibling subscribed under a withdrawn snapshot.
|
|
549
|
+
this.interruptActiveUpdates(reason, [...this.active]);
|
|
550
|
+
// One atomic consent snapshot may withdraw one purpose and grant another.
|
|
551
|
+
// Once initial traversal is complete, start those grants without making
|
|
552
|
+
// withdrawal wait for loader/vendor code. During traversal, queue the grant
|
|
553
|
+
// reconciliation so it cannot duplicate an entry the init loop has not reached.
|
|
554
|
+
if (this.state === "initializing") {
|
|
555
|
+
this.pendingGrantReconciliation = true;
|
|
556
|
+
} else {
|
|
557
|
+
void this.loadNewlyEligible().then(
|
|
558
|
+
() => this.emitBootstrapPageView(),
|
|
559
|
+
() => {},
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (this.state === "initializing") {
|
|
565
|
+
this.pendingUpdates.push(reason);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (this.state !== "initialized") return;
|
|
569
|
+
const run = this.updateQueue.then(() => this.applyUpdate(reason));
|
|
570
|
+
this.updateQueue = run.catch(() => {});
|
|
571
|
+
await run;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private async applyUpdate(reason: LifecycleUpdateReason): Promise<void> {
|
|
357
575
|
if (this.state !== "initialized") return;
|
|
358
576
|
// A consent/config change may make a gated integration eligible: load it now
|
|
359
577
|
// (init), then deliver the update only to integrations that were already live —
|
|
360
578
|
// a just-loaded entry gets `init`, not an immediate redundant `update`.
|
|
361
579
|
const alreadyActive = [...this.active];
|
|
362
580
|
await this.loadNewlyEligible();
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
await module.update(reason, this.buildContext(integration));
|
|
367
|
-
this.observe({ stage: "update", integration, ok: true, reason });
|
|
368
|
-
} catch (error) {
|
|
369
|
-
this.observe({ stage: "update", integration, ok: false, reason, error: messageOf(error) });
|
|
370
|
-
}
|
|
371
|
-
}
|
|
581
|
+
if (this.state !== "initialized") return;
|
|
582
|
+
await this.updateActive(reason, alreadyActive);
|
|
583
|
+
if (this.state !== "initialized") return;
|
|
372
584
|
// A consent grant is the case that matters: it both loads a previously-gated adapter
|
|
373
585
|
// (above) and makes the held-back bootstrap page view emittable, so the document the
|
|
374
586
|
// visitor consented ON is still counted rather than only its successors.
|
|
@@ -378,16 +590,38 @@ export class IntegrationLifecycle {
|
|
|
378
590
|
/**
|
|
379
591
|
* Dispose every initialized integration on teardown. Idempotent — a second dispose is a
|
|
380
592
|
* no-op — and terminal: after it, `update` and a late `init` do nothing. One integration's
|
|
381
|
-
* throwing `dispose` is isolated; every other integration is still
|
|
593
|
+
* throwing or unresolved `dispose` is isolated; every other integration is still signalled.
|
|
382
594
|
*/
|
|
383
595
|
async dispose(): Promise<void> {
|
|
384
596
|
if (this.state === "disposed" || this.state === "disposing") return;
|
|
385
597
|
this.state = "disposing";
|
|
598
|
+
// Starting modules are already executing tenant/vendor code but are not yet in
|
|
599
|
+
// `active`. Cancel and signal teardown synchronously so a late init settlement
|
|
600
|
+
// cannot publish after this terminal transition.
|
|
601
|
+
for (const entry of this.starting) {
|
|
602
|
+
entry.cancelled = true;
|
|
603
|
+
if (!entry.module.dispose || entry.disposed) continue;
|
|
604
|
+
entry.disposed = true;
|
|
605
|
+
try {
|
|
606
|
+
const result = entry.module.dispose();
|
|
607
|
+
void Promise.resolve(result).then(
|
|
608
|
+
() => this.observe({ stage: "dispose", integration: entry.integration, ok: true }),
|
|
609
|
+
(error) => this.observe({
|
|
610
|
+
stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error),
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
} catch (error) {
|
|
614
|
+
this.observe({ stage: "dispose", integration: entry.integration, ok: false, error: messageOf(error) });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
386
617
|
for (const { integration, module } of this.active) {
|
|
387
618
|
if (!module.dispose) continue;
|
|
388
619
|
try {
|
|
389
|
-
|
|
390
|
-
|
|
620
|
+
const result = module.dispose();
|
|
621
|
+
void Promise.resolve(result).then(
|
|
622
|
+
() => this.observe({ stage: "dispose", integration, ok: true }),
|
|
623
|
+
(error) => this.observe({ stage: "dispose", integration, ok: false, error: messageOf(error) }),
|
|
624
|
+
);
|
|
391
625
|
} catch (error) {
|
|
392
626
|
this.observe({ stage: "dispose", integration, ok: false, error: messageOf(error) });
|
|
393
627
|
}
|
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
import { MANAGED_ANALYTICS_PII_VALUE_PATTERN } from "./managed-analytics-api-v1-safety.mjs";
|
|
3
3
|
|
|
4
4
|
const MANAGED_ANALYTICS_PII_VALUE = new RegExp(MANAGED_ANALYTICS_PII_VALUE_PATTERN);
|
|
5
|
+
const MANAGED_ANALYTICS_PROTECTED_VALUE =
|
|
6
|
+
/(?:@|\d{7,}|(?:^|[./_~-])(?:bearer|password|secret|token|authorization|preview)(?:$|[./_~-]))/i;
|
|
5
7
|
|
|
6
8
|
/** True when a public string resembles an email, SSN, payment card, or phone number. */
|
|
7
9
|
export function resemblesManagedAnalyticsPii(value: unknown): boolean {
|
|
8
10
|
return typeof value === "string" && MANAGED_ANALYTICS_PII_VALUE.test(value);
|
|
9
11
|
}
|
|
10
12
|
|
|
13
|
+
/** True when a public value resembles PII or an authentication/protected token. */
|
|
14
|
+
export function resemblesManagedAnalyticsProtectedValue(value: unknown): boolean {
|
|
15
|
+
return typeof value === "string" && (
|
|
16
|
+
resemblesManagedAnalyticsPii(value) || MANAGED_ANALYTICS_PROTECTED_VALUE.test(value)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
/** Defense-in-depth for identifiers copied into structured broker observations. */
|
|
12
21
|
export function safeManagedAnalyticsDiagnosticId(value: string): string {
|
|
13
22
|
return resemblesManagedAnalyticsPii(value) ? "[redacted]" : value;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/** Runtime bundled inline into the versioned GA4 sandbox HTML. */
|
|
2
|
+
import { APPROVED_MARKETING_ACTION_NAMES } from "./events.js";
|
|
3
|
+
import {
|
|
4
|
+
NATIVE_GA4_SANDBOX_MAX_DESTINATIONS,
|
|
5
|
+
NATIVE_GA4_SANDBOX_PROTOCOL,
|
|
6
|
+
type NativeGa4SandboxPortMessage,
|
|
7
|
+
} from "./native-ga4-sandbox.js";
|
|
8
|
+
import { resemblesManagedAnalyticsProtectedValue } from "./managed-analytics-value-safety.js";
|
|
9
|
+
|
|
10
|
+
const MEASUREMENT_ID = /^G-[A-Z0-9]{4,20}$/;
|
|
11
|
+
const CAMPAIGN_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
|
|
12
|
+
const APPROVED_ACTIONS: ReadonlySet<string> = new Set(APPROVED_MARKETING_ACTION_NAMES);
|
|
13
|
+
|
|
14
|
+
function plainRecord(value: unknown): Record<string, unknown> | null {
|
|
15
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
16
|
+
const proto = Object.getPrototypeOf(value);
|
|
17
|
+
return proto === Object.prototype || proto === null ? value as Record<string, unknown> : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], required = allowed): boolean {
|
|
21
|
+
const keys = Object.keys(value);
|
|
22
|
+
return required.every((key) => keys.includes(key)) && keys.every((key) => allowed.includes(key));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validOrigin(value: unknown): value is string {
|
|
26
|
+
if (typeof value !== "string") return false;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = new URL(value);
|
|
29
|
+
return parsed.origin === value && (
|
|
30
|
+
parsed.protocol === "https:" ||
|
|
31
|
+
(parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname))
|
|
32
|
+
);
|
|
33
|
+
} catch { return false; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validPath(value: unknown): value is string {
|
|
37
|
+
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") &&
|
|
38
|
+
!value.includes("\\") && !value.includes("?") && !value.includes("#") &&
|
|
39
|
+
!resemblesManagedAnalyticsProtectedValue(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validIds(value: unknown): value is string[] {
|
|
43
|
+
return Array.isArray(value) && value.length > 0 && value.length <= NATIVE_GA4_SANDBOX_MAX_DESTINATIONS &&
|
|
44
|
+
value.every((id) => typeof id === "string" && MEASUREMENT_ID.test(id)) && new Set(value).size === value.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validCampaign(value: unknown): value is Record<string, string> {
|
|
48
|
+
const record = plainRecord(value);
|
|
49
|
+
if (!record || !exactKeys(record, ["source", "medium", "campaign", "term", "content"], [])) return false;
|
|
50
|
+
return Object.values(record).every((item) =>
|
|
51
|
+
typeof item === "string" && CAMPAIGN_VALUE.test(item) && !resemblesManagedAnalyticsProtectedValue(item));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NativeGa4SandboxRuntimeWindow extends Pick<Window, "parent" | "location"> {
|
|
55
|
+
addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
|
56
|
+
removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
|
57
|
+
dataLayer?: unknown[];
|
|
58
|
+
gtag?: (...args: unknown[]) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function startNativeGa4Sandbox(win: NativeGa4SandboxRuntimeWindow, doc: Document): void {
|
|
62
|
+
let bound = false;
|
|
63
|
+
|
|
64
|
+
const bind = (event: MessageEvent): void => {
|
|
65
|
+
try {
|
|
66
|
+
const data = plainRecord(event.data);
|
|
67
|
+
const ownOrigin = new URL(win.location.href).origin;
|
|
68
|
+
if (
|
|
69
|
+
bound || event.source !== win.parent || event.origin !== ownOrigin || event.ports.length !== 1 ||
|
|
70
|
+
!data || !exactKeys(data, ["protocol", "type"]) ||
|
|
71
|
+
data["protocol"] !== NATIVE_GA4_SANDBOX_PROTOCOL || data["type"] !== "bind"
|
|
72
|
+
) return;
|
|
73
|
+
bound = true;
|
|
74
|
+
win.removeEventListener("message", bind);
|
|
75
|
+
runPort(event.ports[0]!, win, doc);
|
|
76
|
+
} catch {
|
|
77
|
+
// Hostile window messages fail closed without exposing an oracle.
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
win.addEventListener("message", bind);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Document): void {
|
|
85
|
+
let active = false;
|
|
86
|
+
let disposed = false;
|
|
87
|
+
let canonicalOrigin = "";
|
|
88
|
+
let currentGroup = "";
|
|
89
|
+
let lastPageLocation = "";
|
|
90
|
+
let generation = 0;
|
|
91
|
+
let tagLoaded = false;
|
|
92
|
+
|
|
93
|
+
const dataLayer: unknown[] = [];
|
|
94
|
+
const gtag = function(..._args: unknown[]): void {
|
|
95
|
+
dataLayer.push(arguments);
|
|
96
|
+
};
|
|
97
|
+
win.dataLayer = dataLayer;
|
|
98
|
+
win.gtag = gtag;
|
|
99
|
+
|
|
100
|
+
const configure = (message: Record<string, unknown>): void => {
|
|
101
|
+
if (
|
|
102
|
+
!exactKeys(message, ["schemaVersion", "type", "destinationIds", "canonicalOrigin"]) ||
|
|
103
|
+
message["schemaVersion"] !== 1 || message["type"] !== "configure" ||
|
|
104
|
+
!validIds(message["destinationIds"]) || !validOrigin(message["canonicalOrigin"])
|
|
105
|
+
) return;
|
|
106
|
+
const ids = message["destinationIds"];
|
|
107
|
+
canonicalOrigin = message["canonicalOrigin"];
|
|
108
|
+
currentGroup = `tot_storefront_${++generation}`;
|
|
109
|
+
lastPageLocation = "";
|
|
110
|
+
active = true;
|
|
111
|
+
|
|
112
|
+
if (!tagLoaded) {
|
|
113
|
+
tagLoaded = true;
|
|
114
|
+
gtag("consent", "default", {
|
|
115
|
+
analytics_storage: "denied",
|
|
116
|
+
ad_storage: "denied",
|
|
117
|
+
ad_user_data: "denied",
|
|
118
|
+
ad_personalization: "denied",
|
|
119
|
+
});
|
|
120
|
+
gtag("js", new Date());
|
|
121
|
+
}
|
|
122
|
+
for (const id of ids) {
|
|
123
|
+
gtag("config", id, {
|
|
124
|
+
groups: currentGroup,
|
|
125
|
+
send_page_view: false,
|
|
126
|
+
allow_google_signals: false,
|
|
127
|
+
allow_ad_personalization_signals: false,
|
|
128
|
+
restricted_data_processing: true,
|
|
129
|
+
ignore_referrer: true,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (doc.querySelector('script[data-tot-ga4="1"]') === null) {
|
|
133
|
+
const script = doc.createElement("script");
|
|
134
|
+
script.async = true;
|
|
135
|
+
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(ids[0]!)}`;
|
|
136
|
+
script.setAttribute("data-tot-ga4", "1");
|
|
137
|
+
doc.head.append(script);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const deliver = (message: Record<string, unknown>): void => {
|
|
142
|
+
if (!active || disposed || message["schemaVersion"] !== 1) return;
|
|
143
|
+
if (message["type"] === "page_view") {
|
|
144
|
+
if (!exactKeys(message, ["schemaVersion", "type", "path", "referrerOrigin", "campaign"], ["schemaVersion", "type", "path"])) return;
|
|
145
|
+
if (!validPath(message["path"])) return;
|
|
146
|
+
if (message["referrerOrigin"] !== undefined && !validOrigin(message["referrerOrigin"])) return;
|
|
147
|
+
if (message["campaign"] !== undefined && !validCampaign(message["campaign"])) return;
|
|
148
|
+
const pageLocation = `${canonicalOrigin}${message["path"]}`;
|
|
149
|
+
lastPageLocation = pageLocation;
|
|
150
|
+
const params: Record<string, unknown> = {
|
|
151
|
+
send_to: currentGroup,
|
|
152
|
+
page_location: pageLocation,
|
|
153
|
+
page_path: message["path"],
|
|
154
|
+
};
|
|
155
|
+
if (message["referrerOrigin"] !== undefined) params["page_referrer"] = message["referrerOrigin"];
|
|
156
|
+
if (message["campaign"] !== undefined) {
|
|
157
|
+
const campaign = message["campaign"] as Record<string, string>;
|
|
158
|
+
for (const key of ["source", "medium", "campaign", "term", "content"] as const) {
|
|
159
|
+
const ga4Key = key === "campaign" ? "name" : key;
|
|
160
|
+
if (campaign[key] !== undefined) params[`campaign_${ga4Key}`] = campaign[key];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
gtag("event", "page_view", params);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (message["type"] === "action") {
|
|
167
|
+
if (!exactKeys(message, ["schemaVersion", "type", "name", "pageLocation"])) return;
|
|
168
|
+
if (
|
|
169
|
+
typeof message["name"] !== "string" || !APPROVED_ACTIONS.has(message["name"]) ||
|
|
170
|
+
message["pageLocation"] !== lastPageLocation || !lastPageLocation
|
|
171
|
+
) return;
|
|
172
|
+
gtag("event", message["name"], { send_to: currentGroup, page_location: lastPageLocation });
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
port.onmessage = (event: MessageEvent<NativeGa4SandboxPortMessage>) => {
|
|
177
|
+
try {
|
|
178
|
+
const message = plainRecord(event.data);
|
|
179
|
+
if (!message || disposed) return;
|
|
180
|
+
if (message["type"] === "configure") return configure(message);
|
|
181
|
+
if (message["type"] === "disable") {
|
|
182
|
+
if (exactKeys(message, ["schemaVersion", "type"]) && message["schemaVersion"] === 1) {
|
|
183
|
+
active = false;
|
|
184
|
+
lastPageLocation = "";
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (message["type"] === "dispose") {
|
|
189
|
+
if (exactKeys(message, ["schemaVersion", "type"]) && message["schemaVersion"] === 1) {
|
|
190
|
+
active = false;
|
|
191
|
+
disposed = true;
|
|
192
|
+
lastPageLocation = "";
|
|
193
|
+
port.close();
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
deliver(message);
|
|
198
|
+
} catch {
|
|
199
|
+
// Every malformed port message fails closed without exposing an oracle.
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
port.start();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
206
|
+
startNativeGa4Sandbox(window, document);
|
|
207
|
+
}
|