@tokenoftrust/cli 2.0.23 → 2.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.0.23",
3
+ "version": "2.0.25",
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",
@@ -36,6 +36,9 @@
36
36
  "access": "public",
37
37
  "registry": "https://registry.npmjs.org"
38
38
  },
39
+ "dependencies": {
40
+ "sharp": "^0.34.5"
41
+ },
39
42
  "devDependencies": {
40
43
  "@types/node": "^20.16.0",
41
44
  "typescript": "^5.6.3"
@@ -24,12 +24,13 @@
24
24
  *
25
25
  * TRANSPORT — `POST /api/changes/revert` (opens the corrective candidate, then
26
26
  * runs it through the SAME `TenantIntegrationQueue.enqueue` accept pipeline
27
- * `tot accept` drives). Like `tot accept` / `tot ship`, the CLI reaches it with
28
- * the OPERATOR-SECRET Bearer transport (`resolveOperatorSecret` + `X-Tot-Owner`
29
- * + `x-tot-capability`). The response is the honest terminal aggregate state —
30
- * `queueState` / `runState` / `pointerMoved` / `aggregateSha` / `changeId` (the
31
- * corrective candidate's own id) / `statusMessage` — rendered VERBATIM, never a
32
- * bare "reverted".
27
+ * `tot accept` drives). Like `tot accept`, the CLI reaches it with either the
28
+ * operator-secret Bearer transport (`resolveOperatorSecret` + `X-Tot-Owner` +
29
+ * `x-tot-capability`) or a signed-in developer's viewer session when they hold
30
+ * a live `ship-on-behalf` grant. The response is the honest terminal aggregate
31
+ * state — `queueState` / `runState` / `pointerMoved` / `aggregateSha` /
32
+ * `changeId` (the corrective candidate's own id) / `statusMessage` — rendered
33
+ * VERBATIM, never a bare "reverted".
33
34
  *
34
35
  * HUMAN GATE — correcting the shared preview is a decision a human makes, so
35
36
  * this ALWAYS states the EXACT plan (a NEW corrective candidate, merged through
@@ -44,6 +45,9 @@ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
44
45
  // Reuse `tot ship`'s operator-secret precedence verbatim so accept + ship + revert
45
46
  // speak ONE operator-auth contract.
46
47
  import { resolveOperatorSecret } from "./ship.mjs";
48
+ // No-operator-secret path: mint a viewer session from the developer's OWN `tot`
49
+ // login. The server still gates the request on their live ship-on-behalf grant.
50
+ import { resolveViewerTransport } from "../viewer-session.mjs";
47
51
 
48
52
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
49
53
 
@@ -97,6 +101,9 @@ Options:
97
101
 
98
102
  To undo something already LIVE, use \`tot rollback\` — not \`tot revert\`.
99
103
 
104
+ Authentication: a signed-in developer with a live \`ship-on-behalf\` grant can
105
+ use their own \`tot login\` session. Operators/CI may instead provide a secret.
106
+
100
107
  Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
101
108
  GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).`;
102
109
 
@@ -212,22 +219,25 @@ export function reportReverted(result, { tenant, label }) {
212
219
 
213
220
  /**
214
221
  * The revert flow after args are parsed: state the exact plan, confirm, then POST
215
- * `/api/changes/revert` (operator-secret transport) and render the honest terminal
216
- * aggregate status. `fetch`/`confirmPlan` injected so it is unit-tested with no
217
- * network/TTY.
222
+ * `/api/changes/revert` over either the operator-secret or developer viewer-session
223
+ * transport and render the honest terminal aggregate status. Dependencies are
224
+ * injected so it is unit-tested with no network/TTY.
218
225
  *
219
226
  * @param {{ tenant:string, prNumber:number|null, changeId:string|null,
220
- * message:string|null, secret:string, storefrontUrl?:string|null, yes?:boolean }} params
221
- * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm }} [deps]
227
+ * message:string|null, secret:string, storefrontUrl?:string|null, yes?:boolean,
228
+ * env?:NodeJS.ProcessEnv }} params
229
+ * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
230
+ * resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
222
231
  * @returns {Promise<number>} process exit code
223
232
  */
224
233
  export async function runRevert(
225
- { tenant, prNumber, changeId, message = null, secret, storefrontUrl = null, yes = false },
234
+ { tenant, prNumber, changeId, message = null, secret, storefrontUrl = null, yes = false, env = process.env },
226
235
  deps = {},
227
236
  ) {
228
237
  const fetchImpl = deps.fetch || globalThis.fetch;
229
238
  const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
230
- const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
239
+ const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
240
+ let base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
231
241
  const label = prNumber != null ? `PR #${prNumber}` : `change ${changeId}`;
232
242
 
233
243
  // 1. State the EXACT plan (shared affordance) — a NEW corrective candidate
@@ -257,22 +267,25 @@ export async function runRevert(
257
267
  return 1;
258
268
  }
259
269
 
260
- // 2. Operator-secret transport — the CLI holds no storefront cookie.
261
- if (!secret) {
262
- console.error(
263
- fail(
264
- "undoing an integrated PR is an OPERATOR action — it needs an operator secret",
265
- "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
266
- ),
267
- );
268
- return 2;
270
+ // 2. Resolve the transport. Operators/CI use the secret on the generic host;
271
+ // invited developers use their own `tot login` identity on the tenant-scoped
272
+ // host. Both reach the same server route and the same live capability gate.
273
+ let authHeaders;
274
+ if (secret) {
275
+ authHeaders = {
276
+ authorization: `Bearer ${secret}`,
277
+ "x-tot-owner": tenant,
278
+ "x-tot-capability": "ship-on-behalf",
279
+ };
280
+ } else {
281
+ const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
282
+ if (!viewer.ok) {
283
+ console.error(fail(viewer.message, viewer.hint));
284
+ return 2;
285
+ }
286
+ base = viewer.base;
287
+ authHeaders = viewer.authHeaders;
269
288
  }
270
- const authHeaders = {
271
- "content-type": "application/json",
272
- authorization: `Bearer ${secret}`,
273
- "x-tot-owner": tenant,
274
- "x-tot-capability": "ship-on-behalf",
275
- };
276
289
  const requestBody = {
277
290
  repo: tenant,
278
291
  ...(prNumber != null ? { prNumber } : {}),
@@ -285,7 +298,7 @@ export async function runRevert(
285
298
  try {
286
299
  res = await fetchImpl(`${base}/api/changes/revert`, {
287
300
  method: "POST",
288
- headers: authHeaders,
301
+ headers: { "content-type": "application/json", ...authHeaders },
289
302
  body: JSON.stringify(requestBody),
290
303
  });
291
304
  } catch (e) {
@@ -311,7 +324,7 @@ export async function runRevert(
311
324
  fail(
312
325
  `the revert request was refused: ${msg}`,
313
326
  res.status === 401 || res.status === 403
314
- ? "check the operator secret and that it's authorised for this tenant"
327
+ ? "you need a live ship-on-behalf grant on this tenant (ask the store owner) — or an operator secret authorised for it"
315
328
  : "check --tenant / --url / --preview, then re-run",
316
329
  ),
317
330
  );
@@ -366,5 +379,6 @@ export async function run(argv, ctx) {
366
379
  secret: resolveOperatorSecret(args.secret, env),
367
380
  storefrontUrl,
368
381
  yes: args.yes,
382
+ env,
369
383
  });
370
384
  }
@@ -16,14 +16,19 @@
16
16
  import { existsSync } from "node:fs";
17
17
  import { join, resolve } from "node:path";
18
18
  import { validateTenant, ERROR, WARN, printLoudAdvisories } from "../validate.mjs";
19
+ import { validateVisualParityClosure } from "../visual-parity.mjs";
19
20
  import { fail } from "../errors.mjs";
20
21
  import { tenantDirSegments } from "../tenant-dirs.mjs";
21
22
 
23
+ const mergeFindings = (...groups) => [...new Map(groups.flat().map((item) => [JSON.stringify(item), item])).values()]
24
+ .sort((a, b) => `${a.file}\0${a.rule}\0${a.message}`.localeCompare(`${b.file}\0${b.rule}\0${b.message}`));
25
+
22
26
  function parseArgs(argv) {
23
- const a = { tenant: null, workspace: null, json: false, help: false };
27
+ const a = { tenant: null, workspace: null, json: false, help: false, profile: null, profileSpecified: false };
24
28
  for (let i = 0; i < argv.length; i++) {
25
29
  const t = argv[i];
26
30
  if (t === "--workspace") a.workspace = argv[++i];
31
+ else if (t === "--profile") { a.profileSpecified = true; a.profile = argv[++i] ?? null; }
27
32
  else if (t === "--json") a.json = true;
28
33
  else if (t === "--help" || t === "-h") a.help = true;
29
34
  else if (!t.startsWith("--") && !a.tenant) a.tenant = t;
@@ -36,6 +41,7 @@ const USAGE = `tot validate — lint your store before you submit
36
41
  tot validate (inside a checkout) lint this store
37
42
  tot validate <tenant> (inside the monorepo) lint tenants/<tenant>/
38
43
  tot validate --workspace <dir> lint a specific checkout directory
44
+ tot validate --profile visual-parity close portable page/asset/link migration scope
39
45
  tot validate --json machine-readable findings`;
40
46
 
41
47
  /**
@@ -78,7 +84,11 @@ export function run(argv, ctx) {
78
84
  return 2;
79
85
  }
80
86
 
81
- const { ok, findings } = validateTenant(dir, {
87
+ if (args.profileSpecified && args.profile !== "visual-parity") {
88
+ console.error(fail(`unknown validation profile: ${args.profile}`, "use --profile visual-parity"));
89
+ return 2;
90
+ }
91
+ const baseline = validateTenant(dir, {
82
92
  tenantId: target.tenantId ?? undefined,
83
93
  scope: target.scope ?? undefined,
84
94
  // A checkout / bare-tenant target is served by the ToT storefront platform, so
@@ -86,6 +96,14 @@ export function run(argv, ctx) {
86
96
  // arbitrary --workspace <dir> stays conservative (unless its config resolves the host).
87
97
  mode: args.workspace ? "workspace" : "monorepo",
88
98
  });
99
+ const result = args.profile === "visual-parity"
100
+ ? (() => {
101
+ const parity = validateVisualParityClosure(dir, { tenantId: target.tenantId ?? undefined });
102
+ const findings = mergeFindings(baseline.findings, parity.findings);
103
+ return { ok: !findings.some((item) => item.level === ERROR), findings };
104
+ })()
105
+ : baseline;
106
+ const { ok, findings } = result;
89
107
 
90
108
  if (args.json) {
91
109
  console.log(JSON.stringify({ ok, findings }, null, 2));
@@ -105,7 +123,7 @@ export function run(argv, ctx) {
105
123
  // the findings below say why; the header should say so too, not disguise
106
124
  // a directory as a domain.
107
125
  const label = target.tenantId ? target.tenantId : `(tenant unresolved) ${target.dir}`;
108
- console.log(`\ntot validate — ${label}\n`);
126
+ console.log(`\ntot validate${args.profile ? ` --profile ${args.profile}` : ""} — ${label}\n`);
109
127
  for (const f of findings) {
110
128
  const tag = f.level === ERROR ? "✗" : "⚠";
111
129
  console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
package/src/validate.mjs CHANGED
@@ -142,8 +142,8 @@ function validateRawHtmlBody(html) {
142
142
  // CLI bundle can't reach the repo's public-runtime package at runtime (same
143
143
  // constraint as the KNOWN_EMBEDS mirror above), so this is a copy — parity
144
144
  // test: packages/public-runtime/tests/chrome-config-shape-parity.test.ts.
145
- const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal"]);
146
- const CHROME_FOOTER_VARIANTS = new Set(["default"]);
145
+ const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal", "compact-centered"]);
146
+ const CHROME_FOOTER_VARIANTS = new Set(["default", "light-collapsible"]);
147
147
  /**
148
148
  * The declarative-interaction-tracking rule, mirrored: every actionable item —
149
149
  * an object carrying both `id` and `href` — declares EXACTLY ONE of a non-empty
@@ -538,6 +538,9 @@ function validateTrustStrip(block, file, at, out, tenantId) {
538
538
  }
539
539
 
540
540
  function validateFaq(block, file, at, out) {
541
+ if (block.lede !== undefined && (typeof block.lede !== "string" || !block.lede.trim())) {
542
+ out.push(mk(ERROR, "block-prop", file, `${at}.lede must be a non-empty plain-text string`));
543
+ }
541
544
  if (!Array.isArray(block.items)) {
542
545
  out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
543
546
  return;
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Read-only closure checks for the optional visual-parity migration module.
3
+ * The tenant checkout remains portable: this reads only content/, public/, and
4
+ * .tot/visual-parity.json, never a preview host or a delivery provider.
5
+ */
6
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
8
+ import { extname, join, relative } from "node:path";
9
+
10
+ const ERROR = "error";
11
+ // Public routes implemented by the renderer. Authentication and cart URLs are
12
+ // deliberately absent: they are flow-specific, not portable parity targets.
13
+ const PLATFORM_ROUTES = ["/collections", "/collections/", "/products/", "/search", "/saved", "/account", "/account/", "/blog", "/blog/"];
14
+ const ASSET_EXTENSIONS = new Set([".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"]);
15
+
16
+ const finding = (rule, file, message, fix) => ({ level: ERROR, rule, file, message, fix });
17
+ const walk = (dir) => !existsSync(dir) ? [] : readdirSync(dir).flatMap((name) => {
18
+ const path = join(dir, name);
19
+ return statSync(path).isDirectory() ? walk(path) : [path];
20
+ });
21
+ const pointer = (parts) => parts.length ? `/${parts.map((part) => String(part).replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}` : "/";
22
+ const stripQuery = (value) => value.split(/[?#]/, 1)[0];
23
+ const normalizeRoute = (value) => {
24
+ const path = stripQuery(value).replace(/\/+/g, "/").replace(/\/$/, "") || "/";
25
+ return path.startsWith("/") ? path : `/${path}`;
26
+ };
27
+ const hasDotSegment = (value) => stripQuery(value).split("/").some((part) => part === "." || part === ".." || /^%2e(?:%2e)?$/i.test(part));
28
+ const safeLocator = (value) => {
29
+ try {
30
+ const url = new URL(value.startsWith("//") ? `https:${value}` : value);
31
+ return url.origin;
32
+ } catch {
33
+ if (value.startsWith("/")) return normalizeRoute(value);
34
+ return "[redacted authored value]";
35
+ }
36
+ };
37
+
38
+ function parseConfig(tenantDir) {
39
+ const path = join(tenantDir, ".tot", "visual-parity.json");
40
+ if (!existsSync(path)) return { value: {}, findings: [] };
41
+ try {
42
+ const value = JSON.parse(readFileSync(path, "utf8"));
43
+ if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("must be a JSON object");
44
+ const findings = [];
45
+ if (value.externalNavigationAllowlist !== undefined && !Array.isArray(value.externalNavigationAllowlist)) {
46
+ findings.push(finding("parity-config-shape", ".tot/visual-parity.json", "externalNavigationAllowlist must be an array of HTTPS origins or URLs", "replace it with an explicit reviewed array, for example [\"https://partner.example\"]"));
47
+ }
48
+ for (const [index, entry] of (Array.isArray(value.externalNavigationAllowlist) ? value.externalNavigationAllowlist : []).entries()) {
49
+ try {
50
+ const url = new URL(entry);
51
+ if (typeof entry !== "string" || url.protocol !== "https:" || url.username || url.password || /[?#]/.test(entry)) throw new Error("invalid");
52
+ } catch {
53
+ findings.push(finding("parity-allowlist-entry", `.tot/visual-parity.json/externalNavigationAllowlist/${index}`, "allowlist entries must be HTTPS origins or exact path prefixes without credentials, query, or fragment", "use https://partner.example for an origin or https://partner.example/returns for that path boundary"));
54
+ }
55
+ }
56
+ return { value, findings };
57
+ } catch {
58
+ return { value: {}, findings: [finding("parity-config-parse", ".tot/visual-parity.json", "invalid JSON", "fix .tot/visual-parity.json before closing visual parity")] };
59
+ }
60
+ }
61
+
62
+ function tenantFromConfig(tenantDir) {
63
+ try {
64
+ const value = JSON.parse(readFileSync(join(tenantDir, ".tot", "config.json"), "utf8"));
65
+ return typeof value?.tenant === "string" ? value.tenant : null;
66
+ } catch { return null; }
67
+ }
68
+
69
+ function allowedNavigation(value, allowlist) {
70
+ try {
71
+ const url = new URL(value);
72
+ return allowlist.some((entry) => {
73
+ try {
74
+ const allowed = new URL(entry);
75
+ if (allowed.protocol !== "https:" || allowed.username || allowed.password) return false;
76
+ if (allowed.pathname === "/") return url.origin === allowed.origin;
77
+ return url.origin === allowed.origin && (url.pathname === allowed.pathname || url.pathname.startsWith(`${allowed.pathname.replace(/\/$/, "")}/`));
78
+ } catch { return false; }
79
+ });
80
+ } catch { return false; }
81
+ }
82
+
83
+ function assetType(bytes) {
84
+ const ascii = bytes.subarray(0, 256).toString("utf8").trimStart().toLowerCase();
85
+ if (pngValid(bytes)) return ".png";
86
+ if (bytes.subarray(0, 3).toString("ascii") === "GIF") return ".gif";
87
+ if (jpegValid(bytes)) return ".jpg";
88
+ if (bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return ".webp";
89
+ if (bytes.subarray(4, 8).toString("ascii") === "ftyp" && bytes.subarray(8, 12).toString("ascii").toLowerCase().includes("avif")) return ".avif";
90
+ if (bytes.subarray(0, 4).toString("ascii") === "wOFF") return ".woff";
91
+ if (bytes.subarray(0, 4).toString("ascii") === "wOF2") return ".woff2";
92
+ if (icoValid(bytes)) return ".ico";
93
+ if (ascii.startsWith("<svg") || ascii.startsWith("<?xml") && ascii.includes("<svg")) return ".svg";
94
+ return null;
95
+ }
96
+ function pngValid(bytes) {
97
+ if (bytes.length < 45 || !bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return false;
98
+ let pos = 8, sawIhdr = false;
99
+ let sawIdat = false;
100
+ while (pos + 12 <= bytes.length) {
101
+ const length = bytes.readUInt32BE(pos); const type = bytes.subarray(pos + 4, pos + 8).toString("ascii");
102
+ if (length > bytes.length - pos - 12) return false;
103
+ if (type === "IHDR") { if (sawIhdr || length !== 13 || bytes.readUInt32BE(pos + 8) === 0 || bytes.readUInt32BE(pos + 12) === 0) return false; sawIhdr = true; }
104
+ if (type === "IDAT" && length > 0) sawIdat = true;
105
+ pos += 12 + length; if (type === "IEND") return sawIhdr && sawIdat && length === 0 && pos === bytes.length;
106
+ } return false;
107
+ }
108
+ function jpegValid(bytes) {
109
+ if (bytes.length < 10 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
110
+ let pos = 2, sof = false;
111
+ while (pos < bytes.length) {
112
+ if (bytes[pos++] !== 0xff) return false; while (bytes[pos] === 0xff) pos++;
113
+ const marker = bytes[pos++]; if (marker === 0xd9) return sof && pos === bytes.length;
114
+ if (marker === 0xda) { if (pos + 2 > bytes.length) return false; const length = bytes.readUInt16BE(pos); const scan = pos + length; return sof && scan < bytes.length - 2 && bytes[bytes.length - 2] === 0xff && bytes[bytes.length - 1] === 0xd9; }
115
+ if (marker === 0x01 || marker >= 0xd0 && marker <= 0xd7) continue;
116
+ if (pos + 2 > bytes.length) return false; const length = bytes.readUInt16BE(pos); if (length < 2 || pos + length > bytes.length) return false;
117
+ if (marker >= 0xc0 && marker <= 0xc3 && length >= 8) { if (bytes.readUInt16BE(pos + 3) === 0 || bytes.readUInt16BE(pos + 5) === 0) return false; sof = true; }
118
+ pos += length;
119
+ } return false;
120
+ }
121
+ function icoValid(bytes) {
122
+ if (bytes.length < 22 || bytes.readUInt16LE(0) !== 0 || bytes.readUInt16LE(2) !== 1) return false;
123
+ const count = bytes.readUInt16LE(4); if (!count || bytes.length < 6 + count * 16) return false;
124
+ for (let i = 0; i < count; i++) { const at = 6 + i * 16, size = bytes.readUInt32LE(at + 8), offset = bytes.readUInt32LE(at + 12); if (size && offset >= 6 + count * 16 && offset + size <= bytes.length) { const payload = bytes.subarray(offset, offset + size); if (pngValid(payload) || payload.length >= 40 && payload.readInt32LE(0) >= 40 && payload.readInt32LE(4) > 0 && payload.readInt32LE(8) > 0) return true; } }
125
+ return false;
126
+ }
127
+
128
+ function checkAsset(value, at, file, context) {
129
+ const { findings, tenantId, publicDir } = context;
130
+ if (!value || value.startsWith("#")) return;
131
+ if (/^data:/i.test(value)) {
132
+ findings.push(finding("parity-data-asset", `${file}${at}`, "embedded data assets are not portable parity inputs", "copy the asset into public/ and use a tenant-local path"));
133
+ return;
134
+ }
135
+ if (/^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith("//")) {
136
+ findings.push(finding("parity-external-asset", `${file}${at}`, `asset source ${safeLocator(value)} is off-host`, "copy the asset into public/ and reference it with /tenants/<tenant>/..."));
137
+ return;
138
+ }
139
+ const path = stripQuery(value);
140
+ let rel;
141
+ if (path.startsWith("/tenants/")) {
142
+ const prefix = `/tenants/${tenantId}/`;
143
+ if (!tenantId || !path.startsWith(prefix)) {
144
+ findings.push(finding("parity-cross-tenant-asset", `${file}${at}`, "asset does not belong to this tenant", "copy the asset into this tenant's public/ directory"));
145
+ return;
146
+ }
147
+ rel = path.slice(prefix.length);
148
+ } else if (path.startsWith("/")) {
149
+ findings.push(finding("parity-unowned-asset", `${file}${at}`, "asset is not tenant-local", "use a tenant-local asset committed under public/"));
150
+ return;
151
+ } else rel = path;
152
+ if (!rel || hasDotSegment(rel)) {
153
+ findings.push(finding("parity-asset-path", `${file}${at}`, "asset path escapes or does not name public/", "use a direct tenant-local path under public/"));
154
+ return;
155
+ }
156
+ const asset = join(publicDir, rel);
157
+ if (!existsSync(asset)) {
158
+ findings.push(finding("parity-asset-missing", `${file}${at}`, "tenant-local asset is absent from public/", "add the referenced asset under public/ or correct the reference"));
159
+ return;
160
+ }
161
+ const extension = extname(rel).toLowerCase();
162
+ if (!ASSET_EXTENSIONS.has(extension)) {
163
+ findings.push(finding("parity-asset-extension", `${file}${at}`, "asset has an unsupported extension", "re-export favicon/assets as PNG or SVG; font containers require platform-managed validation"));
164
+ return;
165
+ }
166
+ if (statSync(asset).isDirectory()) {
167
+ findings.push(finding("parity-asset-directory", `${file}${at}`, "asset names a directory", "reference a file under public/"));
168
+ return;
169
+ }
170
+ if (ASSET_EXTENSIONS.has(extension)) {
171
+ const actual = assetType(readFileSync(asset));
172
+ const compatible = actual === extension || actual === ".jpg" && extension === ".jpeg";
173
+ if (!actual) findings.push(finding("parity-asset-undecodable", `${file}${at}`, `asset does not pass bounded ${extension} structural integrity checks`, "replace it with a structurally valid file of the declared type"));
174
+ else if (!compatible) findings.push(finding("parity-asset-type", `${file}${at}`, `asset bytes do not match the ${extension} extension`, "export the asset in the declared format"));
175
+ else if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"].includes(extension)) context.decodeAssets.push({ asset, file: `${file}${at}` });
176
+ }
177
+ }
178
+
179
+ function decodeImages(entries) {
180
+ if (!entries.length) return new Set();
181
+ const source = "const{createRequire}=require('node:module');const sharp=createRequire(process.argv[1])('sharp');(async()=>{let a=JSON.parse(process.argv[2]);let o=[];for(const p of a){try{let r=await sharp(p,{failOn:'error'}).raw().toBuffer({resolveWithObject:true});o.push(r.info.width>0&&r.info.height>0)}catch{o.push(false)}}process.stdout.write(JSON.stringify(o))})().catch(()=>process.exit(1));";
182
+ const result = spawnSync(process.execPath, ["-e", source, import.meta.url, JSON.stringify(entries.map((entry) => entry.asset))], { encoding: "utf8", timeout: 15000 });
183
+ try { if (result.status !== 0) throw new Error(); const output = JSON.parse(result.stdout); return new Set(entries.flatMap((_, index) => output[index] ? [] : [index])); } catch { return new Set(entries.map((_, index) => index)); }
184
+ }
185
+
186
+ function inspectHtml(html, file, at, context) {
187
+ const attr = /\b(href|src)\s*=\s*(?:(["'])(.*?)\2|([^\s>]+))/gi;
188
+ let index = 0;
189
+ for (const match of html.matchAll(attr)) {
190
+ const value = (match[3] ?? match[4] ?? "").trim();
191
+ inspectReference(match[1].toLowerCase(), value, `${at}/html-attribute-${index++}`, file, context);
192
+ }
193
+ }
194
+ function inspectReference(kind, value, at, file, context) {
195
+ if (kind === "src") return checkAsset(value, at, file, context);
196
+ if (!value || value.startsWith("#") || /^(mailto:|tel:)/i.test(value)) return;
197
+ if (/^(javascript|vbscript|data):/i.test(value)) {
198
+ context.findings.push(finding("parity-executable-link", `${file}${at}`, "link uses an executable scheme", "replace it with a local route or a reviewed HTTPS destination"));
199
+ return;
200
+ }
201
+ if (/^https?:\/\//i.test(value) || value.startsWith("//")) {
202
+ if (!allowedNavigation(value.startsWith("//") ? `https:${value}` : value, context.allowlist)) context.findings.push(finding("parity-external-navigation", `${file}${at}`, `external navigation ${safeLocator(value)} is not on the reviewed allowlist`, "add its HTTPS origin or URL to .tot/visual-parity.json only after human review"));
203
+ return;
204
+ }
205
+ if (!value.startsWith("/")) {
206
+ context.findings.push(finding("parity-relative-link", `${file}${at}`, "relative link has no stable paired route", "use a root-absolute local route"));
207
+ return;
208
+ }
209
+ if (hasDotSegment(value)) {
210
+ context.findings.push(finding("parity-route-path", `${file}${at}`, "local route contains a dot segment", "use a canonical root-absolute route"));
211
+ return;
212
+ }
213
+ const route = normalizeRoute(value);
214
+ if (!context.routes.has(route) && !PLATFORM_ROUTES.some((platform) => platform.endsWith("/") ? route.startsWith(platform) : route === platform)) context.findings.push(finding("parity-route-missing", `${file}${at}`, "local route is not declared by a page slug or platform", "add the matching content/pages JSON record or correct the link"));
215
+ }
216
+
217
+ function inspectValue(value, file, parts, context) {
218
+ if (Array.isArray(value)) return value.forEach((item, index) => inspectValue(item, file, [...parts, index], context));
219
+ if (!value || typeof value !== "object") return;
220
+ for (const [key, child] of Object.entries(value)) {
221
+ const next = [...parts, key];
222
+ if (key === "body_html" && typeof child === "string") inspectHtml(child, file, pointer(next), context);
223
+ if ((key === "href" || key === "ctaHref") && typeof child === "string") inspectReference("href", child, pointer(next), file, context);
224
+ if ((key === "src" || key === "image" || key === "icon" || key === "logo" || key === "logoDark" || key === "favicon") && typeof child === "string") checkAsset(child, pointer(next), file, context);
225
+ if (typeof child === "string" && /(?:css|style|stylesheet)$/i.test(key)) {
226
+ for (const match of child.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gi)) checkAsset(match[2].trim(), `${pointer(next)}/css`, file, context);
227
+ }
228
+ inspectValue(child, file, next, context);
229
+ }
230
+ }
231
+
232
+ function pageShape(value, file) {
233
+ const findings = [];
234
+ const required = [["slug", "string"], ["title", "string"]];
235
+ for (const [key, type] of required) {
236
+ const valid = type === "array" ? Array.isArray(value[key]) : typeof value[key] === type;
237
+ if (!valid || (type === "string" && value[key].trim() === "")) findings.push(finding("parity-page-field", `${file}/${key}`, `${key} must be a non-empty ${type}`, `set ${key} to the page value expected by the renderer`));
238
+ }
239
+ if (value.isPolicy !== undefined && typeof value.isPolicy !== "boolean") findings.push(finding("parity-page-field", `${file}/isPolicy`, "isPolicy must be a boolean when present", "set isPolicy to true or false"));
240
+ if (value.sections !== undefined && !Array.isArray(value.sections)) findings.push(finding("parity-page-field", `${file}/sections`, "sections must be an array when present", "set sections to an array of renderer-supported sections"));
241
+ if (Array.isArray(value.sections)) value.sections.forEach((section, index) => {
242
+ const at = `${file}/sections/${index}`;
243
+ if (!section || typeof section !== "object" || Array.isArray(section)) return findings.push(finding("parity-section-shape", at, "section must be an object", "use a renderer-supported prose section"));
244
+ const string = (v) => typeof v === "string" && v.trim() !== "";
245
+ const rich = (v) => typeof v === "string" ? v.trim() !== "" : Array.isArray(v) && v.length > 0 && v.every((run) => run && typeof run === "object" && !Array.isArray(run) && ((run.kind === "text" && string(run.text) && (run.strong === undefined || typeof run.strong === "boolean") && (run.em === undefined || typeof run.em === "boolean")) || (run.kind === "link" && string(run.text) && string(run.href) && (run.external === undefined || typeof run.external === "boolean"))));
246
+ switch (section.kind) {
247
+ case "heading": if (!string(section.text) || section.level !== undefined && section.level !== 2 && section.level !== 3 || section.id !== undefined && !string(section.id)) findings.push(finding("parity-section-shape", at, "heading requires string text, optional id, and level 2 or 3", "supply a valid heading")); break;
248
+ case "paragraph": if (!rich(section.text)) findings.push(finding("parity-section-shape", at, "paragraph requires RichText", "supply a valid string or inline-run array")); break;
249
+ case "list": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !rich(item)) || section.ordered !== undefined && typeof section.ordered !== "boolean") findings.push(finding("parity-section-shape", at, "list requires RichText items and optional boolean ordered", "supply valid list items")); break;
250
+ case "definitions": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !item || !string(item.term) || !rich(item.description))) findings.push(finding("parity-section-shape", at, "definitions require string terms and RichText descriptions", "supply valid definition items")); break;
251
+ case "callout": if (!rich(section.body) || !["info", "warning", "legal", "success"].includes(section.tone) || section.title !== undefined && !string(section.title)) findings.push(finding("parity-section-shape", at, "callout requires supported tone, RichText body, and optional string title", "supply a valid callout")); break;
252
+ case "contact": if (section.heading !== undefined && !string(section.heading) || section.email !== undefined && !string(section.email) || section.phone !== undefined && !string(section.phone) || section.address !== undefined && (!Array.isArray(section.address) || section.address.some((line) => !string(line))) || !string(section.email) && !string(section.phone) && !(Array.isArray(section.address) && section.address.length)) findings.push(finding("parity-section-shape", at, "contact requires string members and email, phone, or address", "supply valid contact details")); break;
253
+ case "needs_review": if (!string(section.reason) || section.severity !== undefined && !["blocker", "note"].includes(section.severity) || section.owner !== undefined && !string(section.owner) || section.href !== undefined && !string(section.href)) findings.push(finding("parity-section-shape", at, "needs_review requires reason and valid optional severity/owner/href", "supply a valid needs_review section")); break;
254
+ default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, or needs_review"));
255
+ }
256
+ });
257
+ const bodyHtml = typeof value.body_html === "string" && value.body_html.trim() !== "";
258
+ const sections = Array.isArray(value.sections) && value.sections.length > 0;
259
+ if (!bodyHtml && !sections) findings.push(finding("parity-page-body", file, "page needs non-empty body_html or non-empty sections", "add rendered body_html or one or more sections"));
260
+ return findings;
261
+ }
262
+
263
+ function readStructured(path, file, findings) {
264
+ try { return JSON.parse(readFileSync(path, "utf8")); }
265
+ catch { findings.push(finding("parity-content-parse", file, "invalid JSON", "repair the JSON before visual-parity closure")); return null; }
266
+ }
267
+
268
+ /** @typedef {{level:string, rule:string, file:string, message:string, fix:string}} ParityFinding */
269
+ /** @typedef {{asset:string, file:string}} DecodeAsset */
270
+ /** @typedef {{tenantId?: string}} VisualParityOptions */
271
+ /** Validate the portable content closure required before human visual-parity review.
272
+ * @param {string} tenantDir
273
+ * @param {VisualParityOptions} [options]
274
+ */
275
+ export function validateVisualParityClosure(tenantDir, options = {}) {
276
+ const { tenantId } = options;
277
+ /** @type {ParityFinding[]} */
278
+ const findings = [];
279
+ const { value: config, findings: configFindings } = parseConfig(tenantDir);
280
+ findings.push(...configFindings);
281
+ const pagesDir = join(tenantDir, "content", "pages");
282
+ const files = walk(pagesDir).filter((file) => file.endsWith(".json")).sort();
283
+ if (files.length === 0) findings.push(finding("parity-pages-missing", "content/pages/", "no page JSON files define the visual-parity route scope", "add the paired authored page records under content/pages/ before closing parity"));
284
+ const pages = [];
285
+ for (const path of files) {
286
+ const file = relative(tenantDir, path);
287
+ try {
288
+ const value = JSON.parse(readFileSync(path, "utf8"));
289
+ if (!value || Array.isArray(value) || typeof value !== "object") {
290
+ findings.push(finding("parity-page-shape", file, "page JSON must be an object", "write a page object with a non-empty slug")); continue;
291
+ }
292
+ findings.push(...pageShape(value, file));
293
+ if (typeof value.slug !== "string" || !value.slug.trim()) continue;
294
+ pages.push({ file, value });
295
+ } catch { findings.push(finding("parity-page-parse", file, "invalid JSON", "repair the JSON before visual-parity closure")); }
296
+ }
297
+ const routes = new Set(["/"]);
298
+ for (const page of pages) routes.add(normalizeRoute(page.value.slug));
299
+ /** @type {{findings:ParityFinding[], tenantId:string|null, publicDir:string, routes:Set<string>, allowlist:string[], decodeAssets:DecodeAsset[]}} */
300
+ const context = { findings, tenantId: tenantId ?? tenantFromConfig(tenantDir), publicDir: join(tenantDir, "public"), routes, allowlist: Array.isArray(config.externalNavigationAllowlist) ? config.externalNavigationAllowlist : [], decodeAssets: [] };
301
+ if (!context.tenantId) findings.push(finding("parity-tenant-id", ".tot/config.json", "tenant id is required to verify /tenants/<tenant>/ assets", "set tenant in .tot/config.json or pass a resolved tenant id"));
302
+ for (const page of pages) inspectValue(page.value, page.file, [], context);
303
+ for (const name of ["home.json", "chrome.json"]) {
304
+ const path = join(tenantDir, "content", name);
305
+ if (existsSync(path)) {
306
+ const value = readStructured(path, `content/${name}`, findings);
307
+ if (value != null) inspectValue(value, `content/${name}`, [], context);
308
+ }
309
+ }
310
+ const themePath = join(tenantDir, "theme.json");
311
+ if (existsSync(themePath)) {
312
+ const value = readStructured(themePath, "theme.json", findings);
313
+ if (value != null) inspectValue(value, "theme.json", [], context);
314
+ }
315
+ for (const index of decodeImages(context.decodeAssets)) {
316
+ const entry = context.decodeAssets[index];
317
+ if (entry) findings.push(finding("parity-asset-decode", entry.file, "image decoder could not produce nonzero pixels", "replace the image with a decodable file"));
318
+ }
319
+ findings.sort((a, b) => `${a.file}\0${a.rule}`.localeCompare(`${b.file}\0${b.rule}`));
320
+ return { ok: findings.length === 0, profile: "visual-parity", findings };
321
+ }