@tokenoftrust/cli 2.2.17 → 2.3.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/commands/hotfix.mjs +2 -0
- package/src/commands/preview-token.mjs +148 -0
- package/src/commands/preview.mjs +7 -0
- package/src/declared-config.d.mts +65 -0
- package/src/declared-config.mjs +566 -32
- package/src/mcp.mjs +9 -2
- package/src/validate.mjs +48 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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",
|
package/src/commands/hotfix.mjs
CHANGED
|
@@ -180,6 +180,8 @@ export function hotfixNextStep(reason) {
|
|
|
180
180
|
return "re-run `tot hotfix` and confirm the plan";
|
|
181
181
|
case "digest_mismatch":
|
|
182
182
|
return "main moved under review — re-preview the hotfix against current main, then re-run `tot hotfix`";
|
|
183
|
+
case "provider_unsupported":
|
|
184
|
+
return "publish the fix through `tot ship` (Add to release, then Publish) instead";
|
|
183
185
|
default:
|
|
184
186
|
return "re-run `tot hotfix`";
|
|
185
187
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot preview token` — mint a shared-preview `__preview` token for a store you have
|
|
3
|
+
* access to, for validation runs (`validate-site --preview-token`, `parity-check`,
|
|
4
|
+
* Lighthouse against the preview channel).
|
|
5
|
+
*
|
|
6
|
+
* AUTH — your own `tot login`. The CLI exchanges your MCP session for a storefront
|
|
7
|
+
* viewer session (`resolveViewerTransport`), then asks `POST /api/preview/token`,
|
|
8
|
+
* which mints only when that session may already view this store's preview. No
|
|
9
|
+
* operator secret, and no token for a store you cannot see.
|
|
10
|
+
*
|
|
11
|
+
* OUTPUT — the token alone on stdout, so `export PARITY_PREVIEW_TOKEN="$(tot preview
|
|
12
|
+
* token)"` works; its expiry and the commit the preview channel serves go to stderr.
|
|
13
|
+
* `--json` prints `{ token, exp, ttlSeconds, tenantId, previewVersion }` instead.
|
|
14
|
+
* Treat the token like a password: anyone holding it sees the unpublished site.
|
|
15
|
+
*/
|
|
16
|
+
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
17
|
+
|
|
18
|
+
const MAX_TTL_SECONDS = 24 * 3600;
|
|
19
|
+
|
|
20
|
+
/** Parse `tot preview token` argv. Pure — unit-tested. */
|
|
21
|
+
export function parseTokenArgs(argv) {
|
|
22
|
+
const a = { tenant: null, ttl: null, url: null, json: false, help: false };
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const t = argv[i];
|
|
25
|
+
if (t === "--tenant") a.tenant = argv[++i];
|
|
26
|
+
else if (t === "--ttl") a.ttl = argv[++i];
|
|
27
|
+
else if (t === "--url") a.url = argv[++i];
|
|
28
|
+
else if (t === "--json") a.json = true;
|
|
29
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
30
|
+
}
|
|
31
|
+
return a;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderUsage() {
|
|
35
|
+
return `tot preview token — mint a preview token for validation runs
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
tot preview token [--tenant <appDomain>] [--ttl <seconds>] [--json]
|
|
39
|
+
|
|
40
|
+
Prints a signed token for the store's SHARED preview channel. Pass it to
|
|
41
|
+
validate-site / parity-check as --preview-token (or PARITY_PREVIEW_TOKEN).
|
|
42
|
+
Requires \`tot login\` and access to the store; the token is never longer-lived
|
|
43
|
+
than you asked for (default 1h, max 24h).
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--tenant <appDomain> Store to mint for. Defaults to the current checkout's store.
|
|
47
|
+
--ttl <seconds> Token lifetime (default 3600, max ${MAX_TTL_SECONDS}).
|
|
48
|
+
--url <origin> Storefront origin. Defaults to $TOT_STOREFRONT_URL or the
|
|
49
|
+
hosted storefront.
|
|
50
|
+
--json Print { token, exp, ttlSeconds, tenantId, previewVersion }.
|
|
51
|
+
--help, -h Show this help.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
node agency-kit/tools/validate-site.mjs ... --preview-token "$(tot preview token)"`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string[]} argv
|
|
59
|
+
* @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
|
|
60
|
+
* @param {{ resolveViewerTransport?: typeof resolveViewerTransport, fetchImpl?: typeof fetch,
|
|
61
|
+
* env?: NodeJS.ProcessEnv, out?: (s: string) => void, err?: (s: string) => void }} [deps]
|
|
62
|
+
*/
|
|
63
|
+
export async function run(argv, ctx, deps = {}) {
|
|
64
|
+
const out = deps.out || ((s) => process.stdout.write(`${s}\n`));
|
|
65
|
+
const err = deps.err || ((s) => process.stderr.write(`${s}\n`));
|
|
66
|
+
const env = deps.env || process.env;
|
|
67
|
+
const fetchImpl = deps.fetchImpl || fetch;
|
|
68
|
+
const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
|
|
69
|
+
|
|
70
|
+
const args = parseTokenArgs(argv);
|
|
71
|
+
if (args.help) {
|
|
72
|
+
out(renderUsage());
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tenant = String(args.tenant || ctx?.tenant || "").trim().toLowerCase();
|
|
77
|
+
if (!tenant) {
|
|
78
|
+
err("✗ no store to mint for.\n\n → next: pass --tenant <appDomain>, or run inside a store checkout.");
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let ttlSeconds;
|
|
83
|
+
if (args.ttl != null) {
|
|
84
|
+
ttlSeconds = Number(args.ttl);
|
|
85
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > MAX_TTL_SECONDS) {
|
|
86
|
+
err(`✗ --ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}.`);
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || null;
|
|
92
|
+
const transport = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
|
|
93
|
+
if (!transport.ok) {
|
|
94
|
+
err(`✗ ${transport.message}\n\n → next: ${transport.hint}`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let res;
|
|
99
|
+
try {
|
|
100
|
+
res = await fetchImpl(`${transport.base}/api/preview/token`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { ...transport.authHeaders, "content-type": "application/json" },
|
|
103
|
+
body: JSON.stringify({ tenant, ...(ttlSeconds ? { ttlSeconds } : {}) }),
|
|
104
|
+
});
|
|
105
|
+
} catch (e) {
|
|
106
|
+
err(`✗ could not reach ${transport.base}: ${e?.message || e}\n\n → next: check --url / your network.`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (res.status === 401 || res.status === 403) {
|
|
111
|
+
err(
|
|
112
|
+
`✗ your account can't view ${tenant}'s preview.\n\n` +
|
|
113
|
+
" → next: ask the store owner for developer access (or check `tot whoami`), then re-run.",
|
|
114
|
+
);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
let data = null;
|
|
118
|
+
try {
|
|
119
|
+
data = await res.json();
|
|
120
|
+
} catch {
|
|
121
|
+
/* non-JSON error body */
|
|
122
|
+
}
|
|
123
|
+
if (!res.ok || typeof data?.token !== "string") {
|
|
124
|
+
err(`✗ the store didn't mint a token (HTTP ${res.status}).\n\n → next: retry shortly; if it persists, report it with \`tot feedback\`.`);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (args.json) {
|
|
129
|
+
out(JSON.stringify({
|
|
130
|
+
token: data.token,
|
|
131
|
+
exp: data.exp ?? null,
|
|
132
|
+
ttlSeconds: data.ttlSeconds ?? null,
|
|
133
|
+
tenantId: data.tenantId ?? tenant,
|
|
134
|
+
previewVersion: data.previewVersion ?? null,
|
|
135
|
+
}));
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
out(data.token);
|
|
139
|
+
const expires = Number.isFinite(data.exp) ? new Date(data.exp * 1000).toISOString() : "in 1h";
|
|
140
|
+
err(` ✓ preview token for ${data.tenantId ?? tenant} — expires ${expires}.`);
|
|
141
|
+
err(
|
|
142
|
+
data.previewVersion
|
|
143
|
+
? ` the preview channel serves ${data.previewVersion} (pass it as --preview-version).`
|
|
144
|
+
: " the preview channel has not reconciled a commit yet.",
|
|
145
|
+
);
|
|
146
|
+
err(" anyone holding this token sees the unpublished site — don't paste it into tickets.");
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
package/src/commands/preview.mjs
CHANGED
|
@@ -75,6 +75,13 @@ export async function run(argv, ctx, { alias = null } = {}) {
|
|
|
75
75
|
return runRetryEvidence(argv.slice(1), ctx);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// `tot preview token` mints a shared-preview token for validation runs, from the
|
|
79
|
+
// developer's own login — dispatched before the submit-flow arg parse, like `build`.
|
|
80
|
+
if (!alias && argv[0] === "token") {
|
|
81
|
+
const { run: runToken } = await import("./preview-token.mjs");
|
|
82
|
+
return runToken(argv.slice(1), ctx);
|
|
83
|
+
}
|
|
84
|
+
|
|
78
85
|
const verb = alias || "preview";
|
|
79
86
|
const args = parseArgs(argv);
|
|
80
87
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Types for the dependency-free declared-config module (declared-config.mjs). */
|
|
2
|
+
export interface DeclaredConfigFinding {
|
|
3
|
+
level: string;
|
|
4
|
+
rule: string;
|
|
5
|
+
file: string;
|
|
6
|
+
message: string;
|
|
7
|
+
fix?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const TOT_CONFIG_KEYS: string[];
|
|
11
|
+
export const COMPLIANCE_KEYS: string[];
|
|
12
|
+
export const FEATURE_KEYS: string[];
|
|
13
|
+
export const SITE_TYPES: string[];
|
|
14
|
+
export const COMPLIANCE_NOTICES: Record<string, string>;
|
|
15
|
+
export const MANDATED_NOTICE_KEYS: string[];
|
|
16
|
+
export function nearestKey(key: string, known: readonly string[]): string | null;
|
|
17
|
+
export function unknownConfigKeys(
|
|
18
|
+
config: Record<string, unknown>,
|
|
19
|
+
): { path: string; suggestion: string | null; consequence: string }[];
|
|
20
|
+
export function droppedMandatedNotices(
|
|
21
|
+
previous: Record<string, unknown> | null | undefined,
|
|
22
|
+
current: Record<string, unknown> | null | undefined,
|
|
23
|
+
): { key: string; notice: string }[];
|
|
24
|
+
export function declarationFindings(
|
|
25
|
+
current: Record<string, unknown> | null | undefined,
|
|
26
|
+
previous: Record<string, unknown> | null | undefined,
|
|
27
|
+
): string[];
|
|
28
|
+
export function isRegulatedCompliance(compliance: object | null | undefined): boolean;
|
|
29
|
+
export function declaresJurisdictionNotice(compliance: object | null | undefined): boolean;
|
|
30
|
+
export function complianceCoherenceFailures(
|
|
31
|
+
compliance: object | null | undefined,
|
|
32
|
+
): Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string; detail: string; hint: string }>>;
|
|
33
|
+
export function complianceFindings(config: Record<string, unknown>, file?: string): DeclaredConfigFinding[];
|
|
34
|
+
export function validateDeclaredConfig(config: Record<string, unknown>, file?: string): DeclaredConfigFinding[];
|
|
35
|
+
|
|
36
|
+
export interface NicotineWarningPresentation {
|
|
37
|
+
background: string;
|
|
38
|
+
text: string;
|
|
39
|
+
border: string;
|
|
40
|
+
textTransform: "uppercase" | "none";
|
|
41
|
+
fontWeight: number;
|
|
42
|
+
fontSizePx: number;
|
|
43
|
+
}
|
|
44
|
+
export interface ResolvedNicotineWarning {
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
presentation: NicotineWarningPresentation;
|
|
47
|
+
preset: string | null;
|
|
48
|
+
custom: boolean;
|
|
49
|
+
problems: string[];
|
|
50
|
+
}
|
|
51
|
+
export const NICOTINE_WARNING_TEXT: string;
|
|
52
|
+
export const CONSPICUITY_FLOOR: Readonly<{ contrast: number; fontSizePx: number; fontWeight: number }>;
|
|
53
|
+
export const DEFAULT_NICOTINE_WARNING_PRESET: string;
|
|
54
|
+
export const NICOTINE_WARNING_PRESETS: Readonly<Record<string, Readonly<NicotineWarningPresentation>>>;
|
|
55
|
+
export const NICOTINE_WARNING_STYLE_KEYS: readonly string[];
|
|
56
|
+
export function contrastRatio(a: string, b: string): number;
|
|
57
|
+
export function conspicuityProblems(p: NicotineWarningPresentation): string[];
|
|
58
|
+
export function resolveNicotineWarning(declaration: unknown): ResolvedNicotineWarning;
|
|
59
|
+
export function describeNicotineWarningPresentation(p: NicotineWarningPresentation): string;
|
|
60
|
+
export function unreviewedNicotineWarningPresentation(
|
|
61
|
+
current: Record<string, unknown> | null | undefined,
|
|
62
|
+
previous: Record<string, unknown> | null | undefined,
|
|
63
|
+
): string | null;
|
|
64
|
+
export function nicotineWarningHtml(declaration: unknown, variant: "bar" | "block"): string;
|
|
65
|
+
export function nicotineWarningStyle(p: NicotineWarningPresentation, variant: "bar" | "block"): string;
|
package/src/declared-config.mjs
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `.tot/config.json` keys the platform reads, and
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The `.tot/config.json` keys the platform reads, and what happens to everything else.
|
|
3
|
+
* Shared by `tot validate` (src/validate.mjs), the monorepo/dev-loop validator
|
|
4
|
+
* (scripts/tenant/validate.mjs), the hosted reconcile, and the publication validator
|
|
5
|
+
* (scripts/publish/validate-bundle.mjs), so every surface says the same thing.
|
|
6
|
+
*
|
|
7
|
+
* An unknown key is the store owner's to decide about: it is reported as a WARNING the
|
|
8
|
+
* owner must approve before publication, naming the nearest key the platform does read
|
|
9
|
+
* and what will not render because of it. A wrong-shaped value of a known key (a
|
|
10
|
+
* `siteType` that is not a site type, a `compliance` that is not an object) stays an
|
|
11
|
+
* error: there is no reading of it the platform could act on.
|
|
5
12
|
*
|
|
6
13
|
* 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
|
|
8
|
-
*
|
|
9
|
-
* can never pass. Dependency-free: the storefront runner copies this file as-is.
|
|
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.
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
18
|
export const TOT_CONFIG_KEYS = [
|
|
13
19
|
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
14
|
-
"siteType", "compliance", "capabilities", "features", "embeds",
|
|
20
|
+
"displayName", "siteType", "compliance", "capabilities", "features", "embeds",
|
|
15
21
|
];
|
|
16
22
|
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
17
23
|
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
@@ -24,53 +30,330 @@ export const FEATURE_KEYS = [
|
|
|
24
30
|
];
|
|
25
31
|
export const SITE_TYPES = ["commerce", "marketing"];
|
|
26
32
|
|
|
33
|
+
/** What each compliance key puts on the page — the thing that is missing when it is. */
|
|
34
|
+
export const COMPLIANCE_NOTICES = {
|
|
35
|
+
ruleProfile: "checkout compliance rules",
|
|
36
|
+
minAge: "age gate",
|
|
37
|
+
nicotineWarning: "FDA nicotine warning",
|
|
38
|
+
shippingRestriction: "shipping-restriction notice",
|
|
39
|
+
pactAct: "PACT Act notice",
|
|
40
|
+
adultSignature: "adult-signature notice",
|
|
41
|
+
prop65: "California Prop 65 warning",
|
|
42
|
+
purchaseLimit: "purchase-limit notice",
|
|
43
|
+
stateEligibility: "state-eligibility notice",
|
|
44
|
+
exciseTax: "excise-tax notice",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The compliance keys that each turn on a mandated duty in the platform's obligation
|
|
49
|
+
* model (packages/public-runtime/src/compliance-obligations.ts). Dropping one between
|
|
50
|
+
* the live revision and the one being published removes a mandated notice from the
|
|
51
|
+
* site — a decision the owner must make explicitly.
|
|
52
|
+
*/
|
|
53
|
+
export const MANDATED_NOTICE_KEYS = [
|
|
54
|
+
"minAge", "nicotineWarning", "pactAct", "adultSignature", "prop65",
|
|
55
|
+
"stateEligibility", "shippingRestriction", "exciseTax",
|
|
56
|
+
];
|
|
57
|
+
|
|
27
58
|
const ERROR = "error";
|
|
59
|
+
const WARN = "warn";
|
|
28
60
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
29
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Compliance coherence — the declared-config half of the hosted preview's `age_gate`,
|
|
64
|
+
* `excise` and `jurisdiction` evidence checks, so `tot validate` fails locally on
|
|
65
|
+
* exactly what the reconcile fails. Mirrors
|
|
66
|
+
* packages/public-runtime/src/compliance-coherence.mjs (the platform's single source);
|
|
67
|
+
* test/declared-config-parity.test.mjs pins the two together.
|
|
68
|
+
*/
|
|
69
|
+
export function isRegulatedCompliance(compliance) {
|
|
70
|
+
const minAge = compliance?.minAge;
|
|
71
|
+
return (typeof minAge === "number" && minAge > 0) || !!compliance?.nicotineWarning;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function declaresJurisdictionNotice(compliance) {
|
|
75
|
+
const text = compliance?.shippingRestriction;
|
|
76
|
+
return compliance?.pactAct === true || (typeof text === "string" && text.trim().length > 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {Record<string, any> | null | undefined} compliance
|
|
81
|
+
* @returns {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>}
|
|
82
|
+
*/
|
|
83
|
+
export function complianceCoherenceFailures(compliance) {
|
|
84
|
+
/** @type {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>} */
|
|
85
|
+
const out = {};
|
|
86
|
+
if (!isRegulatedCompliance(compliance)) return out;
|
|
87
|
+
const minAge = typeof compliance?.minAge === "number" ? compliance.minAge : null;
|
|
88
|
+
if (minAge == null || minAge < 18) {
|
|
89
|
+
out.age_gate = {
|
|
90
|
+
field: "compliance.minAge",
|
|
91
|
+
detail: "Age-restricted/nicotine products require `compliance.minAge` (18 or higher) — none is set.",
|
|
92
|
+
hint: "Set `compliance.minAge` (18+) in .tot/config.json.",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (compliance?.nicotineWarning && (minAge == null || minAge < 21)) {
|
|
96
|
+
out.excise = {
|
|
97
|
+
field: "compliance.minAge",
|
|
98
|
+
detail: "Nicotine products (`compliance.nicotineWarning`) require `compliance.minAge` of 21 or higher.",
|
|
99
|
+
hint: "Set `compliance.minAge` to 21 in .tot/config.json (nicotine requires 21+, not just 18+).",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (!declaresJurisdictionNotice(compliance)) {
|
|
103
|
+
out.jurisdiction = {
|
|
104
|
+
field: "compliance.pactAct",
|
|
105
|
+
detail:
|
|
106
|
+
"Regulated products require a shipping/jurisdiction notice: set `compliance.pactAct: true` " +
|
|
107
|
+
"or `compliance.shippingRestriction` — neither is set.",
|
|
108
|
+
hint:
|
|
109
|
+
"Set `compliance.pactAct: true` for the platform's PACT Act notice, or " +
|
|
110
|
+
"`compliance.shippingRestriction` to your own restriction text, in .tot/config.json.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The compliance findings `tot validate` reports for a declared block: an ERROR per
|
|
118
|
+
* coherence failure the hosted preview would fail (`evidence-<check>`), and a WARNING
|
|
119
|
+
* when a regulated store declares no `ruleProfile` — its preview renders, but going
|
|
120
|
+
* live refuses because no checkout rules resolve.
|
|
121
|
+
* @param {Record<string, any>} config
|
|
122
|
+
* @param {string} file
|
|
123
|
+
* @returns {Finding[]}
|
|
124
|
+
*/
|
|
125
|
+
export function complianceFindings(config, file = ".tot/config.json") {
|
|
126
|
+
const compliance = isObject(config?.compliance) ? config.compliance : null;
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const [check, failure] of Object.entries(complianceCoherenceFailures(compliance))) {
|
|
129
|
+
out.push(mk(ERROR, `compliance-${check.replace("_", "-")}`, `${file} compliance`,
|
|
130
|
+
`${failure.detail} The hosted preview fails its \`${check}\` check on this.`, failure.hint));
|
|
131
|
+
}
|
|
132
|
+
// The hosted reconcile refuses a declaration whose `capabilities` switch off the
|
|
133
|
+
// compliance floor (packages/public-runtime/src/capabilities.ts capabilityFloorViolations).
|
|
134
|
+
const capabilities = isObject(config?.capabilities) ? config.capabilities : {};
|
|
135
|
+
if (compliance?.minAge && capabilities.ageVerification?.enabled === false) {
|
|
136
|
+
out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
|
|
137
|
+
"`capabilities.ageVerification` cannot be disabled for a store with `compliance.minAge` — the hosted preview refuses this revision.",
|
|
138
|
+
"Remove `capabilities.ageVerification.enabled: false` from .tot/config.json."));
|
|
139
|
+
}
|
|
140
|
+
if (compliance?.nicotineWarning && capabilities.exciseTax?.enabled === false) {
|
|
141
|
+
out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
|
|
142
|
+
"`capabilities.exciseTax` cannot be disabled for a store with `compliance.nicotineWarning` — the hosted preview refuses this revision.",
|
|
143
|
+
"Remove `capabilities.exciseTax.enabled: false` from .tot/config.json."));
|
|
144
|
+
}
|
|
145
|
+
if (isRegulatedCompliance(compliance) && !compliance?.ruleProfile) {
|
|
146
|
+
out.push(mk(WARN, "compliance-rule-profile", `${file} compliance`,
|
|
147
|
+
"This store is regulated (`compliance.minAge`/`compliance.nicotineWarning`) but declares no " +
|
|
148
|
+
"`compliance.ruleProfile`, so no checkout rules resolve: the preview renders, but checkout blocks " +
|
|
149
|
+
"and going live refuses.",
|
|
150
|
+
'Declare `compliance.ruleProfile` (e.g. "vape-nicotine-us") in .tot/config.json.'));
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
30
155
|
/** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
|
|
31
156
|
function mk(level, rule, file, message, fix) {
|
|
32
157
|
return { level, rule, file, message, fix };
|
|
33
158
|
}
|
|
34
159
|
|
|
160
|
+
/** Case-insensitive edit distance. */
|
|
161
|
+
function distance(a, b) {
|
|
162
|
+
const s = a.toLowerCase();
|
|
163
|
+
const t = b.toLowerCase();
|
|
164
|
+
const row = Array.from({ length: t.length + 1 }, (_, j) => j);
|
|
165
|
+
for (let i = 1; i <= s.length; i++) {
|
|
166
|
+
let prev = row[0];
|
|
167
|
+
row[0] = i;
|
|
168
|
+
for (let j = 1; j <= t.length; j++) {
|
|
169
|
+
const cur = row[j];
|
|
170
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (s[i - 1] === t[j - 1] ? 0 : 1));
|
|
171
|
+
prev = cur;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return row[t.length];
|
|
175
|
+
}
|
|
176
|
+
|
|
35
177
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
178
|
+
* The known key an unknown one most plausibly meant, or null when none is close.
|
|
179
|
+
* @param {string} key
|
|
180
|
+
* @param {readonly string[]} known
|
|
181
|
+
* @returns {string | null}
|
|
182
|
+
*/
|
|
183
|
+
export function nearestKey(key, known) {
|
|
184
|
+
let best = null;
|
|
185
|
+
let bestDistance = Infinity;
|
|
186
|
+
for (const candidate of known) {
|
|
187
|
+
const d = distance(key, candidate);
|
|
188
|
+
if (d < bestDistance) {
|
|
189
|
+
best = candidate;
|
|
190
|
+
bestDistance = d;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const lower = key.toLowerCase();
|
|
194
|
+
const near = best !== null && (bestDistance <= Math.max(2, Math.floor(best.length / 3)) ||
|
|
195
|
+
best.toLowerCase().startsWith(lower) || lower.startsWith(best.toLowerCase()));
|
|
196
|
+
return near ? best : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Every key in a `.tot/config.json` the platform does not read, with the key it most
|
|
201
|
+
* plausibly meant and the consequence. `path` is dotted (`compliance.nicotineWarnings`).
|
|
202
|
+
* @param {Record<string, any>} config
|
|
203
|
+
* @returns {{ path: string, suggestion: string | null, consequence: string }[]}
|
|
204
|
+
*/
|
|
205
|
+
export function unknownConfigKeys(config) {
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const key of Object.keys(config)) {
|
|
208
|
+
if (TOT_CONFIG_KEYS.includes(key)) continue;
|
|
209
|
+
const suggestion = RENAMED_CONFIG_KEYS[/** @type {keyof typeof RENAMED_CONFIG_KEYS} */ (key)] ??
|
|
210
|
+
nearestKey(key, TOT_CONFIG_KEYS);
|
|
211
|
+
out.push({ path: key, suggestion, consequence: "the platform never reads it" });
|
|
212
|
+
}
|
|
213
|
+
/** @type {Array<[string, string[]]>} */
|
|
214
|
+
const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
|
|
215
|
+
for (const [field, known] of objectFields) {
|
|
216
|
+
const value = config[field];
|
|
217
|
+
if (!isObject(value)) continue;
|
|
218
|
+
for (const key of Object.keys(value)) {
|
|
219
|
+
if (known.includes(key)) continue;
|
|
220
|
+
const suggestion = nearestKey(key, known);
|
|
221
|
+
const notice = field === "compliance" && suggestion && !(suggestion in value)
|
|
222
|
+
? COMPLIANCE_NOTICES[/** @type {keyof typeof COMPLIANCE_NOTICES} */ (suggestion)]
|
|
223
|
+
: null;
|
|
224
|
+
out.push({
|
|
225
|
+
path: `${field}.${key}`,
|
|
226
|
+
suggestion: suggestion ? `${field}.${suggestion}` : null,
|
|
227
|
+
consequence: notice
|
|
228
|
+
? `this setting does nothing — your ${notice} will NOT render`
|
|
229
|
+
: "this setting does nothing",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The one-line, owner-facing sentence for an unknown key. */
|
|
237
|
+
function unknownKeyMessage({ path, suggestion, consequence }) {
|
|
238
|
+
return `\`${path}\` is not a .tot/config.json key${suggestion ? ` — did you mean \`${suggestion}\`?` : ""} ` +
|
|
239
|
+
`${consequence.charAt(0).toUpperCase()}${consequence.slice(1)}.`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The mandated notices the live revision declared that this one no longer does.
|
|
244
|
+
* @param {Record<string, any> | null | undefined} previous the live revision's `.tot/config.json`
|
|
245
|
+
* @param {Record<string, any> | null | undefined} current this revision's `.tot/config.json`
|
|
246
|
+
* @returns {{ key: string, notice: string }[]}
|
|
247
|
+
*/
|
|
248
|
+
export function droppedMandatedNotices(previous, current) {
|
|
249
|
+
const before = isObject(previous?.compliance) ? previous.compliance : {};
|
|
250
|
+
const now = isObject(current?.compliance) ? current.compliance : {};
|
|
251
|
+
return MANDATED_NOTICE_KEYS
|
|
252
|
+
.filter((key) => Boolean(before[key]) && !now[key])
|
|
253
|
+
.map((key) => ({ key, notice: COMPLIANCE_NOTICES[/** @type {keyof typeof COMPLIANCE_NOTICES} */ (key)] }));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The FDA nicotine warning presentation this revision would put live that the live site does
|
|
258
|
+
* not already show: a custom (non-preset) presentation whose resolved values differ from the
|
|
259
|
+
* live revision's. Null when the warning is off, invalid, a validated preset, or unchanged.
|
|
260
|
+
* @param {Record<string, any> | null | undefined} current
|
|
261
|
+
* @param {Record<string, any> | null | undefined} previous
|
|
262
|
+
* @returns {string | null} the exact presentation, as describeNicotineWarningPresentation states it
|
|
263
|
+
*/
|
|
264
|
+
export function unreviewedNicotineWarningPresentation(current, previous) {
|
|
265
|
+
const now = resolveNicotineWarning(isObject(current?.compliance) ? current.compliance.nicotineWarning : undefined);
|
|
266
|
+
if (!now.enabled || !now.custom) return null;
|
|
267
|
+
const before = resolveNicotineWarning(isObject(previous?.compliance) ? previous.compliance.nicotineWarning : undefined);
|
|
268
|
+
const spec = describeNicotineWarningPresentation(now.presentation);
|
|
269
|
+
return before.enabled && before.custom && describeNicotineWarningPresentation(before.presentation) === spec ? null : spec;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The publication validator's findings for a revision's declaration: an owner-approvable
|
|
274
|
+
* `CONFIG:` finding per unknown key or invalid nicotine-warning presentation, a `COMPLIANCE:`
|
|
275
|
+
* finding per mandated notice the live site shows and this revision drops, and a
|
|
276
|
+
* `COMPLIANCE:` finding for a custom nicotine-warning presentation the live site does not
|
|
277
|
+
* already show. All are QUALITY findings (scripts/publish/lib/finding-severity.mjs):
|
|
278
|
+
* publication halts until the owner accepts them through "Accept issue & continue", which
|
|
279
|
+
* records who accepted these exact findings, when, and why.
|
|
280
|
+
* @param {Record<string, any> | null | undefined} current
|
|
281
|
+
* @param {Record<string, any> | null | undefined} previous
|
|
282
|
+
* @returns {string[]}
|
|
283
|
+
*/
|
|
284
|
+
export function declarationFindings(current, previous) {
|
|
285
|
+
const out = [];
|
|
286
|
+
if (isObject(current)) {
|
|
287
|
+
for (const unknown of unknownConfigKeys(current)) out.push(`CONFIG: ${unknownKeyMessage(unknown)}`);
|
|
288
|
+
const warning = resolveNicotineWarning(isObject(current.compliance) ? current.compliance.nicotineWarning : undefined);
|
|
289
|
+
if (warning.enabled && warning.problems.length > 0) {
|
|
290
|
+
out.push(`CONFIG: ${nicotineWarningProblemMessage(warning.problems)}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const { key, notice } of droppedMandatedNotices(previous, current)) {
|
|
294
|
+
out.push(
|
|
295
|
+
`COMPLIANCE: the live site shows the ${notice} (\`compliance.${key}\`) and this revision no longer ` +
|
|
296
|
+
`declares it — publishing removes a mandated notice from the store.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const custom = unreviewedNicotineWarningPresentation(current, previous);
|
|
300
|
+
if (custom) {
|
|
301
|
+
out.push(
|
|
302
|
+
`COMPLIANCE: the FDA nicotine warning uses a custom presentation outside the validated presets — ${custom}. ` +
|
|
303
|
+
`Approving records that you reviewed exactly these values; changing any of them asks again.`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The owner-facing sentence for an invalid `compliance.nicotineWarning`. */
|
|
310
|
+
function nicotineWarningProblemMessage(problems) {
|
|
311
|
+
return `\`compliance.nicotineWarning\` is not a valid presentation (${problems.join("; ")}). ` +
|
|
312
|
+
`The warning renders in the default "dark" preset instead.`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The validator's report on a `.tot/config.json`: a WARNING per unknown key (owner-approved
|
|
317
|
+
* before publication), an error per wrong-shaped value of a known key.
|
|
40
318
|
* @param {Record<string, any>} config parsed `.tot/config.json` (an object)
|
|
41
319
|
* @param {string} [file]
|
|
42
320
|
* @returns {Finding[]}
|
|
43
321
|
*/
|
|
44
322
|
export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
45
323
|
const out = [];
|
|
46
|
-
for (const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
324
|
+
for (const unknown of unknownConfigKeys(config)) {
|
|
325
|
+
const nested = unknown.path.includes(".");
|
|
326
|
+
out.push(mk(WARN, nested ? `config-${unknown.path.split(".")[0]}-unknown-key` : "config-unknown-key",
|
|
327
|
+
nested ? `${file} ${unknown.path.split(".")[0]}` : file,
|
|
328
|
+
unknownKeyMessage(unknown),
|
|
329
|
+
unknown.suggestion
|
|
330
|
+
? `rename it to \`${unknown.suggestion}\`, or keep it and approve the warning when you publish`
|
|
331
|
+
: "remove it, or keep it and approve the warning when you publish"));
|
|
332
|
+
}
|
|
333
|
+
if (config.displayName !== undefined && !(typeof config.displayName === "string" && config.displayName.trim())) {
|
|
334
|
+
out.push(mk(ERROR, "config-display-name", file,
|
|
335
|
+
"`displayName` must be a non-empty string — the store's name as visitors read it (page titles, og:site_name, the copyright line)"));
|
|
53
336
|
}
|
|
54
337
|
if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
|
|
55
338
|
out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
|
|
56
339
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
for (const [field, known] of objectFields) {
|
|
60
|
-
const value = config[field];
|
|
61
|
-
if (value === undefined) continue;
|
|
62
|
-
if (!isObject(value)) {
|
|
340
|
+
for (const field of ["compliance", "features"]) {
|
|
341
|
+
if (config[field] !== undefined && !isObject(config[field])) {
|
|
63
342
|
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
343
|
}
|
|
73
344
|
}
|
|
345
|
+
out.push(...complianceFindings(config, file));
|
|
346
|
+
const warning = resolveNicotineWarning(isObject(config.compliance) ? config.compliance.nicotineWarning : undefined);
|
|
347
|
+
if (warning.enabled && warning.problems.length > 0) {
|
|
348
|
+
out.push(mk(ERROR, "config-nicotine-warning", `${file} compliance`, nicotineWarningProblemMessage(warning.problems),
|
|
349
|
+
'use `true`, `{ "preset": "dark" }` or `{ "preset": "light" }`, or fix the style values named above'));
|
|
350
|
+
} else if (warning.custom) {
|
|
351
|
+
out.push(mk(WARN, "config-nicotine-warning-custom", `${file} compliance`,
|
|
352
|
+
`\`compliance.nicotineWarning\` uses a custom presentation outside the validated presets — ` +
|
|
353
|
+
`${describeNicotineWarningPresentation(warning.presentation)}. It renders in preview; it goes live only ` +
|
|
354
|
+
`after you approve it in the go-live compliance review.`,
|
|
355
|
+
'use a preset (`"dark"`, `"light"`) to skip the review, or keep it and approve it when you publish'));
|
|
356
|
+
}
|
|
74
357
|
if (config.capabilities !== undefined && !isObject(config.capabilities)) {
|
|
75
358
|
out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
|
|
76
359
|
}
|
|
@@ -80,3 +363,254 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
80
363
|
}
|
|
81
364
|
return out;
|
|
82
365
|
}
|
|
366
|
+
|
|
367
|
+
// ── The FDA nicotine warning (21 CFR 1143.3): what compliance.nicotineWarning may declare ──
|
|
368
|
+
//
|
|
369
|
+
// The wording is fixed by law and is not configurable. The presentation is one of the
|
|
370
|
+
// validated presets, or a custom style built on one:
|
|
371
|
+
//
|
|
372
|
+
// "nicotineWarning": true → the "dark" preset
|
|
373
|
+
// "nicotineWarning": { "preset": "light" } → a validated preset
|
|
374
|
+
// "nicotineWarning": { "preset": "light", "style": {...} } → a custom presentation
|
|
375
|
+
//
|
|
376
|
+
// Every presentation — preset or custom — must clear CONSPICUITY_FLOOR. A preset needs no
|
|
377
|
+
// review. A custom presentation is a deviation the owner approves at go-live:
|
|
378
|
+
// declarationFindings raises it as a COMPLIANCE finding that spells out every resolved style
|
|
379
|
+
// value, and the owner's "Accept issue & continue" records who approved exactly those values,
|
|
380
|
+
// when, and why. Any change to the styling produces a different finding, so it is reviewed again.
|
|
381
|
+
//
|
|
382
|
+
// The style keys are a closed set of colours, case, weight and size. Nothing here can position,
|
|
383
|
+
// hide, shrink below the floor, or dismiss the warning, and a declaration that is invalid renders
|
|
384
|
+
// the default preset rather than nothing. The hosted renderer (NicotineWarning.astro and the
|
|
385
|
+
// raw-HTML path) renders through nicotineWarningHtml, so every surface presents it identically.
|
|
386
|
+
|
|
387
|
+
export const NICOTINE_WARNING_TEXT =
|
|
388
|
+
"WARNING: This product contains nicotine. Nicotine is an addictive chemical.";
|
|
389
|
+
|
|
390
|
+
/** The floor every presentation clears: WCAG AAA text contrast, a 12px minimum, semibold or heavier. */
|
|
391
|
+
export const CONSPICUITY_FLOOR = Object.freeze({ contrast: 7, fontSizePx: 12, fontWeight: 600 });
|
|
392
|
+
|
|
393
|
+
/** Bounds on the size a custom style may declare, so the bar cannot swallow the header. */
|
|
394
|
+
const MAX_FONT_SIZE_PX = 24;
|
|
395
|
+
|
|
396
|
+
export const DEFAULT_NICOTINE_WARNING_PRESET = "dark";
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The validated presets. `dark` is the platform default; `light` matches a light bar with a
|
|
400
|
+
* dark border and bold normal-case text.
|
|
401
|
+
* @type {Readonly<Record<string, Readonly<NicotineWarningPresentation>>>}
|
|
402
|
+
*/
|
|
403
|
+
export const NICOTINE_WARNING_PRESETS = Object.freeze({
|
|
404
|
+
dark: Object.freeze({
|
|
405
|
+
background: "#111111",
|
|
406
|
+
text: "#ffffff",
|
|
407
|
+
border: "#111111",
|
|
408
|
+
textTransform: "uppercase",
|
|
409
|
+
fontWeight: 600,
|
|
410
|
+
fontSizePx: 12,
|
|
411
|
+
}),
|
|
412
|
+
light: Object.freeze({
|
|
413
|
+
background: "#e6e6e6",
|
|
414
|
+
text: "#000000",
|
|
415
|
+
border: "#000000",
|
|
416
|
+
textTransform: "none",
|
|
417
|
+
fontWeight: 700,
|
|
418
|
+
fontSizePx: 14,
|
|
419
|
+
}),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @typedef {{
|
|
424
|
+
* background: string,
|
|
425
|
+
* text: string,
|
|
426
|
+
* border: string,
|
|
427
|
+
* textTransform: "uppercase" | "none",
|
|
428
|
+
* fontWeight: number,
|
|
429
|
+
* fontSizePx: number,
|
|
430
|
+
* }} NicotineWarningPresentation
|
|
431
|
+
*/
|
|
432
|
+
|
|
433
|
+
export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
|
|
434
|
+
"background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
|
|
435
|
+
]);
|
|
436
|
+
const TEXT_TRANSFORMS = ["uppercase", "none"];
|
|
437
|
+
const FONT_WEIGHTS = [600, 700, 800, 900];
|
|
438
|
+
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
439
|
+
|
|
440
|
+
/** `#abc` / `#AABBCC` → `#aabbcc`. */
|
|
441
|
+
function normalizeHex(value) {
|
|
442
|
+
const hex = value.slice(1).toLowerCase();
|
|
443
|
+
return `#${hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function luminance(hex) {
|
|
447
|
+
const n = normalizeHex(hex);
|
|
448
|
+
const [r, g, b] = [1, 3, 5].map((i) => {
|
|
449
|
+
const c = parseInt(n.slice(i, i + 2), 16) / 255;
|
|
450
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
451
|
+
});
|
|
452
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** WCAG 2 contrast ratio between two hex colours. */
|
|
456
|
+
export function contrastRatio(a, b) {
|
|
457
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
458
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Why a presentation is not conspicuous enough — empty when it clears the floor.
|
|
463
|
+
* @param {NicotineWarningPresentation} p
|
|
464
|
+
* @returns {string[]}
|
|
465
|
+
*/
|
|
466
|
+
export function conspicuityProblems(p) {
|
|
467
|
+
const out = [];
|
|
468
|
+
const ratio = contrastRatio(p.text, p.background);
|
|
469
|
+
if (ratio < CONSPICUITY_FLOOR.contrast) {
|
|
470
|
+
out.push(`text ${p.text} on background ${p.background} has contrast ${ratio.toFixed(2)}:1 — the floor is ${CONSPICUITY_FLOOR.contrast}:1`);
|
|
471
|
+
}
|
|
472
|
+
if (p.fontSizePx < CONSPICUITY_FLOOR.fontSizePx) {
|
|
473
|
+
out.push(`font size ${p.fontSizePx}px is below the ${CONSPICUITY_FLOOR.fontSizePx}px floor`);
|
|
474
|
+
}
|
|
475
|
+
if (p.fontWeight < CONSPICUITY_FLOOR.fontWeight) {
|
|
476
|
+
out.push(`font weight ${p.fontWeight} is below the ${CONSPICUITY_FLOOR.fontWeight} floor`);
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Why one declared style value is not usable, or null. */
|
|
482
|
+
function styleValueProblem(key, value) {
|
|
483
|
+
switch (key) {
|
|
484
|
+
case "background":
|
|
485
|
+
case "text":
|
|
486
|
+
case "border":
|
|
487
|
+
return typeof value === "string" && HEX.test(value) ? null : `\`${key}\` must be a hex colour like "#e6e6e6"`;
|
|
488
|
+
case "textTransform":
|
|
489
|
+
return TEXT_TRANSFORMS.includes(value) ? null : `\`textTransform\` must be one of ${TEXT_TRANSFORMS.map((t) => `"${t}"`).join(", ")}`;
|
|
490
|
+
case "fontWeight":
|
|
491
|
+
return FONT_WEIGHTS.includes(value) ? null : `\`fontWeight\` must be one of ${FONT_WEIGHTS.join(", ")}`;
|
|
492
|
+
case "fontSizePx":
|
|
493
|
+
return typeof value === "number" && Number.isFinite(value) && value >= CONSPICUITY_FLOOR.fontSizePx && value <= MAX_FONT_SIZE_PX
|
|
494
|
+
? null
|
|
495
|
+
: `\`fontSizePx\` must be a number from ${CONSPICUITY_FLOOR.fontSizePx} to ${MAX_FONT_SIZE_PX}`;
|
|
496
|
+
default:
|
|
497
|
+
return `\`${key}\` is not a nicotine-warning style key (allowed: ${NICOTINE_WARNING_STYLE_KEYS.join(", ")})`;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function samePresentation(a, b) {
|
|
502
|
+
return NICOTINE_WARNING_STYLE_KEYS.every((k) => a[k] === b[k]);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* @typedef {{
|
|
507
|
+
* enabled: boolean,
|
|
508
|
+
* presentation: NicotineWarningPresentation,
|
|
509
|
+
* preset: string | null,
|
|
510
|
+
* custom: boolean,
|
|
511
|
+
* problems: string[],
|
|
512
|
+
* }} ResolvedNicotineWarning
|
|
513
|
+
*/
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Resolve a `compliance.nicotineWarning` declaration to what renders.
|
|
517
|
+
*
|
|
518
|
+
* `preset` names the preset the presentation equals (null for a custom one); `custom` is
|
|
519
|
+
* true when the owner must review it; `problems` lists why the declaration is invalid, in
|
|
520
|
+
* which case the default preset renders. A disabled declaration (absent, `false`) resolves
|
|
521
|
+
* `enabled: false`.
|
|
522
|
+
* @param {unknown} declaration
|
|
523
|
+
* @returns {ResolvedNicotineWarning}
|
|
524
|
+
*/
|
|
525
|
+
export function resolveNicotineWarning(declaration) {
|
|
526
|
+
const fallback = NICOTINE_WARNING_PRESETS[DEFAULT_NICOTINE_WARNING_PRESET];
|
|
527
|
+
const resolved = (/** @type {Partial<ResolvedNicotineWarning>} */ fields) => ({
|
|
528
|
+
enabled: true,
|
|
529
|
+
presentation: { ...fallback },
|
|
530
|
+
preset: DEFAULT_NICOTINE_WARNING_PRESET,
|
|
531
|
+
custom: false,
|
|
532
|
+
problems: [],
|
|
533
|
+
...fields,
|
|
534
|
+
});
|
|
535
|
+
if (!declaration) return resolved({ enabled: false });
|
|
536
|
+
if (declaration === true) return resolved({});
|
|
537
|
+
if (!isObject(declaration)) {
|
|
538
|
+
return resolved({ problems: ["must be `true` or an object like { \"preset\": \"light\" }"] });
|
|
539
|
+
}
|
|
540
|
+
const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, ...rest } = /** @type {Record<string, unknown>} */ (declaration);
|
|
541
|
+
const problems = Object.keys(rest).map((k) => `\`${k}\` is not a nicotineWarning key (allowed: preset, style)`);
|
|
542
|
+
const base = typeof presetName === "string" ? NICOTINE_WARNING_PRESETS[presetName] : undefined;
|
|
543
|
+
if (!base) {
|
|
544
|
+
problems.push(`\`preset\` must be one of ${Object.keys(NICOTINE_WARNING_PRESETS).map((p) => `"${p}"`).join(", ")}`);
|
|
545
|
+
}
|
|
546
|
+
if (style !== undefined && !isObject(style)) problems.push("`style` must be an object");
|
|
547
|
+
const declaredStyle = isObject(style) ? /** @type {Record<string, unknown>} */ (style) : {};
|
|
548
|
+
for (const [key, value] of Object.entries(declaredStyle)) {
|
|
549
|
+
const problem = styleValueProblem(key, value);
|
|
550
|
+
if (problem) problems.push(problem);
|
|
551
|
+
}
|
|
552
|
+
if (problems.length > 0 || !base) return resolved({ problems });
|
|
553
|
+
|
|
554
|
+
/** @type {NicotineWarningPresentation} */
|
|
555
|
+
const presentation = { ...base };
|
|
556
|
+
for (const [key, value] of Object.entries(declaredStyle)) {
|
|
557
|
+
presentation[/** @type {keyof NicotineWarningPresentation} */ (key)] =
|
|
558
|
+
/** @type {never} */ (typeof value === "string" && HEX.test(value) ? normalizeHex(value) : value);
|
|
559
|
+
}
|
|
560
|
+
const conspicuity = conspicuityProblems(presentation);
|
|
561
|
+
if (conspicuity.length > 0) return resolved({ problems: conspicuity });
|
|
562
|
+
const matching = Object.keys(NICOTINE_WARNING_PRESETS).find((name) => samePresentation(NICOTINE_WARNING_PRESETS[name], presentation));
|
|
563
|
+
return resolved({ presentation, preset: matching ?? null, custom: !matching });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The exact, human-readable statement of a custom presentation — what the owner approves.
|
|
568
|
+
* Every resolved value is in it, so two presentations that differ in any value describe
|
|
569
|
+
* differently.
|
|
570
|
+
* @param {NicotineWarningPresentation} p
|
|
571
|
+
*/
|
|
572
|
+
export function describeNicotineWarningPresentation(p) {
|
|
573
|
+
return `background ${p.background}, text ${p.text}, border ${p.border}, ` +
|
|
574
|
+
`${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* The warning's markup — the one rendering shared by NicotineWarning.astro and the raw-HTML
|
|
579
|
+
* serve path, so both present the same declaration identically. "bar" is the strip at the top
|
|
580
|
+
* of every page; "block" is the reinforced box on the product page. Every interpolated value
|
|
581
|
+
* is a validated hex colour, enum or number. Styling is inline (no utility classes), so the
|
|
582
|
+
* markup renders the same wherever it is served, whatever stylesheet the page carries.
|
|
583
|
+
* @param {unknown} declaration the tenant's `compliance.nicotineWarning`
|
|
584
|
+
* @param {"bar" | "block"} variant
|
|
585
|
+
*/
|
|
586
|
+
export function nicotineWarningHtml(declaration, variant) {
|
|
587
|
+
const { presentation, preset } = resolveNicotineWarning(declaration);
|
|
588
|
+
const attrs = `style="${nicotineWarningStyle(presentation, variant)}" role="note" aria-label="Health warning" ` +
|
|
589
|
+
`data-compliance="nicotine-warning" data-nicotine-warning-preset="${preset ?? "custom"}"`;
|
|
590
|
+
return variant === "bar"
|
|
591
|
+
? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:0.5rem;text-align:center">${NICOTINE_WARNING_TEXT}</p></div>`
|
|
592
|
+
: `<div ${attrs}><p style="margin:0">${NICOTINE_WARNING_TEXT}</p></div>`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Inline CSS for a presentation. `variant` "block" (the product page) never renders smaller
|
|
597
|
+
* than 14px.
|
|
598
|
+
* @param {NicotineWarningPresentation} p
|
|
599
|
+
* @param {"bar" | "block"} variant
|
|
600
|
+
*/
|
|
601
|
+
export function nicotineWarningStyle(p, variant) {
|
|
602
|
+
const size = variant === "block" ? Math.max(p.fontSizePx, 14) : p.fontSizePx;
|
|
603
|
+
const frame = variant === "bar"
|
|
604
|
+
? [`border-bottom:2px solid ${p.border}`]
|
|
605
|
+
: [`border:2px solid ${p.border}`, "border-radius:var(--radius-md)", "padding:0.75rem 1rem"];
|
|
606
|
+
return [
|
|
607
|
+
...frame,
|
|
608
|
+
`background-color:${p.background}`,
|
|
609
|
+
`color:${p.text}`,
|
|
610
|
+
`text-transform:${p.textTransform}`,
|
|
611
|
+
`letter-spacing:${p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
|
|
612
|
+
`font-weight:${p.fontWeight}`,
|
|
613
|
+
`font-size:${size}px`,
|
|
614
|
+
"line-height:1.4",
|
|
615
|
+
].join(";");
|
|
616
|
+
}
|
package/src/mcp.mjs
CHANGED
|
@@ -205,8 +205,15 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
205
205
|
try {
|
|
206
206
|
text = await res.text();
|
|
207
207
|
} catch (cause) {
|
|
208
|
-
|
|
209
|
-
|
|
208
|
+
// The request timeout covers the body read (MCP streams over SSE, so the
|
|
209
|
+
// headers arrive first): our own timeout firing mid-body is a timeout, and a
|
|
210
|
+
// stream that ends early is a network failure — neither is a malformed body.
|
|
211
|
+
const category = signal.aborted ? "timeout" : "network";
|
|
212
|
+
const message = signal.aborted
|
|
213
|
+
? `${method} timed out after ${timeoutMs}ms while reading the response`
|
|
214
|
+
: `${method} failed: the response stream ended early: ${sanitizeDiagnosticText(cause?.message || cause, MAX_SERVER_MESSAGE)}`;
|
|
215
|
+
throw diagnosticError(message, {
|
|
216
|
+
operation: method, category, message, httpStatus: res.status,
|
|
210
217
|
statusText: res.statusText, traceId: responseTraceId, requestId: headerRequestId,
|
|
211
218
|
contentType: ct,
|
|
212
219
|
}, cause);
|
package/src/validate.mjs
CHANGED
|
@@ -311,6 +311,7 @@ function validateCapabilitiesDoc(doc, file, config) {
|
|
|
311
311
|
return [mk(ERROR, "capabilities-root", file, "capabilities must be a JSON object")];
|
|
312
312
|
}
|
|
313
313
|
for (const [key, value] of Object.entries(doc)) {
|
|
314
|
+
if (key === "$schema") continue; // an editor's schema hint, which the devkit tells stores to add
|
|
314
315
|
if (!CAPABILITY_KEYS.has(key)) {
|
|
315
316
|
out.push(mk(ERROR, "capability-unknown", file, `unknown capability "${key}"`));
|
|
316
317
|
continue;
|
|
@@ -352,6 +353,31 @@ function validateScriptsDoc(doc, file, config) {
|
|
|
352
353
|
return out;
|
|
353
354
|
}
|
|
354
355
|
|
|
356
|
+
// Standalone published-CLI mirror of extension-contract-values.mjs
|
|
357
|
+
// (THEME_HEADING_LEVELS / isThemeCssSize); packages/cli/test/validate.test.mjs pins it.
|
|
358
|
+
export const THEME_HEADING_LEVELS = Object.freeze(["hero", "h1", "h2", "h3"]);
|
|
359
|
+
export function isThemeCssSize(value) {
|
|
360
|
+
return typeof value === "string" && value.length <= 120 && /^[0-9a-z.%(),+\-*/ ]*[0-9a-z%)]$/i.test(value.trim());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** theme.json `type.headings`: fixed heading sizes the renderer emits as --heading-<level>. */
|
|
364
|
+
function validateThemeHeadings(theme, file) {
|
|
365
|
+
const out = [];
|
|
366
|
+
const headings = theme?.type?.headings;
|
|
367
|
+
if (headings === undefined) return out;
|
|
368
|
+
if (headings == null || typeof headings !== "object" || Array.isArray(headings)) {
|
|
369
|
+
return [mk(ERROR, "theme-token", file, "type.headings must be an object of hero/h1/h2/h3 sizes")];
|
|
370
|
+
}
|
|
371
|
+
for (const [level, size] of Object.entries(headings)) {
|
|
372
|
+
if (!THEME_HEADING_LEVELS.includes(level)) {
|
|
373
|
+
out.push(mk(ERROR, "theme-token", file, `type.headings.${level} is not a heading level (use ${THEME_HEADING_LEVELS.join(", ")})`));
|
|
374
|
+
} else if (!isThemeCssSize(size)) {
|
|
375
|
+
out.push(mk(ERROR, "theme-token", file, `type.headings.${level} must be a CSS length such as "28px", "1.75rem" or "clamp(1.5rem, 4vw, 2rem)"`));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
355
381
|
// Standalone published-CLI mirror. packages/cli/test/validate.test.mjs pins it
|
|
356
382
|
// to extension-contract-values.mjs, the repository/runtime authority.
|
|
357
383
|
export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
|
|
@@ -361,6 +387,7 @@ export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
|
|
|
361
387
|
"block-palette@4",
|
|
362
388
|
"block-palette@5",
|
|
363
389
|
"block-palette@6",
|
|
390
|
+
"block-palette@7",
|
|
364
391
|
]);
|
|
365
392
|
export const FEATURED_PRODUCT_PRESENTATIONS = Object.freeze(["grid", "carousel"]);
|
|
366
393
|
const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
|
|
@@ -645,6 +672,9 @@ function validateHomeDoc(doc, file, tenantId) {
|
|
|
645
672
|
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
646
673
|
}
|
|
647
674
|
if (component === "hero" && block.slides !== undefined) validateHeroSlides(block.slides, file, at, out, tenantId);
|
|
675
|
+
if (component === "hero" && block.notice !== undefined && (typeof block.notice !== "string" || !block.notice.trim())) {
|
|
676
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.notice must be a non-empty plain-text string`));
|
|
677
|
+
}
|
|
648
678
|
if (component === "editorial" && block.image !== undefined) {
|
|
649
679
|
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
650
680
|
if (typeof block.imageAlt !== "string") {
|
|
@@ -723,6 +753,21 @@ export const LARGE_PUBLIC_ASSET_FIX =
|
|
|
723
753
|
* above); `printLoudAdvisories` is the shared renderer.
|
|
724
754
|
*/
|
|
725
755
|
export const LOUD_ADVISORY_RULES = [
|
|
756
|
+
[
|
|
757
|
+
"config-compliance-unknown-key",
|
|
758
|
+
"a compliance setting the platform does not read — a mandated notice may be missing",
|
|
759
|
+
"The store builds and every other check passes, but the notice this key was meant to turn on does not render. Publishing halts until the owner approves it.",
|
|
760
|
+
],
|
|
761
|
+
[
|
|
762
|
+
"config-unknown-key",
|
|
763
|
+
"a .tot/config.json key the platform does not read",
|
|
764
|
+
"It does nothing. Publishing halts until the owner approves it.",
|
|
765
|
+
],
|
|
766
|
+
[
|
|
767
|
+
"config-features-unknown-key",
|
|
768
|
+
"a features flag the platform does not read",
|
|
769
|
+
"The feature it names keeps its default. Publishing halts until the owner approves it.",
|
|
770
|
+
],
|
|
726
771
|
[
|
|
727
772
|
"git-conflict-markers",
|
|
728
773
|
"git conflict markers in submitted content — an unfinished merge/rebase?",
|
|
@@ -1128,12 +1173,14 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1128
1173
|
// 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
|
|
1129
1174
|
const themePath = located("theme.json");
|
|
1130
1175
|
if (existsSync(themePath)) {
|
|
1131
|
-
const { error } = readJsonSafe(themePath);
|
|
1176
|
+
const { value: theme, error } = readJsonSafe(themePath);
|
|
1132
1177
|
if (error) {
|
|
1133
1178
|
findings.push(
|
|
1134
1179
|
mk(ERROR, "theme-parse", rel(themePath),
|
|
1135
1180
|
`invalid JSON: ${error}`, "theme.json is loaded by an eager Vite glob — a parse error fails the entire storefront build"),
|
|
1136
1181
|
);
|
|
1182
|
+
} else {
|
|
1183
|
+
findings.push(...validateThemeHeadings(theme, rel(themePath)));
|
|
1137
1184
|
}
|
|
1138
1185
|
}
|
|
1139
1186
|
|