@tokenoftrust/cli 2.2.18 → 2.4.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/bin/tot.mjs CHANGED
@@ -61,6 +61,7 @@ const COMMANDS = [
61
61
  { invocation: "report go-live --pages <dir>", blurb: "your store's go-live readiness report, from its page-check results" },
62
62
  { invocation: "dev", blurb: "run your store locally with save→reload" },
63
63
  { invocation: "preview", blurb: "push your store to a reviewable preview" },
64
+ { invocation: "status", blurb: "the pipeline verdict for your active tenant — progressing / stuck / idle, and why" },
64
65
  { invocation: "ship", blurb: "publish the tenant's current green aggregate live (plan → confirm → ship)" },
65
66
  { invocation: "accept / tot merge", blurb: "queue a PR into the preview aggregate — operator verb, no go-live (plan → confirm → integrate)" },
66
67
  { invocation: "sync", blurb: "fetch `preview` and merge it into your local branch (accept-conflict recovery)" },
@@ -201,6 +202,11 @@ async function dispatch(cmd, rest, ctx) {
201
202
  return run(rest, ctx);
202
203
  }
203
204
 
205
+ if (cmd === "status") {
206
+ const { run } = await import("../src/commands/status.mjs");
207
+ return run(rest, ctx);
208
+ }
209
+
204
210
  if (cmd === "submit" || cmd === "deploy") {
205
211
  const { run } = await import("../src/commands/preview.mjs");
206
212
  return run(rest, ctx, { alias: cmd });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.2.18",
3
+ "version": "2.4.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",
@@ -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
+ }
@@ -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,109 @@
1
+ /**
2
+ * `tot status` — the pipeline verdict for your active tenant (migration DAG §U4):
3
+ * the SAME `kind`/`reason`/`nextAction` the Publish console and `tot preview`/`tot
4
+ * submit` print, available on its own without pushing anything. Reads
5
+ * `store_status` (tenant/repo-scoped, zero live Gitea calls) and prints its
6
+ * `verdict` field — pipeline state comes from there, never re-derived here.
7
+ *
8
+ * Dependency-free (global fetch via mcp.mjs's client).
9
+ */
10
+ import { createMcpClient } from "../mcp.mjs";
11
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
12
+ import { fail } from "../errors.mjs";
13
+ import { scopeToTenant } from "../tenant-scope.mjs";
14
+ import { resolveRepoAndTenant } from "./branches.mjs";
15
+ import { formatVerdictBlock } from "./submit.mjs";
16
+
17
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
18
+
19
+ const USAGE = `tot status — the pipeline verdict for your active tenant
20
+
21
+ tot status verdict for the current checkout's repo
22
+ tot status --tenant <d> verdict for a specific store (e.g. --tenant acme.com)
23
+ tot status --json machine-readable output
24
+
25
+ Options:
26
+ --tenant <appDomain> the store to read (default: the current checkout / its remote)
27
+ --repo <repo> an explicit repo name, when it differs from --tenant
28
+ --mcp <url> override the MCP base URL
29
+ --json print the raw { verdict, ... } payload instead of prose
30
+ `;
31
+
32
+ /**
33
+ * @param {string[]} argv
34
+ * @returns {{ tenant: string|null, repo: string|null, mcp: string|null, json: boolean, help: boolean }}
35
+ */
36
+ function parseStatusArgs(argv) {
37
+ const args = /** @type {{ tenant: string|null, repo: string|null, mcp: string|null, json: boolean, help: boolean }} */ ({
38
+ tenant: null, repo: null, mcp: null, json: false, help: false,
39
+ });
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i];
42
+ if (a === "--help" || a === "-h") args.help = true;
43
+ else if (a === "--json") args.json = true;
44
+ else if (a === "--tenant") args.tenant = argv[++i] ?? null;
45
+ else if (a === "--repo") args.repo = argv[++i] ?? null;
46
+ else if (a === "--mcp") args.mcp = argv[++i] ?? null;
47
+ }
48
+ return args;
49
+ }
50
+
51
+ /** @param {string[]} argv @param {any} ctx */
52
+ export async function run(argv, ctx) {
53
+ const env = process.env;
54
+ const args = parseStatusArgs(argv);
55
+ if (args.help) {
56
+ console.log(USAGE);
57
+ return 0;
58
+ }
59
+
60
+ const { repo, tenant, error } = resolveRepoAndTenant(args, ctx);
61
+ if (error) {
62
+ console.error(error);
63
+ return 2;
64
+ }
65
+
66
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
67
+ const client = createMcpClient(baseUrl);
68
+ try {
69
+ await establishSession(client, { env });
70
+ const scoped = await scopeToTenant(client, /** @type {string} */ (tenant));
71
+ const result = await scoped.callTool("store_status", { repo, include: ["branches"] });
72
+
73
+ if (result?.status === "error" || result?.status === "invalid_input" || result?.error) {
74
+ console.error(
75
+ fail(
76
+ `couldn't read status for ${repo}: ${result.error || result.message || "unknown error"}`,
77
+ "check --tenant / --repo, then re-run",
78
+ ),
79
+ );
80
+ return 1;
81
+ }
82
+
83
+ if (args.json) {
84
+ console.log(JSON.stringify({ verdict: result?.verdict ?? null, tenant: repo }, null, 2));
85
+ return 0;
86
+ }
87
+
88
+ const verdict = result?.verdict ?? null;
89
+ if (!verdict) {
90
+ console.log(` (no pipeline verdict available for ${repo} right now.)`);
91
+ return 0;
92
+ }
93
+ console.log(`${repo}:`);
94
+ for (const line of formatVerdictBlock(verdict)) console.log(line);
95
+ return 0;
96
+ } catch (e) {
97
+ if (e instanceof AuthUnavailableError) {
98
+ console.error(fail("sign in to read status", e.hint || "run `tot login`, then re-run"));
99
+ return 1;
100
+ }
101
+ console.error(
102
+ fail(
103
+ `couldn't reach the status service: ${String(e?.message || e)}`,
104
+ "check your connection and that you're signed in, then re-run",
105
+ ),
106
+ );
107
+ return 1;
108
+ }
109
+ }
@@ -962,14 +962,28 @@ export function chooseChangeId({ tenant, actorKey, branch = null, active = null,
962
962
  * @param {{ repo: string, changeId: string }} opts
963
963
  * @returns {Promise<string|null>}
964
964
  */
965
+ /**
966
+ * The FULL candidate_status record for one candidate (changeId/prNumber/branch/headSha/
967
+ * state/…), tolerant of the same three response shapes `candidateStateFor` tolerates
968
+ * (a bare candidate, `{candidates:[…]}`, or a plain array). Null when the tool throws or
969
+ * names no matching candidate — never thrown. Shared by `candidateStateFor` (state only)
970
+ * and `submitCandidate`'s 502-despite-success recovery (the full record).
971
+ * @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
972
+ * @param {{ repo: string, changeId: string }} opts
973
+ */
974
+ export async function fetchCandidateStatus(scoped, { repo, changeId }) {
975
+ const r = await scoped.callTool("candidate_status", { repo, changeId });
976
+ const c = Array.isArray(r)
977
+ ? r.find((x) => x?.changeId === changeId)
978
+ : Array.isArray(r?.candidates)
979
+ ? r.candidates.find((x) => x?.changeId === changeId)
980
+ : r;
981
+ return c ?? null;
982
+ }
983
+
965
984
  export async function candidateStateFor(scoped, { repo, changeId }) {
966
985
  try {
967
- const r = await scoped.callTool("candidate_status", { repo, changeId });
968
- const c = Array.isArray(r)
969
- ? r.find((x) => x?.changeId === changeId)
970
- : Array.isArray(r?.candidates)
971
- ? r.candidates.find((x) => x?.changeId === changeId)
972
- : r;
986
+ const c = await fetchCandidateStatus(scoped, { repo, changeId });
973
987
  return c && typeof c.state === "string" ? c.state : null;
974
988
  } catch (error) {
975
989
  recordDiagnostic(error, {
@@ -1086,6 +1100,28 @@ export async function submitCandidate(scoped, { repo, changeId, ref, headSha, ch
1086
1100
  return result;
1087
1101
  } catch (e) {
1088
1102
  if (e instanceof CandidateRegistrationError) throw e;
1103
+ // "502 despite success" (migration DAG §U4): candidate_open's forge write is
1104
+ // idempotent and may have LANDED even though its HTTP response never reached us
1105
+ // (a 5xx between MCP and its own caller, a dropped connection, …). Before reporting
1106
+ // a fatal "candidate not created," re-read it by its stable changeId — if it's
1107
+ // already there with the exact ref/head we just pushed, this submit DID succeed;
1108
+ // report that instead of a false failure. Purely a recovery path: any error from
1109
+ // the re-read (including "not found," which is the ordinary case for a genuine
1110
+ // registration failure) falls through to the original fatal error, unchanged.
1111
+ const httpStatus = /** @type {any} */ (e)?.diagnostic?.httpStatus;
1112
+ if (typeof httpStatus === "number" && httpStatus >= 500) {
1113
+ const existing = await fetchCandidateStatus(scoped, { repo, changeId }).catch(() => null);
1114
+ if (
1115
+ existing
1116
+ && typeof existing.prNumber === "number"
1117
+ && existing.branch === ref
1118
+ && existing.headSha === headSha
1119
+ && !isTerminalCandidateState(existing.state)
1120
+ ) {
1121
+ reportCandidate(existing, changeId, { quiet });
1122
+ return existing;
1123
+ }
1124
+ }
1089
1125
  throw new CandidateRegistrationError(`Candidate not created: ${describeReadbackError(e)}`, e);
1090
1126
  }
1091
1127
  }
@@ -1134,7 +1170,7 @@ function reportCandidate(result, changeId, { quiet = false } = {}) {
1134
1170
  * candidate?: {prNumber?: number, number?: number, state?: string, url?: string, headSha?: string|null}|null,
1135
1171
  * status?: {status?: string, reconcile?: object|null, compliance?: object|null,
1136
1172
  * previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
1137
- * notDispatched?: boolean, delivery?: object|null}|null,
1173
+ * notDispatched?: boolean, delivery?: object|null, verdict?: object|null}|null,
1138
1174
  * previewPrUrl?: string|null, error?: string|null, note?: string|null, noChanges?: boolean,
1139
1175
  * degraded?: boolean, diagnosticId?: string|null }} input
1140
1176
  */
@@ -1160,6 +1196,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
1160
1196
  notDispatched: status?.notDispatched ?? false,
1161
1197
  forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
1162
1198
  delivery: status?.delivery ?? null,
1199
+ verdict: status?.verdict ?? null,
1163
1200
  previewPrUrl,
1164
1201
  ...(degraded ? { degraded: true } : {}),
1165
1202
  ...(diagnosticId ? { diagnosticId } : {}),
@@ -1868,6 +1905,10 @@ export function normalizePreviewStatus(r) {
1868
1905
  shipped: r?.shipped ?? null,
1869
1906
  delivery,
1870
1907
  dispatched: delivery != null,
1908
+ // The ONE pipeline-status read every surface (console, `preview_status`, `tot
1909
+ // status`) shares (migration DAG §U4) — null when it could not be read. Printed
1910
+ // alongside the legacy fields above, never replacing them.
1911
+ verdict: r?.verdict ?? null,
1871
1912
  raw: r,
1872
1913
  };
1873
1914
  }
@@ -2122,6 +2163,29 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
2122
2163
  ];
2123
2164
  }
2124
2165
 
2166
+ /**
2167
+ * The pipeline-verdict lines (migration DAG §U4): kind, reason, next action — the SAME
2168
+ * verdict the Publish console and `tot status` print, so a developer never sees a
2169
+ * different story in two places. On `stuck`, also prints the support bundle text when
2170
+ * the caller fetched one (preview_status attaches it only when `contactSupport` is
2171
+ * true). Pure — unit-tested. Returns `[]` when there is nothing to print (no verdict,
2172
+ * or a verdict object that isn't shaped as expected — never throws on a malformed one).
2173
+ * @param {any} verdict @param {string|null} [supportBundleText]
2174
+ * @returns {string[]}
2175
+ */
2176
+ export function formatVerdictBlock(verdict, supportBundleText = null) {
2177
+ if (!verdict || typeof verdict !== "object" || typeof verdict.kind !== "string") return [];
2178
+ const lines = [`\n verdict: ${verdict.kind}${verdict.reason ? ` — ${verdict.reason}` : ""}`];
2179
+ const next = verdict.nextAction;
2180
+ if (next && typeof next.label === "string") {
2181
+ lines.push(` next: ${next.label}${next.command ? ` (${next.command})` : ""}`);
2182
+ }
2183
+ if (verdict.kind === "stuck" && supportBundleText) {
2184
+ lines.push("", " — support bundle (share this with support) —", supportBundleText);
2185
+ }
2186
+ return lines;
2187
+ }
2188
+
2125
2189
  /**
2126
2190
  * Print the reconcile/compliance/preview result and, on a clean reconcile with a
2127
2191
  * preview URL, open it in the browser (unless opts.open === false). `quiet`
@@ -2135,6 +2199,7 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
2135
2199
  * @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
2136
2200
  */
2137
2201
  function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
2202
+ if (!quiet) for (const line of formatVerdictBlock(s?.verdict, s?.supportBundleText)) console.log(line);
2138
2203
  if (!s || s.status === "unknown") {
2139
2204
  if (!quiet) {
2140
2205
  console.log(
@@ -25,4 +25,41 @@ export function declarationFindings(
25
25
  current: Record<string, unknown> | null | undefined,
26
26
  previous: Record<string, unknown> | null | undefined,
27
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[];
28
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;
@@ -17,7 +17,7 @@
17
17
 
18
18
  export const TOT_CONFIG_KEYS = [
19
19
  "$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
20
- "siteType", "compliance", "capabilities", "features", "embeds",
20
+ "displayName", "siteType", "compliance", "capabilities", "features", "embeds",
21
21
  ];
22
22
  // Keys other tooling has written that the platform does NOT read, with the key it does.
23
23
  const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
@@ -59,6 +59,99 @@ const ERROR = "error";
59
59
  const WARN = "warn";
60
60
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
61
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
+
62
155
  /** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
63
156
  function mk(level, rule, file, message, fix) {
64
157
  return { level, rule, file, message, fix };
@@ -160,12 +253,30 @@ export function droppedMandatedNotices(previous, current) {
160
253
  .map((key) => ({ key, notice: COMPLIANCE_NOTICES[/** @type {keyof typeof COMPLIANCE_NOTICES} */ (key)] }));
161
254
  }
