@tokenoftrust/cli 2.5.5 → 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/commands/report.mjs +7 -4
- package/src/declared-config.d.mts +14 -0
- package/src/declared-config.mjs +183 -28
- package/src/readiness-checks.mjs +32 -1
- 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",
|
package/src/commands/report.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export const USAGE = `tot report go-live — the go-live readiness report for yo
|
|
|
39
39
|
|
|
40
40
|
Save each page's JSON from the devkit page checks into <dir> first, e.g.
|
|
41
41
|
validate-page.sh <base> / > <dir>/home.json
|
|
42
|
-
validate-page-browser.mjs <base> / --provider cloudflare > <dir>/home.browser.json
|
|
42
|
+
validate-page-browser.mjs <base> / --store . --provider cloudflare > <dir>/home.browser.json
|
|
43
43
|
|
|
44
44
|
${TEASER}`;
|
|
45
45
|
|
|
@@ -147,9 +147,9 @@ export function storeConfigCheck(lint) {
|
|
|
147
147
|
/**
|
|
148
148
|
* Everything the report needs, from a page-results directory. A site-wide run's summary is used as
|
|
149
149
|
* is; otherwise the checks are derived here from the single-page results.
|
|
150
|
-
* @param {{ pagesDir: string, stylePath?: string|null, lint?: any }} input
|
|
150
|
+
* @param {{ pagesDir: string, stylePath?: string|null, lint?: any, config?: Record<string, any>|null }} input the store checkout's lint and `.tot/config.json`
|
|
151
151
|
*/
|
|
152
|
-
export function evidenceFromDir({ pagesDir, stylePath = null, lint = null }) {
|
|
152
|
+
export function evidenceFromDir({ pagesDir, stylePath = null, lint = null, config = null }) {
|
|
153
153
|
const summary = readJson(join(pagesDir, "site-summary.json"));
|
|
154
154
|
const styleJson = readJson(stylePath ?? join(pagesDir, "style.json"));
|
|
155
155
|
const browserDir = join(pagesDir, "browser");
|
|
@@ -177,6 +177,7 @@ export function evidenceFromDir({ pagesDir, stylePath = null, lint = null }) {
|
|
|
177
177
|
aggregate: { ...summary, totals: { ...summary.totals, issues: summary.totals?.issues ?? { lowRiskOpen: 0 } } },
|
|
178
178
|
styleJson: styleJson ?? (summary.style?.gates ? { gates: summary.style.gates } : null),
|
|
179
179
|
args: { manifest: summary.manifest ?? "(none supplied to the style-concept audit)" },
|
|
180
|
+
config,
|
|
180
181
|
}),
|
|
181
182
|
];
|
|
182
183
|
return {
|
|
@@ -195,6 +196,7 @@ export function evidenceFromDir({ pagesDir, stylePath = null, lint = null }) {
|
|
|
195
196
|
aggregate: { pages: results, totals: { issues: { lowRiskOpen: 0 } } },
|
|
196
197
|
styleJson,
|
|
197
198
|
args: { manifest: "(none supplied to the style-concept audit)" },
|
|
199
|
+
config,
|
|
198
200
|
}).filter((c) => c.area !== "Low-risk fix loop"),
|
|
199
201
|
];
|
|
200
202
|
const failing = results.filter((r) => Object.values(r.gates).some((v) => v === false)).length;
|
|
@@ -288,7 +290,8 @@ export function run(argv, ctx, io = {}) {
|
|
|
288
290
|
return 2;
|
|
289
291
|
}
|
|
290
292
|
const lint = storeExists ? validateTenant(store, { tenantId: tenant, mode: args.workspace ? "workspace" : "monorepo" }) : null;
|
|
291
|
-
const
|
|
293
|
+
const config = storeExists ? readJson(join(store, ".tot", "config.json")) : null;
|
|
294
|
+
const evidence = evidenceFromDir({ pagesDir, stylePath: args.style ? resolve(args.style) : null, lint, config });
|
|
292
295
|
if (!evidence.measured) {
|
|
293
296
|
err(`${fail(
|
|
294
297
|
`no page-check results under ${pagesDir}`,
|
|
@@ -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[];
|
|
@@ -59,7 +70,10 @@ export const NICOTINE_WARNING_TEXT: string;
|
|
|
59
70
|
export const CONSPICUITY_FLOOR: Readonly<{ contrast: number; fontSizePx: number; fontWeight: number }>;
|
|
60
71
|
export const DEFAULT_NICOTINE_WARNING_PRESET: string;
|
|
61
72
|
export const NICOTINE_WARNING_PRESETS: Readonly<Record<string, Readonly<NicotineWarningPresentation>>>;
|
|
73
|
+
export const NICOTINE_WARNING_KEYS: readonly string[];
|
|
62
74
|
export const NICOTINE_WARNING_STYLE_KEYS: readonly string[];
|
|
75
|
+
export const NICOTINE_WARNING_CHECK_WIDTH_BOUNDS: Readonly<{ min: number; max: number }>;
|
|
76
|
+
export function nicotineWarningCheckWidths(declaration: unknown): { widths: number[]; problems: string[] };
|
|
63
77
|
export function contrastRatio(a: string, b: string): number;
|
|
64
78
|
export function conspicuityProblems(p: NicotineWarningPresentation): string[];
|
|
65
79
|
export function resolveNicotineWarning(declaration: unknown): ResolvedNicotineWarning;
|
package/src/declared-config.mjs
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Mirrors packages/public-runtime/src/declared-tenant-config.mjs (this package ships
|
|
14
14
|
* to npm on its own); test/declared-config-parity.test.mjs pins the two together.
|
|
15
|
-
* Dependency-free: the storefront runner copies this file as-is
|
|
15
|
+
* Dependency-free: the storefront runner copies this file as-is, and the devkit page checks carry
|
|
16
|
+
* a byte-identical copy (docs/devkit-staging/tools/lib/declared-config.mjs, pinned by
|
|
17
|
+
* test/declared-config-devkit-copy.test.mjs) so their gates read a store's declaration the same way.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
export const TOT_CONFIG_KEYS = [
|
|
@@ -184,8 +186,43 @@ export const MANDATED_NOTICE_KEYS = [
|
|
|
184
186
|
|
|
185
187
|
const ERROR = "error";
|
|
186
188
|
const WARN = "warn";
|
|
189
|
+
/** @type {(v: unknown) => v is Record<string, any>} */
|
|
187
190
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
188
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
|
+
|
|
189
226
|
/**
|
|
190
227
|
* Compliance coherence — the declared-config half of the hosted preview's `age_gate`,
|
|
191
228
|
* `excise` and `jurisdiction` evidence checks, so `tot validate` fails locally on
|
|
@@ -193,9 +230,42 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
|
193
230
|
* packages/public-runtime/src/compliance-coherence.mjs (the platform's single source);
|
|
194
231
|
* test/declared-config-parity.test.mjs pins the two together.
|
|
195
232
|
*/
|
|
196
|
-
export
|
|
197
|
-
|
|
198
|
-
|
|
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));
|
|
199
269
|
}
|
|
200
270
|
|
|
201
271
|
export function declaresJurisdictionNotice(compliance) {
|
|
@@ -203,19 +273,48 @@ export function declaresJurisdictionNotice(compliance) {
|
|
|
203
273
|
return compliance?.pactAct === true || (typeof text === "string" && text.trim().length > 0);
|
|
204
274
|
}
|
|
205
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
|
+
|
|
206
303
|
/**
|
|
207
304
|
* @param {Record<string, any> | null | undefined} compliance
|
|
305
|
+
* @param {readonly { id: string, surface: string }[]} [obligations] defaults to what the block declares
|
|
208
306
|
* @returns {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>}
|
|
209
307
|
*/
|
|
210
|
-
export function complianceCoherenceFailures(compliance) {
|
|
308
|
+
export function complianceCoherenceFailures(compliance, obligations = declaredObligations(compliance)) {
|
|
211
309
|
/** @type {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>} */
|
|
212
310
|
const out = {};
|
|
213
|
-
|
|
311
|
+
const checks = coherenceChecksFor(obligations);
|
|
312
|
+
const regulated = isRegulatedObligationSet(obligations);
|
|
214
313
|
const minAge = typeof compliance?.minAge === "number" ? compliance.minAge : null;
|
|
215
|
-
if (minAge == null || minAge < 18) {
|
|
314
|
+
if (checks.includes("age_gate") && regulated && (minAge == null || minAge < 18)) {
|
|
216
315
|
out.age_gate = {
|
|
217
316
|
field: "compliance.minAge",
|
|
218
|
-
detail:
|
|
317
|
+
detail: `Age-restricted products (${regulatedBy(compliance, obligations)}) require \`compliance.minAge\` (18 or higher) — none is set.`,
|
|
219
318
|
hint: "Set `compliance.minAge` (18+) in .tot/config.json.",
|
|
220
319
|
};
|
|
221
320
|
}
|
|
@@ -226,16 +325,29 @@ export function complianceCoherenceFailures(compliance) {
|
|
|
226
325
|
hint: "Set `compliance.minAge` to 21 in .tot/config.json (nicotine requires 21+, not just 18+).",
|
|
227
326
|
};
|
|
228
327
|
}
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
}
|
|
239
351
|
}
|
|
240
352
|
return out;
|
|
241
353
|
}
|
|
@@ -251,27 +363,29 @@ export function complianceCoherenceFailures(compliance) {
|
|
|
251
363
|
*/
|
|
252
364
|
export function complianceFindings(config, file = ".tot/config.json") {
|
|
253
365
|
const compliance = isObject(config?.compliance) ? config.compliance : null;
|
|
366
|
+
const obligations = declaredObligations(compliance);
|
|
367
|
+
const owed = new Set(obligations.map((o) => o.id));
|
|
254
368
|
const out = [];
|
|
255
|
-
for (const [check, failure] of Object.entries(complianceCoherenceFailures(compliance))) {
|
|
369
|
+
for (const [check, failure] of Object.entries(complianceCoherenceFailures(compliance, obligations))) {
|
|
256
370
|
out.push(mk(ERROR, `compliance-${check.replace("_", "-")}`, `${file} compliance`,
|
|
257
371
|
`${failure.detail} The hosted preview fails its \`${check}\` check on this.`, failure.hint));
|
|
258
372
|
}
|
|
259
373
|
// The hosted reconcile refuses a declaration whose `capabilities` switch off the
|
|
260
374
|
// compliance floor (packages/public-runtime/src/capabilities.ts capabilityFloorViolations).
|
|
261
375
|
const capabilities = isObject(config?.capabilities) ? config.capabilities : {};
|
|
262
|
-
if (
|
|
376
|
+
if (owed.has("age_gate") && capabilities.ageVerification?.enabled === false) {
|
|
263
377
|
out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
|
|
264
378
|
"`capabilities.ageVerification` cannot be disabled for a store with `compliance.minAge` — the hosted preview refuses this revision.",
|
|
265
379
|
"Remove `capabilities.ageVerification.enabled: false` from .tot/config.json."));
|
|
266
380
|
}
|
|
267
|
-
if (
|
|
381
|
+
if (owed.has("excise") && capabilities.exciseTax?.enabled === false) {
|
|
268
382
|
out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
|
|
269
|
-
"`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.",
|
|
270
384
|
"Remove `capabilities.exciseTax.enabled: false` from .tot/config.json."));
|
|
271
385
|
}
|
|
272
|
-
if (
|
|
386
|
+
if (isRegulatedObligationSet(obligations) && !compliance?.ruleProfile) {
|
|
273
387
|
out.push(mk(WARN, "compliance-rule-profile", `${file} compliance`,
|
|
274
|
-
|
|
388
|
+
`This store is regulated (${regulatedBy(compliance, obligations)}) but declares no ` +
|
|
275
389
|
"`compliance.ruleProfile`, so no checkout rules resolve: the preview renders, but checkout blocks " +
|
|
276
390
|
"and going live refuses.",
|
|
277
391
|
'Declare `compliance.ruleProfile` (e.g. "vape-nicotine-us") in .tot/config.json.'));
|
|
@@ -470,7 +584,13 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
470
584
|
}
|
|
471
585
|
}
|
|
472
586
|
out.push(...complianceFindings(config, file));
|
|
473
|
-
const
|
|
587
|
+
const declaration = isObject(config.compliance) ? config.compliance.nicotineWarning : undefined;
|
|
588
|
+
for (const problem of nicotineWarningCheckWidths(declaration).problems) {
|
|
589
|
+
out.push(mk(ERROR, "config-nicotine-warning-check-widths", `${file} compliance`,
|
|
590
|
+
`\`compliance.nicotineWarning\`: ${problem}. The page browser gate cannot check the warning at the widths you meant.`,
|
|
591
|
+
"list the widths where your layout changes (e.g. where your header becomes fixed), or remove `checkWidthsPx`"));
|
|
592
|
+
}
|
|
593
|
+
const warning = resolveNicotineWarning(declaration);
|
|
474
594
|
if (warning.enabled && warning.problems.length > 0) {
|
|
475
595
|
out.push(mk(ERROR, "config-nicotine-warning", `${file} compliance`, nicotineWarningProblemMessage(warning.problems),
|
|
476
596
|
'use `true`, `{ "preset": "dark" }` or `{ "preset": "light" }`, or fix the style values named above'));
|
|
@@ -504,6 +624,7 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
504
624
|
// "nicotineWarning": true → the "dark" preset
|
|
505
625
|
// "nicotineWarning": { "preset": "light" } → a validated preset
|
|
506
626
|
// "nicotineWarning": { "preset": "light", "style": {...} } → a custom presentation
|
|
627
|
+
// "nicotineWarning": { "checkWidthsPx": [800] } → also checked seen at 800px wide
|
|
507
628
|
//
|
|
508
629
|
// Every presentation — preset or custom — must clear CONSPICUITY_FLOOR. A preset needs no
|
|
509
630
|
// review. A custom presentation is a deviation the owner approves at go-live:
|
|
@@ -562,6 +683,9 @@ export const NICOTINE_WARNING_PRESETS = Object.freeze({
|
|
|
562
683
|
* }} NicotineWarningPresentation
|
|
563
684
|
*/
|
|
564
685
|
|
|
686
|
+
/** Every key a `compliance.nicotineWarning` object may declare. */
|
|
687
|
+
export const NICOTINE_WARNING_KEYS = Object.freeze(["preset", "style", "checkWidthsPx"]);
|
|
688
|
+
|
|
565
689
|
export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
|
|
566
690
|
"background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
|
|
567
691
|
]);
|
|
@@ -669,8 +793,9 @@ export function resolveNicotineWarning(declaration) {
|
|
|
669
793
|
if (!isObject(declaration)) {
|
|
670
794
|
return resolved({ problems: ["must be `true` or an object like { \"preset\": \"light\" }"] });
|
|
671
795
|
}
|
|
672
|
-
const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, ...rest } =
|
|
673
|
-
|
|
796
|
+
const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, checkWidthsPx: _checkWidths, ...rest } =
|
|
797
|
+
/** @type {Record<string, unknown>} */ (declaration);
|
|
798
|
+
const problems = Object.keys(rest).map((k) => `\`${k}\` is not a nicotineWarning key (allowed: ${NICOTINE_WARNING_KEYS.join(", ")})`);
|
|
674
799
|
const base = typeof presetName === "string" ? NICOTINE_WARNING_PRESETS[presetName] : undefined;
|
|
675
800
|
if (!base) {
|
|
676
801
|
problems.push(`\`preset\` must be one of ${Object.keys(NICOTINE_WARNING_PRESETS).map((p) => `"${p}"`).join(", ")}`);
|
|
@@ -695,6 +820,35 @@ export function resolveNicotineWarning(declaration) {
|
|
|
695
820
|
return resolved({ presentation, preset: matching ?? null, custom: !matching });
|
|
696
821
|
}
|
|
697
822
|
|
|
823
|
+
/** The viewport widths a store may add to the page browser gate's nicotine-warning check. */
|
|
824
|
+
export const NICOTINE_WARNING_CHECK_WIDTH_BOUNDS = Object.freeze({ min: 320, max: 3840 });
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The extra viewport widths at which the page browser gate checks the nicotine warning is seen.
|
|
828
|
+
*
|
|
829
|
+
* The gate always checks its own phone, tablet, laptop and desktop widths; `checkWidthsPx` adds
|
|
830
|
+
* the store's own breakpoints (e.g. the width where its header becomes fixed) and never removes
|
|
831
|
+
* a default. A store that does not declare the warning has no widths; `true` and an object
|
|
832
|
+
* without `checkWidthsPx` add none.
|
|
833
|
+
*
|
|
834
|
+
* "nicotineWarning": { "preset": "dark", "checkWidthsPx": [800, 1180] }
|
|
835
|
+
*
|
|
836
|
+
* `problems` lists why a declared value is unusable, in which case no extra width is used.
|
|
837
|
+
* @param {unknown} declaration the store's `compliance.nicotineWarning`
|
|
838
|
+
* @returns {{ widths: number[], problems: string[] }}
|
|
839
|
+
*/
|
|
840
|
+
export function nicotineWarningCheckWidths(declaration) {
|
|
841
|
+
if (!isObject(declaration)) return { widths: [], problems: [] };
|
|
842
|
+
const value = /** @type {Record<string, unknown>} */ (declaration).checkWidthsPx;
|
|
843
|
+
if (value === undefined) return { widths: [], problems: [] };
|
|
844
|
+
const { min, max } = NICOTINE_WARNING_CHECK_WIDTH_BOUNDS;
|
|
845
|
+
const usable = (/** @type {unknown} */ w) => Number.isInteger(w) && /** @type {number} */ (w) >= min && /** @type {number} */ (w) <= max;
|
|
846
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every(usable)) {
|
|
847
|
+
return { widths: [], problems: [`\`checkWidthsPx\` must be a non-empty array of whole pixel widths from ${min} to ${max}, e.g. [800]`] };
|
|
848
|
+
}
|
|
849
|
+
return { widths: [...new Set(/** @type {number[]} */ (value))].sort((a, b) => a - b), problems: [] };
|
|
850
|
+
}
|
|
851
|
+
|
|
698
852
|
/**
|
|
699
853
|
* The exact, human-readable statement of a custom presentation — what the owner approves.
|
|
700
854
|
* Every resolved value is in it, so two presentations that differ in any value describe
|
|
@@ -720,7 +874,8 @@ export function describeNicotineWarningPresentation(p) {
|
|
|
720
874
|
* fixed or sticky header pinned to the top of the viewport). Raising the warning above page
|
|
721
875
|
* chrome would paint it over the tenant's own open menus and drawers, so occlusion is measured
|
|
722
876
|
* instead: the page browser gate (`validate-page-browser.mjs`, `nicotineWarning`) fails a page
|
|
723
|
-
* whose warning is covered at a phone, tablet or desktop width
|
|
877
|
+
* whose warning is covered at a phone, tablet or desktop width, or at a width the store declares
|
|
878
|
+
* in `checkWidthsPx`, and names the covering element.
|
|
724
879
|
* @param {unknown} declaration the tenant's `compliance.nicotineWarning`
|
|
725
880
|
* @param {"bar" | "block"} variant
|
|
726
881
|
*/
|
package/src/readiness-checks.mjs
CHANGED
|
@@ -178,8 +178,38 @@ export function ecommerceStyleGuideCheck(styleJson) {
|
|
|
178
178
|
};
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
|
|
181
|
+
/**
|
|
182
|
+
* The store's declared compliance notices, judged on every page. Only a notice the store declares
|
|
183
|
+
* is a row, and a page that did not judge it fails: a declared legal notice nobody has seen on the
|
|
184
|
+
* page is not ready, whatever the page check reported.
|
|
185
|
+
* @param {{ gates?: Record<string, boolean | null> }[]} results
|
|
186
|
+
* @param {Record<string, any> | null | undefined} config the store's `.tot/config.json`
|
|
187
|
+
*/
|
|
188
|
+
export function complianceNoticesCheck(results, config) {
|
|
189
|
+
if (!config?.compliance?.nicotineWarning) return null;
|
|
190
|
+
const counts = countGate(results, "nicotineWarning");
|
|
191
|
+
const problem = counts.total === 0
|
|
192
|
+
? "No pages were discovered for validation."
|
|
193
|
+
: counts.fail > 0 || counts.unknown > 0
|
|
194
|
+
? `The store declares the FDA nicotine warning: ${counts.fail}/${counts.total} page(s) fail the visibility gate` +
|
|
195
|
+
`${counts.unknown > 0 ? ` and ${counts.unknown}/${counts.total} were not judged against the declaration` : ""}.`
|
|
196
|
+
: `The FDA nicotine warning is seen on all ${counts.total} page(s) at every checked width.`;
|
|
197
|
+
return {
|
|
198
|
+
area: "Compliance notices",
|
|
199
|
+
status: counts.total > 0 && counts.fail === 0 && counts.unknown === 0 ? "PASS" : "FAIL",
|
|
200
|
+
why: "A store that declares the FDA nicotine warning must show it, conspicuously and uncovered, on every page (21 CFR 1143.3).",
|
|
201
|
+
problem,
|
|
202
|
+
fix: "Run the browser page checks with --store <your checkout> on every page, then offset any fixed or sticky header the gate names; never restyle or move the warning.",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {{ aggregate: any, styleJson: any, args: { manifest: string }, config?: Record<string, any> | null }} input
|
|
208
|
+
* `config` is the store's `.tot/config.json`, which decides the compliance notices row.
|
|
209
|
+
*/
|
|
210
|
+
export function buildReadinessChecks({ aggregate, styleJson, args, config = null }) {
|
|
182
211
|
const results = aggregate.pages;
|
|
212
|
+
const compliance = complianceNoticesCheck(results, config);
|
|
183
213
|
const render = countGate(results, "render");
|
|
184
214
|
const boundary = countGate(results, "boundary");
|
|
185
215
|
const seo = combineCounts(countGate(results, "seo"), countGate(results, "sitemap"));
|
|
@@ -191,6 +221,7 @@ export function buildReadinessChecks({ aggregate, styleJson, args }) {
|
|
|
191
221
|
const lowRisk = aggregate.totals.issues.lowRiskOpen ?? 0;
|
|
192
222
|
|
|
193
223
|
return [
|
|
224
|
+
...(compliance ? [compliance] : []),
|
|
194
225
|
styleGuideCheck(styleJson, args.manifest),
|
|
195
226
|
ecommerceStyleGuideCheck(styleJson),
|
|
196
227
|
styleQualityCheck(styleJson),
|
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
|
}
|