@tokenoftrust/storefront-runner 4.9.2 → 4.10.0
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/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
- package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
- package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
- package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
- package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
- package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
- package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
- package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
- package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
- package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
- package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
- package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
- package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
- package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
- package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
- package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
- package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
- package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
- package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
- package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
- package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
- package/apps/storefront/src/pages/api/contact.ts +16 -4
- package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
- package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
- package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
- package/package.json +1 -1
- package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
- package/packages/public-runtime/src/integration/events.ts +15 -19
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
- package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
- package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
- package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
- package/packages/public-runtime/src/integration/plan.ts +6 -4
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
- package/packages/public-runtime/src/integration/render-phases.ts +10 -18
- package/packages/public-runtime/src/integration/resolve.ts +3 -3
- package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
- package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
- package/packages/public-runtime/src/tenant.ts +3 -3
- package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
- package/scripts/dev/ga4-gs2-canary.mjs +36 -10
- package/scripts/tenant/validate.mjs +9 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hourly contact vault sweep — the Worker's `scheduled` handler body. One `now` per firing,
|
|
3
|
+
* taken from its scheduled time: purge expired order contacts and contact messages, shorten terminal
|
|
4
|
+
* orders' retention, and seal any contact message still held as legacy plaintext. Expiry predicates
|
|
5
|
+
* only; it never touches an order, audit or other row.
|
|
6
|
+
*
|
|
7
|
+
* It logs ONE structured line of counts on success and ONE fixed error code on failure — never an
|
|
8
|
+
* error message, id or row, since either could carry data. It composes orders and engagement, which
|
|
9
|
+
* no module under src/lib may do, so it lives beside the Worker entry.
|
|
10
|
+
*/
|
|
11
|
+
import { fromD1, type Queryable } from "@/lib/catalog/d1/catalog.js";
|
|
12
|
+
import type { VaultKeyring } from "@/lib/customers/contactVault/envelope.js";
|
|
13
|
+
import { purgeExpiredContactMessages, sealLegacyContactMessages } from "@/lib/engagement/contact/contactMessages.js";
|
|
14
|
+
import { loadVaultKeyring } from "@/lib/orders/vault/keyringEnv.js";
|
|
15
|
+
import { purgeOrderContacts, type PurgeCounts } from "@/lib/orders/vault/orderContactVault.js";
|
|
16
|
+
import { readD1 } from "@/lib/publication/shared/env";
|
|
17
|
+
|
|
18
|
+
export const SWEEP_LEGACY_SEAL_LIMIT = 500;
|
|
19
|
+
export const CONTACT_VAULT_SWEEP_EVENT = "contact-vault-sweep";
|
|
20
|
+
export const SWEEP_DB_UNAVAILABLE = "contact-vault-sweep-db-unavailable";
|
|
21
|
+
export const SWEEP_FAILED = "contact-vault-sweep-failed";
|
|
22
|
+
|
|
23
|
+
export interface SweepCounts {
|
|
24
|
+
orders: PurgeCounts;
|
|
25
|
+
messagesPurged: number;
|
|
26
|
+
legacySealed: number;
|
|
27
|
+
legacyRemaining: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runContactVaultSweep(db: Queryable, keyring: VaultKeyring | null, now: Date): Promise<SweepCounts> {
|
|
31
|
+
if (Number.isNaN(now.getTime())) throw new Error("contact vault sweep: unreadable clock");
|
|
32
|
+
const orders = await purgeOrderContacts(db, now);
|
|
33
|
+
const messagesPurged = await purgeExpiredContactMessages(db, now);
|
|
34
|
+
const legacy = keyring
|
|
35
|
+
? await sealLegacyContactMessages(db, keyring, now, SWEEP_LEGACY_SEAL_LIMIT)
|
|
36
|
+
: { sealed: 0, remaining: -1 };
|
|
37
|
+
return { orders, messagesPurged, legacySealed: legacy.sealed, legacyRemaining: legacy.remaining };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ScheduledSweepDeps {
|
|
41
|
+
openDb(): Promise<Queryable | null>;
|
|
42
|
+
loadKeyring(): Promise<VaultKeyring | null>;
|
|
43
|
+
sweep(db: Queryable, keyring: VaultKeyring | null, now: Date): Promise<SweepCounts>;
|
|
44
|
+
log(line: string): void;
|
|
45
|
+
logError(line: string): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const workerDeps: ScheduledSweepDeps = {
|
|
49
|
+
openDb: async () => {
|
|
50
|
+
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
51
|
+
return d1 ? fromD1(d1) : null;
|
|
52
|
+
},
|
|
53
|
+
loadKeyring: loadVaultKeyring,
|
|
54
|
+
sweep: runContactVaultSweep,
|
|
55
|
+
log: (line) => console.log(line),
|
|
56
|
+
logError: (line) => console.error(line),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Never throws: every failure is reported as a fixed code. */
|
|
60
|
+
export async function runScheduledContactVaultSweep(scheduledTime: number, deps: ScheduledSweepDeps = workerDeps): Promise<void> {
|
|
61
|
+
const fail = (error: string) => deps.logError(JSON.stringify({ event: CONTACT_VAULT_SWEEP_EVENT, error }));
|
|
62
|
+
try {
|
|
63
|
+
const db = await deps.openDb();
|
|
64
|
+
if (!db) return fail(SWEEP_DB_UNAVAILABLE);
|
|
65
|
+
const keyring = await deps.loadKeyring();
|
|
66
|
+
const counts = await deps.sweep(db, keyring, new Date(scheduledTime));
|
|
67
|
+
deps.log(JSON.stringify({
|
|
68
|
+
event: CONTACT_VAULT_SWEEP_EVENT,
|
|
69
|
+
keyringAvailable: keyring !== null,
|
|
70
|
+
ordersPurged: counts.orders.purged,
|
|
71
|
+
tombstonesRemoved: counts.orders.tombstonesRemoved,
|
|
72
|
+
ordersShortened: counts.orders.shortened,
|
|
73
|
+
messagesPurged: counts.messagesPurged,
|
|
74
|
+
legacySealed: counts.legacySealed,
|
|
75
|
+
legacyRemaining: counts.legacyRemaining,
|
|
76
|
+
}));
|
|
77
|
+
} catch {
|
|
78
|
+
fail(SWEEP_FAILED);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -40,13 +40,17 @@ type PointerRecord = Awaited<ReturnType<PointerWriter["read"]>>;
|
|
|
40
40
|
* Production's ratios, in seconds: a CI window (45 min) outlasts the lease an evicted object waits
|
|
41
41
|
* out before it resumes (10 min, here `leaseMs`), so a job resumed after an eviction is still inside
|
|
42
42
|
* its window — as in production.
|
|
43
|
+
*
|
|
44
|
+
* A hotfix's stage window ends the job `stuck` when it expires, and the fault matrix answers the
|
|
45
|
+
* prebuild from a poll loop that a loaded host starves for several seconds at a time (a 30 ms sleep
|
|
46
|
+
* measured at 4–8 s on an oversubscribed machine). The window is sized well past that stall.
|
|
43
47
|
*/
|
|
44
48
|
export const PUBLISH_HARNESS_TIMING: PublishTiming = {
|
|
45
49
|
awaitWindowMs: 4_000,
|
|
46
50
|
runGraceMs: 500,
|
|
47
51
|
proofDelayMs: 30,
|
|
48
52
|
proofProbes: { synchronous: 3, asynchronous: 5 },
|
|
49
|
-
stageWindowMs:
|
|
53
|
+
stageWindowMs: 15_000,
|
|
50
54
|
stageChecks: 3,
|
|
51
55
|
};
|
|
52
56
|
|
|
@@ -150,7 +150,7 @@ async function cloudFrontServesEarlier(t: Tenant, provider: Provider): Promise<v
|
|
|
150
150
|
async function prerequisite(t: Tenant, kind: string, input: unknown): Promise<void> {
|
|
151
151
|
const jobId = await t.submit(kind, input);
|
|
152
152
|
const { job } = await drive(t, jobId);
|
|
153
|
-
if (job.state !== "succeeded") throw new Error(`setup ${kind} ended ${job.state}: ${JSON.stringify(job.error)}`);
|
|
153
|
+
if (job.state !== "succeeded") throw new Error(`setup ${kind} ended ${job.state}: ${JSON.stringify(job.error)} outcome: ${outcomeOf(job)}`);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
async function goLive(t: Tenant, provider: Provider, sha: string, runId: string): Promise<void> {
|
|
@@ -578,6 +578,17 @@ export function overlapViolations(all: TransitionView[]): string[] {
|
|
|
578
578
|
return [];
|
|
579
579
|
}
|
|
580
580
|
|
|
581
|
+
/**
|
|
582
|
+
* What the job's own outcome says about why it ended (a `JobEnded` error's reason only points at
|
|
583
|
+
* it): its reason code and message, or null when the job recorded none.
|
|
584
|
+
*/
|
|
585
|
+
export function outcomeOf(job: JobView): string | null {
|
|
586
|
+
const outcome = job.produces?.outcome as { reason?: unknown; message?: unknown } | undefined;
|
|
587
|
+
if (!outcome || typeof outcome !== "object") return null;
|
|
588
|
+
const parts = [outcome.reason, outcome.message].filter((p): p is string => typeof p === "string" && p.length > 0);
|
|
589
|
+
return parts.length ? parts.join(": ") : JSON.stringify(outcome);
|
|
590
|
+
}
|
|
591
|
+
|
|
581
592
|
function diffKeys(a: Record<string, unknown>, b: Record<string, unknown>, keys: string[]): string[] {
|
|
582
593
|
return keys.filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]));
|
|
583
594
|
}
|
|
@@ -615,7 +626,10 @@ export function convergence(cell: CellRun, base: CellRun, opts: ConvergenceOptio
|
|
|
615
626
|
const v: string[] = [];
|
|
616
627
|
const { job } = cell;
|
|
617
628
|
if (job.state !== base.job.state || job.error?.code !== base.job.error?.code) {
|
|
618
|
-
|
|
629
|
+
const outcome = outcomeOf(job);
|
|
630
|
+
v.push(
|
|
631
|
+
`verdict: ${job.state}${job.error ? ` (${job.error.code}: ${job.error.reason})` : ""}${outcome ? ` outcome: ${outcome}` : ""}, unfaulted ${base.job.state}`,
|
|
632
|
+
);
|
|
619
633
|
}
|
|
620
634
|
if (opts.expectKill && cell.kills.length === 0) v.push("fired: the armed fault never fired");
|
|
621
635
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.10.0",
|
|
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",
|
|
@@ -30,6 +30,7 @@ import { CONSENT_PURPOSES, type ConsentPurpose } from "../integration-capabiliti
|
|
|
30
30
|
import { CONSENT_SNAPSHOT_SCHEMA_VERSION } from "./version.js";
|
|
31
31
|
import type { EventConsentSnapshot } from "./events.js";
|
|
32
32
|
import type { EuUkVisitorPolicy } from "./plan.js";
|
|
33
|
+
import { schedulePostPaint, type PostPaintWindow } from "./post-paint.js";
|
|
33
34
|
|
|
34
35
|
/** The purpose that is always on and cannot be gated (charter floor). */
|
|
35
36
|
const ESSENTIAL: ConsentPurpose = "essential";
|
|
@@ -479,8 +480,10 @@ export class ConsentBroker {
|
|
|
479
480
|
* The tenant configuration supplies the analytics default and a stored viewer choice
|
|
480
481
|
* overrides it. Global Privacy Control always denies analytics. Visitors the edge
|
|
481
482
|
* classes as EU/EEA/UK/CH, or cannot class, get the tenant's EU/UK visitor policy:
|
|
482
|
-
* `
|
|
483
|
-
*
|
|
483
|
+
* `ask` (default) counts page views without storing anything and prompts the visitor,
|
|
484
|
+
* and an Allow lifts the restriction for this context; `count-only` keeps page views
|
|
485
|
+
* but stores nothing on the device; `none` denies analytics; and `full` applies the
|
|
486
|
+
* default behavior. The same synchronous
|
|
484
487
|
* early boot runs in both document mounts before any integration or measurement loader
|
|
485
488
|
* can execute.
|
|
486
489
|
*/
|
|
@@ -515,6 +518,16 @@ export interface ViewerAnalyticsConsentState {
|
|
|
515
518
|
* visitor-continuity storage reads this and falls back to a per-page memory id.
|
|
516
519
|
*/
|
|
517
520
|
visitorStorage: "persistent" | "none";
|
|
521
|
+
/**
|
|
522
|
+
* Whether the EU/UK visitor policy currently restricts this visitor: no device
|
|
523
|
+
* storage, no marketing or personalization, and no tenant-local code.
|
|
524
|
+
*/
|
|
525
|
+
regionRestricted: boolean;
|
|
526
|
+
/**
|
|
527
|
+
* `pending` when the `ask` policy applies and the visitor has not decided; the
|
|
528
|
+
* control then opens itself after paint. `none` otherwise.
|
|
529
|
+
*/
|
|
530
|
+
prompt: "pending" | "none";
|
|
518
531
|
tenantPublicId: string;
|
|
519
532
|
environment: string;
|
|
520
533
|
siteSurface: string;
|
|
@@ -616,18 +629,31 @@ export function applyEarlyViewerAnalyticsConsent(
|
|
|
616
629
|
|
|
617
630
|
// The edge sets a class-only `tot_rgn` cookie on every HTML response
|
|
618
631
|
// (visitor-region.mjs). An absent or unreadable class is unknown, and unknown gets
|
|
619
|
-
// the EU/UK policy until the region is known.
|
|
632
|
+
// the EU/UK policy until the region is known. Several `tot_rgn` cookies that disagree
|
|
633
|
+
// (one planted with another Domain or Path beside the edge's) are unknown too, so a
|
|
634
|
+
// planted class can never widen the edge's.
|
|
620
635
|
let regionClass = "u";
|
|
621
636
|
try {
|
|
622
|
-
const
|
|
623
|
-
|
|
637
|
+
const regionClasses: string[] = [];
|
|
638
|
+
for (const part of String(win.document?.cookie ?? "").split(";")) {
|
|
639
|
+
const separator = part.indexOf("=");
|
|
640
|
+
if (separator !== -1 && part.slice(0, separator).trim() === "tot_rgn") regionClasses.push(part.slice(separator + 1).trim());
|
|
641
|
+
}
|
|
642
|
+
const firstClass = regionClasses[0];
|
|
643
|
+
if (
|
|
644
|
+
(firstClass === "eu" || firstClass === "x" || firstClass === "u") &&
|
|
645
|
+
regionClasses.every((candidate) => candidate === firstClass)
|
|
646
|
+
) {
|
|
647
|
+
regionClass = firstClass;
|
|
648
|
+
}
|
|
624
649
|
} catch {
|
|
625
650
|
// An unreadable cookie jar leaves the region unknown.
|
|
626
651
|
}
|
|
652
|
+
// An absent or unrecognized policy is `ask`: it never widens collection.
|
|
627
653
|
const euUkVisitors: EuUkVisitorPolicy =
|
|
628
|
-
plan?.euUkVisitors === "
|
|
629
|
-
|
|
630
|
-
|
|
654
|
+
plan?.euUkVisitors === "count-only" || plan?.euUkVisitors === "full" || plan?.euUkVisitors === "none"
|
|
655
|
+
? plan.euUkVisitors
|
|
656
|
+
: "ask";
|
|
631
657
|
|
|
632
658
|
let stored: { decision: ViewerAnalyticsConsentDecision; expiresAt: number } | null = null;
|
|
633
659
|
// A stored record that is present but unreadable may have been a denial, so it denies
|
|
@@ -679,8 +705,19 @@ export function applyEarlyViewerAnalyticsConsent(
|
|
|
679
705
|
}
|
|
680
706
|
}
|
|
681
707
|
|
|
708
|
+
// Under `ask`, a visitor's own Allow for this exact context lifts the restriction.
|
|
709
|
+
const regionSensitive = regionClass !== "x";
|
|
710
|
+
const askUpgraded =
|
|
711
|
+
euUkVisitors === "ask" && stored?.decision === "granted" && !globalPrivacyControl && tenantApprovedAnalytics;
|
|
712
|
+
const regionRestricted = regionSensitive && euUkVisitors !== "full" && !askUpgraded;
|
|
713
|
+
const regionDeniesAnalytics = regionRestricted && euUkVisitors === "none";
|
|
714
|
+
// Until the visitor allows, `ask` counts page views exactly as `count-only` does; a
|
|
715
|
+
// Decline, a Close or no answer leaves it there.
|
|
716
|
+
const askRestricted = regionRestricted && euUkVisitors === "ask";
|
|
717
|
+
|
|
682
718
|
const decision: ViewerAnalyticsConsentDecision =
|
|
683
|
-
globalPrivacyControl || !tenantApprovedAnalytics || regionDeniesAnalytics ||
|
|
719
|
+
globalPrivacyControl || !tenantApprovedAnalytics || regionDeniesAnalytics || askRestricted ||
|
|
720
|
+
(!stored && storedInvalid)
|
|
684
721
|
? "denied"
|
|
685
722
|
: stored?.decision ?? (tenantDefaultAnalytics ? "granted" : "denied");
|
|
686
723
|
const source: ViewerAnalyticsConsentState["source"] = globalPrivacyControl
|
|
@@ -691,9 +728,12 @@ export function applyEarlyViewerAnalyticsConsent(
|
|
|
691
728
|
? "stored"
|
|
692
729
|
: storedInvalid
|
|
693
730
|
? "stored-invalid"
|
|
694
|
-
:
|
|
695
|
-
? "
|
|
696
|
-
:
|
|
731
|
+
: askRestricted
|
|
732
|
+
? "eu-uk-policy"
|
|
733
|
+
: tenantDefaultAnalytics
|
|
734
|
+
? "tenant-default"
|
|
735
|
+
: "default-deny";
|
|
736
|
+
const prompt = askRestricted && !stored && !globalPrivacyControl && tenantApprovedAnalytics ? "pending" : "none";
|
|
697
737
|
|
|
698
738
|
// Under the EU/UK policy, marketing and personalization never run: neither can work
|
|
699
739
|
// without storing an identifier on the device.
|
|
@@ -701,7 +741,9 @@ export function applyEarlyViewerAnalyticsConsent(
|
|
|
701
741
|
(purpose) =>
|
|
702
742
|
purpose === "essential" ||
|
|
703
743
|
(purpose === "analytics"
|
|
704
|
-
?
|
|
744
|
+
? askRestricted
|
|
745
|
+
? tenantDefaultAnalytics && !globalPrivacyControl
|
|
746
|
+
: decision === "granted" && tenantApprovedAnalytics
|
|
705
747
|
: !regionRestricted && rawEligible.includes(purpose)),
|
|
706
748
|
);
|
|
707
749
|
const approved = canonicalPurposes.filter((purpose) => rawApproved.includes(purpose));
|
|
@@ -735,6 +777,8 @@ export function applyEarlyViewerAnalyticsConsent(
|
|
|
735
777
|
region: regionClass === "eu" ? "eu-uk" : regionClass === "x" ? "other" : "unknown",
|
|
736
778
|
euUkVisitors,
|
|
737
779
|
visitorStorage,
|
|
780
|
+
regionRestricted,
|
|
781
|
+
prompt,
|
|
738
782
|
tenantPublicId,
|
|
739
783
|
environment,
|
|
740
784
|
siteSurface,
|
|
@@ -820,22 +864,33 @@ export function setViewerAnalyticsConsent(
|
|
|
820
864
|
|
|
821
865
|
function renderControlState(root: HTMLElement, state: ViewerAnalyticsConsentState): void {
|
|
822
866
|
const status = root.querySelector<HTMLElement>("[data-tot-analytics-consent-status]");
|
|
867
|
+
const detail = root.querySelector<HTMLElement>("[data-tot-analytics-consent-detail]");
|
|
823
868
|
const grant = root.querySelector<HTMLButtonElement>('[data-tot-analytics-consent-decision="granted"]');
|
|
824
869
|
const deny = root.querySelector<HTMLButtonElement>('[data-tot-analytics-consent-decision="denied"]');
|
|
870
|
+
const countingOnly = state.regionRestricted && state.euUkVisitors === "ask" && state.canGrant;
|
|
825
871
|
if (status) {
|
|
826
872
|
status.textContent = state.source === "global-privacy-control"
|
|
827
873
|
? "Analytics off — Global Privacy Control"
|
|
828
874
|
: state.decision === "granted"
|
|
829
875
|
? "Analytics on"
|
|
830
|
-
:
|
|
876
|
+
: countingOnly
|
|
877
|
+
? "Page counts only"
|
|
878
|
+
: "Analytics off";
|
|
879
|
+
}
|
|
880
|
+
if (detail) {
|
|
881
|
+
detail.textContent = countingOnly
|
|
882
|
+
? "Page views are counted without storing anything on this device. Allow analytics to remember this device between visits."
|
|
883
|
+
: "Choose whether analytics may run on this site.";
|
|
831
884
|
}
|
|
832
885
|
if (grant) {
|
|
833
886
|
grant.disabled = !state.canGrant || state.decision === "granted";
|
|
834
887
|
grant.setAttribute("aria-pressed", String(state.decision === "granted"));
|
|
835
888
|
}
|
|
836
889
|
if (deny) {
|
|
837
|
-
|
|
838
|
-
|
|
890
|
+
// A visitor counted under `ask` has not declined until they say so.
|
|
891
|
+
const declined = state.decision === "denied" && (!countingOnly || state.source === "stored");
|
|
892
|
+
deny.disabled = declined;
|
|
893
|
+
deny.setAttribute("aria-pressed", String(declined));
|
|
839
894
|
}
|
|
840
895
|
}
|
|
841
896
|
|
|
@@ -844,7 +899,7 @@ function firstEnabledButton(root: HTMLElement): HTMLButtonElement | null {
|
|
|
844
899
|
}
|
|
845
900
|
|
|
846
901
|
/**
|
|
847
|
-
* Bind each
|
|
902
|
+
* Bind each tenant Privacy Policy "Analytics choices" disclosure to the control its
|
|
848
903
|
* `aria-controls` names: a click opens the control and moves focus into it; a second
|
|
849
904
|
* click, the control's Close button, or Escape inside it closes it again, and the
|
|
850
905
|
* latter two return focus to the toggle.
|
|
@@ -882,6 +937,38 @@ function bindViewerAnalyticsChoicesToggles(win: ViewerConsentWindow): void {
|
|
|
882
937
|
});
|
|
883
938
|
}
|
|
884
939
|
|
|
940
|
+
/** Show or hide a control and keep every disclosure that names it in step. */
|
|
941
|
+
function setControlVisible(win: ViewerConsentWindow, root: HTMLElement, visible: boolean): void {
|
|
942
|
+
root.hidden = !visible;
|
|
943
|
+
if (!root.id) return;
|
|
944
|
+
win.document?.querySelectorAll<HTMLElement>("[data-tot-analytics-choices-toggle]").forEach((toggle) => {
|
|
945
|
+
if (toggle.getAttribute("aria-controls") === root.id) toggle.setAttribute("aria-expanded", String(visible));
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Open each control for a visitor the `ask` policy has not heard from. It runs after
|
|
951
|
+
* paint and idle so it never delays the page, and the control is a fixed overlay so it
|
|
952
|
+
* shifts no layout. Focus stays where the visitor left it.
|
|
953
|
+
*/
|
|
954
|
+
function scheduleViewerAnalyticsPrompt(
|
|
955
|
+
win: ViewerConsentWindow,
|
|
956
|
+
roots: NodeListOf<HTMLElement> | undefined,
|
|
957
|
+
state: ViewerAnalyticsConsentState,
|
|
958
|
+
): void {
|
|
959
|
+
if (state.prompt !== "pending" || !roots || roots.length === 0) return;
|
|
960
|
+
if (typeof win["setTimeout"] !== "function" || typeof win.addEventListener !== "function") return;
|
|
961
|
+
schedulePostPaint(win as unknown as PostPaintWindow, () => {
|
|
962
|
+
const latest = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined;
|
|
963
|
+
if (latest?.prompt !== "pending") return;
|
|
964
|
+
roots.forEach((root) => {
|
|
965
|
+
renderControlState(root, latest);
|
|
966
|
+
root.dataset["totAnalyticsConsentPrompted"] = "true";
|
|
967
|
+
setControlVisible(win, root, true);
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
885
972
|
/** Bind the renderer-owned control once; safe when a document contains no analytics plan. */
|
|
886
973
|
export function bootViewerAnalyticsConsentControl(
|
|
887
974
|
win: ViewerConsentWindow,
|
|
@@ -897,15 +984,28 @@ export function bootViewerAnalyticsConsentControl(
|
|
|
897
984
|
root.addEventListener("click", (event) => {
|
|
898
985
|
const target = event.target;
|
|
899
986
|
if (!(target instanceof Element)) return;
|
|
987
|
+
// Close records nothing, so an unanswered prompt returns on the next page.
|
|
988
|
+
if (target.closest("[data-tot-analytics-consent-close]")) {
|
|
989
|
+
delete root.dataset["totAnalyticsConsentPrompted"];
|
|
990
|
+
setControlVisible(win, root, false);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
900
993
|
const button = target.closest<HTMLButtonElement>("[data-tot-analytics-consent-decision]");
|
|
901
994
|
const decision = button?.dataset["totAnalyticsConsentDecision"];
|
|
902
995
|
if (decision !== "granted" && decision !== "denied") return;
|
|
903
996
|
renderControlState(root, setViewerAnalyticsConsent(win, decision, now()));
|
|
997
|
+
// An answered prompt goes away; the disclosure reopens the control on demand.
|
|
998
|
+
if (root.dataset["totAnalyticsConsentPrompted"] === "true") {
|
|
999
|
+
delete root.dataset["totAnalyticsConsentPrompted"];
|
|
1000
|
+
setControlVisible(win, root, false);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
904
1003
|
// The pressed button is now disabled; keep keyboard focus inside the control.
|
|
905
1004
|
if (button?.disabled) firstEnabledButton(root)?.focus();
|
|
906
1005
|
});
|
|
907
1006
|
});
|
|
908
1007
|
bindViewerAnalyticsChoicesToggles(win);
|
|
1008
|
+
scheduleViewerAnalyticsPrompt(win, roots, current);
|
|
909
1009
|
|
|
910
1010
|
const storageBoundGlobal = "__TOT_VIEWER_ANALYTICS_CONSENT_STORAGE_BOUND__";
|
|
911
1011
|
if (win.addEventListener && win[storageBoundGlobal] !== true) {
|
|
@@ -57,8 +57,9 @@ import { INTEGRATION_ENVIRONMENTS, INTEGRATION_SITE_SURFACES } from "./environme
|
|
|
57
57
|
import type { IntegrationEnvironment, IntegrationSiteSurface } from "./environment.js";
|
|
58
58
|
import { findForbiddenFields } from "./secrets.js";
|
|
59
59
|
import {
|
|
60
|
+
MANAGED_ANALYTICS_CAMPAIGN_FIELDS,
|
|
60
61
|
resemblesManagedAnalyticsPii,
|
|
61
|
-
|
|
62
|
+
screenManagedAnalyticsCampaign,
|
|
62
63
|
} from "./managed-analytics-value-safety.js";
|
|
63
64
|
|
|
64
65
|
/**
|
|
@@ -349,8 +350,6 @@ function validateLineItems(parent: Record<string, unknown>, v: Violations, path:
|
|
|
349
350
|
});
|
|
350
351
|
}
|
|
351
352
|
|
|
352
|
-
const UTM_FIELDS = ["source", "medium", "campaign", "term", "content"] as const;
|
|
353
|
-
const SAFE_UTM_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
|
|
354
353
|
function validatePageAttribution(parent: Record<string, unknown>, v: Violations): void {
|
|
355
354
|
const attribution = childObject(parent, "attribution", v, "data");
|
|
356
355
|
if (!attribution) return;
|
|
@@ -389,27 +388,24 @@ function validatePageAttribution(parent: Record<string, unknown>, v: Violations)
|
|
|
389
388
|
if ("utm" in attribution) {
|
|
390
389
|
const utm = childObject(attribution, "utm", v, "data.attribution");
|
|
391
390
|
if (utm) {
|
|
392
|
-
rejectUnknownKeys(utm,
|
|
391
|
+
rejectUnknownKeys(utm, MANAGED_ANALYTICS_CAMPAIGN_FIELDS, v, "data.attribution.utm");
|
|
393
392
|
if (Object.keys(utm).length === 0) {
|
|
394
393
|
v.add("data.attribution.utm", "empty", "UTM must contain at least one allowlisted field.");
|
|
395
394
|
}
|
|
396
|
-
|
|
395
|
+
const strings: Record<string, string> = {};
|
|
396
|
+
for (const key of MANAGED_ANALYTICS_CAMPAIGN_FIELDS) {
|
|
397
397
|
if (!(key in utm)) continue;
|
|
398
398
|
const value = reqString(utm, key, v, "data.attribution.utm");
|
|
399
|
-
if (value !== undefined
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
"protected_value",
|
|
410
|
-
`UTM ${key} resembles PII or protected material and must not be emitted.`,
|
|
411
|
-
);
|
|
412
|
-
}
|
|
399
|
+
if (value !== undefined) strings[key] = value;
|
|
400
|
+
}
|
|
401
|
+
for (const { field, reason } of screenManagedAnalyticsCampaign(strings).rejected) {
|
|
402
|
+
v.add(
|
|
403
|
+
`data.attribution.utm.${field}`,
|
|
404
|
+
reason,
|
|
405
|
+
reason === "format"
|
|
406
|
+
? `UTM ${field} must be a 1-128 character opaque campaign token.`
|
|
407
|
+
: `UTM ${field} resembles PII or protected material and must not be emitted.`,
|
|
408
|
+
);
|
|
413
409
|
}
|
|
414
410
|
}
|
|
415
411
|
}
|
|
@@ -56,6 +56,7 @@ export * from "./manifest.js";
|
|
|
56
56
|
export * from "./plan.js";
|
|
57
57
|
export * from "./resolve.js";
|
|
58
58
|
export * from "./platform-adapters.js";
|
|
59
|
+
export * from "./tenant-analytics.js";
|
|
59
60
|
export * from "./native-amplitude.js";
|
|
60
61
|
export * from "./native-ga4.js";
|
|
61
62
|
export * from "./native-ga4-sandbox.js";
|
|
@@ -59,8 +59,8 @@ import {
|
|
|
59
59
|
import { schedulePostPaint, type PostPaintWindow } from "./post-paint.js";
|
|
60
60
|
import { scheduleGa4Sandbox, type Ga4SandboxScheduleWindow } from "./ga4-sandbox-schedule.js";
|
|
61
61
|
import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
|
|
62
|
-
import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
|
|
63
|
-
import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety.js";
|
|
62
|
+
import { minimizePageAttribution, readGoogleAdsClickIds, type ManagedPageViewInput } from "./managed-analytics.js";
|
|
63
|
+
import { managedAnalyticsOpaqueRevision, type GoogleAdsClickIds } from "./managed-analytics-value-safety.js";
|
|
64
64
|
import {
|
|
65
65
|
CONTACT_SALES_ACTION_REGISTRY,
|
|
66
66
|
createContactSalesActionSource,
|
|
@@ -349,13 +349,13 @@ function defaultConsentGate(
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
/**
|
|
352
|
-
* Whether the tenant's EU/UK visitor policy governs this document
|
|
353
|
-
*
|
|
354
|
-
*
|
|
352
|
+
* Whether the tenant's EU/UK visitor policy governs this document, as the early consent
|
|
353
|
+
* boot decided it (`regionRestricted`). Only an explicit `false` lifts it: a missing
|
|
354
|
+
* state global or a missing or non-boolean flag applies it (fail closed).
|
|
355
355
|
*/
|
|
356
356
|
function euUkPolicyApplies(win: LifecycleWindow): boolean {
|
|
357
|
-
const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined;
|
|
358
|
-
return
|
|
357
|
+
const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as Partial<ViewerAnalyticsConsentState> | undefined;
|
|
358
|
+
return state?.regionRestricted !== false;
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
/**
|
|
@@ -367,6 +367,39 @@ function persistentVisitorStorage(win: LifecycleWindow): boolean {
|
|
|
367
367
|
return state?.visitorStorage === "persistent";
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Whether landing click identifiers may reach GA4: the visitor is known to be outside
|
|
372
|
+
* the EU/UK and analytics may keep them on this device. GPC, a saved denial, `count-only`,
|
|
373
|
+
* an unknown region, and an EU/UK visitor under any tenant policy all forward nothing.
|
|
374
|
+
* A missing state global allows nothing (fail closed).
|
|
375
|
+
*/
|
|
376
|
+
function landingClickIdsAllowed(win: LifecycleWindow): boolean {
|
|
377
|
+
const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined;
|
|
378
|
+
return state?.visitorStorage === "persistent" && state.region === "other";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Windows whose landing click identifiers already went out; a back-forward restore never resends them. */
|
|
382
|
+
const landingClickIdsTaken = new WeakSet<object>();
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The GA4 host capability over this document's landing URL: identifiers are read once,
|
|
386
|
+
* when the lifecycle is built, and released once, to the page view of the landing path,
|
|
387
|
+
* only while {@link landingClickIdsAllowed} holds at that moment.
|
|
388
|
+
*/
|
|
389
|
+
function landingClickIdSource(
|
|
390
|
+
win: LifecycleWindow,
|
|
391
|
+
tenantPublicId: string,
|
|
392
|
+
): ((path: string) => GoogleAdsClickIds | undefined) | undefined {
|
|
393
|
+
const clickIds = readGoogleAdsClickIds(win.location.href);
|
|
394
|
+
if (!clickIds) return undefined;
|
|
395
|
+
const landingPath = canonicalDocumentPath(win.location.pathname, tenantPublicId);
|
|
396
|
+
return (path) => {
|
|
397
|
+
if (path !== landingPath || landingClickIdsTaken.has(win) || !landingClickIdsAllowed(win)) return undefined;
|
|
398
|
+
landingClickIdsTaken.add(win);
|
|
399
|
+
return clickIds;
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
370
403
|
/**
|
|
371
404
|
* Under the EU/UK policy only a reviewed platform adapter may run: its
|
|
372
405
|
* visitor storage goes through this host's continuity seam, which then keeps nothing on
|
|
@@ -500,6 +533,7 @@ function createDocumentLifecycle(
|
|
|
500
533
|
? win["localStorage"] as VisitorContinuityStorage | undefined
|
|
501
534
|
: undefined),
|
|
502
535
|
}),
|
|
536
|
+
takeLandingClickIds: landingClickIdSource(win, descriptor.tenantPublicId),
|
|
503
537
|
nativeGa4Transport: () => {
|
|
504
538
|
if (options.nativeGa4Transport) return options.nativeGa4Transport;
|
|
505
539
|
if (typeof document === "undefined" || typeof MessageChannel === "undefined") {
|
|
@@ -1,12 +1,95 @@
|
|
|
1
1
|
/** Shared PII-value canary for managed browser events and their diagnostics. */
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
MANAGED_ANALYTICS_PII_VALUE_PATTERN,
|
|
4
|
+
MANAGED_ANALYTICS_SECRET_VALUE_PATTERN,
|
|
5
|
+
} from "./managed-analytics-api-v1-safety.mjs";
|
|
3
6
|
import { fnv1a128Hex } from "./digest.js";
|
|
4
7
|
|
|
5
8
|
const MANAGED_ANALYTICS_PII_VALUE = new RegExp(MANAGED_ANALYTICS_PII_VALUE_PATTERN);
|
|
9
|
+
const MANAGED_ANALYTICS_SECRET_VALUE = new RegExp(MANAGED_ANALYTICS_SECRET_VALUE_PATTERN);
|
|
6
10
|
const MANAGED_ANALYTICS_PROTECTED_VALUE =
|
|
7
11
|
/(?:@|\d{7,}|(?:^|[./_~-])(?:bearer|password|secret|token|authorization|preview)(?:$|[./_~-]))/i;
|
|
8
12
|
const OPAQUE_HEX_ALPHABET = "abcdefghijklmnop";
|
|
9
13
|
|
|
14
|
+
/** The five standard UTM fields, without their `utm_` prefix. */
|
|
15
|
+
export const MANAGED_ANALYTICS_CAMPAIGN_FIELDS = ["source", "medium", "campaign", "term", "content"] as const;
|
|
16
|
+
export type ManagedAnalyticsCampaignField = (typeof MANAGED_ANALYTICS_CAMPAIGN_FIELDS)[number];
|
|
17
|
+
export type ManagedAnalyticsCampaign = Partial<Record<ManagedAnalyticsCampaignField, string>>;
|
|
18
|
+
|
|
19
|
+
/** A dropped campaign field. It names the field and the reason, never the value. */
|
|
20
|
+
export interface ManagedAnalyticsCampaignRejection {
|
|
21
|
+
field: ManagedAnalyticsCampaignField;
|
|
22
|
+
reason: "format" | "protected_value";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ManagedAnalyticsCampaignScreen {
|
|
26
|
+
kept: ManagedAnalyticsCampaign;
|
|
27
|
+
rejected: ManagedAnalyticsCampaignRejection[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CAMPAIGN_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
|
|
31
|
+
/** Credential words as a whole token segment. `token` and `preview` are ordinary campaign words. */
|
|
32
|
+
const CAMPAIGN_CREDENTIAL_WORD = /(?:^|[._~-])(?:bearer|password|secret|authorization|credential)(?:$|[._~-])/i;
|
|
33
|
+
/** A labelled token may carry a 7-8 digit stamp (`_20261027`); a longer run could be a dialable number. */
|
|
34
|
+
const CAMPAIGN_LONG_DIGIT_RUN = /\d{9,}/;
|
|
35
|
+
/**
|
|
36
|
+
* A bare number is never a campaign token. A Google Ads ValueTrack `{campaignid}` has the
|
|
37
|
+
* same shape as an international phone number written without `+`, and neither `utm_source`
|
|
38
|
+
* nor length separates them, so it is rejected; GA4 still attributes Ads traffic by click id.
|
|
39
|
+
*/
|
|
40
|
+
const CAMPAIGN_BARE_DIGITS = /^\d{7,}$/;
|
|
41
|
+
|
|
42
|
+
function campaignTokenRejection(value: unknown): ManagedAnalyticsCampaignRejection["reason"] | undefined {
|
|
43
|
+
if (typeof value !== "string" || !CAMPAIGN_TOKEN.test(value)) return "format";
|
|
44
|
+
if (
|
|
45
|
+
resemblesManagedAnalyticsPii(value) || MANAGED_ANALYTICS_SECRET_VALUE.test(value) ||
|
|
46
|
+
CAMPAIGN_CREDENTIAL_WORD.test(value)
|
|
47
|
+
) return "protected_value";
|
|
48
|
+
return CAMPAIGN_BARE_DIGITS.test(value) || CAMPAIGN_LONG_DIGIT_RUN.test(value) ? "protected_value" : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The one campaign-token policy for every layer that carries UTM values: minimization,
|
|
53
|
+
* event validation, the GA4 parent and sandbox, and Amplitude. Each present field is kept
|
|
54
|
+
* or rejected on its own, so one unsafe value never costs the page view its other fields.
|
|
55
|
+
*/
|
|
56
|
+
export function screenManagedAnalyticsCampaign(utm: Readonly<Record<string, unknown>>): ManagedAnalyticsCampaignScreen {
|
|
57
|
+
const kept: ManagedAnalyticsCampaign = {};
|
|
58
|
+
const rejected: ManagedAnalyticsCampaignRejection[] = [];
|
|
59
|
+
for (const field of MANAGED_ANALYTICS_CAMPAIGN_FIELDS) {
|
|
60
|
+
if (!Object.hasOwn(utm, field)) continue;
|
|
61
|
+
const value = utm[field];
|
|
62
|
+
const reason = campaignTokenRejection(value);
|
|
63
|
+
if (reason) rejected.push({ field, reason });
|
|
64
|
+
else kept[field] = value as string;
|
|
65
|
+
}
|
|
66
|
+
return { kept, rejected };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The Google Ads click identifiers a landing URL may carry to GA4. */
|
|
70
|
+
export const GOOGLE_ADS_CLICK_ID_KEYS = ["gclid", "gbraid", "wbraid"] as const;
|
|
71
|
+
export type GoogleAdsClickIdKey = (typeof GOOGLE_ADS_CLICK_ID_KEYS)[number];
|
|
72
|
+
export type GoogleAdsClickIds = Partial<Record<GoogleAdsClickIdKey, string>>;
|
|
73
|
+
|
|
74
|
+
/** Auto-tagging writes URL-safe base64; the length bound only caps hostile input. */
|
|
75
|
+
const GOOGLE_ADS_CLICK_ID = /^[A-Za-z0-9_-]{1,512}$/;
|
|
76
|
+
|
|
77
|
+
/** A click identifier safe to place on a GA4 `page_location`: URL-safe, bounded, and not PII- or secret-shaped. */
|
|
78
|
+
export function isGoogleAdsClickIdValue(value: unknown): value is string {
|
|
79
|
+
return typeof value === "string" && GOOGLE_ADS_CLICK_ID.test(value) &&
|
|
80
|
+
!resemblesManagedAnalyticsPii(value) && !MANAGED_ANALYTICS_SECRET_VALUE.test(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A non-empty record of known click-identifier keys, every value valid. */
|
|
84
|
+
export function isGoogleAdsClickIds(value: unknown): value is GoogleAdsClickIds {
|
|
85
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
86
|
+
const proto = Object.getPrototypeOf(value);
|
|
87
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
88
|
+
const entries = Object.entries(value);
|
|
89
|
+
return entries.length > 0 && entries.every(([key, id]) =>
|
|
90
|
+
(GOOGLE_ADS_CLICK_ID_KEYS as readonly string[]).includes(key) && isGoogleAdsClickIdValue(id));
|
|
91
|
+
}
|
|
92
|
+
|
|
10
93
|
/** True when a public string resembles an email, SSN, payment card, or phone number. */
|
|
11
94
|
export function resemblesManagedAnalyticsPii(value: unknown): boolean {
|
|
12
95
|
return typeof value === "string" && MANAGED_ANALYTICS_PII_VALUE.test(value);
|