@tokenoftrust/cli 2.0.24 → 2.0.26

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.24",
3
+ "version": "2.0.26",
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
  }
@@ -36,11 +36,9 @@
36
36
  * `promote_failed` / `record_failed` (a partial failure, recoverable by
37
37
  * re-running `tot ship`, never a false live claim).
38
38
  *
39
- * TRANSPORT: the CLI holds no storefront cookie (its OAuth login is with the
40
- * MCP, a different trust boundary), so `/api/changes/ship` is reached with a
41
- * Bearer OPERATOR SECRET + `X-Tot-Owner` — the SAME transport the retired
42
- * `tot ship <N> --tenant` operator-by-PR path used. No MCP client, no git: ship
43
- * is now pure HTTP (dependency-free — global `fetch` only).
39
+ * TRANSPORT: operators/CI may use a Bearer operator secret. A signed-in owner or
40
+ * developer instead mints a storefront viewer session from their OWN `tot login`
41
+ * identity; the server still enforces the live owner / `ship-on-behalf` grant.
44
42
  */
45
43
  import { fail } from "../errors.mjs";
46
44
  import { planForAction, printPlanAndConfirm } from "../plan.mjs";
@@ -52,6 +50,7 @@ import { emitActivity } from "../activity.mjs";
52
50
  // sees WHICH changes are keeping the aggregate from going green without a separate
53
51
  // `tot preview doctor` run.
54
52
  import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
53
+ import { resolveViewerTransport } from "../viewer-session.mjs";
55
54
 
56
55
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
57
56
 
@@ -62,10 +61,15 @@ const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live
62
61
  tot ship --yes confirm non-interactively (skip the [y/N] prompt)
63
62
  tot ship --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
64
63
  tot ship --secret <s> operator secret (prefer the env vars below)
64
+ tot ship --accept-quality-findings <reason>
65
+ accept this revision's overridable quality findings,
66
+ record the required reason, then continue publishing
65
67
  tot ship --no-open don't open the live URL in your browser
66
68
 
67
- Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
68
- GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).
69
+ Authentication: a signed-in owner/developer with a live ship-on-behalf grant
70
+ can use their own \`tot login\` session. Operators/CI may instead provide a
71
+ secret via PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET,
72
+ TOT_OPERATOR_SECRET, or --secret.
69
73
 
70
74
  \`tot ship\` has ONE meaning: it publishes the tenant's shared preview
71
75
  aggregate — the batch of PRs that integrated cleanly and went GREEN — to
@@ -88,6 +92,7 @@ export function parseShipArgs(argv) {
88
92
  noOpen: false,
89
93
  url: null,
90
94
  secret: null,
95
+ acceptQualityReason: null,
91
96
  help: false,
92
97
  };
93
98
  for (let i = 0; i < argv.length; i++) {
@@ -97,6 +102,7 @@ export function parseShipArgs(argv) {
97
102
  else if (t === "--no-open") a.noOpen = true;
98
103
  else if (t === "--url") a.url = argv[++i];
99
104
  else if (t === "--secret") a.secret = argv[++i];
105
+ else if (t === "--accept-quality-findings") a.acceptQualityReason = argv[++i];
100
106
  else if (t === "--help" || t === "-h") a.help = true;
101
107
  }
102
108
  return a;
@@ -305,32 +311,46 @@ async function readJsonSafe(res) {
305
311
  * injected so it's unit-tested with no live network/TTY.
306
312
  *
307
313
  * @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
308
- * yes?:boolean, noOpen?:boolean }} params
314
+ * yes?:boolean, noOpen?:boolean, acceptQualityReason?:string|null,
315
+ * env?:NodeJS.ProcessEnv }} params
309
316
  * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
310
- * openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
317
+ * openUrl?:(u:string)=>boolean, progress?:boolean,
318
+ * resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
311
319
  * @returns {Promise<number>} process exit code
312
320
  */
313
- export async function runShip({ tenant, secret, storefrontUrl = null, yes = false, noOpen = false }, deps = {}) {
321
+ export async function runShip(
322
+ {
323
+ tenant,
324
+ secret,
325
+ storefrontUrl = null,
326
+ yes = false,
327
+ noOpen = false,
328
+ acceptQualityReason = null,
329
+ env = process.env,
330
+ },
331
+ deps = {},
332
+ ) {
314
333
  const fetchImpl = deps.fetch || globalThis.fetch;
315
334
  const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
316
- const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
317
-
318
- if (!secret) {
319
- console.error(
320
- fail(
321
- "shipping is an OPERATOR action — it needs an operator secret",
322
- "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
323
- ),
324
- );
325
- return 2;
335
+ const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
336
+ let base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
337
+ let authHeaders;
338
+ if (secret) {
339
+ authHeaders = {
340
+ authorization: `Bearer ${secret}`,
341
+ "x-tot-owner": tenant,
342
+ "x-tot-capability": "ship-on-behalf",
343
+ };
344
+ } else {
345
+ const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
346
+ if (!viewer.ok) {
347
+ console.error(fail(viewer.message, viewer.hint));
348
+ return 2;
349
+ }
350
+ base = viewer.base;
351
+ authHeaders = viewer.authHeaders;
326
352
  }
327
353
 
328
- const authHeaders = {
329
- authorization: `Bearer ${secret}`,
330
- "x-tot-owner": tenant,
331
- "x-tot-capability": "ship-on-behalf",
332
- };
333
-
334
354
  // Auto-surface the merge-doctor on a refused/failed ship. Best-effort:
335
355
  // fetches the hosted doctor over the SAME operator transport and prints the COMPACT
336
356
  // summary; never throws and never touches the exit code. A clean/unreachable doctor
@@ -387,6 +407,10 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
387
407
  paywall: plan.paywall,
388
408
  targets: { live: liveUrl },
389
409
  });
410
+ if (acceptQualityReason) {
411
+ planLines.push(" quality findings: accept this exact revision and continue");
412
+ planLines.push(` acceptance reason: ${acceptQualityReason.trim() || "(missing)"}`);
413
+ }
390
414
  const { confirmed, reason } = await confirmPlan(planLines, {
391
415
  yes,
392
416
  question: `Ship ${tenant}'s current green aggregate live?`,
