@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.
Files changed (31) hide show
  1. package/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
  2. package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
  3. package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
  4. package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
  5. package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
  6. package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
  7. package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
  8. package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
  9. package/package.json +1 -1
  10. package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
  11. package/packages/public-runtime/src/integration/events.ts +15 -19
  12. package/packages/public-runtime/src/integration/index.ts +1 -0
  13. package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
  14. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
  15. package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
  16. package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
  17. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
  18. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
  19. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
  20. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
  21. package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
  22. package/packages/public-runtime/src/integration/plan.ts +6 -4
  23. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
  24. package/packages/public-runtime/src/integration/render-phases.ts +10 -18
  25. package/packages/public-runtime/src/integration/resolve.ts +3 -3
  26. package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
  27. package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
  28. package/packages/public-runtime/src/tenant.ts +3 -3
  29. package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
  30. package/scripts/dev/ga4-gs2-canary.mjs +36 -10
  31. package/scripts/tenant/validate.mjs +9 -0
@@ -108,7 +108,7 @@ export interface IntegrationBootDescriptor {
108
108
  siteSurface: EffectivePlan["environment"]["siteSurface"];
109
109
  tenantPublicId: string;
110
110
  /**
111
- * The tenant's EU/UK visitor policy when it is not the `count-only` default. The
111
+ * The tenant's EU/UK visitor policy when it is not the `ask` default. The
112
112
  * early consent boot reads it together with the edge's region class.
113
113
  */
114
114
  euUkVisitors?: EffectivePlan["euUkVisitors"];
@@ -182,7 +182,6 @@ export function renderIntegrationPhases(
182
182
  const bodyDeferred =
183
183
  phaseBoundary("document.deferred") +
184
184
  `<div data-tot-integration-deferred hidden></div>` +
185
- viewerAnalyticsChoicesLink(plan) +
186
185
  lifecycleHostScript();
187
186
 
188
187
  return { head, bodyStart, bodyDeferred };
@@ -240,17 +239,23 @@ function inlineScript(source: string, nonce: string): string {
240
239
 
241
240
  /** Renderer-owned, provider-neutral viewer control: identical in both document mounts. */
242
241
  function viewerAnalyticsConsentStyle(nonce: string): string {
243
- const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}.tot-analytics-consent[hidden]{display:none}.tot-analytics-choices{display:flex;justify-content:center;padding:.75rem 1rem;background:var(--color-bg,#fff)}.tot-analytics-choices__toggle{border:0;background:none;padding:.25rem;color:var(--color-muted,#475569);font:500 .75rem/1.25 system-ui,sans-serif;text-decoration:underline;cursor:pointer}`;
242
+ const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}.tot-analytics-consent[hidden]{display:none}`;
244
243
  return `<style${nonce ? ` nonce="${nonce}"` : ""}>${style}</style>`;
245
244
  }
246
245
 
247
- /** The id the persistent "Analytics choices" toggle names in `aria-controls`. */
246
+ /**
247
+ * The id a tenant's Privacy Policy "Analytics choices" button names in `aria-controls`.
248
+ * The engine renders no visible route to the control: the tenant places that button in
249
+ * its own Privacy Policy, and the lifecycle host binds it
250
+ * (`bootViewerAnalyticsConsentControl`). Markup contract:
251
+ * `docs/architecture/analytics-activation.md`.
252
+ */
248
253
  const VIEWER_ANALYTICS_CONSENT_CONTROL_ID = "tot-analytics-consent";
249
254
 
250
255
  function viewerAnalyticsConsentControl(): string {
251
256
  return `<aside id="${VIEWER_ANALYTICS_CONSENT_CONTROL_ID}" class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings" hidden>` +
252
257
  `<span class="tot-analytics-consent__copy"><strong data-tot-analytics-consent-status aria-live="polite">Analytics off</strong>` +
253
- `<span class="tot-analytics-consent__detail">Choose whether analytics may run on this site.</span></span>` +
258
+ `<span class="tot-analytics-consent__detail" data-tot-analytics-consent-detail>Choose whether analytics may run on this site.</span></span>` +
254
259
  `<span class="tot-analytics-consent__actions">` +
255
260
  `<button type="button" data-tot-analytics-consent-decision="granted" aria-pressed="false" disabled>Allow</button>` +
256
261
  `<button type="button" data-tot-analytics-consent-decision="denied" aria-pressed="true" disabled>Decline</button>` +
@@ -258,19 +263,6 @@ function viewerAnalyticsConsentControl(): string {
258
263
  `</span></aside>`;
259
264
  }
260
265
 
261
- /**
262
- * The persistent, platform-owned way to reach the control: a disclosure button placed
263
- * after the tenant's own footer, on every page whose plan asks for analytics consent.
264
- * The lifecycle host binds it (`bootViewerAnalyticsConsentControl`); no inline handler.
265
- */
266
- function viewerAnalyticsChoicesLink(plan: EffectivePlan): string {
267
- if (!plan.consentNeeds.includes("analytics")) return "";
268
- return `<div class="tot-analytics-choices">` +
269
- `<button type="button" class="tot-analytics-choices__toggle" data-tot-analytics-choices-toggle ` +
270
- `aria-controls="${VIEWER_ANALYTICS_CONSENT_CONTROL_ID}" aria-expanded="false">Analytics choices</button>` +
271
- `</div>`;
272
- }
273
-
274
266
  /**
275
267
  * Replace every {@link INTEGRATION_NONCE_PLACEHOLDER} in `text` (an HTML body or a
276
268
  * CSP header value) with one fresh per-response `nonce`. Idempotent on text with no
@@ -68,7 +68,7 @@ export interface ResolvePlanInput {
68
68
  tenantPublicId: string;
69
69
  environment: BrowserEnvironment;
70
70
  declarations: readonly IntegrationDeclarationInput[];
71
- /** The tenant's EU/UK visitor policy; absent means `count-only`. */
71
+ /** The tenant's EU/UK visitor policy; absent means `ask`. */
72
72
  euUkVisitors?: EuUkVisitorPolicy;
73
73
  }
74
74
 
@@ -98,7 +98,7 @@ export function resolveEffectivePlan(
98
98
  }
99
99
 
100
100
  if (input.euUkVisitors !== undefined && !isEuUkVisitorPolicy(input.euUkVisitors)) {
101
- v.add("euUkVisitors", "unknown_enum", 'euUkVisitors must be "count-only", "full", or "none".');
101
+ v.add("euUkVisitors", "unknown_enum", 'euUkVisitors must be "ask", "count-only", "full", or "none".');
102
102
  }
103
103
 
104
104
  if (!Array.isArray(input.declarations)) {
@@ -218,7 +218,7 @@ export function resolveEffectivePlan(
218
218
  schemaVersion: EFFECTIVE_PLAN_SCHEMA_VERSION,
219
219
  environment: envResult.ok ? envResult.value : input.environment,
220
220
  tenantPublicId: input.tenantPublicId,
221
- ...(input.euUkVisitors === "full" || input.euUkVisitors === "none"
221
+ ...(isEuUkVisitorPolicy(input.euUkVisitors) && input.euUkVisitors !== "ask"
222
222
  ? { euUkVisitors: input.euUkVisitors }
223
223
  : {}),
224
224
  integrations,
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Tenant-owned analytics declaration surface (TIER 1).
3
+ *
4
+ * A tenant commits an `analytics.json` at the root of its own tree (`tenants/<id>/**`), and
5
+ * this materializes it into the already-consumed declaration seam
6
+ * ({@link IntegrationDeclarationInput}[], merged into the render plan by the interim bridge).
7
+ * It adds no runtime: it is a third producer into an existing consumer, alongside the
8
+ * platform-authored `tenant.integrations` and the D1-backed managed installs.
9
+ *
10
+ * Each declared destination is built by the reviewed {@link buildPlatformAdapterDeclaration},
11
+ * which fail-closes on an unknown adapter, a missing/invalid live id, an invalid per-stage id,
12
+ * or a non-live stage that reuses the live id.
13
+ *
14
+ * One core, every caller:
15
+ * - PUBLISH validation — the controlplane's `analytics-declaration` validator runs this on
16
+ * every candidate, reconcile, preview build and publish, and refuses the artifact on any
17
+ * violation. `agency-kit/tools/lib/analytics-declaration.mjs` reports the same violations
18
+ * to the plain-node surfaces (`tot validate`, validate-site, materialize-tenant); a shared
19
+ * fixture set pins the two together.
20
+ * - RUNTIME materialization — the storefront's `loadTenantAnalyticsDeclarations` is
21
+ * fail-closed: an invalid file yields no declarations and a value-free diagnostic.
22
+ */
23
+ import {
24
+ analyticsViolationText,
25
+ parseAnalyticsDeclaration,
26
+ } from "../../../../agency-kit/tools/lib/analytics-declaration.mjs";
27
+ import type { BrowserEnvironment } from "./environment.js";
28
+ import { buildPlatformAdapterDeclaration } from "./platform-adapters.js";
29
+ import { isEuUkVisitorPolicy, type EuUkVisitorPolicy } from "./plan.js";
30
+ import type { IntegrationDeclarationInput } from "./resolve.js";
31
+ import { Violations, isPlainObject, rejectUnknownKeys, type ContractViolation } from "./result.js";
32
+ import { ENVIRONMENT_SCHEMA_VERSION } from "./version.js";
33
+
34
+ /** The tenant-owned file that declares measurement destinations, at the tenant tree's root. */
35
+ export const TENANT_ANALYTICS_FILE = "analytics.json";
36
+
37
+ /**
38
+ * The current `analytics.json` contract version. Kept out of `version.ts`, which the GA4 sandbox
39
+ * bundles: a new export there changes that bundle's pinned CSP hash.
40
+ */
41
+ export const TENANT_ANALYTICS_SCHEMA_VERSION = 1 as const;
42
+
43
+ /** One declared measurement destination: a first-class adapter + its per-stage public ids. */
44
+ export interface TenantAnalyticsDestination {
45
+ /** The first-class platform adapter id, e.g. `platform.google-measurement`. */
46
+ adapter: string;
47
+ /** Public per-stage destination ids. `live` is required; non-live stages take SEPARATE test ids. */
48
+ ids: Record<string, unknown>;
49
+ }
50
+
51
+ /** The result of materializing a tenant's `analytics.json` into declarations. */
52
+ export interface TenantAnalyticsMaterialization {
53
+ /** True only when EVERY declared destination validated; false surfaces `violations`. */
54
+ ok: boolean;
55
+ /** The materialized declarations, in declared order. Empty whenever `ok` is false. */
56
+ declarations: readonly IntegrationDeclarationInput[];
57
+ /**
58
+ * The declared `euUkVisitors` policy when it is not the `ask` default. Absent
59
+ * whenever `ok` is false, so an invalid file never widens EU/UK collection.
60
+ */
61
+ euUkVisitors?: Exclude<EuUkVisitorPolicy, "ask">;
62
+ /** Per-field violations for the publish surface. Empty whenever `ok` is true. */
63
+ violations: readonly ContractViolation[];
64
+ }
65
+
66
+ /**
67
+ * The fixed environment the declaration is CONSTRUCTED against. A first-class adapter
68
+ * declaration is environment-INDEPENDENT in content — its per-stage ids are all baked
69
+ * into `publicConfig` and the client picks the current stage at render time — so the
70
+ * environment here only satisfies {@link buildPlatformAdapterDeclaration}'s validation
71
+ * and its plan-derived CSP requirements. The caller must supply the tenant's real
72
+ * canonical host so those requirements authorize the same-site adapter frame that the
73
+ * browser runtime will construct.
74
+ */
75
+ function constructionEnvironment(canonicalHost: string): BrowserEnvironment {
76
+ return {
77
+ schemaVersion: ENVIRONMENT_SCHEMA_VERSION,
78
+ siteSurface: "storefront",
79
+ environment: "live",
80
+ canonicalHost,
81
+ revision: "tenant-analytics-declaration",
82
+ };
83
+ }
84
+
85
+ const EMPTY: readonly IntegrationDeclarationInput[] = Object.freeze([]);
86
+
87
+ /** Re-anchor a builder violation path under the declaring `destinations[i]` element. */
88
+ function reanchor(violation: ContractViolation, index: number): ContractViolation {
89
+ const base = `destinations[${index}]`;
90
+ const path =
91
+ violation.path === "(root)" || violation.path === ""
92
+ ? base
93
+ : violation.path === "adapterId"
94
+ ? `${base}.adapter`
95
+ : violation.path.startsWith("adapterId.")
96
+ ? `${base}.adapter${violation.path.slice("adapterId".length)}`
97
+ : `${base}.${violation.path}`;
98
+ return { path, code: violation.code, message: violation.message };
99
+ }
100
+
101
+ /** Validate the closed shape of one declared destination before it reaches the builder. */
102
+ function readDestination(
103
+ raw: unknown,
104
+ index: number,
105
+ v: Violations,
106
+ ): TenantAnalyticsDestination | undefined {
107
+ const base = `destinations[${index}]`;
108
+ if (!isPlainObject(raw)) {
109
+ v.add(base, "type", "A destination must be an object of { adapter, ids }.");
110
+ return undefined;
111
+ }
112
+ rejectUnknownKeys(raw, ["adapter", "ids"], v, base);
113
+ const adapter = raw["adapter"];
114
+ if (typeof adapter !== "string" || adapter.length === 0) {
115
+ v.add(`${base}.adapter`, "type", "A destination adapter must be a non-empty string.");
116
+ return undefined;
117
+ }
118
+ if (!isPlainObject(raw["ids"])) {
119
+ v.add(`${base}.ids`, "type", "ids must be an object of { stage: destinationId[] }.");
120
+ return undefined;
121
+ }
122
+ return { adapter, ids: raw["ids"] };
123
+ }
124
+
125
+ /**
126
+ * Parse and materialize a tenant's `analytics.json` content into first-class adapter
127
+ * declarations. `raw` is the already-parsed JSON value (or `undefined`/`null` when the
128
+ * tenant declares none). Fail-closed and whole-file: if ANY declared destination is
129
+ * invalid, NO declaration is produced and the violations name every offending field —
130
+ * a half-valid analytics file never partially materializes.
131
+ */
132
+ export function materializeTenantAnalyticsDeclarations(input: {
133
+ raw: unknown;
134
+ tenantPublicId: string;
135
+ canonicalHost: string;
136
+ }): TenantAnalyticsMaterialization {
137
+ const { raw, tenantPublicId, canonicalHost } = input;
138
+ // An undeclared tenant (no file) is the zero-cost norm, not an error.
139
+ if (raw === undefined || raw === null) {
140
+ return { ok: true, declarations: EMPTY, violations: [] };
141
+ }
142
+
143
+ const v = new Violations();
144
+ if (!isPlainObject(raw)) {
145
+ v.add("(root)", "type", "analytics.json must be a JSON object with a `destinations` array.");
146
+ return { ok: false, declarations: EMPTY, violations: v.items };
147
+ }
148
+ rejectUnknownKeys(raw, ["schemaVersion", "destinations", "euUkVisitors"], v, "");
149
+ const euUkVisitors = raw["euUkVisitors"];
150
+ if ("euUkVisitors" in raw && !isEuUkVisitorPolicy(euUkVisitors)) {
151
+ v.add(
152
+ "euUkVisitors",
153
+ "unknown_enum",
154
+ 'euUkVisitors must be "ask" (the default), "count-only", "full", or "none".',
155
+ );
156
+ }
157
+ if ("schemaVersion" in raw && raw["schemaVersion"] !== TENANT_ANALYTICS_SCHEMA_VERSION) {
158
+ v.add(
159
+ "schemaVersion",
160
+ "unsupported_version",
161
+ `schemaVersion must equal ${TENANT_ANALYTICS_SCHEMA_VERSION}.`,
162
+ );
163
+ }
164
+ const destinationsRaw = raw["destinations"];
165
+ if (!Array.isArray(destinationsRaw)) {
166
+ v.add("destinations", "type", "`destinations` must be an array.");
167
+ return { ok: false, declarations: EMPTY, violations: v.items };
168
+ }
169
+
170
+ const declarations: IntegrationDeclarationInput[] = [];
171
+ const seenAdapters = new Set<string>();
172
+ destinationsRaw.forEach((destinationRaw, index) => {
173
+ const destination = readDestination(destinationRaw, index, v);
174
+ if (!destination) return;
175
+ // The plan resolves an app at most once and refuses the whole plan on a repeat.
176
+ if (seenAdapters.has(destination.adapter)) {
177
+ v.add(`destinations[${index}].adapter`, "duplicate", "Each adapter may be declared by one destination only.");
178
+ return;
179
+ }
180
+ seenAdapters.add(destination.adapter);
181
+ const built = buildPlatformAdapterDeclaration({
182
+ adapterId: destination.adapter,
183
+ ids: destination.ids,
184
+ tenantPublicId,
185
+ environment: constructionEnvironment(canonicalHost),
186
+ });
187
+ if (!built.ok) {
188
+ for (const violation of built.violations) v.items.push(reanchor(violation, index));
189
+ return;
190
+ }
191
+ // Namespace the ref so a tenant-declared adapter can never be mistaken for — or
192
+ // collide with — the same adapter installed through the managed D1 path.
193
+ declarations.push({ ...built.declaration, ref: `tenant-analytics:${destination.adapter}` });
194
+ });
195
+
196
+ if (!v.ok) return { ok: false, declarations: EMPTY, violations: v.items };
197
+ return {
198
+ ok: true,
199
+ declarations,
200
+ violations: [],
201
+ ...(isEuUkVisitorPolicy(euUkVisitors) && euUkVisitors !== "ask" ? { euUkVisitors } : {}),
202
+ };
203
+ }
204
+
205
+ /** A publish-surface violation: value-free, so it is safe to log, return or render. */
206
+ export interface TenantAnalyticsFinding {
207
+ path: string;
208
+ code: string;
209
+ /** `<path>: [<code>] <fixed explanation>` — never an id or any other value from the file. */
210
+ message: string;
211
+ remedy: string;
212
+ }
213
+
214
+ function finding(violation: { path: string; code: string }): TenantAnalyticsFinding {
215
+ const { message, remedy } = analyticsViolationText(violation);
216
+ return {
217
+ path: violation.path,
218
+ code: violation.code,
219
+ message: `${violation.path}: [${violation.code}] ${message}`,
220
+ remedy,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * The publish-time check of one `analytics.json` file's TEXT: every violation the materializer
226
+ * reports, with no declaration produced. A file that is present but unparseable is a `(root)`
227
+ * `invalid_json` violation and a present `null` is a `(root)` `type` violation — only an absent
228
+ * file declares nothing. Validity does not depend on the tenant or its host, so fixed
229
+ * construction values are used. The builder's own messages may quote a declared id, so each
230
+ * violation is re-described by its path and code alone.
231
+ */
232
+ export function tenantAnalyticsDeclarationFindings(text: string): TenantAnalyticsFinding[] {
233
+ const parsed = parseAnalyticsDeclaration(text);
234
+ if (!parsed.ok) return parsed.violations.map(finding);
235
+ if (parsed.value === null) return [finding({ path: "(root)", code: "type" })];
236
+ return materializeTenantAnalyticsDeclarations({
237
+ raw: parsed.value,
238
+ tenantPublicId: "analytics-declaration-check",
239
+ canonicalHost: "analytics-declaration-check.invalid",
240
+ }).violations.map(finding);
241
+ }
242
+
243
+ /**
244
+ * The value-free diagnostic for a materialization the runtime refused: path and code only.
245
+ */
246
+ export function describeTenantAnalyticsViolations(
247
+ violations: readonly ContractViolation[],
248
+ ): { path: string; code: string }[] {
249
+ return violations.map(({ path, code }) => ({ path, code }));
250
+ }
@@ -12,7 +12,7 @@
12
12
  * `x` — a known country outside them
13
13
  * `u` — the edge could not geolocate the request
14
14
  *
15
- * The browser treats `eu`, `u`, and an absent or unreadable cookie alike (count-only),
15
+ * The browser treats `eu`, `u`, and an absent or unreadable cookie alike (the EU/UK policy),
16
16
  * so a missing signal fails closed.
17
17
  *
18
18
  * `visitorRegionClass` is closure-free: the CloudFront generator serializes it with
@@ -763,10 +763,10 @@ export interface TenantConfig {
763
763
  integrations?: readonly IntegrationDeclarationInput[];
764
764
  /**
765
765
  * The tenant's analytics policy for EU/EEA/UK/CH visitors and visitors whose region
766
- * is unknown, from its `analytics.json`. Absent means `count-only`; only `full` and
767
- * `none` are ever set. It reaches the browser through the server-stamped plan.
766
+ * is unknown, from its `analytics.json`. Absent means `ask`; only `count-only`, `full`
767
+ * and `none` are ever set. It reaches the browser through the server-stamped plan.
768
768
  */
769
- euUkVisitors?: Exclude<EuUkVisitorPolicy, "count-only">;
769
+ euUkVisitors?: Exclude<EuUkVisitorPolicy, "ask">;
770
770
  /**
771
771
  * URL form and structured data, declared in `.tot/config.json` (validated by
772
772
  * `seoDeclarationProblems`, declared-tenant-config.mjs). A migrated store keeps the
@@ -0,0 +1,75 @@
1
+ import type {
2
+ ManagedAnalyticsCampaign,
3
+ ManagedAnalyticsCampaignField,
4
+ ManagedAnalyticsCampaignRejection,
5
+ } from "../../../src/integration/managed-analytics-value-safety.js";
6
+
7
+ /** One UTM set and the exact per-field outcome every analytics layer must agree on. */
8
+ export interface CampaignCorpusCase {
9
+ name: string;
10
+ utm: ManagedAnalyticsCampaign;
11
+ kept: ManagedAnalyticsCampaign;
12
+ rejected: ManagedAnalyticsCampaignRejection[];
13
+ }
14
+
15
+ /** Date-stamped, brand and test labels that operators write by hand. */
16
+ export const LEGITIMATE_CAMPAIGN_LABELS = [
17
+ "internal_attribution_20261027",
18
+ "ga4_receipt_20261027",
19
+ "gads_campaign_1234567",
20
+ "preview-test",
21
+ "brand_token_q4",
22
+ ] as const;
23
+
24
+ /** A Google Ads ValueTrack `{campaignid}` shape (11 digits); not a live campaign. */
25
+ export const VALUETRACK_CAMPAIGN_ID = "24398041332";
26
+
27
+ function labelCase(field: ManagedAnalyticsCampaignField, label: string): CampaignCorpusCase {
28
+ const utm: ManagedAnalyticsCampaign = { source: "newsletter", medium: "email", [field]: label };
29
+ return { name: `${field} keeps ${label}`, utm, kept: utm, rejected: [] };
30
+ }
31
+
32
+ function rejectedCase(
33
+ name: string,
34
+ field: "campaign" | "term" | "content",
35
+ value: string,
36
+ reason: ManagedAnalyticsCampaignRejection["reason"],
37
+ source = "newsletter",
38
+ ): CampaignCorpusCase {
39
+ const base: ManagedAnalyticsCampaign = { source, medium: "cpc" };
40
+ return { name, utm: { ...base, [field]: value }, kept: base, rejected: [{ field, reason }] };
41
+ }
42
+
43
+ export const CAMPAIGN_TOKEN_CORPUS: readonly CampaignCorpusCase[] = [
44
+ ...(["source", "medium", "campaign", "term", "content"] as const).flatMap((field) =>
45
+ LEGITIMATE_CAMPAIGN_LABELS.map((label) => labelCase(field, label)),
46
+ ),
47
+ rejectedCase("a ValueTrack id is rejected beside source google", "campaign", VALUETRACK_CAMPAIGN_ID, "protected_value", "google"),
48
+ rejectedCase("a ValueTrack id is rejected in any field", "term", VALUETRACK_CAMPAIGN_ID, "protected_value", "google"),
49
+ rejectedCase("a US national number", "campaign", "2125550147", "protected_value", "google"),
50
+ rejectedCase("an 11-digit E.164 number without +", "campaign", "33612345678", "protected_value", "google"),
51
+ rejectedCase("a 12-digit E.164 number without +", "campaign", "447911123456", "protected_value", "google"),
52
+ rejectedCase("a 13-digit E.164 number without +", "campaign", "4915112345678", "protected_value", "google"),
53
+ rejectedCase("a 9-digit bare id", "campaign", "718234567", "protected_value", "google"),
54
+ rejectedCase("a card-length id stays blocked", "campaign", "4111111111111111", "protected_value", "google"),
55
+ rejectedCase("an embedded nine-digit run stays blocked", "campaign", "promo_212555014", "protected_value"),
56
+ rejectedCase("an email is not a token", "campaign", "shopper@example.com", "format"),
57
+ rejectedCase("a dashed US phone", "term", "212-555-0147", "protected_value"),
58
+ rejectedCase("an SSN", "content", "123-45-6789", "protected_value"),
59
+ rejectedCase("a provider secret", "campaign", ["sk", "live", "abcdefghijkl"].join("_"), "protected_value"),
60
+ rejectedCase("a cloud access key", "content", "AKIA" + "ABCDEFGHIJKLMNOP", "protected_value"),
61
+ rejectedCase("a bearer word", "campaign", "bearer-launch", "protected_value"),
62
+ rejectedCase("a password word", "term", "reset.password", "protected_value"),
63
+ rejectedCase("a secret word", "content", "secret_token", "protected_value"),
64
+ {
65
+ name: "one unsafe field costs only itself",
66
+ utm: { source: "newsletter", medium: "email", campaign: "ga4_receipt_20261027", content: "shopper@example.com" },
67
+ kept: { source: "newsletter", medium: "email", campaign: "ga4_receipt_20261027" },
68
+ rejected: [{ field: "content", reason: "format" }],
69
+ },
70
+ ];
71
+
72
+ /** The raw values a case rejects, for asserting none of them reaches a sink or diagnostic. */
73
+ export function rejectedCampaignValues(corpusCase: CampaignCorpusCase): string[] {
74
+ return corpusCase.rejected.map(({ field }) => corpusCase.utm[field]!);
75
+ }
@@ -5,7 +5,11 @@
5
5
  // into an in-memory jar so GA4 reports the parent's visitor and session. This loads the BUILT
6
6
  // sandbox document and the CURRENT live gtag.js in headless Chromium, drives three page loads
7
7
  // (first visit, continuing, new session) through the real bind/configure protocol, and asserts
8
- // what gtag puts on /g/collect. Every collect request is ABORTED: nothing reaches Google.
8
+ // what gtag puts on /g/collect. The frame carries the production box, and any event beyond
9
+ // the page view and gtag's engagement timing (an automatic `scroll`) fails the canary. The first
10
+ // load carries a landing click identifier, which must reach that page view's `dl` with every ad_*
11
+ // signal still denied, and no other event.
12
+ // Every collect request is ABORTED: nothing reaches Google.
9
13
  //
10
14
  // Needs the network (gtag.js), so it is a canary, not a CI test. Exit 0 = gtag behaves as the
11
15
  // seed expects; 1 = drift (the docs say what GA4 then reports); 2 = could not run.
@@ -16,9 +20,11 @@ import { createRequire } from "node:module";
16
20
  const storefrontRequire = createRequire(new URL("../../apps/storefront/package.json", import.meta.url));
17
21
  let chromium;
18
22
  let buildGoogleMeasurementSandboxHtml;
23
+ let NATIVE_GA4_SANDBOX_FRAME_STYLE;
19
24
  try {
20
25
  ({ chromium } = storefrontRequire("@playwright/test"));
21
26
  ({ buildGoogleMeasurementSandboxHtml } = await import("../../apps/storefront/integrations/google-measurement-sandbox-asset.mjs"));
27
+ ({ NATIVE_GA4_SANDBOX_FRAME_STYLE } = await import("../../packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs"));
22
28
  } catch (error) {
23
29
  console.error(`ga4-gs2-canary: cannot run — ${error instanceof Error ? error.message : String(error)}`);
24
30
  process.exit(2);
@@ -26,33 +32,44 @@ try {
26
32
 
27
33
  const PARENT = "https://shop.canary.test";
28
34
  const FRAME = "https://www.shop.canary.test/platform/google-measurement/v2/sandbox.html";
29
- const MEASUREMENT_ID = "G-CANARY0001";
35
+ // gtag fetches enhanced-measurement settings for the stream, so the scroll assertion is only
36
+ // exercised against a stream with Scrolls on: pass its public measurement id as
37
+ // GA4_CANARY_MEASUREMENT_ID. Collection stays aborted either way.
38
+ const MEASUREMENT_ID = process.env.GA4_CANARY_MEASUREMENT_ID ?? "G-CANARY0001";
39
+ // A labelled stand-in: GA4 never receives it, since every collect request is aborted.
40
+ const CLICK_ID = "TEST_gclid_canary";
30
41
  const CLIENT_ID = "3f2b1c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d";
31
42
  const FIRST_SESSION_MS = Date.now() - 5 * 60 * 1000;
32
43
  const SECOND_SESSION_MS = Date.now();
33
44
  const seconds = (ms) => String(Math.floor(ms / 1000));
45
+ const SETTLE_MS = 6_000;
46
+ /** The page view this canary sends, plus gtag's own engagement timing. Anything else (a `scroll`) was fabricated in the frame. */
47
+ const FRAME_EVENTS = new Set(["page_view", "user_engagement"]);
34
48
 
35
49
  const LOADS = [
36
50
  {
37
51
  name: "first visit",
38
52
  visitor: { clientId: CLIENT_ID, sessionId: FIRST_SESSION_MS, sessionState: "first_visit", sessionCount: 1 },
39
- expect: { cid: CLIENT_ID, sid: seconds(FIRST_SESSION_MS), sct: "1", _ss: "1", _fv: "1" },
53
+ clickIds: { gclid: CLICK_ID },
54
+ expect: { cid: CLIENT_ID, sid: seconds(FIRST_SESSION_MS), sct: "1", _ss: "1", _fv: "1", dl: `${PARENT}/canary?gclid=${CLICK_ID}`, gcs: "G101" },
40
55
  },
41
56
  {
42
57
  name: "next page, same session",
43
58
  visitor: { clientId: CLIENT_ID, sessionId: FIRST_SESSION_MS, sessionState: "continuing", sessionCount: 1 },
44
- expect: { cid: CLIENT_ID, sid: seconds(FIRST_SESSION_MS), sct: "1", _ss: null, _fv: null },
59
+ expect: { cid: CLIENT_ID, sid: seconds(FIRST_SESSION_MS), sct: "1", _ss: null, _fv: null, dl: `${PARENT}/canary`, gcs: "G101" },
45
60
  },
46
61
  {
47
62
  name: "return, new session",
48
63
  visitor: { clientId: CLIENT_ID, sessionId: SECOND_SESSION_MS, sessionState: "new_session", sessionCount: 2 },
49
- expect: { cid: CLIENT_ID, sid: seconds(SECOND_SESSION_MS), sct: "2", _ss: "1", _fv: null },
64
+ expect: { cid: CLIENT_ID, sid: seconds(SECOND_SESSION_MS), sct: "2", _ss: "1", _fv: null, dl: `${PARENT}/canary`, gcs: "G101" },
50
65
  },
51
66
  ];
52
67
 
53
- const parentPage = (visitor) => `<!doctype html><body><script>
68
+ /** @param {{ visitor: object, clickIds?: Record<string, string> }} load */
69
+ const parentPage = ({ visitor, clickIds }) => `<!doctype html><body><script>
54
70
  const frame = document.createElement("iframe");
55
71
  frame.setAttribute("sandbox", "allow-scripts allow-same-origin");
72
+ for (const [name, value] of Object.entries(${JSON.stringify(NATIVE_GA4_SANDBOX_FRAME_STYLE)})) frame.style.setProperty(name, value);
56
73
  frame.src = ${JSON.stringify(FRAME)};
57
74
  frame.addEventListener("load", () => {
58
75
  const channel = new MessageChannel();
@@ -61,7 +78,7 @@ frame.addEventListener("load", () => {
61
78
  canonicalOrigin: location.origin, analyticsConsent: "granted",
62
79
  governedProperties: { tenant: "canary", environment: "live", site_surface: "storefront" },
63
80
  visitor: ${JSON.stringify(visitor)} });
64
- channel.port1.postMessage({ schemaVersion: 1, type: "page_view", path: "/canary" });
81
+ channel.port1.postMessage({ schemaVersion: 1, type: "page_view", path: "/canary"${clickIds ? `, clickIds: ${JSON.stringify(clickIds)}` : ""} });
65
82
  });
66
83
  document.body.append(frame);
67
84
  </script></body>`;
@@ -73,12 +90,14 @@ let gtagLoaded = false;
73
90
  try {
74
91
  const context = await browser.newContext();
75
92
  const pageViews = new Map();
93
+ const eventNames = new Set();
94
+ let clickIdOutsidePageView = false;
76
95
  let current = 0;
77
96
  await context.route("**/*", async (route) => {
78
97
  const request = route.request();
79
98
  const url = new URL(request.url());
80
99
  if (request.url().startsWith(FRAME)) return route.fulfill({ status: 200, contentType: "text/html", body: html });
81
- if (url.origin === PARENT) return route.fulfill({ status: 200, contentType: "text/html", body: parentPage(LOADS[current].visitor) });
100
+ if (url.origin === PARENT) return route.fulfill({ status: 200, contentType: "text/html", body: parentPage(LOADS[current]) });
82
101
  if (url.host === "www.googletagmanager.com" && url.pathname === "/gtag/js") {
83
102
  gtagLoaded = true;
84
103
  return route.continue();
@@ -87,8 +106,10 @@ try {
87
106
  for (const line of [url.search.slice(1), ...(request.postData() ?? "").split("\n")].filter(Boolean)) {
88
107
  const params = new URLSearchParams(url.search);
89
108
  for (const [key, value] of new URLSearchParams(line)) params.set(key, value);
109
+ if (params.get("en")) eventNames.add(params.get("en"));
110
+ if (params.get("en") !== "page_view" && params.get("dl")?.includes(CLICK_ID)) clickIdOutsidePageView = true;
90
111
  if (params.get("en") === "page_view" && !pageViews.has(current)) {
91
- pageViews.set(current, Object.fromEntries(["cid", "sid", "sct", "_ss", "_fv"].map((key) => [key, params.get(key)])));
112
+ pageViews.set(current, Object.fromEntries(["cid", "sid", "sct", "_ss", "_fv", "dl", "gcs"].map((key) => [key, params.get(key)])));
92
113
  }
93
114
  }
94
115
  }
@@ -98,6 +119,8 @@ try {
98
119
  for (current = 0; current < LOADS.length; current += 1) {
99
120
  await page.goto(`${PARENT}/?load=${current}`);
100
121
  for (let waited = 0; !pageViews.has(current) && waited < 15_000; waited += 250) await page.waitForTimeout(250);
122
+ // Enhanced measurement fires its automatic scroll within a few seconds of gtag loading.
123
+ await page.waitForTimeout(SETTLE_MS);
101
124
  await page.goto("about:blank");
102
125
  await page.waitForTimeout(500);
103
126
  }
@@ -115,6 +138,9 @@ try {
115
138
  }
116
139
  console.log(`${load.name}: ${JSON.stringify({ ...actual, cid: actual.cid === CLIENT_ID ? "<seeded>" : actual.cid })}`);
117
140
  });
141
+ const fabricated = [...eventNames].filter((name) => !FRAME_EVENTS.has(name));
142
+ if (fabricated.length > 0) failures.push(`events the storefront never sent left the frame: ${fabricated.join(", ")}`);
143
+ if (clickIdOutsidePageView) failures.push("a click identifier left the frame on an event other than its landing page view");
118
144
  if (cookies.length > 0) failures.push(`cookies written on the frame or parent origin: ${cookies.map((c) => c.name).join(", ")}`);
119
145
  if (failures.length > 0) {
120
146
  console.error("ga4-gs2-canary: DRIFT — gtag no longer honours the sandbox seed as expected:");
@@ -122,7 +148,7 @@ try {
122
148
  console.error("See docs/architecture/analytics-activation.md (GA4) for what GA4 reports in this state.");
123
149
  process.exitCode = 1;
124
150
  } else {
125
- console.log("ga4-gs2-canary: OK — gtag reads the GS2 seed; cid, sid, sct, _ss and _fv match, no cookies written.");
151
+ console.log("ga4-gs2-canary: OK — gtag reads the GS2 seed; cid, sid, sct, _ss and _fv match, the landing gclid rides only its page view with ad consent denied (gcs=G101), no cookies written.");
126
152
  }
127
153
  }
128
154
  } finally {
@@ -48,6 +48,7 @@ import {
48
48
  } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
49
49
  import { isForbiddenPublicPath, publicBinaryFindings } from "../../agency-kit/tools/lib/public-binaries.mjs";
50
50
  import { pageSlugFindings } from "../../agency-kit/tools/lib/page-slugs.mjs";
51
+ import { ANALYTICS_DECLARATION_FILE, analyticsDeclarationFindings } from "../../agency-kit/tools/lib/analytics-declaration.mjs";
51
52
  import { validateVisualParityClosure } from "../../packages/cli/src/visual-parity.mjs";
52
53
  import { declaredObligations, isRegulatedCompliance, validateDeclaredConfig } from "../../packages/cli/src/declared-config.mjs";
53
54
 
@@ -1200,6 +1201,14 @@ export function validateTenant(tenantDir, opts = {}) {
1200
1201
  }
1201
1202
  }
1202
1203
 
1204
+ // analytics.json — the contract publication refuses; an unreadable file is an error, never "no analytics"
1205
+ const analyticsPath = located(ANALYTICS_DECLARATION_FILE);
1206
+ if (existsSync(analyticsPath)) {
1207
+ for (const f of analyticsDeclarationFindings(readFileSync(analyticsPath, "utf8"))) {
1208
+ findings.push(mk(ERROR, "analytics-declaration", rel(analyticsPath), f.message, f.remedy));
1209
+ }
1210
+ }
1211
+
1203
1212
  // 3. content JSON — parse + blocks shape
1204
1213
  for (const name of ["home.json", "chrome.json"]) {
1205
1214
  const p = join(contentDir, name);