@tokenoftrust/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Tenant-contract validation CORE — the pure, I/O-light checks `tot validate`
3
+ * runs before you commit/submit, so the classes of defect that otherwise fail
4
+ * silently (or as a cryptic reconcile/build error) are caught locally.
5
+ *
6
+ * Design principle — "valid locally ≡ will reconcile in storefront": these checks
7
+ * mirror the reconcile pipeline's own gate (@tot/private-controlplane
8
+ * customization-reconcile.ts `mapToArtifact` / `validateRawHtml`). Kept in step
9
+ * with the in-repo source of truth `scripts/tenant/validate.mjs` (which the CI
10
+ * regression test imports). FOLLOW-UP: collapse the two onto this module as the
11
+ * single source once the package is the install surface.
12
+ *
13
+ * Pure + dependency-free (readFileSync only). `validateTenant(dir)` is the entry
14
+ * point; `validateConfigShape` is exported for focused testing.
15
+ */
16
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
17
+ import { join, relative } from "node:path";
18
+
19
+ const NUL = String.fromCharCode(0);
20
+ export const ERROR = "error";
21
+ export const WARN = "warn";
22
+
23
+ /** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
24
+ function mk(level, rule, file, message, fix) {
25
+ return { level, rule, file, message, fix };
26
+ }
27
+
28
+ // --- canonical `.tot/config.json` shape (the #24/#25 regression guard) --------
29
+ const KNOWN_KINDS = new Set(["file", "tree"]);
30
+ const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
31
+
32
+ /**
33
+ * Validate a parsed `.tot/config.json` against the shape the reconcile pipeline
34
+ * (`mapToArtifact`) requires. Pure — no I/O.
35
+ * @returns {Finding[]}
36
+ */
37
+ export function validateConfigShape(config, file = ".tot/config.json") {
38
+ const out = [];
39
+ if (config == null || typeof config !== "object") {
40
+ return [mk(ERROR, "config-parse", file, "config is not a JSON object")];
41
+ }
42
+ if (typeof config.tenant !== "string" || !config.tenant) {
43
+ out.push(mk(ERROR, "config-tenant", file, "`tenant` must be a non-empty string"));
44
+ }
45
+ if (!Array.isArray(config.mappings)) {
46
+ out.push(mk(ERROR, "config-mappings", file, "`mappings` must be an array", "seed shape: [{workspace,repo,kind}]"));
47
+ return out;
48
+ }
49
+ const seen = new Set();
50
+ for (const [i, m] of config.mappings.entries()) {
51
+ const at = `${file} mappings[${i}]`;
52
+ if (m == null || typeof m !== "object") {
53
+ out.push(mk(ERROR, "mapping-shape", at, "mapping must be an object"));
54
+ continue;
55
+ }
56
+ if (typeof m.workspace !== "string" || !m.workspace) {
57
+ out.push(mk(ERROR, "mapping-workspace", at, "`workspace` must be a non-empty string"));
58
+ } else {
59
+ seen.add(m.workspace.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
60
+ seen.add(m.workspace);
61
+ }
62
+ // THE regression guard: reconcile's mapToArtifact dereferences m.repo and only
63
+ // knows "file" vs tree — a missing repo or kind:"dir" makes it throw at runtime.
64
+ if (typeof m.repo !== "string" || !m.repo) {
65
+ out.push(
66
+ mk(ERROR, "mapping-repo", at,
67
+ `mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (reconcile mapToArtifact throws on undefined.repo)`,
68
+ `add "repo": "tenants/${config.tenant ?? "<id>"}/${(m.workspace ?? "").replace(/\/+$/, "")}${m.kind === "file" ? "" : "/"}"`),
69
+ );
70
+ }
71
+ if (!KNOWN_KINDS.has(m.kind)) {
72
+ out.push(
73
+ mk(ERROR, "mapping-kind", at,
74
+ `\`kind\` is "${m.kind}" — must be "file" or "tree" (legacy "dir" is the #24/#25 bug)`,
75
+ m.kind === "dir" ? 'change "dir" → "tree"' : 'set "file" for a single file, "tree" for a directory'),
76
+ );
77
+ }
78
+ }
79
+ for (const req of REQUIRED_WORKSPACES) {
80
+ const bare = req.replace(/\/+$/, "");
81
+ if (!seen.has(req) && !seen.has(bare)) {
82
+ out.push(mk(WARN, "config-coverage", file, `mappings do not cover "${req}" — edits there won't flow back`));
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // --- tiny HTML checks (mirror customization-reconcile.ts validateRawHtml) ------
89
+ function isFullDocument(html) {
90
+ const head = html.slice(0, 512).toLowerCase();
91
+ return head.includes("<!doctype") || head.includes("<html");
92
+ }
93
+ function validateRawHtmlBody(html) {
94
+ if (typeof html !== "string" || html.length === 0) return "empty document";
95
+ if (html.includes(NUL)) return "contains NUL byte (not text/HTML)";
96
+ if (!html.includes("<")) return "no markup tags found";
97
+ return null;
98
+ }
99
+
100
+ // --- filesystem helpers ------------------------------------------------------
101
+ function readJsonSafe(path) {
102
+ try {
103
+ return { value: JSON.parse(readFileSync(path, "utf8")), error: null };
104
+ } catch (e) {
105
+ return { value: null, error: /** @type {Error} */ (e).message };
106
+ }
107
+ }
108
+ function walk(dir, pred) {
109
+ const out = [];
110
+ if (!existsSync(dir)) return out;
111
+ for (const name of readdirSync(dir)) {
112
+ const p = join(dir, name);
113
+ const st = statSync(p);
114
+ if (st.isDirectory()) out.push(...walk(p, pred));
115
+ else if (pred(p)) out.push(p);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // --- link / asset extraction -------------------------------------------------
121
+ const HREF_RE = /\bhref\s*=\s*"([^"]*)"/gi;
122
+ const SRC_RE = /\b(?:src|srcset)\s*=\s*"([^"]*)"/gi;
123
+ const STYLE_OPEN_WITH_ATTRS_RE = /<style\s+[^>]*>/i;
124
+
125
+ /**
126
+ * Full static validation of a tenant directory (content/ public/ theme.json [.tot/]).
127
+ * @param {string} tenantDir absolute path to the tenant dir
128
+ * @param {{tenantId?:string, scope?:string}} [opts]
129
+ * @returns {{ok:boolean, findings:Finding[]}}
130
+ */
131
+ export function validateTenant(tenantDir, opts = {}) {
132
+ /** @type {Finding[]} */
133
+ const findings = [];
134
+ const rel = (p) => relative(tenantDir, p) || p;
135
+ const contentDir = join(tenantDir, "content");
136
+ const publicDir = join(tenantDir, "public");
137
+ const pagesDir = join(contentDir, "pages-html");
138
+
139
+ // 0. basic layout
140
+ if (!existsSync(contentDir)) {
141
+ findings.push(mk(ERROR, "layout", "content/", "no content/ directory"));
142
+ }
143
+
144
+ // 1. .tot/config.json — shape (flow-back readiness)
145
+ const configPath = join(tenantDir, ".tot", "config.json");
146
+ let config = null;
147
+ if (existsSync(configPath)) {
148
+ const { value, error } = readJsonSafe(configPath);
149
+ if (error) {
150
+ findings.push(mk(ERROR, "config-parse", ".tot/config.json", `invalid JSON: ${error}`));
151
+ } else {
152
+ config = value;
153
+ findings.push(...validateConfigShape(value, ".tot/config.json"));
154
+ }
155
+ } else {
156
+ findings.push(
157
+ mk(WARN, "config-absent", ".tot/config.json",
158
+ "no .tot/config.json — tenant can serve but is NOT flow-back-ready (reconcile has nothing to map)"),
159
+ );
160
+ }
161
+ const scope = opts.scope || config?.scope;
162
+
163
+ // 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
164
+ const themePath = join(tenantDir, "theme.json");
165
+ if (existsSync(themePath)) {
166
+ const { error } = readJsonSafe(themePath);
167
+ if (error) {
168
+ findings.push(
169
+ mk(ERROR, "theme-parse", "theme.json",
170
+ `invalid JSON: ${error}`, "theme.json is loaded by an eager Vite glob — a parse error fails the entire storefront build"),
171
+ );
172
+ }
173
+ }
174
+
175
+ // 3. content JSON — parse + blocks shape
176
+ for (const name of ["home.json", "chrome.json"]) {
177
+ const p = join(contentDir, name);
178
+ if (existsSync(p)) {
179
+ const { value, error } = readJsonSafe(p);
180
+ if (error) {
181
+ findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
182
+ } else if (name === "home.json" && value && "blocks" in value && !Array.isArray(value.blocks)) {
183
+ findings.push(mk(ERROR, "home-blocks", "content/home.json", "`blocks` must be an array (index.astro throws otherwise)"));
184
+ }
185
+ }
186
+ }
187
+
188
+ // 4. raw HTML documents — validate + portability + resolution
189
+ const chromePath = join(contentDir, "chrome.html");
190
+ const hasChrome = existsSync(chromePath);
191
+ const htmlFiles = walk(contentDir, (p) => p.endsWith(".html"));
192
+ const pageTargets = buildPageTargetSet(contentDir, pagesDir);
193
+
194
+ for (const p of htmlFiles) {
195
+ const r = rel(p);
196
+ const html = readFileSync(p, "utf8");
197
+ const base = r === "content/chrome.html";
198
+
199
+ const bad = validateRawHtmlBody(html);
200
+ if (bad) {
201
+ findings.push(mk(ERROR, "html-invalid", r, `not a servable document: ${bad}`));
202
+ continue;
203
+ }
204
+ if (!base && !isFullDocument(html) && !hasChrome) {
205
+ findings.push(
206
+ mk(ERROR, "html-fragment", r,
207
+ "is an HTML fragment but the tenant ships no content/chrome.html to wrap it — it will serve unwrapped/naked",
208
+ "make it a full <!doctype html> document, or add content/chrome.html with <!--PAGE_BODY-->"),
209
+ );
210
+ }
211
+ if (STYLE_OPEN_WITH_ATTRS_RE.test(html)) {
212
+ findings.push(
213
+ mk(WARN, "style-attrs", r,
214
+ "has an inline <style …> with attributes — the CSP nonce injector only matches a bare <style>, so this CSS is dropped when served",
215
+ "use a bare <style> tag"),
216
+ );
217
+ }
218
+
219
+ for (const m of html.matchAll(HREF_RE)) {
220
+ findings.push(...checkLink(m[1].trim(), r, scope, pageTargets));
221
+ }
222
+ for (const m of html.matchAll(SRC_RE)) {
223
+ const f = checkAsset(m[1].trim(), r, publicDir, opts.tenantId || config?.tenant);
224
+ if (f) findings.push(f);
225
+ }
226
+ }
227
+
228
+ const ok = !findings.some((f) => f.level === ERROR);
229
+ return { ok, findings };
230
+ }
231
+
232
+ function buildPageTargetSet(contentDir, pagesDir) {
233
+ const set = new Set(["/", ""]);
234
+ if (existsSync(join(contentDir, "home.html"))) set.add("/");
235
+ for (const p of walk(pagesDir, (x) => x.endsWith(".html"))) {
236
+ const relPath = relative(pagesDir, p).replace(/\.html$/, "");
237
+ const slug = relPath === "index" ? "" : relPath;
238
+ set.add(`/${slug}/`.replace(/\/+/g, "/"));
239
+ set.add(`/${slug}`.replace(/\/+$/, "") || "/");
240
+ }
241
+ return set;
242
+ }
243
+
244
+ function checkLink(href, file, scope, pageTargets) {
245
+ const out = [];
246
+ if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return out;
247
+ if (/^https?:\/\/localhost(?::\d+)?/i.test(href)) {
248
+ out.push(mk(ERROR, "link-localhost", file, `links to a localhost URL: ${href}`, "use a root-absolute path (/foo/)"));
249
+ return out;
250
+ }
251
+ if (scope && new RegExp(`^https?://(www\\.)?${escapeRe(scope)}(/|$)`, "i").test(href)) {
252
+ const path = href.replace(/^https?:\/\/(www\.)?[^/]+/i, "") || "/";
253
+ out.push(
254
+ mk(ERROR, "link-nonportable", file,
255
+ `internal CTA is hardcoded to https://${scope}${path} — it leaves localhost/preview when served`,
256
+ `use the root-absolute form: ${path}`),
257
+ );
258
+ return out;
259
+ }
260
+ if (/^([a-z]+:)?\/\//i.test(href)) return out;
261
+ if (href.startsWith("/")) {
262
+ if (href.startsWith("/tenants/")) return out;
263
+ const path = href.split(/[?#]/)[0];
264
+ const norm = path.replace(/\/+$/, "") || "/";
265
+ if (!pageTargets.has(path) && !pageTargets.has(norm) && !pageTargets.has(norm + "/")) {
266
+ out.push(mk(WARN, "link-dangling", file, `internal link ${path} does not resolve to a known page (would 404)`));
267
+ }
268
+ return out;
269
+ }
270
+ out.push(mk(WARN, "link-relative", file, `relative link "${href}" — internal links should be root-absolute (/foo/)`));
271
+ return out;
272
+ }
273
+
274
+ function checkAsset(src, file, publicDir, tenantId) {
275
+ if (!src || /^([a-z]+:)?\/\//i.test(src) || src.startsWith("data:")) return null;
276
+ let relPath = null;
277
+ if (tenantId && src.startsWith(`/tenants/${tenantId}/`)) {
278
+ relPath = src.slice(`/tenants/${tenantId}/`.length).split(/[?#]/)[0];
279
+ } else if (src.startsWith("/")) {
280
+ return null;
281
+ } else {
282
+ relPath = src.split(/[?#]/)[0];
283
+ }
284
+ if (!relPath) return null;
285
+ if (!existsSync(join(publicDir, relPath))) {
286
+ return mk(WARN, "asset-missing", file, `references public asset "${src}" which is not present under public/`);
287
+ }
288
+ return null;
289
+ }
290
+
291
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");