@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.5.6",
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[];
@@ -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 function isRegulatedCompliance(compliance) {
200
- const minAge = compliance?.minAge;
201
- return (typeof minAge === "number" && minAge > 0) || !!compliance?.nicotineWarning;
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
- if (!isRegulatedCompliance(compliance)) return out;
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: "Age-restricted/nicotine products require `compliance.minAge` (18 or higher) — none is set.",
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 (!declaresJurisdictionNotice(compliance)) {
233
- out.jurisdiction = {
234
- field: "compliance.pactAct",
235
- detail:
236
- "Regulated products require a shipping/jurisdiction notice: set `compliance.pactAct: true` " +
237
- "or `compliance.shippingRestriction` — neither is set.",
238
- hint:
239
- "Set `compliance.pactAct: true` for the platform's PACT Act notice, or " +
240
- "`compliance.shippingRestriction` to your own restriction text, in .tot/config.json.",
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 (compliance?.minAge && capabilities.ageVerification?.enabled === false) {
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 (compliance?.nicotineWarning && capabilities.exciseTax?.enabled === false) {
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 (isRegulatedCompliance(compliance) && !compliance?.ruleProfile) {
386
+ if (isRegulatedObligationSet(obligations) && !compliance?.ruleProfile) {
276
387
  out.push(mk(WARN, "compliance-rule-profile", `${file} compliance`,
277
- "This store is regulated (`compliance.minAge`/`compliance.nicotineWarning`) but declares no " +
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 compliance = config?.compliance || {};
296
+ const owed = new Set(declaredObligations(config?.compliance).map((o) => o.id));
296
297
  return {
297
- ageVerification: !!compliance.minAge,
298
- exciseTax: !!compliance.nicotineWarning,
298
+ ageVerification: owed.has("age_gate"),
299
+ exciseTax: owed.has("excise"),
299
300
  };
300
301
  }
301
302
 
302
303
  function rawHtmlAllowed(config) {
303
- const obligations = complianceObligations(config);
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
  }