@tokenoftrust/storefront-runner 4.9.2 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
  2. package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
  3. package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
  4. package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
  5. package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
  6. package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
  7. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
  8. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
  9. package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
  10. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
  11. package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
  12. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
  13. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
  14. package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
  15. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
  16. package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
  17. package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
  18. package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
  19. package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
  20. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
  21. package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
  22. package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
  23. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
  24. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
  25. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
  26. package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
  27. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
  28. package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
  29. package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
  30. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
  31. package/apps/storefront/src/pages/api/contact.ts +16 -4
  32. package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
  33. package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
  34. package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
  35. package/package.json +1 -1
  36. package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
  37. package/packages/public-runtime/src/integration/events.ts +15 -19
  38. package/packages/public-runtime/src/integration/index.ts +1 -0
  39. package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
  40. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
  41. package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
  42. package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
  43. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
  44. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
  45. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
  46. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
  47. package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
  48. package/packages/public-runtime/src/integration/plan.ts +6 -4
  49. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
  50. package/packages/public-runtime/src/integration/render-phases.ts +10 -18
  51. package/packages/public-runtime/src/integration/resolve.ts +3 -3
  52. package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
  53. package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
  54. package/packages/public-runtime/src/tenant.ts +3 -3
  55. package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
  56. package/scripts/dev/ga4-gs2-canary.mjs +36 -10
  57. package/scripts/tenant/validate.mjs +9 -0
@@ -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);