@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.
- package/apps/storefront/src/config/devTenantSeed.ts +6 -1
- package/apps/storefront/src/lib/cspStaticHashes.ts +1 -1
- package/apps/storefront/src/lib/integration/renderIntegrationState.ts +2 -2
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +29 -4
- package/apps/storefront/src/lib/visitorRegionCookie.ts +68 -0
- package/apps/storefront/src/middleware/index.ts +21 -1
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -1
- package/packages/public-runtime/src/integration/consent-broker.ts +55 -13
- package/packages/public-runtime/src/integration/interim-bridge.ts +7 -2
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +43 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +17 -0
- package/packages/public-runtime/src/integration/plan.ts +19 -0
- package/packages/public-runtime/src/integration/render-phases.ts +6 -0
- package/packages/public-runtime/src/integration/resolve.ts +10 -1
- package/packages/public-runtime/src/integration/visitor-region.d.mts +6 -0
- package/packages/public-runtime/src/integration/visitor-region.mjs +64 -0
- package/packages/public-runtime/src/tenant.ts +7 -0
|
@@ -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(
|
|
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-
|
|
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
|
|
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:
|
|
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 {
|
|
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
|
-
):
|
|
202
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
90
|
-
|
|
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 {
|
|
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) =>
|
|
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
|
|
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
|
/**
|