@tokenoftrust/cli 2.5.6 → 2.5.7
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.d.mts +11 -0
- package/src/declared-config.mjs +134 -23
- package/src/validate.mjs +7 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.7",
|
|
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",
|
|
@@ -32,10 +32,21 @@ export function declarationFindings(
|
|
|
32
32
|
current: Record<string, unknown> | null | undefined,
|
|
33
33
|
previous: Record<string, unknown> | null | undefined,
|
|
34
34
|
): string[];
|
|
35
|
+
export interface DeclaredObligation {
|
|
36
|
+
id: string;
|
|
37
|
+
surface: string;
|
|
38
|
+
}
|
|
39
|
+
export const COMPLIANCE_OBLIGATIONS: readonly Readonly<{ id: string; surface: string; keys: string[] }>[];
|
|
40
|
+
export function declaredObligations(compliance: object | null | undefined): DeclaredObligation[];
|
|
35
41
|
export function isRegulatedCompliance(compliance: object | null | undefined): boolean;
|
|
42
|
+
export const COHERENCE_CHECK_IDS: readonly ("age_gate" | "excise" | "jurisdiction")[];
|
|
43
|
+
export const OBLIGATION_EVIDENCE_CHECK: Readonly<Record<string, "age_gate" | "excise" | "jurisdiction" | null>>;
|
|
44
|
+
export function isRegulatedObligationSet(obligations: readonly DeclaredObligation[]): boolean;
|
|
45
|
+
export function coherenceChecksFor(obligations: readonly DeclaredObligation[]): ("age_gate" | "excise" | "jurisdiction")[];
|
|
36
46
|
export function declaresJurisdictionNotice(compliance: object | null | undefined): boolean;
|
|
37
47
|
export function complianceCoherenceFailures(
|
|
38
48
|
compliance: object | null | undefined,
|
|
49
|
+
obligations?: readonly DeclaredObligation[],
|
|
39
50
|
): Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string; detail: string; hint: string }>>;
|
|
40
51
|
export function complianceFindings(config: Record<string, unknown>, file?: string): DeclaredConfigFinding[];
|
|
41
52
|
export function validateDeclaredConfig(config: Record<string, unknown>, file?: string): DeclaredConfigFinding[];
|
package/src/declared-config.mjs
CHANGED
|
@@ -189,6 +189,40 @@ const WARN = "warn";
|
|
|
189
189
|
/** @type {(v: unknown) => v is Record<string, any>} */
|
|
190
190
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The platform's compliance obligations — the registry in
|
|
194
|
+
* packages/public-runtime/src/compliance-obligations.ts, mirrored here because this file
|
|
195
|
+
* is dependency-free: each duty's id, its compliance-authority surface, and the declared
|
|
196
|
+
* `compliance.*` keys that switch it on. packages/private-controlplane/tests/
|
|
197
|
+
* compliance-obligation-checks.test.ts pins this list to the registry, key for key.
|
|
198
|
+
*/
|
|
199
|
+
export const COMPLIANCE_OBLIGATIONS = Object.freeze([
|
|
200
|
+
{ id: "age_gate", surface: "regulated", keys: ["minAge"] },
|
|
201
|
+
{ id: "excise", surface: "regulated", keys: ["nicotineWarning", "exciseTax"] },
|
|
202
|
+
{ id: "nicotine_warning", surface: "regulated", keys: ["nicotineWarning"] },
|
|
203
|
+
{ id: "pact_act", surface: "regulated", keys: ["pactAct"] },
|
|
204
|
+
{ id: "adult_signature", surface: "regulated", keys: ["adultSignature"] },
|
|
205
|
+
{ id: "prop65", surface: "commerce", keys: ["prop65"] },
|
|
206
|
+
{ id: "jurisdiction", surface: "commerce", keys: ["stateEligibility", "shippingRestriction"] },
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The obligations a declared compliance block switches on, as `resolveObligations` returns
|
|
211
|
+
* them (id + surface), in registry order.
|
|
212
|
+
* @param {Record<string, any> | null | undefined} compliance
|
|
213
|
+
* @returns {{ id: string, surface: string }[]}
|
|
214
|
+
*/
|
|
215
|
+
export function declaredObligations(compliance) {
|
|
216
|
+
return COMPLIANCE_OBLIGATIONS
|
|
217
|
+
.filter((o) => o.keys.some((key) => !!compliance?.[key]))
|
|
218
|
+
.map(({ id, surface }) => ({ id, surface }));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** True when the declared block puts the store on the regulated surface. */
|
|
222
|
+
export function isRegulatedCompliance(compliance) {
|
|
223
|
+
return isRegulatedObligationSet(declaredObligations(compliance));
|
|
224
|
+
}
|
|
225
|
+
|
|
192
226
|
/**
|
|
193
227
|
* Compliance coherence — the declared-config half of the hosted preview's `age_gate`,
|
|
194
228
|
* `excise` and `jurisdiction` evidence checks, so `tot validate` fails locally on
|
|
@@ -196,9 +230,42 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
|
196
230
|
* packages/public-runtime/src/compliance-coherence.mjs (the platform's single source);
|
|
197
231
|
* test/declared-config-parity.test.mjs pins the two together.
|
|
198
232
|
*/
|
|
199
|
-
export
|
|
200
|
-
|
|
201
|
-
|
|
233
|
+
export const COHERENCE_CHECK_IDS = Object.freeze(/** @type {const} */ (["age_gate", "excise", "jurisdiction"]));
|
|
234
|
+
|
|
235
|
+
export const OBLIGATION_EVIDENCE_CHECK = Object.freeze({
|
|
236
|
+
age_gate: "age_gate",
|
|
237
|
+
excise: "excise",
|
|
238
|
+
nicotine_warning: "excise",
|
|
239
|
+
pact_act: "jurisdiction",
|
|
240
|
+
adult_signature: "jurisdiction",
|
|
241
|
+
jurisdiction: "jurisdiction",
|
|
242
|
+
prop65: null,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/** @param {readonly { id: string, surface: string }[]} obligations */
|
|
246
|
+
export function isRegulatedObligationSet(obligations) {
|
|
247
|
+
return obligations.some((o) => o.surface === "regulated");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {readonly { id: string, surface: string }[]} obligations
|
|
252
|
+
* @returns {("age_gate" | "excise" | "jurisdiction")[]}
|
|
253
|
+
*/
|
|
254
|
+
export function coherenceChecksFor(obligations) {
|
|
255
|
+
const out = new Set();
|
|
256
|
+
if (isRegulatedObligationSet(obligations)) {
|
|
257
|
+
out.add("age_gate");
|
|
258
|
+
out.add("jurisdiction");
|
|
259
|
+
}
|
|
260
|
+
for (const { id } of obligations) {
|
|
261
|
+
if (!Object.hasOwn(OBLIGATION_EVIDENCE_CHECK, id)) {
|
|
262
|
+
for (const check of COHERENCE_CHECK_IDS) out.add(check);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const check = OBLIGATION_EVIDENCE_CHECK[/** @type {keyof typeof OBLIGATION_EVIDENCE_CHECK} */ (id)];
|
|
266
|
+
if (check) out.add(check);
|
|
267
|
+
}
|
|
268
|
+
return COHERENCE_CHECK_IDS.filter((c) => out.has(c));
|
|
202
269
|
}
|
|
203
270
|
|
|
204
271
|
export function declaresJurisdictionNotice(compliance) {
|
|
@@ -206,19 +273,48 @@ export function declaresJurisdictionNotice(compliance) {
|
|
|
206
273
|
return compliance?.pactAct === true || (typeof text === "string" && text.trim().length > 0);
|
|
207
274
|
}
|
|
208
275
|
|
|
276
|
+
/** @param {unknown} declaration */
|
|
277
|
+
function stateEligibilityHasContent(declaration) {
|
|
278
|
+
if (declaration === null || typeof declaration !== "object" || Array.isArray(declaration)) return false;
|
|
279
|
+
const d = /** @type {Record<string, unknown>} */ (declaration);
|
|
280
|
+
const listed = (v) => Array.isArray(v) && v.some((s) => typeof s === "string" && s.trim().length > 0);
|
|
281
|
+
return listed(d.restrictedStates) || listed(d.eligibleStates) || (typeof d.note === "string" && d.note.trim().length > 0);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const REGULATED_KEY_FOR = Object.freeze({
|
|
285
|
+
age_gate: "minAge",
|
|
286
|
+
excise: "exciseTax",
|
|
287
|
+
nicotine_warning: "nicotineWarning",
|
|
288
|
+
pact_act: "pactAct",
|
|
289
|
+
adult_signature: "adultSignature",
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
function regulatedBy(compliance, obligations) {
|
|
293
|
+
const keys = new Set();
|
|
294
|
+
for (const o of obligations) {
|
|
295
|
+
if (o.surface !== "regulated") continue;
|
|
296
|
+
const key = REGULATED_KEY_FOR[/** @type {keyof typeof REGULATED_KEY_FOR} */ (o.id)];
|
|
297
|
+
if (o.id === "excise" && !compliance?.exciseTax) continue;
|
|
298
|
+
if (key) keys.add(key);
|
|
299
|
+
}
|
|
300
|
+
return [...keys].map((k) => `\`compliance.${k}\``).join(", ") || "a regulated obligation";
|
|
301
|
+
}
|
|
302
|
+
|
|
209
303
|
/**
|
|
210
304
|
* @param {Record<string, any> | null | undefined} compliance
|
|
305
|
+
* @param {readonly { id: string, surface: string }[]} [obligations] defaults to what the block declares
|
|
211
306
|
* @returns {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>}
|
|
212
307
|
*/
|
|
213
|
-
export function complianceCoherenceFailures(compliance) {
|
|
308
|
+
export function complianceCoherenceFailures(compliance, obligations = declaredObligations(compliance)) {
|
|
214
309
|
/** @type {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>} */
|
|
215
310
|
const out = {};
|
|
216
|
-
|
|
311
|
+
const checks = coherenceChecksFor(obligations);
|
|
312
|
+
const regulated = isRegulatedObligationSet(obligations);
|
|
217
313
|
const minAge = typeof compliance?.minAge === "number" ? compliance.minAge : null;
|
|
218
|
-
if (minAge == null || minAge < 18) {
|
|
314
|
+
if (checks.includes("age_gate") && regulated && (minAge == null || minAge < 18)) {
|
|
219
315
|
out.age_gate = {
|
|
220
316
|
field: "compliance.minAge",
|
|
221
|
-
detail:
|
|
317
|
+
detail: `Age-restricted products (${regulatedBy(compliance, obligations)}) require \`compliance.minAge\` (18 or higher) — none is set.`,
|
|
222
318
|
hint: "Set `compliance.minAge` (18+) in .tot/config.json.",
|
|
223
319
|
};
|
|
224
320
|
}
|
|
@@ -229,16 +325,29 @@ export function complianceCoherenceFailures(compliance) {
|
|
|
229
325
|
hint: "Set `compliance.minAge` to 21 in .tot/config.json (nicotine requires 21+, not just 18+).",
|
|
230
326
|
};
|
|
231
327
|
}
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
328
|
+
if (checks.includes("jurisdiction")) {
|
|
329
|
+
if (regulated && !declaresJurisdictionNotice(compliance)) {
|
|
330
|
+
out.jurisdiction = {
|
|
331
|
+
field: "compliance.pactAct",
|
|
332
|
+
detail:
|
|
333
|
+
`Regulated products (${regulatedBy(compliance, obligations)}) require a shipping/jurisdiction notice: ` +
|
|
334
|
+
"set `compliance.pactAct: true` or `compliance.shippingRestriction` — neither is set.",
|
|
335
|
+
hint:
|
|
336
|
+
"Set `compliance.pactAct: true` for the platform's PACT Act notice, or " +
|
|
337
|
+
"`compliance.shippingRestriction` to your own restriction text, in .tot/config.json.",
|
|
338
|
+
};
|
|
339
|
+
} else if (!regulated && !declaresJurisdictionNotice(compliance)
|
|
340
|
+
&& !stateEligibilityHasContent(compliance?.stateEligibility)) {
|
|
341
|
+
const field = compliance?.stateEligibility ? "compliance.stateEligibility" : "compliance.shippingRestriction";
|
|
342
|
+
out.jurisdiction = {
|
|
343
|
+
field,
|
|
344
|
+
detail:
|
|
345
|
+
`\`${field}\` is declared but gives its shipping/jurisdiction notice nothing to say, so no notice renders.`,
|
|
346
|
+
hint:
|
|
347
|
+
"Set `compliance.shippingRestriction` to your restriction text, or give `compliance.stateEligibility` " +
|
|
348
|
+
"`restrictedStates`, `eligibleStates` or a `note`, in .tot/config.json.",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
242
351
|
}
|
|
243
352
|
return out;
|
|
244
353
|
}
|
|
@@ -254,27 +363,29 @@ export function complianceCoherenceFailures(compliance) {
|
|
|
254
363
|
*/
|
|
255
364
|
export function complianceFindings(config, file = ".tot/config.json") {
|
|
256
365
|
const compliance = isObject(config?.compliance) ? config.compliance : null;
|
|
366
|
+
const obligations = declaredObligations(compliance);
|
|
367
|
+
const owed = new Set(obligations.map((o) => o.id));
|
|
257
368
|
const out = [];
|
|
258
|
-
for (const [check, failure] of Object.entries(complianceCoherenceFailures(compliance))) {
|
|
369
|
+
for (const [check, failure] of Object.entries(complianceCoherenceFailures(compliance, obligations))) {
|
|
259
370
|
out.push(mk(ERROR, `compliance-${check.replace("_", "-")}`, `${file} compliance`,
|
|
260
371
|
`${failure.detail} The hosted preview fails its \`${check}\` check on this.`, failure.hint));
|
|
261
372
|
}
|
|
262
373
|
// The hosted reconcile refuses a declaration whose `capabilities` switch off the
|
|
263
374
|
// compliance floor (packages/public-runtime/src/capabilities.ts capabilityFloorViolations).
|
|
264
375
|
const capabilities = isObject(config?.capabilities) ? config.capabilities : {};
|
|
265
|
-
if (
|
|
376
|
+
if (owed.has("age_gate") && capabilities.ageVerification?.enabled === false) {
|
|
266
377
|
out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
|
|
267
378
|
"`capabilities.ageVerification` cannot be disabled for a store with `compliance.minAge` — the hosted preview refuses this revision.",
|
|
268
379
|
"Remove `capabilities.ageVerification.enabled: false` from .tot/config.json."));
|
|
269
380
|
}
|
|
270
|
-
if (
|
|
381
|
+
if (owed.has("excise") && capabilities.exciseTax?.enabled === false) {
|
|
271
382
|
out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
|
|
272
|
-
"`capabilities.exciseTax` cannot be disabled for a store with `compliance.nicotineWarning` — the hosted preview refuses this revision.",
|
|
383
|
+
"`capabilities.exciseTax` cannot be disabled for a store with `compliance.nicotineWarning` or `compliance.exciseTax` — the hosted preview refuses this revision.",
|
|
273
384
|
"Remove `capabilities.exciseTax.enabled: false` from .tot/config.json."));
|
|
274
385
|
}
|
|
275
|
-
if (
|
|
386
|
+
if (isRegulatedObligationSet(obligations) && !compliance?.ruleProfile) {
|
|
276
387
|
out.push(mk(WARN, "compliance-rule-profile", `${file} compliance`,
|
|
277
|
-
|
|
388
|
+
`This store is regulated (${regulatedBy(compliance, obligations)}) but declares no ` +
|
|
278
389
|
"`compliance.ruleProfile`, so no checkout rules resolve: the preview renders, but checkout blocks " +
|
|
279
390
|
"and going live refuses.",
|
|
280
391
|
'Declare `compliance.ruleProfile` (e.g. "vape-nicotine-us") in .tot/config.json.'));
|
package/src/validate.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
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
|
+
import { declaredObligations, isRegulatedCompliance, validateDeclaredConfig } from "./declared-config.mjs";
|
|
21
21
|
import {
|
|
22
22
|
foreignTenantSegments,
|
|
23
23
|
parseMappingTarget,
|
|
@@ -291,18 +291,17 @@ function validateEmbedsDoc(doc, file) {
|
|
|
291
291
|
return out;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
// What the obligation registry says this store owes (declared-config.mjs mirrors it).
|
|
294
295
|
function complianceObligations(config) {
|
|
295
|
-
const
|
|
296
|
+
const owed = new Set(declaredObligations(config?.compliance).map((o) => o.id));
|
|
296
297
|
return {
|
|
297
|
-
ageVerification:
|
|
298
|
-
exciseTax:
|
|
298
|
+
ageVerification: owed.has("age_gate"),
|
|
299
|
+
exciseTax: owed.has("excise"),
|
|
299
300
|
};
|
|
300
301
|
}
|
|
301
302
|
|
|
302
303
|
function rawHtmlAllowed(config) {
|
|
303
|
-
|
|
304
|
-
const regulated = obligations.ageVerification || obligations.exciseTax;
|
|
305
|
-
return !regulated;
|
|
304
|
+
return !isRegulatedCompliance(config?.compliance);
|
|
306
305
|
}
|
|
307
306
|
|
|
308
307
|
function validateCapabilitiesDoc(doc, file, config) {
|
|
@@ -325,7 +324,7 @@ function validateCapabilitiesDoc(doc, file, config) {
|
|
|
325
324
|
out.push(mk(ERROR, "capability-compliance-floor", file, "ageVerification cannot be disabled for a tenant with compliance.minAge"));
|
|
326
325
|
}
|
|
327
326
|
if (obligations.exciseTax && doc.exciseTax?.enabled === false) {
|
|
328
|
-
out.push(mk(ERROR, "capability-compliance-floor", file, "exciseTax cannot be disabled for a tenant with compliance.nicotineWarning"));
|
|
327
|
+
out.push(mk(ERROR, "capability-compliance-floor", file, "exciseTax cannot be disabled for a tenant with compliance.nicotineWarning or compliance.exciseTax"));
|
|
329
328
|
}
|
|
330
329
|
return out;
|
|
331
330
|
}
|