@tokenoftrust/cli 4.9.0 → 4.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "4.9.0",
3
+ "version": "4.9.1",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -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; `count-only` is the default. */
30
+ export const ANALYTICS_EU_UK_POLICIES = Object.freeze(["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
+ }
package/src/validate.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  } from "./tot-repo-mappings.mjs";
26
26
  import { isForbiddenPublicPath, publicBinaryFindings } from "./public-binaries.mjs";
27
27
  import { pageSlugFindings } from "./page-slugs.mjs";
28
+ import { ANALYTICS_DECLARATION_FILE, analyticsDeclarationFindings } from "./analytics-declaration.mjs";
28
29
  import { parseSku, skuCollisions, skuFindings } from "./sku.mjs";
29
30
 
30
31
  export {
@@ -1351,6 +1352,13 @@ export function validateTenant(tenantDir, opts = {}) {
1351
1352
  }
1352
1353
  }
1353
1354
 
1355
+ const analyticsPath = located(ANALYTICS_DECLARATION_FILE);
1356
+ if (existsSync(analyticsPath)) {
1357
+ for (const f of analyticsDeclarationFindings(readFileSync(analyticsPath, "utf8"))) {
1358
+ findings.push(mk(ERROR, "analytics-declaration", ANALYTICS_DECLARATION_FILE, f.message, f.remedy));
1359
+ }
1360
+ }
1361
+
1354
1362
  // 3. content JSON — parse + blocks shape
1355
1363
  for (const name of ["home.json", "chrome.json"]) {
1356
1364
  const p = join(contentDir, name);