@tokenoftrust/storefront-runner 4.9.3 → 4.10.1

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 (33) hide show
  1. package/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
  2. package/apps/storefront/package.json +1 -1
  3. package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
  4. package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
  5. package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
  6. package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
  7. package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
  8. package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
  9. package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
  10. package/package.json +1 -1
  11. package/packages/public-runtime/package.json +1 -1
  12. package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
  13. package/packages/public-runtime/src/integration/events.ts +15 -19
  14. package/packages/public-runtime/src/integration/index.ts +1 -0
  15. package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
  16. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
  17. package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
  18. package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
  19. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
  20. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
  21. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
  22. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
  23. package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
  24. package/packages/public-runtime/src/integration/plan.ts +6 -4
  25. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
  26. package/packages/public-runtime/src/integration/render-phases.ts +10 -18
  27. package/packages/public-runtime/src/integration/resolve.ts +3 -3
  28. package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
  29. package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
  30. package/packages/public-runtime/src/tenant.ts +3 -3
  31. package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
  32. package/scripts/dev/ga4-gs2-canary.mjs +36 -10
  33. package/scripts/tenant/validate.mjs +9 -0
@@ -0,0 +1,299 @@
1
+ // @ts-check
2
+ /**
3
+ * The tenant `analytics.json` contract, for the surfaces that cannot load the platform's
4
+ * TypeScript: `tot validate`, the kit's validate-site, `scripts/tenant/validate.mjs` and
5
+ * materialize-tenant.
6
+ *
7
+ * The platform materializes the file with `materializeTenantAnalyticsDeclarations`
8
+ * (`packages/public-runtime/src/integration/tenant-analytics.ts`), which builds each destination
9
+ * through the reviewed adapter builder. This module reports the same `{ path, code }` set for any
10
+ * input; `agency-kit/tools/lib/fixtures/analytics-declarations.json` is run through both, so a
11
+ * drift on either side fails a test.
12
+ *
13
+ * Messages carry no value from the file — never an id, never a key's value — only the field path
14
+ * and a fixed explanation, so a finding is safe to print, log or forward.
15
+ *
16
+ * Pure: callers pass the file's text. `packages/cli/src/analytics-declaration.mjs` is a
17
+ * byte-identical copy (a test pins it).
18
+ */
19
+
20
+ /** The tenant-owned file, at the tenant tree's root. */
21
+ export const ANALYTICS_DECLARATION_FILE = "analytics.json";
22
+
23
+ /** The current `analytics.json` contract version. */
24
+ export const ANALYTICS_DECLARATION_SCHEMA_VERSION = 1;
25
+
26
+ /** The stages a destination id may be declared for, in the platform's environment order. */
27
+ export const ANALYTICS_STAGES = Object.freeze(["local", "candidate", "staged", "live"]);
28
+
29
+ /** The `euUkVisitors` policies; `ask` is the default. */
30
+ export const ANALYTICS_EU_UK_POLICIES = Object.freeze(["ask", "count-only", "full", "none"]);
31
+
32
+ /**
33
+ * The first-class platform adapters a tenant may declare. Pinned against the platform's
34
+ * `PLATFORM_ADAPTERS` by `packages/public-runtime/tests/integration/tenant-analytics.test.ts`.
35
+ */
36
+ export const ANALYTICS_ADAPTERS = Object.freeze([
37
+ Object.freeze({
38
+ adapterId: "platform.google-measurement",
39
+ maxDestinationsPerStage: 4,
40
+ idPattern: /^G-[A-Z0-9]{4,20}$/,
41
+ idLabel: "GA4 measurement id (e.g. G-XXXXXXX)",
42
+ }),
43
+ Object.freeze({
44
+ adapterId: "platform.meta-pixel",
45
+ maxDestinationsPerStage: 1,
46
+ idPattern: /^\d{10,20}$/,
47
+ idLabel: "Meta pixel id (10–20 digits)",
48
+ }),
49
+ Object.freeze({
50
+ adapterId: "platform.amplitude",
51
+ maxDestinationsPerStage: 1,
52
+ idPattern: /^(?=[a-f0-9]{32}$)(?=.*[a-f])[a-f0-9]+$/i,
53
+ idLabel: "Amplitude project API key (32 hexadecimal characters)",
54
+ }),
55
+ ]);
56
+
57
+ const ANNOTATION_KEYS = ["$schema", "$comment", "$id"];
58
+ const ROOT_KEYS = ["schemaVersion", "destinations", "euUkVisitors"];
59
+ const DESTINATION_KEYS = ["adapter", "ids"];
60
+
61
+ /**
62
+ * @typedef {Object} AnalyticsViolation
63
+ * @property {string} path the offending field, e.g. `destinations[0].ids.live[1]`
64
+ * @property {string} code a stable machine code, e.g. `format`, `unknown_field`
65
+ */
66
+
67
+ /**
68
+ * @typedef {Object} AnalyticsFinding
69
+ * @property {string} path
70
+ * @property {string} code
71
+ * @property {string} message value-free: the path, the code and a fixed explanation
72
+ * @property {string} remedy
73
+ */
74
+
75
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
76
+ function isPlainObject(value) {
77
+ return typeof value === "object" && value !== null && !Array.isArray(value);
78
+ }
79
+
80
+ /**
81
+ * @param {Record<string, unknown>} obj
82
+ * @param {readonly string[]} allowed
83
+ * @param {string} base
84
+ * @param {AnalyticsViolation[]} out
85
+ */
86
+ function unknownKeys(obj, allowed, base, out) {
87
+ for (const key of Object.keys(obj)) {
88
+ if (allowed.includes(key) || ANNOTATION_KEYS.includes(key)) continue;
89
+ out.push({ path: base ? `${base}.${key}` : key, code: "unknown_field" });
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The adapter builder's per-stage id rules, with its paths re-anchored under the destination.
95
+ * @param {Record<string, unknown>} ids
96
+ * @param {(typeof ANALYTICS_ADAPTERS)[number]} adapter
97
+ * @param {string} base `destinations[i]`
98
+ * @param {AnalyticsViolation[]} out
99
+ */
100
+ function stageIdViolations(ids, adapter, base, out) {
101
+ /** @type {Map<string, string[]>} */
102
+ const valid = new Map();
103
+ for (const [stage, raw] of Object.entries(ids)) {
104
+ const at = `${base}.ids.${stage}`;
105
+ if (!ANALYTICS_STAGES.includes(stage)) {
106
+ out.push({ path: at, code: "unknown_enum" });
107
+ continue;
108
+ }
109
+ if (!Array.isArray(raw)) {
110
+ out.push({ path: at, code: "type" });
111
+ continue;
112
+ }
113
+ if (raw.length === 0) {
114
+ out.push({ path: at, code: "empty" });
115
+ continue;
116
+ }
117
+ if (raw.length > adapter.maxDestinationsPerStage) {
118
+ out.push({ path: at, code: "too_many_destinations" });
119
+ continue;
120
+ }
121
+ let stageOk = true;
122
+ raw.forEach((id, index) => {
123
+ if (typeof id !== "string" || id.length === 0) {
124
+ out.push({ path: `${at}[${index}]`, code: "type" });
125
+ stageOk = false;
126
+ return;
127
+ }
128
+ if (!adapter.idPattern.test(id)) {
129
+ out.push({ path: `${at}[${index}]`, code: "format" });
130
+ stageOk = false;
131
+ }
132
+ });
133
+ if (!stageOk) continue;
134
+ if (new Set(raw).size !== raw.length) {
135
+ out.push({ path: at, code: "duplicate" });
136
+ continue;
137
+ }
138
+ valid.set(stage, /** @type {string[]} */ (raw));
139
+ }
140
+ const live = valid.get("live");
141
+ if (live === undefined) {
142
+ out.push({ path: `${base}.ids.live`, code: "required" });
143
+ return;
144
+ }
145
+ for (const stage of ANALYTICS_STAGES) {
146
+ if (stage === "live") continue;
147
+ (valid.get(stage) ?? []).forEach((id, index) => {
148
+ if (live.includes(id)) out.push({ path: `${base}.ids.${stage}[${index}]`, code: "non_live_reuses_live_id" });
149
+ });
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Every violation in an already-parsed `analytics.json` value. `undefined`/`null` (no file) is
155
+ * the undeclared norm and has none.
156
+ * @param {unknown} value
157
+ * @returns {AnalyticsViolation[]}
158
+ */
159
+ export function analyticsDeclarationViolations(value) {
160
+ /** @type {AnalyticsViolation[]} */
161
+ const out = [];
162
+ if (value === undefined || value === null) return out;
163
+ if (!isPlainObject(value)) {
164
+ out.push({ path: "(root)", code: "type" });
165
+ return out;
166
+ }
167
+ unknownKeys(value, ROOT_KEYS, "", out);
168
+ if ("euUkVisitors" in value && !ANALYTICS_EU_UK_POLICIES.includes(/** @type {string} */ (value.euUkVisitors))) {
169
+ out.push({ path: "euUkVisitors", code: "unknown_enum" });
170
+ }
171
+ if ("schemaVersion" in value && value.schemaVersion !== ANALYTICS_DECLARATION_SCHEMA_VERSION) {
172
+ out.push({ path: "schemaVersion", code: "unsupported_version" });
173
+ }
174
+ const destinations = value.destinations;
175
+ if (!Array.isArray(destinations)) {
176
+ out.push({ path: "destinations", code: "type" });
177
+ return out;
178
+ }
179
+ /** @type {Set<string>} */
180
+ const seenAdapters = new Set();
181
+ destinations.forEach((destination, index) => {
182
+ const base = `destinations[${index}]`;
183
+ if (!isPlainObject(destination)) {
184
+ out.push({ path: base, code: "type" });
185
+ return;
186
+ }
187
+ unknownKeys(destination, DESTINATION_KEYS, base, out);
188
+ const adapterId = destination.adapter;
189
+ if (typeof adapterId !== "string" || adapterId.length === 0) {
190
+ out.push({ path: `${base}.adapter`, code: "type" });
191
+ return;
192
+ }
193
+ const ids = destination.ids;
194
+ if (!isPlainObject(ids)) {
195
+ out.push({ path: `${base}.ids`, code: "type" });
196
+ return;
197
+ }
198
+ if (seenAdapters.has(adapterId)) {
199
+ out.push({ path: `${base}.adapter`, code: "duplicate" });
200
+ return;
201
+ }
202
+ seenAdapters.add(adapterId);
203
+ const adapter = ANALYTICS_ADAPTERS.find((a) => a.adapterId === adapterId);
204
+ if (!adapter) {
205
+ out.push({ path: `${base}.adapter`, code: "unknown_adapter" });
206
+ return;
207
+ }
208
+ stageIdViolations(ids, adapter, base, out);
209
+ });
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * Parse `analytics.json` text. Unparseable text is a `(root)` `invalid_json` violation, never an
215
+ * absent file: a declaration that cannot be read must refuse, not publish with no analytics.
216
+ * @param {string} text
217
+ * @returns {{ ok: true, value: unknown } | { ok: false, violations: AnalyticsViolation[] }}
218
+ */
219
+ export function parseAnalyticsDeclaration(text) {
220
+ try {
221
+ return { ok: true, value: JSON.parse(text) };
222
+ } catch {
223
+ return { ok: false, violations: [{ path: "(root)", code: "invalid_json" }] };
224
+ }
225
+ }
226
+
227
+ const ADAPTER_IDS = ANALYTICS_ADAPTERS.map((a) => a.adapterId).join(", ");
228
+ const ID_FORMATS = ANALYTICS_ADAPTERS.map((a) => `${a.adapterId}: ${a.idLabel}`).join("; ");
229
+
230
+ /**
231
+ * A fixed, value-free explanation and remedy for a violation.
232
+ * @param {AnalyticsViolation} violation
233
+ * @returns {{ message: string, remedy: string }}
234
+ */
235
+ export function analyticsViolationText({ path, code }) {
236
+ switch (code) {
237
+ case "invalid_json":
238
+ return { message: "analytics.json is not valid JSON", remedy: "fix the JSON syntax" };
239
+ case "type":
240
+ return {
241
+ message: "has the wrong type",
242
+ remedy: 'use { "schemaVersion": 1, "destinations": [{ "adapter": "<adapter id>", "ids": { "live": ["<id>"] } }] }',
243
+ };
244
+ case "unknown_field":
245
+ return { message: "is not an analytics.json field", remedy: "remove it; analytics.json is a closed contract" };
246
+ case "unknown_enum":
247
+ return path === "euUkVisitors"
248
+ ? { message: "is not an EU/UK visitor policy", remedy: `use one of: ${ANALYTICS_EU_UK_POLICIES.join(", ")}` }
249
+ : { message: "is not a stage", remedy: `use one of: ${ANALYTICS_STAGES.join(", ")}` };
250
+ case "unsupported_version":
251
+ return { message: "is not a supported schemaVersion", remedy: `set it to ${ANALYTICS_DECLARATION_SCHEMA_VERSION}` };
252
+ case "unknown_adapter":
253
+ return { message: "is not a first-class analytics adapter", remedy: `use one of: ${ADAPTER_IDS}` };
254
+ case "empty":
255
+ return { message: "names no destination id", remedy: "name at least one id, or remove the stage" };
256
+ case "too_many_destinations":
257
+ return { message: "names more destination ids than this adapter accepts per stage", remedy: "remove the extra ids" };
258
+ case "format":
259
+ return { message: "is not a valid destination id for this adapter", remedy: `use the adapter's id format (${ID_FORMATS})` };
260
+ case "duplicate":
261
+ return path.endsWith(".adapter")
262
+ ? { message: "names an adapter an earlier destination already names", remedy: "declare each adapter once, with all of its ids" }
263
+ : { message: "names the same destination id twice", remedy: "list each id once" };
264
+ case "required":
265
+ return { message: "has no valid live destination id", remedy: "declare a valid live id for every destination" };
266
+ case "non_live_reuses_live_id":
267
+ return {
268
+ message: "reuses a live destination id, so non-live traffic would reach the live property",
269
+ remedy: "use a separate test id, or leave the stage unset",
270
+ };
271
+ default:
272
+ return { message: "is invalid", remedy: "see docs/architecture/analytics-activation.md" };
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Every violation in an `analytics.json` file's text. A file that is present but holds `null` is
278
+ * a `(root)` `type` violation: only an absent file declares nothing.
279
+ * @param {string} text
280
+ * @returns {AnalyticsViolation[]}
281
+ */
282
+ export function analyticsDeclarationTextViolations(text) {
283
+ const parsed = parseAnalyticsDeclaration(text);
284
+ if (!parsed.ok) return [...parsed.violations];
285
+ if (parsed.value === null) return [{ path: "(root)", code: "type" }];
286
+ return analyticsDeclarationViolations(parsed.value);
287
+ }
288
+
289
+ /**
290
+ * Every finding in an `analytics.json` file's text, each value-free.
291
+ * @param {string} text
292
+ * @returns {AnalyticsFinding[]}
293
+ */
294
+ export function analyticsDeclarationFindings(text) {
295
+ return analyticsDeclarationTextViolations(text).map((violation) => {
296
+ const { message, remedy } = analyticsViolationText(violation);
297
+ return { ...violation, message: `${violation.path}: [${violation.code}] ${message}`, remedy };
298
+ });
299
+ }
@@ -14,7 +14,7 @@
14
14
  "preview": "wrangler dev",
15
15
  "typecheck": "astro check && tsc --noEmit",
16
16
  "astro": "astro",
17
- "test": "NODE_OPTIONS=--experimental-sqlite vitest run --exclude src/lib/integrations/apps/v84CleanCloneReplay.test.ts --exclude src/lib/integrations/apps/v84NonportableRouteDogfood.test.ts --exclude src/lib/integrations/apps/v84CloneLifecycleEvidence.test.ts && NODE_OPTIONS=--experimental-sqlite vitest run src/lib/integrations/apps/v84CleanCloneReplay.test.ts src/lib/integrations/apps/v84NonportableRouteDogfood.test.ts src/lib/integrations/apps/v84CloneLifecycleEvidence.test.ts --fileParallelism=false && pnpm run test:pipeline && pnpm run test:pipeline-faults",
17
+ "test": "NODE_OPTIONS=--experimental-sqlite node ../../scripts/vitest-run.mjs run --exclude src/lib/integrations/apps/v84CleanCloneReplay.test.ts --exclude src/lib/integrations/apps/v84NonportableRouteDogfood.test.ts --exclude src/lib/integrations/apps/v84CloneLifecycleEvidence.test.ts && NODE_OPTIONS=--experimental-sqlite node ../../scripts/vitest-run.mjs run src/lib/integrations/apps/v84CleanCloneReplay.test.ts src/lib/integrations/apps/v84NonportableRouteDogfood.test.ts src/lib/integrations/apps/v84CloneLifecycleEvidence.test.ts --fileParallelism=false && pnpm run test:pipeline && pnpm run test:pipeline-faults",
18
18
  "depcruise": "depcruise src --config .runner-dependency-cruiser.cjs"
19
19
  },
20
20
  "dependencies": {
@@ -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-bANHtvoDkw4GnpBynM5Ib1cxdoDeNsGkPhPnDEphW3Y='",
22
+ "'sha256-MG9XnFVbN8UmLcNws9A8ckOhQJI+MnAKxpCI7oBMS3Q='",
23
23
  ];
24
24
 
25
25
  export const STATIC_STYLE_HASHES: readonly string[] = [
@@ -12,7 +12,7 @@
12
12
  * - a CloudFront-forwarded Tier 3 request (its `cf` describes CloudFront, not the
13
13
  * viewer);
14
14
  * - no `request.cf` at all.
15
- * The page then keeps whatever class it already holds, or none (count-only).
15
+ * The page then keeps whatever class it already holds, or none (the EU/UK policy).
16
16
  *
17
17
  * The local dev loop's `request.cf` comes from the dev runtime's cached `cf.json`, which
18
18
  * describes the developer's own location. `?tot_region=eu|x|u` overrides it so both
@@ -1,228 +1,68 @@
1
1
  /**
2
- * Tenant-owned analytics declaration surface (TIER 1).
2
+ * Runtime read of a tenant's own `analytics.json` (TIER 1) for the render hot path.
3
3
  *
4
- * Today a tenant cannot declare a measurement destination from its OWN tree: the
5
- * declaration lives in `tenant.integrations` (apps/storefront/src/config/tenants.ts),
6
- * which is platform code a tenant may not touch (customization golden rule #1). So a
7
- * merchant must file a request for what should be a self-serve, versioned, publishable
8
- * edit — exactly like `theme.json` or `content/*`.
9
- *
10
- * This module is the tenant-tree producer that closes that gap. A tenant commits an
11
- * `analytics.json` in its own tree (the tenant-owned surface, `tenants/<id>/**`), and
12
- * this materializes it into the SAME already-consumed declaration seam
13
- * ({@link IntegrationDeclarationInput}[], read by
14
- * {@link loadTenantIntegrationDeclarations} and merged into the render plan by the
15
- * interim bridge). It adds NO new runtime: it is a THIRD producer into an existing
16
- * consumer, alongside the platform-authored `tenant.integrations` and the D1-backed
17
- * managed installs.
18
- *
19
- * The heavy validation is NOT hand-rolled — each declared destination is built by the
20
- * shipped, reviewed {@link buildPlatformAdapterDeclaration}, which fail-closes on an
21
- * unknown adapter, a missing/invalid live id, an invalid per-stage id, or a non-live
22
- * stage that reuses the live id. That is the whole point: a tenant supplies only public
23
- * per-stage destination ids and gets a working, consent-gated, CSP-clean, non-live-safe
24
- * measurement integration with no renderer change.
25
- *
26
- * Two callers, one core:
27
- * - PUBLISH validation — {@link materializeTenantAnalyticsDeclarations} returns
28
- * structured {@link ContractViolation}s so the ship/customization pipeline can
29
- * REFUSE an invalid `analytics.json` with an actionable, per-field message, exactly
30
- * as it refuses an invalid `theme.json`.
31
- * - RUNTIME materialization — {@link loadTenantAnalyticsDeclarations} is fail-closed:
32
- * a missing or (defensively) invalid file yields the empty set, never a throw, so an
33
- * undeclared tenant resolves the environment-only plan and a byte-identical page.
4
+ * The contract and its materializer live in `@tot/public-runtime`
5
+ * ({@link materializeTenantAnalyticsDeclarations}); publish validation refuses an invalid file
6
+ * before it can reach here. This loader is the defensive last line: fail-closed and value-free.
34
7
  */
35
8
  import {
36
- buildPlatformAdapterDeclaration,
37
- ENVIRONMENT_SCHEMA_VERSION,
38
- Violations,
39
- isEuUkVisitorPolicy,
40
- isPlainObject,
41
- rejectUnknownKeys,
42
- type BrowserEnvironment,
43
- type ContractViolation,
9
+ describeTenantAnalyticsViolations,
10
+ materializeTenantAnalyticsDeclarations,
44
11
  type IntegrationDeclarationInput,
45
- type TenantConfig,
12
+ type TenantAnalyticsMaterialization,
46
13
  } from "@tot/public-runtime";
47
14
 
48
- /** The tenant-owned file that declares measurement destinations, in the tenant tree. */
49
- export const TENANT_ANALYTICS_FILE = "analytics.json";
50
-
51
- /** The current `analytics.json` contract version. */
52
- export const TENANT_ANALYTICS_SCHEMA_VERSION = 1 as const;
53
-
54
- /** One declared measurement destination: a first-class adapter + its per-stage public ids. */
55
- export interface TenantAnalyticsDestination {
56
- /** The first-class platform adapter id, e.g. `platform.google-measurement`. */
57
- adapter: string;
58
- /** Public per-stage destination ids. `live` is required; non-live stages take SEPARATE test ids. */
59
- ids: Record<string, unknown>;
60
- }
61
-
62
- /** The result of materializing a tenant's `analytics.json` into declarations. */
63
- export interface TenantAnalyticsMaterialization {
64
- /** True only when EVERY declared destination validated; false surfaces `violations`. */
65
- ok: boolean;
66
- /** The materialized declarations, in declared order. Empty whenever `ok` is false. */
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"];
73
- /** Per-field violations for the publish surface. Empty whenever `ok` is true. */
74
- violations: readonly ContractViolation[];
75
- }
76
-
77
- /**
78
- * The fixed environment the declaration is CONSTRUCTED against. A first-class adapter
79
- * declaration is environment-INDEPENDENT in content — its per-stage ids are all baked
80
- * into `publicConfig` and the client picks the current stage at render time — so the
81
- * environment here only satisfies {@link buildPlatformAdapterDeclaration}'s validation
82
- * and its plan-derived CSP requirements. The caller must supply the tenant's real
83
- * canonical host so those requirements authorize the same-site adapter frame that the
84
- * browser runtime will construct.
85
- */
86
- function constructionEnvironment(canonicalHost: string): BrowserEnvironment {
87
- return {
88
- schemaVersion: ENVIRONMENT_SCHEMA_VERSION,
89
- siteSurface: "storefront",
90
- environment: "live",
91
- canonicalHost,
92
- revision: "tenant-analytics-declaration",
93
- };
94
- }
95
-
96
- const EMPTY: readonly IntegrationDeclarationInput[] = Object.freeze([]);
97
-
98
- /** Re-anchor a builder violation path under the declaring `destinations[i]` element. */
99
- function reanchor(violation: ContractViolation, index: number): ContractViolation {
100
- const base = `destinations[${index}]`;
101
- const path =
102
- violation.path === "(root)" || violation.path === ""
103
- ? base
104
- : violation.path === "adapterId"
105
- ? `${base}.adapter`
106
- : violation.path.startsWith("adapterId.")
107
- ? `${base}.adapter${violation.path.slice("adapterId".length)}`
108
- : `${base}.${violation.path}`;
109
- return { path, code: violation.code, message: violation.message };
110
- }
111
-
112
- /** Validate the closed shape of one declared destination before it reaches the builder. */
113
- function readDestination(
114
- raw: unknown,
115
- index: number,
116
- v: Violations,
117
- ): TenantAnalyticsDestination | undefined {
118
- const base = `destinations[${index}]`;
119
- if (!isPlainObject(raw)) {
120
- v.add(base, "type", "A destination must be an object of { adapter, ids }.");
121
- return undefined;
122
- }
123
- rejectUnknownKeys(raw, ["adapter", "ids"], v, base);
124
- const adapter = raw["adapter"];
125
- if (typeof adapter !== "string" || adapter.length === 0) {
126
- v.add(`${base}.adapter`, "type", "A destination adapter must be a non-empty string.");
127
- return undefined;
128
- }
129
- if (!isPlainObject(raw["ids"])) {
130
- v.add(`${base}.ids`, "type", "ids must be an object of { stage: destinationId[] }.");
131
- return undefined;
15
+ function warnDeclaration(summary: string, violations: readonly { path: string; code: string }[]): void {
16
+ try {
17
+ console.warn(`[diag:analytics:declaration] ${summary}`, JSON.stringify({ violations }));
18
+ } catch {
19
+ /* diagnostics must never break the caller */
132
20
  }
133
- return { adapter, ids: raw["ids"] };
134
21
  }
135
22
 
136
23
  /**
137
- * Parse and materialize a tenant's `analytics.json` content into first-class adapter
138
- * declarations. `raw` is the already-parsed JSON value (or `undefined`/`null` when the
139
- * tenant declares none). Fail-closed and whole-file: if ANY declared destination is
140
- * invalid, NO declaration is produced and the violations name every offending field —
141
- * a half-valid analytics file never partially materializes.
142
- */
143
- export function materializeTenantAnalyticsDeclarations(input: {
144
- raw: unknown;
145
- tenantPublicId: string;
146
- canonicalHost: string;
147
- }): TenantAnalyticsMaterialization {
148
- const { raw, tenantPublicId, canonicalHost } = input;
149
- // An undeclared tenant (no file) is the zero-cost norm, not an error.
150
- if (raw === undefined || raw === null) {
151
- return { ok: true, declarations: EMPTY, violations: [] };
152
- }
153
-
154
- const v = new Violations();
155
- if (!isPlainObject(raw)) {
156
- v.add("(root)", "type", "analytics.json must be a JSON object with a `destinations` array.");
157
- return { ok: false, declarations: EMPTY, violations: v.items };
158
- }
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
- }
168
- if ("schemaVersion" in raw && raw["schemaVersion"] !== TENANT_ANALYTICS_SCHEMA_VERSION) {
169
- v.add(
170
- "schemaVersion",
171
- "unsupported_version",
172
- `schemaVersion must equal ${TENANT_ANALYTICS_SCHEMA_VERSION}.`,
173
- );
174
- }
175
- const destinationsRaw = raw["destinations"];
176
- if (!Array.isArray(destinationsRaw)) {
177
- v.add("destinations", "type", "`destinations` must be an array.");
178
- return { ok: false, declarations: EMPTY, violations: v.items };
179
- }
180
-
181
- const declarations: IntegrationDeclarationInput[] = [];
182
- destinationsRaw.forEach((destinationRaw, index) => {
183
- const destination = readDestination(destinationRaw, index, v);
184
- if (!destination) return;
185
- const built = buildPlatformAdapterDeclaration({
186
- adapterId: destination.adapter,
187
- ids: destination.ids,
188
- tenantPublicId,
189
- environment: constructionEnvironment(canonicalHost),
190
- });
191
- if (!built.ok) {
192
- for (const violation of built.violations) v.items.push(reanchor(violation, index));
193
- return;
194
- }
195
- // Namespace the ref so a tenant-declared adapter can never be mistaken for — or
196
- // collide with — the same adapter installed through the managed D1 path.
197
- declarations.push({ ...built.declaration, ref: `tenant-analytics:${destination.adapter}` });
198
- });
199
-
200
- if (!v.ok) return { ok: false, declarations: EMPTY, violations: v.items };
201
- return {
202
- ok: true,
203
- declarations,
204
- violations: [],
205
- ...(euUkVisitors === "full" || euUkVisitors === "none" ? { euUkVisitors } : {}),
206
- };
207
- }
208
-
209
- /**
210
- * Read a tenant's already-materialized analytics declarations for the render hot path,
211
- * fail-closed: a missing OR invalid file yields the empty set (never throws), so an
212
- * undeclared tenant resolves the environment-only plan and a defensively-invalid file
213
- * darks its own analytics rather than the page. Publish validation
214
- * ({@link materializeTenantAnalyticsDeclarations}) is what keeps an invalid file from
215
- * ever reaching here.
24
+ * Read a tenant's already-materialized analytics declarations, fail-closed: a missing OR
25
+ * invalid file yields the empty set (never throws), so an undeclared tenant resolves the
26
+ * environment-only plan and an invalid file darks its own analytics rather than the page.
27
+ * A file that is present but holds `null` is invalid; only an absent file (`undefined`)
28
+ * declares nothing.
29
+ *
30
+ * `installed` is what the tenant already declares in platform code. The plan resolves an app
31
+ * at most once and refuses the whole plan on a repeat, so a destination whose adapter is
32
+ * already installed there is dropped, keeping the platform's install and the rest of the plan.
33
+ *
34
+ * Neither a refusal nor a drop is silent: each emits one `[diag:analytics:declaration]`
35
+ * warning naming the field path and code. The warning carries no value from the file and no
36
+ * tenant or destination identifier.
216
37
  */
217
38
  export function loadTenantAnalyticsDeclarations(
218
39
  raw: unknown,
219
40
  tenantPublicId: string,
220
41
  canonicalHost: string,
42
+ installed: readonly IntegrationDeclarationInput[] = [],
221
43
  ): Pick<TenantAnalyticsMaterialization, "declarations" | "euUkVisitors"> {
222
- const { declarations, euUkVisitors } = materializeTenantAnalyticsDeclarations({
44
+ if (raw === null) {
45
+ warnDeclaration("analytics.json refused; no analytics declared", [{ path: "(root)", code: "type" }]);
46
+ return { declarations: [] };
47
+ }
48
+ const { ok, declarations, euUkVisitors, violations } = materializeTenantAnalyticsDeclarations({
223
49
  raw,
224
50
  tenantPublicId,
225
51
  canonicalHost,
226
52
  });
227
- return euUkVisitors ? { declarations, euUkVisitors } : { declarations };
53
+ if (!ok) {
54
+ warnDeclaration("analytics.json refused; no analytics declared", describeTenantAnalyticsViolations(violations));
55
+ }
56
+ const installedAppIds = new Set(installed.map((declaration) => declaration.manifest.app.id));
57
+ const dropped: { path: string; code: string }[] = [];
58
+ // A valid file materializes one declaration per destination, in order.
59
+ const kept = declarations.filter((declaration, index) => {
60
+ if (!installedAppIds.has(declaration.manifest.app.id)) return true;
61
+ dropped.push({ path: `destinations[${index}].adapter`, code: "platform_installed" });
62
+ return false;
63
+ });
64
+ if (dropped.length > 0) {
65
+ warnDeclaration("analytics.json destination dropped; the platform already installs its adapter", dropped);
66
+ }
67
+ return euUkVisitors ? { declarations: kept, euUkVisitors } : { declarations: kept };
228
68
  }