@tokenoftrust/cli 2.2.14 → 2.2.16
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/declared-config.mjs +82 -0
- package/src/validate.mjs +29 -67
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.16",
|
|
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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.tot/config.json` keys the platform reads, and the refusal of everything
|
|
3
|
+
* else. Shared by `tot validate` (src/validate.mjs) and the monorepo/dev-loop
|
|
4
|
+
* validator (scripts/tenant/validate.mjs), so both refuse the same keys.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors packages/public-runtime/src/declared-tenant-config.mjs (this package ships
|
|
7
|
+
* to npm on its own); test/declared-config-parity.test.mjs pins the two together, so
|
|
8
|
+
* a field the platform honors can never be refused here, and a key no surface reads
|
|
9
|
+
* can never pass. Dependency-free: the storefront runner copies this file as-is.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const TOT_CONFIG_KEYS = [
|
|
13
|
+
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
14
|
+
"siteType", "compliance", "capabilities", "features", "embeds",
|
|
15
|
+
];
|
|
16
|
+
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
17
|
+
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
18
|
+
export const COMPLIANCE_KEYS = [
|
|
19
|
+
"ruleProfile", "minAge", "nicotineWarning", "shippingRestriction", "pactAct",
|
|
20
|
+
"adultSignature", "prop65", "purchaseLimit", "stateEligibility", "exciseTax",
|
|
21
|
+
];
|
|
22
|
+
export const FEATURE_KEYS = [
|
|
23
|
+
"catalog", "productSearch", "savedItems", "quickView", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
|
|
24
|
+
];
|
|
25
|
+
export const SITE_TYPES = ["commerce", "marketing"];
|
|
26
|
+
|
|
27
|
+
const ERROR = "error";
|
|
28
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
29
|
+
|
|
30
|
+
/** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
|
|
31
|
+
function mk(level, rule, file, message, fix) {
|
|
32
|
+
return { level, rule, file, message, fix };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Refuse what the platform would silently ignore: an unknown top-level key, an
|
|
37
|
+
* unknown `compliance`/`features` key, or a declared field of the wrong shape. Each
|
|
38
|
+
* one passes JSON parsing and renders nothing — on a regulated store that is a
|
|
39
|
+
* mandated notice that never appears.
|
|
40
|
+
* @param {Record<string, any>} config parsed `.tot/config.json` (an object)
|
|
41
|
+
* @param {string} [file]
|
|
42
|
+
* @returns {Finding[]}
|
|
43
|
+
*/
|
|
44
|
+
export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const key of Object.keys(config)) {
|
|
47
|
+
if (!TOT_CONFIG_KEYS.includes(key)) {
|
|
48
|
+
const renamed = RENAMED_CONFIG_KEYS[/** @type {keyof typeof RENAMED_CONFIG_KEYS} */ (key)];
|
|
49
|
+
out.push(mk(ERROR, "config-unknown-key", file,
|
|
50
|
+
`\`${key}\` is not a .tot/config.json key — the platform never reads it`,
|
|
51
|
+
renamed ? `use \`${renamed}\`` : `known keys: ${TOT_CONFIG_KEYS.join(", ")}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
|
|
55
|
+
out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
|
|
56
|
+
}
|
|
57
|
+
/** @type {Array<[string, string[]]>} */
|
|
58
|
+
const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
|
|
59
|
+
for (const [field, known] of objectFields) {
|
|
60
|
+
const value = config[field];
|
|
61
|
+
if (value === undefined) continue;
|
|
62
|
+
if (!isObject(value)) {
|
|
63
|
+
out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const key of Object.keys(value)) {
|
|
67
|
+
if (!known.includes(key)) {
|
|
68
|
+
out.push(mk(ERROR, `config-${field}-unknown-key`, `${file} ${field}`,
|
|
69
|
+
`\`${field}.${key}\` is not a ${field} key — nothing renders from it`,
|
|
70
|
+
`known keys: ${known.join(", ")}`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (config.capabilities !== undefined && !isObject(config.capabilities)) {
|
|
75
|
+
out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
|
|
76
|
+
}
|
|
77
|
+
if (config.embeds !== undefined
|
|
78
|
+
&& !(Array.isArray(config.embeds) && config.embeds.every((/** @type {unknown} */ s) => typeof s === "string"))) {
|
|
79
|
+
out.push(mk(ERROR, "config-embeds", file, '`embeds` must be an array of embed slugs, e.g. ["youtube"]'));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
package/src/validate.mjs
CHANGED
|
@@ -17,12 +17,21 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
19
19
|
import { join, relative } from "node:path";
|
|
20
|
+
import { validateDeclaredConfig } from "./declared-config.mjs";
|
|
20
21
|
import {
|
|
21
22
|
foreignTenantSegments,
|
|
22
23
|
parseMappingTarget,
|
|
23
24
|
workspacePathForTenantRelative,
|
|
24
25
|
} from "./tot-repo-mappings.mjs";
|
|
25
26
|
|
|
27
|
+
export {
|
|
28
|
+
TOT_CONFIG_KEYS,
|
|
29
|
+
COMPLIANCE_KEYS,
|
|
30
|
+
FEATURE_KEYS,
|
|
31
|
+
SITE_TYPES,
|
|
32
|
+
validateDeclaredConfig,
|
|
33
|
+
} from "./declared-config.mjs";
|
|
34
|
+
|
|
26
35
|
const NUL = String.fromCharCode(0);
|
|
27
36
|
export const ERROR = "error";
|
|
28
37
|
export const WARN = "warn";
|
|
@@ -68,70 +77,6 @@ const hasScanExt = (p) => CONFLICT_SCAN_EXT.has((p.match(/\.[^./\\]+$/) || [""])
|
|
|
68
77
|
const KNOWN_KINDS = new Set(["file", "tree"]);
|
|
69
78
|
const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
|
|
70
79
|
|
|
71
|
-
// The keys the platform reads in `.tot/config.json`. Mirrors
|
|
72
|
-
// packages/public-runtime/src/declared-tenant-config.mjs (this package ships to npm
|
|
73
|
-
// on its own); test/declared-config-parity.test.mjs pins the two together, so a
|
|
74
|
-
// field the platform honors can never be refused here, and a key no surface reads
|
|
75
|
-
// can never pass.
|
|
76
|
-
export const TOT_CONFIG_KEYS = [
|
|
77
|
-
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
78
|
-
"siteType", "compliance", "capabilities", "features",
|
|
79
|
-
];
|
|
80
|
-
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
81
|
-
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
82
|
-
export const COMPLIANCE_KEYS = [
|
|
83
|
-
"ruleProfile", "minAge", "nicotineWarning", "shippingRestriction", "pactAct",
|
|
84
|
-
"adultSignature", "prop65", "purchaseLimit", "stateEligibility", "exciseTax",
|
|
85
|
-
];
|
|
86
|
-
export const FEATURE_KEYS = [
|
|
87
|
-
"catalog", "productSearch", "savedItems", "quickView", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
|
|
88
|
-
];
|
|
89
|
-
export const SITE_TYPES = ["commerce", "marketing"];
|
|
90
|
-
|
|
91
|
-
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Refuse what the platform would silently ignore: an unknown top-level key, an
|
|
95
|
-
* unknown `compliance`/`features` key, or a declared field of the wrong shape. Each
|
|
96
|
-
* one passes JSON parsing and renders nothing — on a regulated store that is a
|
|
97
|
-
* mandated notice that never appears.
|
|
98
|
-
* @returns {Finding[]}
|
|
99
|
-
*/
|
|
100
|
-
export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
101
|
-
const out = [];
|
|
102
|
-
for (const key of Object.keys(config)) {
|
|
103
|
-
if (!TOT_CONFIG_KEYS.includes(key)) {
|
|
104
|
-
const renamed = RENAMED_CONFIG_KEYS[key];
|
|
105
|
-
out.push(mk(ERROR, "config-unknown-key", file,
|
|
106
|
-
`\`${key}\` is not a .tot/config.json key — the platform never reads it`,
|
|
107
|
-
renamed ? `use \`${renamed}\`` : `known keys: ${TOT_CONFIG_KEYS.join(", ")}`));
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
|
|
111
|
-
out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
|
|
112
|
-
}
|
|
113
|
-
/** @type {Array<[string, string[]]>} */
|
|
114
|
-
const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
|
|
115
|
-
for (const [field, known] of objectFields) {
|
|
116
|
-
const value = config[field];
|
|
117
|
-
if (value === undefined) continue;
|
|
118
|
-
if (!isObject(value)) {
|
|
119
|
-
out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
for (const key of Object.keys(value)) {
|
|
123
|
-
if (!known.includes(key)) {
|
|
124
|
-
out.push(mk(ERROR, `config-${field}-unknown-key`, `${file} ${field}`,
|
|
125
|
-
`\`${field}.${key}\` is not a ${field} key — nothing renders from it`,
|
|
126
|
-
`known keys: ${known.join(", ")}`));
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
if (config.capabilities !== undefined && !isObject(config.capabilities)) {
|
|
131
|
-
out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
|
|
132
|
-
}
|
|
133
|
-
return out;
|
|
134
|
-
}
|
|
135
80
|
|
|
136
81
|
/**
|
|
137
82
|
* Validate a parsed `.tot/config.json` against the shape the reconcile pipeline
|
|
@@ -279,7 +224,7 @@ function providesChrome(contentDir) {
|
|
|
279
224
|
|
|
280
225
|
const CAPABILITY_KEYS = new Set(["cartCheckout", "ageVerification", "exciseTax"]);
|
|
281
226
|
|
|
282
|
-
// Compact mirror of EMBED_PROVIDERS (
|
|
227
|
+
// Compact mirror of EMBED_PROVIDERS (agency-kit/tools/lib/embed-catalog.mjs)
|
|
283
228
|
// for the embed-CSP shift-left scan. The published `tot` CLI bundle can't reach
|
|
284
229
|
// the repo's public-runtime package at runtime, so this is a copy — but a parity
|
|
285
230
|
// test (packages/public-runtime/tests/embed-catalog-parity.test.ts) pins it to
|
|
@@ -292,6 +237,18 @@ export const KNOWN_EMBEDS = [
|
|
|
292
237
|
markers: ["webforms.pipedrive.com", "pipedriveWebForms"],
|
|
293
238
|
origins: ["https://webforms.pipedrive.com"],
|
|
294
239
|
},
|
|
240
|
+
{
|
|
241
|
+
slug: "youtube",
|
|
242
|
+
label: "YouTube video (privacy-enhanced)",
|
|
243
|
+
markers: ["www.youtube-nocookie.com/embed/"],
|
|
244
|
+
origins: ["https://www.youtube-nocookie.com"],
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
slug: "vimeo",
|
|
248
|
+
label: "Vimeo video",
|
|
249
|
+
markers: ["player.vimeo.com/video/"],
|
|
250
|
+
origins: ["https://player.vimeo.com"],
|
|
251
|
+
},
|
|
295
252
|
];
|
|
296
253
|
const KNOWN_EMBED_SLUGS = new Set(KNOWN_EMBEDS.map((p) => p.slug));
|
|
297
254
|
const EMBED_SRC_RE = /<(?:script|iframe)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi;
|
|
@@ -1318,10 +1275,15 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1318
1275
|
// provider not opted into embeds.json, or an unknown external <script>/<iframe>
|
|
1319
1276
|
// origin, is blocking; advisory (info) when there's no embeds.json to compare.
|
|
1320
1277
|
{
|
|
1321
|
-
|
|
1278
|
+
// The opt-in is the `embeds` .tot/config.json declares, plus an embeds.json.
|
|
1279
|
+
let declaredEmbeds = Array.isArray(config?.embeds)
|
|
1280
|
+
? new Set(config.embeds.filter((/** @type {unknown} */ s) => typeof s === "string"))
|
|
1281
|
+
: null;
|
|
1322
1282
|
if (existsSync(embedsPath)) {
|
|
1323
1283
|
const { value } = readJsonSafe(embedsPath);
|
|
1324
|
-
if (Array.isArray(value))
|
|
1284
|
+
if (Array.isArray(value)) {
|
|
1285
|
+
declaredEmbeds = new Set([...(declaredEmbeds ?? []), ...value.filter((s) => typeof s === "string")]);
|
|
1286
|
+
}
|
|
1325
1287
|
}
|
|
1326
1288
|
for (const file of walk(contentDir, (p) => p.endsWith(".html"))) {
|
|
1327
1289
|
const { knownSlugs, unknownOrigins } = scanHtmlForEmbeds(readFileSync(file, "utf8"));
|