@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
|
@@ -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 `
|
|
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}
|
|
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
|
-
/**
|
|
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 `
|
|
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
|
|
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 (
|
|
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 `
|
|
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, "
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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);
|