@tokenoftrust/cli 4.9.0 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/analytics-declaration.mjs +299 -0
- package/src/validate.mjs +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.10.0",
|
|
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; `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
|
+
}
|
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);
|