@tokenoftrust/storefront-runner 4.9.3 → 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/src/lib/access/shared/cspStaticHashes.ts +1 -1
- package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
- package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
- 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/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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The EU/UK visitor policy a tenant declares in its own `analytics.json` (`euUkVisitors`), read and
|
|
3
|
+
* rewritten as TEXT for the admin consent page.
|
|
4
|
+
*
|
|
5
|
+
* The rewrite touches only the top-level `euUkVisitors` member: every other byte of the file —
|
|
6
|
+
* `destinations` above all — is carried through unchanged, so a policy change never reformats or
|
|
7
|
+
* re-serializes the tenant's destination ids. `ask` is the default and is expressed by ABSENCE, so
|
|
8
|
+
* choosing it removes the key.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
EU_UK_VISITOR_POLICIES,
|
|
12
|
+
TENANT_ANALYTICS_SCHEMA_VERSION,
|
|
13
|
+
isEuUkVisitorPolicy,
|
|
14
|
+
tenantAnalyticsDeclarationFindings,
|
|
15
|
+
type EuUkVisitorPolicy,
|
|
16
|
+
type TenantAnalyticsFinding,
|
|
17
|
+
} from "@tot/public-runtime";
|
|
18
|
+
|
|
19
|
+
export { EU_UK_VISITOR_POLICIES, isEuUkVisitorPolicy, type EuUkVisitorPolicy };
|
|
20
|
+
|
|
21
|
+
const POLICY_KEY = "euUkVisitors";
|
|
22
|
+
|
|
23
|
+
/** The declared policy, `ask` when the file or the key is absent. `null` when the file is not a JSON object. */
|
|
24
|
+
export function readEuUkVisitorPolicy(text: string | null): EuUkVisitorPolicy | null {
|
|
25
|
+
if (text === null) return "ask";
|
|
26
|
+
let parsed: unknown;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(text);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err instanceof SyntaxError) return null;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
34
|
+
const value = (parsed as Record<string, unknown>)[POLICY_KEY];
|
|
35
|
+
if (value === undefined) return "ask";
|
|
36
|
+
return isEuUkVisitorPolicy(value) ? value : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Value-free violations for a requested policy: empty when it is one of the four policies. */
|
|
40
|
+
export function euUkVisitorPolicyViolations(value: unknown): TenantAnalyticsFinding[] {
|
|
41
|
+
if (isEuUkVisitorPolicy(value)) return [];
|
|
42
|
+
return tenantAnalyticsDeclarationFindings(JSON.stringify({ [POLICY_KEY]: value ?? 0, destinations: [] }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Member {
|
|
46
|
+
key: string;
|
|
47
|
+
start: number;
|
|
48
|
+
valueStart: number;
|
|
49
|
+
end: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function skipWhitespace(text: string, i: number): number {
|
|
53
|
+
while (i < text.length && /\s/.test(text[i]!)) i += 1;
|
|
54
|
+
return i;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stringEnd(text: string, i: number): number {
|
|
58
|
+
let j = i + 1;
|
|
59
|
+
while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
|
|
60
|
+
return j + 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function valueEnd(text: string, i: number): number {
|
|
64
|
+
if (text[i] === '"') return stringEnd(text, i);
|
|
65
|
+
if (text[i] === "{" || text[i] === "[") {
|
|
66
|
+
let depth = 0;
|
|
67
|
+
let j = i;
|
|
68
|
+
while (j < text.length) {
|
|
69
|
+
const c = text[j]!;
|
|
70
|
+
if (c === '"') {
|
|
71
|
+
j = stringEnd(text, j);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (c === "{" || c === "[") depth += 1;
|
|
75
|
+
if (c === "}" || c === "]") depth -= 1;
|
|
76
|
+
j += 1;
|
|
77
|
+
if (depth === 0) return j;
|
|
78
|
+
}
|
|
79
|
+
return j;
|
|
80
|
+
}
|
|
81
|
+
let j = i;
|
|
82
|
+
while (j < text.length && !/[\s,}\]]/.test(text[j]!)) j += 1;
|
|
83
|
+
return j;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The top-level members of a JSON object text already known to parse, with their byte spans. */
|
|
87
|
+
function topLevelMembers(text: string): { open: number; close: number; members: Member[] } {
|
|
88
|
+
const open = skipWhitespace(text, 0);
|
|
89
|
+
const members: Member[] = [];
|
|
90
|
+
let i = skipWhitespace(text, open + 1);
|
|
91
|
+
while (text[i] === '"') {
|
|
92
|
+
const keyEnd = stringEnd(text, i);
|
|
93
|
+
const key = JSON.parse(text.slice(i, keyEnd)) as string;
|
|
94
|
+
const valueStart = skipWhitespace(text, skipWhitespace(text, keyEnd) + 1);
|
|
95
|
+
const end = valueEnd(text, valueStart);
|
|
96
|
+
members.push({ key, start: i, valueStart, end });
|
|
97
|
+
i = skipWhitespace(text, end);
|
|
98
|
+
if (text[i] === ",") i = skipWhitespace(text, i + 1);
|
|
99
|
+
}
|
|
100
|
+
return { open, close: i, members };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type PolicyEdit =
|
|
104
|
+
| { ok: true; changed: false }
|
|
105
|
+
| { ok: true; changed: true; text: string }
|
|
106
|
+
| { ok: false; violations: TenantAnalyticsFinding[] };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rewrite `analytics.json` so its `euUkVisitors` is `policy`, changing no other byte. An absent file
|
|
110
|
+
* gains a minimal valid declaration only when the policy is not the `ask` default. The result is
|
|
111
|
+
* validated as a whole file; a file the publish validator would refuse is returned as violations.
|
|
112
|
+
*/
|
|
113
|
+
export function editEuUkVisitorPolicy(text: string | null, policy: unknown): PolicyEdit {
|
|
114
|
+
const requested = euUkVisitorPolicyViolations(policy);
|
|
115
|
+
if (requested.length > 0) return { ok: false, violations: requested };
|
|
116
|
+
const next = policy as EuUkVisitorPolicy;
|
|
117
|
+
if (readEuUkVisitorPolicy(text) === next) return { ok: true, changed: false };
|
|
118
|
+
|
|
119
|
+
let updated: string;
|
|
120
|
+
if (text === null) {
|
|
121
|
+
updated = `${JSON.stringify({ schemaVersion: TENANT_ANALYTICS_SCHEMA_VERSION, [POLICY_KEY]: next, destinations: [] }, null, 2)}\n`;
|
|
122
|
+
} else {
|
|
123
|
+
const current = readEuUkVisitorPolicy(text);
|
|
124
|
+
if (current === null && tenantAnalyticsDeclarationFindings(text).some((f) => f.path === "(root)")) {
|
|
125
|
+
return { ok: false, violations: tenantAnalyticsDeclarationFindings(text) };
|
|
126
|
+
}
|
|
127
|
+
updated = rewrite(text, next);
|
|
128
|
+
}
|
|
129
|
+
const findings = tenantAnalyticsDeclarationFindings(updated);
|
|
130
|
+
if (findings.length > 0) return { ok: false, violations: findings };
|
|
131
|
+
return { ok: true, changed: true, text: updated };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function rewrite(text: string, policy: EuUkVisitorPolicy): string {
|
|
135
|
+
const { open, close, members } = topLevelMembers(text);
|
|
136
|
+
const index = members.findIndex((m) => m.key === POLICY_KEY);
|
|
137
|
+
const literal = JSON.stringify(policy);
|
|
138
|
+
if (index >= 0) {
|
|
139
|
+
const member = members[index]!;
|
|
140
|
+
if (policy !== "ask") return text.slice(0, member.valueStart) + literal + text.slice(member.end);
|
|
141
|
+
const following = members[index + 1];
|
|
142
|
+
if (following) return text.slice(0, member.start) + text.slice(following.start);
|
|
143
|
+
const previous = members[index - 1];
|
|
144
|
+
if (previous) return text.slice(0, previous.end) + text.slice(member.end);
|
|
145
|
+
return text.slice(0, open + 1) + text.slice(close);
|
|
146
|
+
}
|
|
147
|
+
const first = members[0];
|
|
148
|
+
if (!first) return `${text.slice(0, open + 1)}"${POLICY_KEY}": ${literal}${text.slice(close)}`;
|
|
149
|
+
const separator = text.slice(open + 1, first.start);
|
|
150
|
+
return `${text.slice(0, first.start)}"${POLICY_KEY}": ${literal},${separator}${text.slice(first.start)}`;
|
|
151
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side wiring for the Visitor regions card on `/admin/consent` (ADR 0013 client-island
|
|
3
|
+
* pattern). Reads and saves the EU/UK visitor policy through `GET`/`POST /api/admin/analytics-regions`;
|
|
4
|
+
* a save opens a preview change, so the status line says the live site changes only once it is
|
|
5
|
+
* published.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface RegionsRead {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
euUkVisitors?: string;
|
|
11
|
+
pending?: boolean;
|
|
12
|
+
message?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RegionsSave {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
status?: string;
|
|
18
|
+
message?: string;
|
|
19
|
+
url?: string;
|
|
20
|
+
violations?: { message: string }[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Wire the card. A no-op when the page did not render it. */
|
|
24
|
+
export function initAnalyticsRegionsCard(apiPath: string): void {
|
|
25
|
+
const form = document.getElementById("regions-form") as HTMLFormElement | null;
|
|
26
|
+
const statusEl = document.getElementById("regions-status");
|
|
27
|
+
if (!form || !statusEl) return;
|
|
28
|
+
|
|
29
|
+
function setStatus(text: string, tone: "ok" | "error" | null): void {
|
|
30
|
+
statusEl!.textContent = text;
|
|
31
|
+
if (tone) statusEl!.setAttribute("data-tone", tone);
|
|
32
|
+
else statusEl!.removeAttribute("data-tone");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function select(policy: string): void {
|
|
36
|
+
const input = form!.querySelector<HTMLInputElement>(`input[name="euUkVisitors"][value="${policy}"]`);
|
|
37
|
+
if (input) input.checked = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function load(): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(apiPath, { headers: { accept: "application/json" } });
|
|
43
|
+
const data = (await res.json()) as RegionsRead;
|
|
44
|
+
if (!data.ok) {
|
|
45
|
+
setStatus(data.message || "Could not read analytics.json.", "error");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
select(data.euUkVisitors ?? "ask");
|
|
49
|
+
form!.setAttribute("data-loaded", "true");
|
|
50
|
+
setStatus(data.pending ? "A change is waiting in preview." : "", null);
|
|
51
|
+
} catch {
|
|
52
|
+
setStatus("Could not read analytics.json.", "error");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
void load();
|
|
56
|
+
|
|
57
|
+
form.addEventListener("submit", async (event) => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
const choice = form.querySelector<HTMLInputElement>('input[name="euUkVisitors"]:checked')?.value ?? "ask";
|
|
60
|
+
const button = form.querySelector<HTMLButtonElement>("button.save");
|
|
61
|
+
if (button) button.disabled = true;
|
|
62
|
+
setStatus("Saving…", null);
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(apiPath, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "content-type": "application/json" },
|
|
67
|
+
body: JSON.stringify({ euUkVisitors: choice }),
|
|
68
|
+
});
|
|
69
|
+
const data = (await res.json()) as RegionsSave;
|
|
70
|
+
if (!data.ok) {
|
|
71
|
+
const detail = data.violations?.map((v) => v.message).join(" ") ?? "";
|
|
72
|
+
setStatus(`${data.message || "Save failed."} ${detail}`.trim(), "error");
|
|
73
|
+
} else if (data.status === "unchanged") {
|
|
74
|
+
setStatus("No change: that is already the setting.", "ok");
|
|
75
|
+
} else {
|
|
76
|
+
setStatus("Saved to preview. The live site changes when you publish.", "ok");
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
setStatus("Save failed: network error.", "error");
|
|
80
|
+
} finally {
|
|
81
|
+
if (button) button.disabled = false;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -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";
|