@tokenoftrust/cli 2.0.24 → 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 +4 -1
- package/src/commands/revert.mjs +44 -30
- package/src/commands/validate.mjs +21 -3
- package/src/validate.mjs +2 -2
- package/src/visual-parity.mjs +321 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.0.
|
|
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"
|
package/src/commands/revert.mjs
CHANGED
|
@@ -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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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`
|
|
216
|
-
*
|
|
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
|
|
221
|
-
*
|
|
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
|
|
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.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
? "
|
|
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
|
-
|
|
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
|
+
}
|