@tokenoftrust/storefront-runner 2.2.80 → 2.2.82

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.
@@ -70,7 +70,11 @@ export function synthesizeDevTenant(
70
70
  const appDomain = hints.appDomain ?? id;
71
71
  // The tenant's OWN tree analytics.json → the render plane's declaration seam,
72
72
  // fail-closed (a missing/invalid file yields no declarations, never a throw).
73
- const integrations = loadTenantAnalyticsDeclarations(hints.analyticsRaw, id, appDomain);
73
+ const { declarations: integrations, euUkVisitors } = loadTenantAnalyticsDeclarations(
74
+ hints.analyticsRaw,
75
+ id,
76
+ appDomain,
77
+ );
74
78
  const base: TenantConfig = {
75
79
  tenant_id: id,
76
80
  appDomain,
@@ -92,6 +96,7 @@ export function synthesizeDevTenant(
92
96
  // Only set when the tenant actually declared one — an undeclared tenant keeps
93
97
  // `integrations` absent, so its resolved plan is byte-identical to before.
94
98
  ...(integrations.length > 0 ? { integrations } : {}),
99
+ ...(euUkVisitors ? { euUkVisitors } : {}),
95
100
  };
96
101
  if (hints.hasCatalog) {
97
102
  // Catalog fixtures on disk → a real commerce tenant (siteType omitted ==
@@ -19,7 +19,7 @@ export const STATIC_SCRIPT_HASHES: readonly string[] = [
19
19
  "'sha256-YicfxUFBkKrQapZ/tzZKlaKTdNHgwVfv7y2Wa0NC6/k='",
20
20
  "'sha256-eIXWvAmxkr251LJZkjniEK5LcPF3NkapbJepohwYRIc='",
21
21
  "'sha256-elMLh/6Oubj8u9FRLwZLyRa6TqKikGs75mf0jfWcoxI='",
22
- "'sha256-ZxqYN/PbpT0W68E87+IwdGfcYJuAVXNPNVxVb3VX0hw='",
22
+ "'sha256-bANHtvoDkw4GnpBynM5Ib1cxdoDeNsGkPhPnDEphW3Y='",
23
23
  ];
24
24
 
25
25
  export const STATIC_STYLE_HASHES: readonly string[] = [
@@ -7,7 +7,7 @@ import {
7
7
  type CompiledIntegrationCsp,
8
8
  type EffectivePlan,
9
9
  type IntegrationDeclarationInput,
10
- type ObligationTenant,
10
+ type InterimBridgeInput,
11
11
  } from "@tot/public-runtime";
12
12
 
13
13
  export interface IntegrationRenderState {
@@ -58,7 +58,7 @@ export function composeIntegrationDeclarations(
58
58
  */
59
59
  export function resolveIntegrationRenderState(input: {
60
60
  tenantPublicId: string;
61
- tenant: ObligationTenant;
61
+ tenant: InterimBridgeInput["tenant"];
62
62
  environment: BrowserEnvironment;
63
63
  declarations: readonly IntegrationDeclarationInput[];
64
64
  }): IntegrationRenderState {
@@ -36,11 +36,13 @@ import {
36
36
  buildPlatformAdapterDeclaration,
37
37
  ENVIRONMENT_SCHEMA_VERSION,
38
38
  Violations,
39
+ isEuUkVisitorPolicy,
39
40
  isPlainObject,
40
41
  rejectUnknownKeys,
41
42
  type BrowserEnvironment,
42
43
  type ContractViolation,
43
44
  type IntegrationDeclarationInput,
45
+ type TenantConfig,
44
46
  } from "@tot/public-runtime";
45
47
 
46
48
  /** The tenant-owned file that declares measurement destinations, in the tenant tree. */
@@ -63,6 +65,11 @@ export interface TenantAnalyticsMaterialization {
63
65
  ok: boolean;
64
66
  /** The materialized declarations, in declared order. Empty whenever `ok` is false. */
65
67
  declarations: readonly IntegrationDeclarationInput[];
68
+ /**
69
+ * The declared `euUkVisitors` policy when it is not the `count-only` default. Absent
70
+ * whenever `ok` is false, so an invalid file never widens EU/UK collection.
71
+ */
72
+ euUkVisitors?: TenantConfig["euUkVisitors"];
66
73
  /** Per-field violations for the publish surface. Empty whenever `ok` is true. */
67
74
  violations: readonly ContractViolation[];
68
75
  }
@@ -149,7 +156,15 @@ export function materializeTenantAnalyticsDeclarations(input: {
149
156
  v.add("(root)", "type", "analytics.json must be a JSON object with a `destinations` array.");
150
157
  return { ok: false, declarations: EMPTY, violations: v.items };
151
158
  }
152
- rejectUnknownKeys(raw, ["schemaVersion", "destinations"], v, "");
159
+ rejectUnknownKeys(raw, ["schemaVersion", "destinations", "euUkVisitors"], v, "");
160
+ const euUkVisitors = raw["euUkVisitors"];
161
+ if ("euUkVisitors" in raw && !isEuUkVisitorPolicy(euUkVisitors)) {
162
+ v.add(
163
+ "euUkVisitors",
164
+ "unknown_enum",
165
+ 'euUkVisitors must be "count-only" (the default), "full", or "none".',
166
+ );
167
+ }
153
168
  if ("schemaVersion" in raw && raw["schemaVersion"] !== TENANT_ANALYTICS_SCHEMA_VERSION) {
154
169
  v.add(
155
170
  "schemaVersion",
@@ -183,7 +198,12 @@ export function materializeTenantAnalyticsDeclarations(input: {
183
198
  });
184
199
 
185
200
  if (!v.ok) return { ok: false, declarations: EMPTY, violations: v.items };
186
- return { ok: true, declarations, violations: [] };
201
+ return {
202
+ ok: true,
203
+ declarations,
204
+ violations: [],
205
+ ...(euUkVisitors === "full" || euUkVisitors === "none" ? { euUkVisitors } : {}),
206
+ };
187
207
  }
188
208
 
189
209
  /**
@@ -198,6 +218,11 @@ export function loadTenantAnalyticsDeclarations(
198
218
  raw: unknown,
199
219
  tenantPublicId: string,
200
220
  canonicalHost: string,
201
- ): readonly IntegrationDeclarationInput[] {
202
- return materializeTenantAnalyticsDeclarations({ raw, tenantPublicId, canonicalHost }).declarations;
221
+ ): Pick<TenantAnalyticsMaterialization, "declarations" | "euUkVisitors"> {
222
+ const { declarations, euUkVisitors } = materializeTenantAnalyticsDeclarations({
223
+ raw,
224
+ tenantPublicId,
225
+ canonicalHost,
226
+ });
227
+ return euUkVisitors ? { declarations, euUkVisitors } : { declarations };
203
228
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The Worker half of the visitor region signal (the CloudFront half is the
3
+ * viewer-response function in scripts/publish/cloudfront/csp-meta-emit.mjs).
4
+ *
5
+ * Every HTML document the Worker answers gets a class-only `tot_rgn` cookie derived
6
+ * from `request.cf.country`, appended after any edge-cache read or write, so cached
7
+ * bytes and cache keys stay region-invariant. The early consent boot reads it to apply
8
+ * the tenant's EU/UK visitor policy.
9
+ *
10
+ * No cookie is set on the GA4 sandbox frame document, which must keep its origin free
11
+ * of state, or when the Worker cannot see the viewer:
12
+ * - a CloudFront-forwarded Tier 3 request (its `cf` describes CloudFront, not the
13
+ * viewer);
14
+ * - no `request.cf` at all (the local dev loop).
15
+ * The page then keeps whatever class it already holds, or none (count-only).
16
+ *
17
+ * The dev loop can exercise both branches with `?tot_region=eu|x|u`, honored only in a
18
+ * dev build (`import.meta.env.DEV`), never on a deployed Worker.
19
+ */
20
+ import {
21
+ visitorRegionClass,
22
+ visitorRegionCookie,
23
+ type VisitorRegionClass,
24
+ } from "@tot/public-runtime/integration/visitor-region";
25
+ import { NATIVE_GA4_SANDBOX_ASSET_URL } from "@tot/public-runtime/integration/native-ga4-sandbox-asset";
26
+
27
+ export const VISITOR_REGION_DEV_OVERRIDE_PARAM = "tot_region";
28
+
29
+ function devOverride(url: URL): VisitorRegionClass | null {
30
+ const value = url.searchParams.get(VISITOR_REGION_DEV_OVERRIDE_PARAM);
31
+ return value === "eu" || value === "x" || value === "u" ? value : null;
32
+ }
33
+
34
+ /** The class this request's response should carry, or null to set none. */
35
+ export function visitorRegionFor(
36
+ request: Request,
37
+ opts: { cdnForwarded: boolean; dev: boolean },
38
+ ): VisitorRegionClass | null {
39
+ const url = new URL(request.url);
40
+ if (url.pathname.endsWith(NATIVE_GA4_SANDBOX_ASSET_URL)) return null;
41
+ if (opts.dev) {
42
+ const override = devOverride(url);
43
+ if (override) return override;
44
+ }
45
+ if (opts.cdnForwarded) return null;
46
+ const cf = (request as Request & { cf?: { country?: unknown } }).cf;
47
+ if (!cf || typeof cf !== "object") return null;
48
+ return visitorRegionClass(cf.country);
49
+ }
50
+
51
+ /** Append the region cookie to an HTML document response; any other response is returned as is. */
52
+ export function withVisitorRegionCookie(
53
+ response: Response,
54
+ regionClass: VisitorRegionClass | null,
55
+ ): Response {
56
+ if (!regionClass) return response;
57
+ if (!(response.headers.get("content-type") ?? "").toLowerCase().includes("text/html")) return response;
58
+ const cookie = visitorRegionCookie(regionClass);
59
+ try {
60
+ response.headers.append("set-cookie", cookie);
61
+ return response;
62
+ } catch {
63
+ // Immutable headers (a fetched or platform response): copy, then append.
64
+ const copy = new Response(response.body, response);
65
+ copy.headers.append("set-cookie", cookie);
66
+ return copy;
67
+ }
68
+ }
@@ -20,6 +20,7 @@
20
20
  * Contract: docs/architecture/decisions/0013-public-serving-contract.md
21
21
  */
22
22
  import { defineMiddleware } from "astro:middleware";
23
+ import type { APIContext, MiddlewareNext } from "astro";
23
24
  import { getTenantResolver } from "@/config/resolver";
24
25
  import { readEnv, readKv, readR2 } from "@/lib/runtimeEnv";
25
26
  import { resolveCdnForwardRouting, resolveTenantRouting } from "@/lib/tenantRouting";
@@ -28,6 +29,7 @@ import { compileRouteTierIndexV1, type RouteTierIndexV1 } from "@tot/public-runt
28
29
  import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
29
30
  import { DEV_DRAFT_COOKIE } from "@/lib/dev/devDraft";
30
31
  import { applySecurityHeaders } from "@/lib/securityHeaders";
32
+ import { visitorRegionFor, withVisitorRegionCookie } from "@/lib/visitorRegionCookie";
31
33
  import { tryServeStaticBundle } from "./staticBundle.js";
32
34
  import { resolveCandidateContentPin } from "./candidatePin.js";
33
35
  import { parseGateMode } from "@/lib/auth/loginGate";
@@ -65,7 +67,24 @@ function routeTierIndexFor(tenant: TenantConfig): RouteTierIndexV1 {
65
67
  return compiled;
66
68
  }
67
69
 
70
+ /**
71
+ * Every HTML document leaves with the visitor's region class (lib/visitorRegionCookie),
72
+ * appended after the branch has read or written any edge cache.
73
+ */
68
74
  export const onRequest = defineMiddleware(async (context, next) => {
75
+ const seen = { cdnForwarded: false };
76
+ const response = await serveRequest(context, next, seen);
77
+ return withVisitorRegionCookie(
78
+ response,
79
+ visitorRegionFor(context.request, { cdnForwarded: seen.cdnForwarded, dev: import.meta.env.DEV }),
80
+ );
81
+ });
82
+
83
+ async function serveRequest(
84
+ context: APIContext,
85
+ next: MiddlewareNext,
86
+ seen: { cdnForwarded: boolean },
87
+ ): Promise<Response> {
69
88
  const { request, locals } = context;
70
89
  const url = new URL(request.url);
71
90
  let host =
@@ -109,6 +128,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
109
128
  // host-keyed decision below (gates, canonical base, the origin check) then sees
110
129
  // the host the viewer addressed, never this Worker's own.
111
130
  const cdn = await verifyCdnForward(request.headers, await readEnv("CDN_ORIGIN_SECRET"));
131
+ seen.cdnForwarded = !!cdn;
112
132
  if (cdn) host = cdn.viewerHost;
113
133
  const origin = cdn ? `https://${cdn.viewerHost}` : url.origin;
114
134
  const crossSite = crossSiteFormRefusal(request, origin);
@@ -340,4 +360,4 @@ export const onRequest = defineMiddleware(async (context, next) => {
340
360
  applySecurityHeaders(response.headers);
341
361
  return response;
342
362
  }
343
- });
363
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.80",
3
+ "version": "2.2.82",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -20,7 +20,8 @@
20
20
  "./retention-holds": "./src/retention-holds.ts",
21
21
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
22
22
  "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
23
- "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs"
23
+ "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs",
24
+ "./integration/visitor-region": "./src/integration/visitor-region.mjs"
24
25
  },
25
26
  "dependencies": {
26
27
  "css-tree": "2.2.1",
@@ -29,6 +29,7 @@
29
29
  import { CONSENT_PURPOSES, type ConsentPurpose } from "../integration-capabilities.js";
30
30
  import { CONSENT_SNAPSHOT_SCHEMA_VERSION } from "./version.js";
31
31
  import type { EventConsentSnapshot } from "./events.js";
32
+ import type { EuUkVisitorPolicy } from "./plan.js";
32
33
 
33
34
  /** The purpose that is always on and cannot be gated (charter floor). */
34
35
  const ESSENTIAL: ConsentPurpose = "essential";
@@ -476,7 +477,10 @@ export class ConsentBroker {
476
477
  * Viewer-owned analytics consent for the public storefront document.
477
478
  *
478
479
  * The tenant configuration supplies the analytics default and a stored viewer choice
479
- * overrides it. Global Privacy Control always denies analytics. The same synchronous
480
+ * overrides it. Global Privacy Control always denies analytics. Visitors the edge
481
+ * classes as EU/EEA/UK/CH, or cannot class, get the tenant's EU/UK visitor policy:
482
+ * `count-only` (default) keeps page views but stores nothing on the device, `none`
483
+ * denies analytics, and `full` applies the default behavior. The same synchronous
480
484
  * early boot runs in both document mounts before any integration or measurement loader
481
485
  * can execute.
482
486
  */
@@ -485,18 +489,32 @@ export const VIEWER_ANALYTICS_CONSENT_STORAGE_KEY = "tot.viewer-analytics-consen
485
489
  export const VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL = "__TOT_VIEWER_ANALYTICS_CONSENT__";
486
490
  export const VIEWER_ANALYTICS_CONSENT_CHANGE_EVENT = "tot:viewer-analytics-consent-changed";
487
491
  export const VIEWER_ANALYTICS_CONSENT_RETENTION_DAYS = 180;
488
- export const VIEWER_ANALYTICS_REGION_POLICY = "universal-opt-in" as const;
489
492
 
490
493
  export type ViewerAnalyticsConsentDecision = "granted" | "denied";
491
494
 
492
495
  export interface ViewerAnalyticsConsentState {
493
496
  schemaVersion: 1;
494
497
  decision: ViewerAnalyticsConsentDecision;
495
- source: "tenant-default" | "default-deny" | "stored" | "stored-invalid" | "global-privacy-control";
498
+ source:
499
+ | "tenant-default"
500
+ | "default-deny"
501
+ | "stored"
502
+ | "stored-invalid"
503
+ | "global-privacy-control"
504
+ | "eu-uk-policy";
496
505
  canGrant: boolean;
497
506
  expiresAt: number | null;
498
507
  retentionDays: typeof VIEWER_ANALYTICS_CONSENT_RETENTION_DAYS;
499
- regionPolicy: typeof VIEWER_ANALYTICS_REGION_POLICY;
508
+ /** The edge's region class (`tot_rgn` cookie); `unknown` when absent or unreadable. */
509
+ region: "eu-uk" | "other" | "unknown";
510
+ /** The tenant's EU/UK visitor policy from the server-stamped plan. */
511
+ euUkVisitors: EuUkVisitorPolicy;
512
+ /**
513
+ * Whether analytics may keep a visitor and session on this device. `none` whenever
514
+ * analytics is ineligible or the EU/UK `count-only` policy applies; the lifecycle's
515
+ * visitor-continuity storage reads this and falls back to a per-page memory id.
516
+ */
517
+ visitorStorage: "persistent" | "none";
500
518
  tenantPublicId: string;
501
519
  environment: string;
502
520
  siteSurface: string;
@@ -547,6 +565,7 @@ export function applyEarlyViewerAnalyticsConsent(
547
565
  tenantPublicId?: unknown;
548
566
  environment?: unknown;
549
567
  siteSurface?: unknown;
568
+ euUkVisitors?: unknown;
550
569
  } | undefined;
551
570
  const candidateEligible = Array.isArray(current?.eligiblePurposes)
552
571
  ? current.eligiblePurposes.filter((purpose): purpose is string =>
@@ -595,6 +614,21 @@ export function applyEarlyViewerAnalyticsConsent(
595
614
  const tenantDefaultAnalytics = tenantApprovedAnalytics && rawEligible.includes("analytics");
596
615
  const globalPrivacyControl = win.navigator?.globalPrivacyControl === true;
597
616
 
617
+ // The edge sets a class-only `tot_rgn` cookie on every HTML response
618
+ // (visitor-region.mjs). An absent or unreadable class is unknown, and unknown gets
619
+ // the EU/UK policy until the region is known.
620
+ let regionClass = "u";
621
+ try {
622
+ const regionMatch = /(?:^|;\s*)tot_rgn=(eu|x|u)(?:;|$)/.exec(String(win.document?.cookie ?? ""));
623
+ if (regionMatch) regionClass = regionMatch[1] as string;
624
+ } catch {
625
+ // An unreadable cookie jar leaves the region unknown.
626
+ }
627
+ const euUkVisitors: EuUkVisitorPolicy =
628
+ plan?.euUkVisitors === "full" || plan?.euUkVisitors === "none" ? plan.euUkVisitors : "count-only";
629
+ const regionRestricted = regionClass !== "x" && euUkVisitors !== "full";
630
+ const regionDeniesAnalytics = regionRestricted && euUkVisitors === "none";
631
+
598
632
  let stored: { decision: ViewerAnalyticsConsentDecision; expiresAt: number } | null = null;
599
633
  // A stored record that is present but unreadable may have been a denial, so it denies
600
634
  // rather than falling through to a granting tenant default. Only a well-formed record
@@ -646,12 +680,14 @@ export function applyEarlyViewerAnalyticsConsent(
646
680
  }
647
681
 
648
682
  const decision: ViewerAnalyticsConsentDecision =
649
- globalPrivacyControl || !tenantApprovedAnalytics || (!stored && storedInvalid)
683
+ globalPrivacyControl || !tenantApprovedAnalytics || regionDeniesAnalytics || (!stored && storedInvalid)
650
684
  ? "denied"
651
685
  : stored?.decision ?? (tenantDefaultAnalytics ? "granted" : "denied");
652
686
  const source: ViewerAnalyticsConsentState["source"] = globalPrivacyControl
653
687
  ? "global-privacy-control"
654
- : stored
688
+ : regionDeniesAnalytics
689
+ ? "eu-uk-policy"
690
+ : stored
655
691
  ? "stored"
656
692
  : storedInvalid
657
693
  ? "stored-invalid"
@@ -659,18 +695,22 @@ export function applyEarlyViewerAnalyticsConsent(
659
695
  ? "tenant-default"
660
696
  : "default-deny";
661
697
 
698
+ // Under the EU/UK policy, marketing and personalization never run: neither can work
699
+ // without storing an identifier on the device.
662
700
  const eligible = canonicalPurposes.filter(
663
701
  (purpose) =>
664
702
  purpose === "essential" ||
665
703
  (purpose === "analytics"
666
704
  ? decision === "granted" && tenantApprovedAnalytics
667
- : rawEligible.includes(purpose)),
705
+ : !regionRestricted && rawEligible.includes(purpose)),
668
706
  );
669
707
  const approved = canonicalPurposes.filter((purpose) => rawApproved.includes(purpose));
670
- // Analytics ineligible here (denial, GPC, tenant not approved) forgets this context's
671
- // Amplitude visitor id and session before any adapter can run. The key is the
672
- // native-amplitude.ts VISITOR_CONTINUITY_STORAGE_KEY prefix over the same contextKey.
673
- if (!eligible.includes("analytics")) {
708
+ const visitorStorage = eligible.includes("analytics") && !regionRestricted ? "persistent" : "none";
709
+ // Without persistent visitor storage (denial, GPC, tenant not approved, or the EU/UK
710
+ // policy) this context's visitor id and session are forgotten before any adapter can
711
+ // run. The key is the native-amplitude.ts VISITOR_CONTINUITY_STORAGE_KEY prefix over
712
+ // the same contextKey.
713
+ if (visitorStorage === "none") {
674
714
  try {
675
715
  win.localStorage?.removeItem?.(`tot.analytics-continuity.v1:${encodeURIComponent(contextKey)}`);
676
716
  } catch {
@@ -689,10 +729,12 @@ export function applyEarlyViewerAnalyticsConsent(
689
729
  schemaVersion: 1,
690
730
  decision,
691
731
  source,
692
- canGrant: tenantApprovedAnalytics && !globalPrivacyControl,
732
+ canGrant: tenantApprovedAnalytics && !globalPrivacyControl && !regionDeniesAnalytics,
693
733
  expiresAt: stored?.expiresAt ?? null,
694
734
  retentionDays,
695
- regionPolicy: "universal-opt-in",
735
+ region: regionClass === "eu" ? "eu-uk" : regionClass === "x" ? "other" : "unknown",
736
+ euUkVisitors,
737
+ visitorStorage,
696
738
  tenantPublicId,
697
739
  environment,
698
740
  siteSurface,
@@ -31,6 +31,7 @@ import type { BootDescriptorIntegration } from "./render-phases.js";
31
31
  import type { AssuranceSurface } from "./assurance.js";
32
32
  import { resolveEffectivePlan, type IntegrationDeclarationInput } from "./resolve.js";
33
33
  import type { ObligationTenant } from "../compliance-obligations.js";
34
+ import type { TenantConfig } from "../tenant.js";
34
35
  import { isRegulated, resolveObligationSurface } from "../compliance-obligations.js";
35
36
  import {
36
37
  isRegulatedParentCertified,
@@ -86,8 +87,11 @@ export type InterimBridgeResult =
86
87
  /** Everything the bridge needs to decide eligibility and resolve the plan. */
87
88
  export interface InterimBridgeInput {
88
89
  tenantPublicId: string;
89
- /** The tenant's authoritative config — the ONLY source of its regulated classification. */
90
- tenant: ObligationTenant;
90
+ /**
91
+ * The tenant's authoritative config — the ONLY source of its regulated classification
92
+ * and of its EU/UK visitor policy.
93
+ */
94
+ tenant: ObligationTenant & Pick<TenantConfig, "euUkVisitors">;
91
95
  environment: BrowserEnvironment;
92
96
  declarations: readonly IntegrationDeclarationInput[];
93
97
  /**
@@ -187,6 +191,7 @@ export function resolveInterimBridgePlan(input: InterimBridgeInput): InterimBrid
187
191
  tenantPublicId: input.tenantPublicId,
188
192
  environment: input.environment,
189
193
  declarations: input.declarations,
194
+ euUkVisitors: input.tenant.euUkVisitors,
190
195
  });
191
196
  if (!result.ok) return { ok: false, reason: "invalid-plan", violations: result.violations };
192
197
  return { ok: true, plan: result.value };
@@ -36,7 +36,13 @@ import type { LifecycleObserver } from "./lifecycle-runtime.js";
36
36
  import type { IntegrationLifecycleModule } from "./lifecycle.js";
37
37
  import type { BootDescriptorIntegration, IntegrationBootDescriptor } from "./render-phases.js";
38
38
  import { INTEGRATION_PLAN_GLOBAL, INTEGRATION_CONSENT_GLOBAL } from "./render-phases.js";
39
- import { parseConsentSnapshot, defaultConsentSnapshot, type ConsentSnapshot } from "./consent-broker.js";
39
+ import {
40
+ parseConsentSnapshot,
41
+ defaultConsentSnapshot,
42
+ VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL,
43
+ type ConsentSnapshot,
44
+ type ViewerAnalyticsConsentState,
45
+ } from "./consent-broker.js";
40
46
  import { integrationConsentEligible } from "./interim-bridge.js";
41
47
  import type { NavigationCompletedEvent, NavigationKind } from "./events.js";
42
48
  import { CommerceEventBroker } from "./commerce-broker.js";
@@ -334,7 +340,39 @@ function readBrowserEnvironment(win: LifecycleWindow): BrowserEnvironment | unde
334
340
  function defaultConsentGate(
335
341
  win: LifecycleWindow,
336
342
  ): (integration: BootDescriptorIntegration) => boolean {
337
- return (integration) => integrationConsentEligible(integration, readConsentSnapshot(win));
343
+ return (integration) =>
344
+ integrationConsentEligible(integration, readConsentSnapshot(win)) &&
345
+ (integration.consent.loadWhen === "essential-only" ||
346
+ !euUkPolicyApplies(win) ||
347
+ storageFreeIntegration(integration));
348
+ }
349
+
350
+ /**
351
+ * Whether the tenant's EU/UK visitor policy governs this document: the edge classed the
352
+ * visitor as EU/EEA/UK/CH or could not class them, and the tenant has not taken `full`
353
+ * responsibility. A missing state global applies it (fail closed).
354
+ */
355
+ function euUkPolicyApplies(win: LifecycleWindow): boolean {
356
+ const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined;
357
+ return !state || (state.region !== "other" && state.euUkVisitors !== "full");
358
+ }
359
+
360
+ /**
361
+ * Whether the early consent boot allows analytics to keep a visitor on this device.
362
+ * A missing state global allows nothing (fail closed).
363
+ */
364
+ function persistentVisitorStorage(win: LifecycleWindow): boolean {
365
+ const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined;
366
+ return state?.visitorStorage === "persistent";
367
+ }
368
+
369
+ /**
370
+ * Under the EU/UK policy only a reviewed platform adapter may run: its
371
+ * visitor storage goes through this host's continuity seam, which then keeps nothing on
372
+ * the device. Tenant-local and managed-app code could write storage itself.
373
+ */
374
+ function storageFreeIntegration(integration: BootDescriptorIntegration): boolean {
375
+ return integration.origin === "platform-adapter" && integration.executionTier === "platform-adapter";
338
376
  }
339
377
 
340
378
  /**
@@ -439,7 +477,9 @@ export function bootIntegrationLifecycle(
439
477
  fallback: ordinaryLoader,
440
478
  visitorContinuity: createVisitorContinuity({
441
479
  storage: options.visitorStorage ??
442
- (() => win["localStorage"] as VisitorContinuityStorage | undefined),
480
+ (() => persistentVisitorStorage(win)
481
+ ? win["localStorage"] as VisitorContinuityStorage | undefined
482
+ : undefined),
443
483
  }),
444
484
  nativeGa4Transport: () => {
445
485
  if (options.nativeGa4Transport) return options.nativeGa4Transport;
@@ -154,6 +154,18 @@ function seedSessionCookies(jar: Map<string, string>, ids: readonly string[], vi
154
154
  }
155
155
  }
156
156
 
157
+ /**
158
+ * gtag's enhanced measurement runs inside this frame and reports the frame's own
159
+ * document: its URL, its title, and a `scroll` at 90% depth that a short hidden
160
+ * document reaches on load (measured 2026-10-06 on a live stream). A frame taller than
161
+ * any viewport never reaches that depth, so the fabricated scroll is never sent.
162
+ * Setting height through the CSSOM is outside the frame's style CSP, which governs markup.
163
+ */
164
+ function suppressFrameScroll(doc: Document): void {
165
+ const root = doc.documentElement as HTMLElement | null | undefined;
166
+ if (root?.style) root.style.height = "1000000px";
167
+ }
168
+
157
169
  function validCampaign(value: unknown): value is Record<string, string> {
158
170
  const record = plainRecord(value);
159
171
  if (!record || !exactKeys(record, ["source", "medium", "campaign", "term", "content"], [])) return false;
@@ -257,6 +269,7 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
257
269
  });
258
270
  }
259
271
  if (doc.querySelector('script[data-tot-ga4="1"]') === null) {
272
+ suppressFrameScroll(doc);
260
273
  const script = doc.createElement("script");
261
274
  script.async = true;
262
275
  script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(ids[0]!)}`;
@@ -274,6 +287,10 @@ function runPort(port: MessagePort, win: NativeGa4SandboxRuntimeWindow, doc: Doc
274
287
  if (message["campaign"] !== undefined && !validCampaign(message["campaign"])) return;
275
288
  const pageLocation = `${canonicalOrigin}${message["path"]}`;
276
289
  lastPageLocation = pageLocation;
290
+ // Events gtag collects on its own (engagement, enhanced measurement) take the page
291
+ // from these defaults, not from the frame. The title crosses the boundary as the path:
292
+ // the frame's own title would label every page "Analytics transport".
293
+ gtag("set", { page_location: pageLocation, page_title: message["path"] });
277
294
  const params: Record<string, unknown> = {
278
295
  ...governedProperties,
279
296
  page_location: pageLocation,
@@ -80,12 +80,31 @@ export interface NormalizedContribution {
80
80
  source: SourceReference;
81
81
  }
82
82
 
83
+ /**
84
+ * The tenant's analytics policy for visitors in the EU/EEA, the UK and Switzerland, and
85
+ * for visitors whose region is unknown:
86
+ * - `count-only` (default): page views are delivered, nothing is stored on the device.
87
+ * - `full`: the tenant takes responsibility for EU/UK consent; the default behavior applies.
88
+ * - `none`: no analytics or marketing destination runs for those visitors.
89
+ */
90
+ export const EU_UK_VISITOR_POLICIES = ["count-only", "full", "none"] as const;
91
+ export type EuUkVisitorPolicy = (typeof EU_UK_VISITOR_POLICIES)[number];
92
+
93
+ export function isEuUkVisitorPolicy(value: unknown): value is EuUkVisitorPolicy {
94
+ return typeof value === "string" && (EU_UK_VISITOR_POLICIES as readonly string[]).includes(value);
95
+ }
96
+
83
97
  export interface EffectivePlan {
84
98
  schemaVersion: typeof EFFECTIVE_PLAN_SCHEMA_VERSION;
85
99
  environment: BrowserEnvironment;
86
100
  /** Participates in candidate/cache identity; changes when the resolved plan changes. */
87
101
  planRevision: string;
88
102
  tenantPublicId: string;
103
+ /**
104
+ * The tenant's {@link EuUkVisitorPolicy}, present only when it is not the
105
+ * `count-only` default. It participates in `planRevision`.
106
+ */
107
+ euUkVisitors?: Exclude<EuUkVisitorPolicy, "count-only">;
89
108
  integrations: PlanIntegration[];
90
109
  contributions: NormalizedContribution[];
91
110
  /** Resolved execution order over contribution ids. */
@@ -107,6 +107,11 @@ export interface IntegrationBootDescriptor {
107
107
  environment: EffectivePlan["environment"]["environment"];
108
108
  siteSurface: EffectivePlan["environment"]["siteSurface"];
109
109
  tenantPublicId: string;
110
+ /**
111
+ * The tenant's EU/UK visitor policy when it is not the `count-only` default. The
112
+ * early consent boot reads it together with the edge's region class.
113
+ */
114
+ euUkVisitors?: EffectivePlan["euUkVisitors"];
110
115
  integrations: BootDescriptorIntegration[];
111
116
  order: string[];
112
117
  phases: Record<string, string[]>;
@@ -295,6 +300,7 @@ function bootDescriptor(plan: EffectivePlan): IntegrationBootDescriptor {
295
300
  environment: plan.environment.environment,
296
301
  siteSurface: plan.environment.siteSurface,
297
302
  tenantPublicId: plan.tenantPublicId,
303
+ ...(plan.euUkVisitors ? { euUkVisitors: plan.euUkVisitors } : {}),
298
304
  integrations: plan.integrations.map((i) => ({
299
305
  origin: i.origin,
300
306
  appId: i.appId,
@@ -40,7 +40,7 @@ import type {
40
40
  } from "./plan.js";
41
41
  import type { CommerceEventType } from "./events.js";
42
42
  import { fnv1a128Hex, stableStringify } from "./digest.js";
43
- import { assertPlanBrowserSafe } from "./plan.js";
43
+ import { assertPlanBrowserSafe, isEuUkVisitorPolicy, type EuUkVisitorPolicy } from "./plan.js";
44
44
  import {
45
45
  INTEGRATION_CSP_DIRECTIVES,
46
46
  validateCspSourceSet,
@@ -68,6 +68,8 @@ 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`. */
72
+ euUkVisitors?: EuUkVisitorPolicy;
71
73
  }
72
74
 
73
75
  const ORIGIN_RANK: Record<IntegrationOrigin, number> = {
@@ -95,6 +97,10 @@ export function resolveEffectivePlan(
95
97
  v.add("tenantPublicId", "type", "tenantPublicId must be a non-empty string.");
96
98
  }
97
99
 
100
+ if (input.euUkVisitors !== undefined && !isEuUkVisitorPolicy(input.euUkVisitors)) {
101
+ v.add("euUkVisitors", "unknown_enum", 'euUkVisitors must be "count-only", "full", or "none".');
102
+ }
103
+
98
104
  if (!Array.isArray(input.declarations)) {
99
105
  v.add("declarations", "type", "declarations must be an array.");
100
106
  return { ok: false, violations: v.items };
@@ -212,6 +218,9 @@ export function resolveEffectivePlan(
212
218
  schemaVersion: EFFECTIVE_PLAN_SCHEMA_VERSION,
213
219
  environment: envResult.ok ? envResult.value : input.environment,
214
220
  tenantPublicId: input.tenantPublicId,
221
+ ...(input.euUkVisitors === "full" || input.euUkVisitors === "none"
222
+ ? { euUkVisitors: input.euUkVisitors }
223
+ : {}),
215
224
  integrations,
216
225
  contributions,
217
226
  order,
@@ -0,0 +1,6 @@
1
+ export type VisitorRegionClass = "eu" | "x" | "u";
2
+ export const EU_UK_COUNTRY_CODES: readonly string[];
3
+ export const VISITOR_REGION_COOKIE: "tot_rgn";
4
+ export const VISITOR_REGION_COOKIE_MAX_AGE: 3600;
5
+ export function visitorRegionClass(country: unknown): VisitorRegionClass;
6
+ export function visitorRegionCookie(regionClass: VisitorRegionClass): string;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The visitor region class: the one non-identifying signal the edge hands the page so
3
+ * the early consent boot can apply the EU/UK analytics policy without a region-varying
4
+ * document.
5
+ *
6
+ * Both CDNs set it on every HTML response, after any cache: the Cloudflare Worker from
7
+ * `request.cf.country` and the CloudFront viewer-response function from
8
+ * `cloudfront-viewer-country`. The cookie carries only the class, never the country, so
9
+ * the page HTML and every cache key stay region-invariant (ADR 0013).
10
+ *
11
+ * `eu` — EU/EEA, the UK, Switzerland, and their territories inside those regimes
12
+ * `x` — a known country outside them
13
+ * `u` — the edge could not geolocate the request
14
+ *
15
+ * The browser treats `eu`, `u`, and an absent or unreadable cookie alike (count-only),
16
+ * so a missing signal fails closed.
17
+ *
18
+ * `visitorRegionClass` is closure-free: the CloudFront generator serializes it with
19
+ * `Function.toString()` next to an injected `EU_UK_COUNTRY_CODES`, so the edge runs the
20
+ * logic the tests prove.
21
+ */
22
+
23
+ /** ISO 3166-1 alpha-2 codes whose visitors get the EU/UK analytics policy. */
24
+ export const EU_UK_COUNTRY_CODES = Object.freeze([
25
+ // EU member states
26
+ "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT",
27
+ "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE",
28
+ // EU territories with their own ISO codes
29
+ "AX", "GF", "GP", "MQ", "RE", "YT", "MF",
30
+ // EEA
31
+ "IS", "LI", "NO",
32
+ // UK, Crown dependencies, Gibraltar
33
+ "GB", "GG", "JE", "IM", "GI",
34
+ // Switzerland
35
+ "CH",
36
+ ]);
37
+
38
+ export const VISITOR_REGION_COOKIE = "tot_rgn";
39
+
40
+ /** Seconds the class is kept; every HTML response refreshes it. */
41
+ export const VISITOR_REGION_COOKIE_MAX_AGE = 3600;
42
+
43
+ /**
44
+ * The region class for an edge-reported country code. A missing, malformed, or
45
+ * placeholder code (`XX` unknown, `T1` Tor) is `u`.
46
+ * @param {unknown} country
47
+ * @returns {"eu" | "x" | "u"}
48
+ */
49
+ export function visitorRegionClass(country) {
50
+ if (typeof country !== "string") return "u";
51
+ var code = country.trim().toUpperCase();
52
+ if (!/^[A-Z]{2}$/.test(code) || code === "XX" || code === "T1") return "u";
53
+ return EU_UK_COUNTRY_CODES.indexOf(code) !== -1 ? "eu" : "x";
54
+ }
55
+
56
+ /**
57
+ * The `Set-Cookie` value for a region class: host-only, first-party, readable by the
58
+ * early consent boot, and short-lived.
59
+ * @param {"eu" | "x" | "u"} regionClass
60
+ * @returns {string}
61
+ */
62
+ export function visitorRegionCookie(regionClass) {
63
+ return `${VISITOR_REGION_COOKIE}=${regionClass}; Path=/; Max-Age=${VISITOR_REGION_COOKIE_MAX_AGE}; SameSite=Lax; Secure`;
64
+ }
@@ -9,6 +9,7 @@ import type { ManagedCheckoutConfig } from "./checkout.js";
9
9
  import type { CapabilityConfig } from "./capabilities.js";
10
10
  import type { AutopilotConfig, MembershipProgramConfig } from "./membership.js";
11
11
  import type { IntegrationDeclarationInput } from "./integration/resolve.js";
12
+ import type { EuUkVisitorPolicy } from "./integration/plan.js";
12
13
  import type { PublicationSpaceManifestV1 } from "./publication-space.js";
13
14
  import type { HtmlCachePolicy } from "./html-cache-policy.js";
14
15
 
@@ -661,6 +662,12 @@ export interface TenantConfig {
661
662
  * complete self-owned declaration, not a reference into a vetted list.
662
663
  */
663
664
  integrations?: readonly IntegrationDeclarationInput[];
665
+ /**
666
+ * The tenant's analytics policy for EU/EEA/UK/CH visitors and visitors whose region
667
+ * is unknown, from its `analytics.json`. Absent means `count-only`; only `full` and
668
+ * `none` are ever set. It reaches the browser through the server-stamped plan.
669
+ */
670
+ euUkVisitors?: Exclude<EuUkVisitorPolicy, "count-only">;
664
671
  }
665
672
 
666
673
  /**