@tokenoftrust/cli 1.3.3 → 1.3.4-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.3.3",
3
+ "version": "1.3.4-rc.0",
4
4
  "description": "Token of Trust developer CLI — check out 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",
package/src/auth.mjs CHANGED
@@ -31,13 +31,74 @@ import { recordServerPolicy } from "./update-check.mjs";
31
31
 
32
32
  /** Thrown when no provider can authenticate — carries actionable guidance. */
33
33
  export class AuthUnavailableError extends Error {
34
- constructor(message, { hint } = {}) {
34
+ constructor(message, { hint, reason } = {}) {
35
35
  super(message);
36
36
  this.name = "AuthUnavailableError";
37
37
  this.hint = hint || null;
38
+ this.reason = reason || null;
38
39
  }
39
40
  }
40
41
 
42
+ const FULL_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
43
+ const SAFE_EMAIL_HINT_RE = /^[a-z0-9._%+\-]{1,96}@[a-z0-9.-]{1,96}\.[a-z]{2,63}$/i;
44
+
45
+ function maskEmailPart(part) {
46
+ if (part.length <= 2) return `${part.slice(0, 1)}...`;
47
+ return `${part.slice(0, 1)}...${part.slice(-1)}`;
48
+ }
49
+
50
+ /** Display-only email hint for URLs/log-safe UX. Never returns the full address. */
51
+ export function redactEmailForHint(email) {
52
+ const raw = String(email || "").trim().toLowerCase();
53
+ const at = raw.indexOf("@");
54
+ if (at <= 0) return null;
55
+ const local = raw.slice(0, at);
56
+ const domain = raw.slice(at + 1);
57
+ const parts = domain.split(".").filter(Boolean);
58
+ if (parts.length < 2) return null;
59
+ const suffix = parts.pop();
60
+ const registrable = parts.join(".");
61
+ if (!suffix || !registrable) return null;
62
+ return `${maskEmailPart(local)}@${maskEmailPart(registrable)}.${suffix}`;
63
+ }
64
+
65
+ export function normalizeEmailHint(input) {
66
+ const raw = String(input || "").trim().toLowerCase();
67
+ if (!raw || raw.length > 160 || /\s/.test(raw) || !raw.includes("@")) return null;
68
+ if (FULL_EMAIL_RE.test(raw) && !raw.includes("...")) return redactEmailForHint(raw);
69
+ return SAFE_EMAIL_HINT_RE.test(raw) ? raw : null;
70
+ }
71
+
72
+ export function credentialEmailHint(creds) {
73
+ return (
74
+ normalizeEmailHint(creds?.emailHint) ||
75
+ normalizeEmailHint(creds?.email) ||
76
+ redactEmailForHint(emailFromJwt(creds?.accessToken || "") || "")
77
+ );
78
+ }
79
+
80
+ /** Hosted cockpit URL for regenerating local CLI credentials, when we cached one. */
81
+ export function cockpitRecoveryUrl(activityUrl, emailHint = null) {
82
+ if (!activityUrl) return null;
83
+ try {
84
+ const url = new URL("/cockpit?recover=cli", String(activityUrl));
85
+ const hint = normalizeEmailHint(emailHint);
86
+ if (hint) url.searchParams.set("email_hint", hint);
87
+ return url.toString();
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /** Next-step copy for a stale local developer credential. */
94
+ export function developerCredentialRecoveryHint(creds) {
95
+ const url = cockpitRecoveryUrl(creds?.activityUrl, credentialEmailHint(creds));
96
+ if (url) {
97
+ return `open ${url}, sign in if prompted, click "Generate a fresh setup command", then paste it into your terminal.`;
98
+ }
99
+ return "run `tot login` to sign in again.";
100
+ }
101
+
41
102
  /** True when a full operator credential triple is present in the environment. */
42
103
  export function hasOperatorCreds(env = process.env) {
43
104
  return Boolean(env.TOT_API_KEY && env.TOT_SECRET_KEY && env.TOT_APP_DOMAIN);
@@ -95,7 +156,10 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
95
156
  if (!creds || !creds.accessToken) {
96
157
  throw new AuthUnavailableError(
97
158
  "you're not signed in to Token of Trust.",
98
- { hint: "run `tot login` to sign in (or `tot login --code <token>` to paste your invite token), then re-run." },
159
+ {
160
+ reason: "missing",
161
+ hint: "run `tot login` to sign in (or `tot login --code <token>` to paste your invite token), then re-run.",
162
+ },
99
163
  );
100
164
  }
101
165
 
@@ -103,7 +167,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
103
167
  if (!creds.refreshToken || !creds.tokenEndpoint) {
104
168
  throw new AuthUnavailableError(
105
169
  "your Token of Trust session has expired.",
106
- { hint: "run `tot login` to sign in again." },
170
+ { reason: "expired", hint: developerCredentialRecoveryHint(creds) },
107
171
  );
108
172
  }
109
173
  let refreshed;
@@ -116,7 +180,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
116
180
  } catch (e) {
117
181
  throw new AuthUnavailableError(
118
182
  `couldn't refresh your Token of Trust session: ${e?.message || e}`,
119
- { hint: "run `tot login` to sign in again." },
183
+ { reason: "refreshFailed", hint: developerCredentialRecoveryHint(creds) },
120
184
  );
121
185
  }
122
186
  const prior = creds;
@@ -138,6 +202,9 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
138
202
  creds.activityToken = prior.activityToken;
139
203
  creds.activityUrl = prior.activityUrl;
140
204
  }
205
+ if (prior.traceId) creds.traceId = prior.traceId;
206
+ const priorEmailHint = credentialEmailHint(prior);
207
+ if (priorEmailHint) creds.emailHint = priorEmailHint;
141
208
  writeCredentials(path, creds);
142
209
  }
143
210
 
@@ -24,6 +24,7 @@ import { createInterface } from "node:readline/promises";
24
24
  import { createMcpClient } from "../mcp.mjs";
25
25
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
26
26
  import { readActivity, formatActivity } from "../activity-log.mjs";
27
+ import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
27
28
  import { fail } from "../errors.mjs";
28
29
 
29
30
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -50,6 +51,30 @@ const USAGE = `tot feedback — send a note to Token of Trust (with your recent
50
51
 
51
52
  Requires a signed-in session — run \`tot login\` first if needed.`;
52
53
 
54
+ /**
55
+ * Assemble the `feedback_submit` payload. Pure + exported so the wire shape
56
+ * (including the additive `sensitive` activity attachment and the invite→problem
57
+ * `traceId` link) is unit-testable without driving the MCP handshake. Both extras
58
+ * are OMITTED when their source is absent — never sent as null/empty.
59
+ * @param {{ type: string, category: string, severity: string, title: string,
60
+ * description: string, activityText?: string|null, traceId?: string|null }} f
61
+ */
62
+ export function buildFeedbackPayload({ type, category, severity, title, description, activityText, traceId }) {
63
+ return {
64
+ type,
65
+ category,
66
+ severity,
67
+ title,
68
+ description,
69
+ scenario: "tot-cli",
70
+ // Activity → `sensitive` (ToT-admins-only, never clustered/shared). It's already
71
+ // secret-redacted; `sensitive` is the belt-and-suspenders home for it.
72
+ ...(activityText ? { sensitive: `tot CLI activity (most recent last):\n${activityText}` } : {}),
73
+ // Invite→terminal→problem trace link (omitted when this wasn't an invite login).
74
+ ...(traceId ? { traceId } : {}),
75
+ };
76
+ }
77
+
53
78
  function parseArgs(argv) {
54
79
  const a = {
55
80
  mcp: null, type: "improvement", category: "product-ux", severity: "medium",
@@ -115,6 +140,14 @@ export async function run(argv) {
115
140
  const activityText = entries.length ? formatActivity(entries) : null;
116
141
  const title = message.length > 80 ? `${message.slice(0, 79)}…` : message;
117
142
 
143
+ // The invite→terminal→problem trace id `tot login` cached from the pasted invite
144
+ // command (see commands/login.mjs `cacheTraceId`). Stamping it onto the report lets
145
+ // triage join this problem back to the developer's invite + their live heartbeat
146
+ // session. Read it BEFORE establishSession — a silent token refresh rewrites the
147
+ // credential file, so read the cached value first. Absent for a bare (non-invite)
148
+ // login; omitted from the payload then.
149
+ const traceId = readCredentials(defaultCredentialsPath(env))?.traceId || null;
150
+
118
151
  // Preview — sending publishes to Token of Trust, so show exactly what goes out.
119
152
  console.error("\nAbout to send to Token of Trust:");
120
153
  console.error(` ${args.type} · ${args.category} · ${args.severity}`);
@@ -152,17 +185,15 @@ export async function run(argv) {
152
185
  env,
153
186
  initialize: () => client.initialize({ name: "tot-cli", version: "feedback" }),
154
187
  });
155
- const payload = {
188
+ const payload = buildFeedbackPayload({
156
189
  type: args.type,
157
190
  category: args.category,
158
191
  severity: args.severity,
159
192
  title,
160
193
  description: message,
161
- scenario: "tot-cli",
162
- // Activity → `sensitive` (ToT-admins-only, never clustered/shared). It's already
163
- // secret-redacted; `sensitive` is the belt-and-suspenders home for it.
164
- ...(activityText ? { sensitive: `tot CLI activity (most recent last):\n${activityText}` } : {}),
165
- };
194
+ activityText,
195
+ traceId,
196
+ });
166
197
  const res = await client.callTool("feedback_submit", payload);
167
198
  const id = res?.reportId || res?.id || null;
168
199
  console.log(`\n+ sent — thank you.${id ? ` (report ${id})` : ""}`);
@@ -23,13 +23,14 @@ import { loginFlow, deviceLoginFlow, redeemCodeFlow, NoOpenerError } from "../oa
23
23
  import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
24
24
  import { openBrowser } from "../open.mjs";
25
25
  import { fail } from "../errors.mjs";
26
+ import { cockpitRecoveryUrl, normalizeEmailHint } from "../auth.mjs";
26
27
 
27
28
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
28
29
 
29
30
  function parseArgs(argv) {
30
31
  const a = {
31
32
  mcp: null, device: false, code: null, help: false,
32
- activityToken: null, activityUrl: null,
33
+ activityToken: null, activityUrl: null, traceId: null, emailHint: null,
33
34
  };
34
35
  for (let i = 0; i < argv.length; i++) {
35
36
  const t = argv[i];
@@ -38,11 +39,44 @@ function parseArgs(argv) {
38
39
  else if (t === "--code" || t === "--token") a.code = argv[++i];
39
40
  else if (t === "--activity-token") a.activityToken = argv[++i];
40
41
  else if (t === "--activity-url") a.activityUrl = argv[++i];
42
+ else if (t === "--trace-id") a.traceId = argv[++i];
43
+ else if (t === "--email-hint") a.emailHint = argv[++i];
44
+ else if (t === "--email") a.emailHint = argv[++i]; // legacy pasted commands
41
45
  else if (t === "--help" || t === "-h") a.help = true;
42
46
  }
43
47
  return a;
44
48
  }
45
49
 
50
+ /** Canonical UUID (8-4-4-4-12 hex). Used to reject a malformed --trace-id paste. */
51
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
52
+
53
+ /**
54
+ * Cache the invite→terminal→problem trace id (storefront mints it when it issues
55
+ * the developer's CLI sign-in code and appends it to the pasted login command as
56
+ * `--trace-id <uuid>` — see apps/storefront/src/lib/dev/cliSignInCode.ts) alongside
57
+ * the MCP creds `tot login` just wrote, so `tot feedback` can stamp a later problem
58
+ * report with it and triage can join the report to the invite + live heartbeat
59
+ * session. Additive, and mirrors cacheActivityBridge. A no-op when the flag is
60
+ * absent (older pastes / a bare `tot login`) or malformed (defensive — skip rather
61
+ * than persist junk that would break the join).
62
+ */
63
+ export function cacheTraceId(env, traceId) {
64
+ if (!traceId || !UUID_RE.test(traceId)) return;
65
+ const path = defaultCredentialsPath(env);
66
+ const current = readCredentials(path) || {};
67
+ writeCredentials(path, { ...current, traceId });
68
+ }
69
+
70
+ /** Cache a display-only email hint from the setup command for later recovery UX. */
71
+ export function cacheEmailHint(env, emailHint) {
72
+ const normalized = normalizeEmailHint(emailHint);
73
+ if (!normalized) return;
74
+ const path = defaultCredentialsPath(env);
75
+ const current = readCredentials(path) || {};
76
+ const { email: _legacyEmail, ...safeCurrent } = current;
77
+ writeCredentials(path, { ...safeCurrent, emailHint: normalized });
78
+ }
79
+
46
80
  /**
47
81
  * Cache the local→hosted activity-bridge credential (storefront's
48
82
  * cli-signin-code mint, piggybacked as two extra login flags — see
@@ -68,9 +102,10 @@ const USAGE = `tot login — sign in to Token of Trust
68
102
  browser opener exists on this box)
69
103
  tot login --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
70
104
 
71
- --activity-token/--activity-url are set automatically by the pasted invite
72
- command (report local dev-loop activity to your hosted /dev panel) — not
73
- meant to be typed by hand.
105
+ --activity-token/--activity-url/--trace-id/--email-hint are set automatically by the pasted
106
+ invite command (report local dev-loop activity to your hosted /dev panel, and
107
+ link a later \`tot feedback\` report to your invite + session) — not meant to be
108
+ typed by hand.
74
109
 
75
110
  After signing in, run \`tot whoami\` to confirm, then \`tot checkout\` / \`tot submit\`.`;
76
111
 
@@ -128,15 +163,31 @@ export async function redeemAndCache(mcpUrl, code, env = process.env) {
128
163
  * prior --code sign-in would otherwise silently drop it on the next `writeCredentials`
129
164
  * (a full-object overwrite, not a merge) — carry it forward when re-authing against
130
165
  * the SAME mcpUrl (a different MCP means a different session; the old bridge
131
- * credential no longer applies).
166
+ * credential and trace no longer apply).
132
167
  */
133
168
  export function mergeActivityBridge(prior, mcpUrl, creds) {
134
- if (prior?.mcpUrl === mcpUrl && prior.activityToken && prior.activityUrl) {
135
- return { ...creds, activityToken: prior.activityToken, activityUrl: prior.activityUrl };
169
+ if (prior?.mcpUrl === mcpUrl) {
170
+ const emailHint = normalizeEmailHint(prior.emailHint) || normalizeEmailHint(prior.email);
171
+ return {
172
+ ...creds,
173
+ ...(prior.activityToken && prior.activityUrl
174
+ ? { activityToken: prior.activityToken, activityUrl: prior.activityUrl }
175
+ : {}),
176
+ ...(prior.traceId ? { traceId: prior.traceId } : {}),
177
+ ...(emailHint ? { emailHint } : {}),
178
+ };
136
179
  }
137
180
  return creds;
138
181
  }
139
182
 
183
+ export function inviteCodeRecoveryNext(activityUrl, emailHint = null) {
184
+ const url = cockpitRecoveryUrl(activityUrl, emailHint);
185
+ if (url) {
186
+ return `open ${url}, sign in if prompted, click "Generate a fresh setup command", then paste it into your terminal.`;
187
+ }
188
+ return 'open your developer invite again. If the link has expired, use "Send a fresh link", then generate a new setup command from cockpit.';
189
+ }
190
+
140
191
  /** @param {string[]} argv @param {any} _ctx */
141
192
  export async function run(argv, _ctx) {
142
193
  const env = process.env;
@@ -153,6 +204,8 @@ export async function run(argv, _ctx) {
153
204
  try {
154
205
  await redeemAndCache(mcpUrl, args.code, env);
155
206
  cacheActivityBridge(env, args.activityToken, args.activityUrl);
207
+ cacheTraceId(env, args.traceId);
208
+ cacheEmailHint(env, args.emailHint);
156
209
  console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
157
210
  console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
158
211
  return 0;
@@ -160,7 +213,7 @@ export async function run(argv, _ctx) {
160
213
  console.error(
161
214
  fail(
162
215
  `sign-in didn't complete: ${e?.message || e}`,
163
- "double-check the sign-in token from your invite — it's single-use and expires, so ask for a fresh invite if needed.",
216
+ inviteCodeRecoveryNext(args.activityUrl, args.emailHint),
164
217
  ),
165
218
  );
166
219
  return 1;
@@ -170,6 +223,7 @@ export async function run(argv, _ctx) {
170
223
  console.error(`~ signing in to Token of Trust (${mcpUrl})`);
171
224
  try {
172
225
  await loginAndCache(mcpUrl, env, { log: (m) => console.error(m), device: args.device });
226
+ cacheTraceId(env, args.traceId);
173
227
  console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
174
228
  console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
175
229
  return 0;
@@ -374,7 +374,7 @@ async function tryResolveSession(client, env, args) {
374
374
  try {
375
375
  return await loginStep(client, env, args);
376
376
  } catch (e) {
377
- if (e instanceof AuthUnavailableError) return null;
377
+ if (e instanceof AuthUnavailableError && e.reason === "missing") return null;
378
378
  if (e instanceof CliError && /can't reach the Token of Trust MCP/.test(e.message)) return null;
379
379
  throw e;
380
380
  }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * { mcpUrl, clientId, tokenEndpoint, scope,
9
9
  * accessToken, refreshToken, expiresAt (epoch ms), obtainedAt,
10
- * activityToken, activityUrl }
10
+ * activityToken, activityUrl, traceId, emailHint }
11
11
  *
12
12
  * We persist `clientId` + `tokenEndpoint` so a refresh needs no re-discovery /
13
13
  * re-registration, and `mcpUrl` so we never present a token minted for one MCP
@@ -16,7 +16,16 @@
16
16
  * invite paste carries it — see commands/login.mjs `cacheActivityBridge` and
17
17
  * commands/dev.mjs `activityBridgeEnv`, which every runner-spawning path (native
18
18
  * monorepo, native standalone, Docker) threads in so the local dev-loop process
19
- * can report file saves to the developer's hosted /dev panel.
19
+ * can report file saves to the developer's hosted /dev panel. `traceId` is the
20
+ * storefront-minted invite→terminal→problem trace id (cached by `tot login`'s
21
+ * `--trace-id` flag, see commands/login.mjs `cacheTraceId`); `tot feedback`
22
+ * stamps it onto a problem report so triage can join it to the invite + the live
23
+ * heartbeat session. `emailHint` is masked, display-only recovery context from
24
+ * the setup command, used to orient cockpit/login after a local credential
25
+ * expires without persisting a raw address.
26
+ *
27
+ * The read/write is a plain JSON pass-through (no schema) — new fields like
28
+ * `traceId` ride along additively; readers simply pick the keys they need.
20
29
  * Dependency-free (node:fs/os/path).
21
30
  *
22
31
  * `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
package/src/validate.mjs CHANGED
@@ -119,6 +119,195 @@ function providesChrome(contentDir) {
119
119
  return true;
120
120
  }
121
121
 
122
+ const CAPABILITY_KEYS = new Set(["cartCheckout", "ageVerification", "exciseTax"]);
123
+
124
+ function complianceObligations(config) {
125
+ const compliance = config?.compliance || {};
126
+ return {
127
+ ageVerification: !!compliance.minAge,
128
+ exciseTax: !!compliance.nicotineWarning,
129
+ };
130
+ }
131
+
132
+ function rawHtmlAllowed(config) {
133
+ const obligations = complianceObligations(config);
134
+ const regulated = obligations.ageVerification || obligations.exciseTax;
135
+ return !regulated;
136
+ }
137
+
138
+ function validateCapabilitiesDoc(doc, file, config) {
139
+ const out = [];
140
+ if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
141
+ return [mk(ERROR, "capabilities-root", file, "capabilities must be a JSON object")];
142
+ }
143
+ for (const [key, value] of Object.entries(doc)) {
144
+ if (!CAPABILITY_KEYS.has(key)) {
145
+ out.push(mk(ERROR, "capability-unknown", file, `unknown capability "${key}"`));
146
+ continue;
147
+ }
148
+ if (value == null || typeof value !== "object" || typeof value.enabled !== "boolean") {
149
+ out.push(mk(ERROR, "capability-shape", file, `${key} must be an object with boolean "enabled"`));
150
+ }
151
+ }
152
+ const obligations = complianceObligations(config);
153
+ if (obligations.ageVerification && doc.ageVerification?.enabled === false) {
154
+ out.push(mk(ERROR, "capability-compliance-floor", file, "ageVerification cannot be disabled for a tenant with compliance.minAge"));
155
+ }
156
+ if (obligations.exciseTax && doc.exciseTax?.enabled === false) {
157
+ out.push(mk(ERROR, "capability-compliance-floor", file, "exciseTax cannot be disabled for a tenant with compliance.nicotineWarning"));
158
+ }
159
+ return out;
160
+ }
161
+
162
+ function validateScriptsDoc(doc, file, config) {
163
+ const out = [];
164
+ if (doc == null || typeof doc !== "object" || !Array.isArray(doc.scripts)) {
165
+ return [mk(ERROR, "scripts-root", file, 'scripts.json must have a "scripts" array')];
166
+ }
167
+ const regulated = !rawHtmlAllowed(config);
168
+ for (const [i, entry] of doc.scripts.entries()) {
169
+ const at = `${file} scripts[${i}]`;
170
+ if (entry == null || typeof entry !== "object") {
171
+ out.push(mk(ERROR, "script-entry", at, "script entry must be an object"));
172
+ continue;
173
+ }
174
+ if (entry.isolation === "inline") {
175
+ out.push(
176
+ regulated
177
+ ? mk(ERROR, "script-inline-regulated", at, 'inline scripts are not allowed for regulated tenants; use isolation:"sandbox"')
178
+ : mk(WARN, "script-inline", at, 'inline scripts run same-origin; prefer isolation:"sandbox"'),
179
+ );
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+
185
+ const SUPPORTED_BLOCK_CONTRACTS = new Set(["block-palette@1", "block-palette@2", "block-palette@3"]);
186
+ const KNOWN_BLOCKS = new Set([
187
+ "hero",
188
+ "promo_tiles",
189
+ "featured_collections",
190
+ "featured_products",
191
+ "editorial",
192
+ "newsletter",
193
+ "marketing_hero",
194
+ "trust_bar",
195
+ "split_compare",
196
+ "steps",
197
+ "card_grid",
198
+ "integrations",
199
+ "proof_strip",
200
+ "testimonials",
201
+ "faq",
202
+ "cta_band",
203
+ ]);
204
+ const REQUIRED_BLOCK_PROPS = {
205
+ hero: ["headline"],
206
+ promo_tiles: ["tiles"],
207
+ featured_collections: ["handles"],
208
+ editorial: ["title"],
209
+ newsletter: ["title"],
210
+ marketing_hero: ["headline"],
211
+ trust_bar: ["items"],
212
+ split_compare: ["title", "before", "after"],
213
+ steps: ["title", "steps"],
214
+ card_grid: ["title", "cards"],
215
+ integrations: ["title", "platforms"],
216
+ proof_strip: ["title"],
217
+ testimonials: ["quotes"],
218
+ faq: ["title", "items"],
219
+ cta_band: ["title"],
220
+ };
221
+ const PRODUCT_SOURCE_KEYS = new Set([
222
+ "collection",
223
+ "tag",
224
+ "featured",
225
+ "newest",
226
+ "bestSelling",
227
+ "onSale",
228
+ "handles",
229
+ "related",
230
+ "recentlyViewed",
231
+ ]);
232
+ const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
233
+
234
+ function validateProductSource(value, file, at) {
235
+ const out = [];
236
+ if (typeof value === "string") {
237
+ if (!PRODUCT_SOURCE_SHORTCUTS.has(value)) {
238
+ out.push(mk(ERROR, "product-source", file, `${at}.source must be one of featured, newest, bestSelling, onSale or a source object`));
239
+ }
240
+ return out;
241
+ }
242
+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
243
+ out.push(mk(ERROR, "product-source", file, `${at}.source must be a source string or object`));
244
+ return out;
245
+ }
246
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined);
247
+ const recognized = keys.filter((k) => PRODUCT_SOURCE_KEYS.has(k));
248
+ if (keys.length !== 1 || recognized.length !== 1) {
249
+ out.push(mk(ERROR, "product-source", file, `${at}.source must declare exactly one recognized source key`));
250
+ return out;
251
+ }
252
+ const key = recognized[0];
253
+ const got = value[key];
254
+ if (key === "handles") {
255
+ if (!Array.isArray(got) || !got.every((v) => typeof v === "string")) {
256
+ out.push(mk(ERROR, "product-source", file, `${at}.source.handles must be a string array`));
257
+ }
258
+ } else if (key === "collection" || key === "tag" || key === "related") {
259
+ if (typeof got !== "string") {
260
+ out.push(mk(ERROR, "product-source", file, `${at}.source.${key} must be a string`));
261
+ }
262
+ } else if (got !== true) {
263
+ out.push(mk(ERROR, "product-source", file, `${at}.source.${key} must be true`));
264
+ }
265
+ return out;
266
+ }
267
+
268
+ function validateHomeDoc(doc, file) {
269
+ const out = [];
270
+ if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
271
+ return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
272
+ }
273
+ if (doc.contract !== undefined && (typeof doc.contract !== "string" || !SUPPORTED_BLOCK_CONTRACTS.has(doc.contract))) {
274
+ out.push(mk(ERROR, "home-contract", file, `unsupported block contract "${String(doc.contract)}"`));
275
+ }
276
+ if (!Array.isArray(doc.blocks)) {
277
+ out.push(mk(ERROR, "home-blocks", file, "`blocks` must be an array"));
278
+ return out;
279
+ }
280
+ doc.blocks.forEach((block, i) => {
281
+ const at = `blocks[${i}]`;
282
+ if (block == null || typeof block !== "object" || Array.isArray(block)) {
283
+ out.push(mk(ERROR, "block-shape", file, `${at} must be an object`));
284
+ return;
285
+ }
286
+ const component = block.component;
287
+ if (typeof component !== "string") {
288
+ out.push(mk(ERROR, "block-component", file, `${at}.component is required`));
289
+ return;
290
+ }
291
+ if (!KNOWN_BLOCKS.has(component)) {
292
+ out.push(mk(ERROR, "block-unknown", file, `${at}.component "${component}" is not in the supported block palette`));
293
+ return;
294
+ }
295
+ for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
296
+ if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
297
+ }
298
+ if (component === "featured_products") {
299
+ if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
300
+ if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
301
+ out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
302
+ }
303
+ if (block.limit !== undefined && typeof block.limit !== "number") {
304
+ out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
305
+ }
306
+ }
307
+ });
308
+ return out;
309
+ }
310
+
122
311
  // --- filesystem helpers ------------------------------------------------------
123
312
  function readJsonSafe(path) {
124
313
  try {
@@ -181,6 +370,9 @@ export function validateTenant(tenantDir, opts = {}) {
181
370
  );
182
371
  }
183
372
  const scope = opts.scope || config?.scope;
373
+ if (config?.capabilities) {
374
+ findings.push(...validateCapabilitiesDoc(config.capabilities, ".tot/config.json capabilities", config));
375
+ }
184
376
 
185
377
  // 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
186
378
  const themePath = join(tenantDir, "theme.json");
@@ -194,6 +386,26 @@ export function validateTenant(tenantDir, opts = {}) {
194
386
  }
195
387
  }
196
388
 
389
+ const capabilitiesPath = join(tenantDir, "capabilities.json");
390
+ if (existsSync(capabilitiesPath)) {
391
+ const { value, error } = readJsonSafe(capabilitiesPath);
392
+ if (error) {
393
+ findings.push(mk(ERROR, "capabilities-parse", "capabilities.json", `invalid JSON: ${error}`));
394
+ } else {
395
+ findings.push(...validateCapabilitiesDoc(value, "capabilities.json", config));
396
+ }
397
+ }
398
+
399
+ const scriptsPath = join(tenantDir, "scripts.json");
400
+ if (existsSync(scriptsPath)) {
401
+ const { value, error } = readJsonSafe(scriptsPath);
402
+ if (error) {
403
+ findings.push(mk(ERROR, "scripts-parse", "scripts.json", `invalid JSON: ${error}`));
404
+ } else {
405
+ findings.push(...validateScriptsDoc(value, "scripts.json", config));
406
+ }
407
+ }
408
+
197
409
  // 3. content JSON — parse + blocks shape
198
410
  for (const name of ["home.json", "chrome.json"]) {
199
411
  const p = join(contentDir, name);
@@ -201,8 +413,8 @@ export function validateTenant(tenantDir, opts = {}) {
201
413
  const { value, error } = readJsonSafe(p);
202
414
  if (error) {
203
415
  findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
204
- } else if (name === "home.json" && value && "blocks" in value && !Array.isArray(value.blocks)) {
205
- findings.push(mk(ERROR, "home-blocks", "content/home.json", "`blocks` must be an array (index.astro throws otherwise)"));
416
+ } else if (name === "home.json") {
417
+ findings.push(...validateHomeDoc(value, "content/home.json"));
206
418
  }
207
419
  }
208
420
  }
@@ -222,6 +434,12 @@ export function validateTenant(tenantDir, opts = {}) {
222
434
  findings.push(mk(ERROR, "html-invalid", r, `not a servable document: ${bad}`));
223
435
  continue;
224
436
  }
437
+ if (config && !rawHtmlAllowed(config)) {
438
+ findings.push(
439
+ mk(ERROR, "raw-html-compliance-bypass", r,
440
+ "raw HTML bypasses platform Layout compliance; regulated commerce tenants must use block composition or an extracted runtime"),
441
+ );
442
+ }
225
443
  if (!base && !isFullDocument(html) && !hasChrome) {
226
444
  findings.push(
227
445
  mk(ERROR, "html-fragment", r,