162
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
+
163
272
  /**
164
273
  * The publication validator's findings for a revision's declaration: an owner-approvable
165
- * `CONFIG:` finding per unknown key and a `COMPLIANCE:` finding per mandated notice the
166
- * live site shows and this revision drops. Both are QUALITY findings
167
- * (scripts/publish/lib/finding-severity.mjs): publication halts until the owner accepts
168
- * them through "Accept issue & continue".
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.
169
280
  * @param {Record<string, any> | null | undefined} current
170
281
  * @param {Record<string, any> | null | undefined} previous
171
282
  * @returns {string[]}
@@ -174,6 +285,10 @@ export function declarationFindings(current, previous) {
174
285
  const out = [];
175
286
  if (isObject(current)) {
176
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
+ }
177
292
  }
178
293
  for (const { key, notice } of droppedMandatedNotices(previous, current)) {
179
294
  out.push(
@@ -181,9 +296,22 @@ export function declarationFindings(current, previous) {
181
296
  `declares it — publishing removes a mandated notice from the store.`,
182
297
  );
183
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
+ }
184
306
  return out;
185
307
  }
186
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
+
187
315
  /**
188
316
  * The validator's report on a `.tot/config.json`: a WARNING per unknown key (owner-approved
189
317
  * before publication), an error per wrong-shaped value of a known key.
@@ -202,6 +330,10 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
202
330
  ? `rename it to \`${unknown.suggestion}\`, or keep it and approve the warning when you publish`
203
331
  : "remove it, or keep it and approve the warning when you publish"));
204
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)"));
336
+ }
205
337
  if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
206
338
  out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
207
339
  }
@@ -210,6 +342,18 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
210
342
  out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
211
343
  }
212
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
+ }
213
357
  if (config.capabilities !== undefined && !isObject(config.capabilities)) {
214
358
  out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
215
359
  }
@@ -219,3 +363,254 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
219
363
  }
220
364
  return out;
221
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
- throw diagnosticError(`${method} failed: response body was unreadable`, {
209
- operation: method, category: "malformed_response", httpStatus: res.status,
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);
@@ -51,6 +51,7 @@ export const PIN_SCOPED_TOOLS = Object.freeze(new Set([
51
51
  "candidate_list",
52
52
  "candidate_delete",
53
53
  "candidate_refresh",
54
+ "store_status",
54
55
  ]));
55
56
 
56
57
  /**
package/src/validate.mjs CHANGED
@@ -353,6 +353,31 @@ function validateScriptsDoc(doc, file, config) {
353
353
  return out;
354
354
  }
355
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
+
356
381
  // Standalone published-CLI mirror. packages/cli/test/validate.test.mjs pins it
357
382
  // to extension-contract-values.mjs, the repository/runtime authority.
358
383
  export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
@@ -362,6 +387,7 @@ export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
362
387
  "block-palette@4",
363
388
  "block-palette@5",
364
389
  "block-palette@6",
390
+ "block-palette@7",
365
391
  ]);
366
392
  export const FEATURED_PRODUCT_PRESENTATIONS = Object.freeze(["grid", "carousel"]);
367
393
  const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
@@ -646,6 +672,9 @@ function validateHomeDoc(doc, file, tenantId) {
646
672
  validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
647
673
  }
648
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
+ }
649
678
  if (component === "editorial" && block.image !== undefined) {
650
679
  validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
651
680
  if (typeof block.imageAlt !== "string") {
@@ -1144,12 +1173,14 @@ export function validateTenant(tenantDir, opts = {}) {
1144
1173
  // 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
1145
1174
  const themePath = located("theme.json");
1146
1175
  if (existsSync(themePath)) {
1147
- const { error } = readJsonSafe(themePath);
1176
+ const { value: theme, error } = readJsonSafe(themePath);
1148
1177
  if (error) {
1149
1178
  findings.push(
1150
1179
  mk(ERROR, "theme-parse", rel(themePath),
1151
1180
  `invalid JSON: ${error}`, "theme.json is loaded by an eager Vite glob — a parse error fails the entire storefront build"),
1152
1181
  );
1182
+ } else {
1183
+ findings.push(...validateThemeHeadings(theme, rel(themePath)));
1153
1184
  }
1154
1185
  }
1155
1186