@@ -405,6 +429,36 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
405
429
  return 0;
406
430
  }
407
431
 
432
+ // This is the same revision-bound acceptance as the Publish dialog, not a raw validator bypass.
433
+ // The server refuses it unless the current durable problem is quality-only and the reason is
434
+ // substantive. Only after that accountable record exists do we re-dispatch the release.
435
+ if (acceptQualityReason !== null) {
436
+ let acceptRes;
437
+ try {
438
+ acceptRes = await fetchImpl(`${base}/api/changes/accept-issue`, {
439
+ method: "POST",
440
+ headers: { "content-type": "application/json", ...authHeaders },
441
+ body: JSON.stringify({ operation: "publication", reason: acceptQualityReason }),
442
+ });
443
+ } catch (e) {
444
+ console.error(
445
+ fail(`couldn't record the quality acceptance at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
446
+ );
447
+ return 1;
448
+ }
449
+ const acceptData = await readJsonSafe(acceptRes);
450
+ if (!acceptRes.ok || acceptData?.ok !== true) {
451
+ console.error(
452
+ fail(
453
+ acceptData?.error || acceptData?.message || `quality acceptance was refused (HTTP ${acceptRes.status})`,
454
+ "review the current publish findings; only quality-only findings on this exact revision can be accepted",
455
+ ),
456
+ );
457
+ return 1;
458
+ }
459
+ console.log(" ✓ quality findings accepted for this revision; continuing the publish.");
460
+ }
461
+
408
462
  // 3. SHIP — the human go-live gate (confirmGoLive) plus the reviewed pin, so a
409
463
  // STALE plan (the aggregate moved / the artifact changed since GET) is
410
464
  // refused rather than shipping something nobody reviewed.
@@ -528,6 +582,8 @@ export async function run(argv, ctx) {
528
582
  storefrontUrl,
529
583
  yes: args.yes,
530
584
  noOpen: args.noOpen,
585
+ acceptQualityReason: args.acceptQualityReason,
586
+ env,
531
587
  },
532
588
  { openUrl: (u) => openBrowser(u) },
533
589
  );
@@ -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
@@ -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
+ }