@tokenoftrust/cli 2.0.21 → 2.0.22

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/validate.mjs +157 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.0.21",
3
+ "version": "2.0.22",
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",
package/src/validate.mjs CHANGED
@@ -313,15 +313,20 @@ function validateScriptsDoc(doc, file, config) {
313
313
  return out;
314
314
  }
315
315
 
316
- const SUPPORTED_BLOCK_CONTRACTS = new Set([
316
+ // Standalone published-CLI mirror. packages/cli/test/validate.test.mjs pins it
317
+ // to extension-contract-values.mjs, the repository/runtime authority.
318
+ export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
317
319
  "block-palette@1",
318
320
  "block-palette@2",
319
321
  "block-palette@3",
320
322
  "block-palette@4",
323
+ "block-palette@5",
321
324
  ]);
325
+ const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
322
326
  const KNOWN_BLOCKS = new Set([
323
327
  "hero",
324
328
  "promo_tiles",
329
+ "trust_strip",
325
330
  "featured_collections",
326
331
  "featured_products",
327
332
  "editorial",
@@ -341,6 +346,7 @@ const KNOWN_BLOCKS = new Set([
341
346
  const REQUIRED_BLOCK_PROPS = {
342
347
  hero: ["headline"],
343
348
  promo_tiles: ["tiles"],
349
+ trust_strip: ["items"],
344
350
  featured_collections: ["handles"],
345
351
  editorial: ["title"],
346
352
  newsletter: ["title"],
@@ -368,6 +374,37 @@ const PRODUCT_SOURCE_KEYS = new Set([
368
374
  "recentlyViewed",
369
375
  ]);
370
376
  const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
377
+ export const PROMO_TILE_PRESENTATIONS = Object.freeze(["overlay", "portrait-cards", "editorial-cards", "logo-strip"]);
378
+ const PROMO_TILE_PRESENTATION_SET = new Set(PROMO_TILE_PRESENTATIONS);
379
+
380
+ export function isSafeRehostedAssetPath(value) {
381
+ if (typeof value !== "string" || !value.startsWith("/")) return false;
382
+ if (value.includes("\\") || value.includes("?") || value.includes("#")) return false;
383
+ let decoded;
384
+ try { decoded = decodeURIComponent(value); } catch { return false; }
385
+ if (decoded !== value || decoded.includes("..") || decoded.includes("//")) return false;
386
+ return /^\/(?:tenants\/[^/]+|shared)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value);
387
+ }
388
+
389
+ export function isSafeRehostedAssetPathForTenant(value, tenantId) {
390
+ if (!isSafeRehostedAssetPath(value) || typeof tenantId !== "string" || !tenantId) return false;
391
+ return value.startsWith("/shared/") || value.startsWith(`/tenants/${tenantId}/`);
392
+ }
393
+
394
+ export function isSafeStorefrontHref(value) {
395
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return false;
396
+ if (value.includes("\\") || value.includes("?") || value.includes("#") || /[\u0000-\u001f\u007f]/.test(value)) return false;
397
+ try {
398
+ const decoded = decodeURIComponent(value);
399
+ if (decoded !== value || decoded.includes("\\") || /[\u0000-\u001f\u007f]/.test(decoded)) return false;
400
+ const segments = value.split("/");
401
+ return !segments.some((segment, index) =>
402
+ index > 0 && (segment === "." || segment === ".." || (segment === "" && index < segments.length - 1))
403
+ );
404
+ } catch {
405
+ return false;
406
+ }
407
+ }
371
408
 
372
409
  function validateProductSource(value, file, at) {
373
410
  const out = [];
@@ -403,7 +440,108 @@ function validateProductSource(value, file, at) {
403
440
  return out;
404
441
  }
405
442
 
406
- function validateHomeDoc(doc, file) {
443
+ function validateVisualAsset(value, file, at, out, tenantId) {
444
+ const safe = tenantId
445
+ ? isSafeRehostedAssetPathForTenant(value, tenantId)
446
+ : isSafeRehostedAssetPath(value);
447
+ if (!safe) {
448
+ out.push(mk(ERROR, "asset-path", file, `${at} must be a /shared/... asset or belong to the active tenant`));
449
+ }
450
+ }
451
+
452
+ function validatePromoTiles(block, file, at, out, tenantId) {
453
+ if (block.presentation !== undefined && !PROMO_TILE_PRESENTATION_SET.has(block.presentation)) {
454
+ out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be overlay, portrait-cards, editorial-cards, or logo-strip`));
455
+ }
456
+ if (!Array.isArray(block.tiles)) {
457
+ out.push(mk(ERROR, "block-prop", file, `${at}.tiles must be an array`));
458
+ return;
459
+ }
460
+ block.tiles.forEach((tile, index) => {
461
+ const tileAt = `${at}.tiles[${index}]`;
462
+ if (tile == null || typeof tile !== "object" || Array.isArray(tile)) {
463
+ out.push(mk(ERROR, "block-prop", file, `${tileAt} must be an object`));
464
+ return;
465
+ }
466
+ for (const prop of ["label", "href"]) {
467
+ if (typeof tile[prop] !== "string" || !tile[prop].trim()) {
468
+ out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a non-empty string`));
469
+ }
470
+ }
471
+ for (const prop of ["imageAlt", "meta"]) {
472
+ if (tile[prop] !== undefined && typeof tile[prop] !== "string") {
473
+ out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a string`));
474
+ }
475
+ }
476
+ if (tile.image !== undefined) validateVisualAsset(tile.image, file, `${tileAt}.image`, out, tenantId);
477
+ if (typeof tile.href === "string" && !isSafeStorefrontHref(tile.href)) {
478
+ out.push(mk(ERROR, "navigation-href", file, `${tileAt}.href must be a same-storefront root-relative path`));
479
+ }
480
+ if ((block.presentation ?? "overlay") !== "overlay" && tile.image === undefined) {
481
+ out.push(mk(ERROR, "block-required", file, `${tileAt}.image is required for ${block.presentation}`));
482
+ }
483
+ });
484
+ if ((block.ctaLabel === undefined) !== (block.ctaHref === undefined)) {
485
+ out.push(mk(ERROR, "block-required", file, `${at}.ctaLabel and ${at}.ctaHref must be supplied together`));
486
+ }
487
+ for (const prop of ["ctaLabel", "ctaHref"]) {
488
+ if (block[prop] !== undefined && (typeof block[prop] !== "string" || !block[prop].trim())) {
489
+ out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a non-empty string`));
490
+ }
491
+ }
492
+ if (typeof block.ctaHref === "string" && !isSafeStorefrontHref(block.ctaHref)) {
493
+ out.push(mk(ERROR, "navigation-href", file, `${at}.ctaHref must be a same-storefront root-relative path`));
494
+ }
495
+ }
496
+
497
+ function validateTrustStrip(block, file, at, out, tenantId) {
498
+ if (!Array.isArray(block.items)) {
499
+ out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
500
+ return;
501
+ }
502
+ block.items.forEach((item, index) => {
503
+ const itemAt = `${at}.items[${index}]`;
504
+ if (item == null || typeof item !== "object" || Array.isArray(item) || !["claim", "rating"].includes(item.kind)) {
505
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.kind must be claim or rating`));
506
+ return;
507
+ }
508
+ if (item.kind === "claim") {
509
+ if (typeof item.label !== "string" || !item.label.trim()) {
510
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.label must be a non-empty string`));
511
+ }
512
+ if (item.icon !== undefined) validateVisualAsset(item.icon, file, `${itemAt}.icon`, out, tenantId);
513
+ if (item.iconAlt !== undefined && typeof item.iconAlt !== "string") {
514
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.iconAlt must be a string`));
515
+ }
516
+ } else if (
517
+ typeof item.average !== "number" || item.average <= 0 || item.average > 5 ||
518
+ typeof item.count !== "number" || item.count <= 0
519
+ ) {
520
+ out.push(mk(ERROR, "block-prop", file, `${itemAt} requires average 1–5 and a positive count`));
521
+ }
522
+ });
523
+ }
524
+
525
+ function validateFaq(block, file, at, out) {
526
+ if (!Array.isArray(block.items)) {
527
+ out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
528
+ return;
529
+ }
530
+ block.items.forEach((item, index) => {
531
+ const itemAt = `${at}.items[${index}]`;
532
+ if (item == null || typeof item !== "object" || Array.isArray(item)) {
533
+ out.push(mk(ERROR, "block-prop", file, `${itemAt} must be an object`));
534
+ return;
535
+ }
536
+ for (const prop of ["q", "a"]) {
537
+ if (typeof item[prop] !== "string" || !item[prop].trim()) {
538
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.${prop} must be a non-empty string`));
539
+ }
540
+ }
541
+ });
542
+ }
543
+
544
+ function validateHomeDoc(doc, file, tenantId) {
407
545
  const out = [];
408
546
  if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
409
547
  return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
@@ -433,6 +571,9 @@ function validateHomeDoc(doc, file) {
433
571
  for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
434
572
  if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
435
573
  }
574
+ if (component === "featured_collections" && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
575
+ out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
576
+ }
436
577
  if (component === "featured_products") {
437
578
  if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
438
579
  if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
@@ -442,6 +583,18 @@ function validateHomeDoc(doc, file) {
442
583
  out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
443
584
  }
444
585
  }
586
+ if (component === "hero" && block.image !== undefined) {
587
+ validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
588
+ }
589
+ if (component === "editorial" && block.image !== undefined) {
590
+ validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
591
+ if (typeof block.imageAlt !== "string") {
592
+ out.push(mk(ERROR, "block-required", file, `${at}.imageAlt is required when ${at}.image is supplied`));
593
+ }
594
+ }
595
+ if (component === "promo_tiles") validatePromoTiles(block, file, at, out, tenantId);
596
+ if (component === "trust_strip") validateTrustStrip(block, file, at, out, tenantId);
597
+ if (component === "faq") validateFaq(block, file, at, out);
445
598
  });
446
599
  return out;
447
600
  }
@@ -852,6 +1005,7 @@ export function validateTenant(tenantDir, opts = {}) {
852
1005
  );
853
1006
  }
854
1007
  const scope = opts.scope || config?.scope;
1008
+ const activeTenantId = opts.tenantId || scope || config?.tenant;
855
1009
  // platform-route awareness: which framework routes the host platform owns for this tenant
856
1010
  const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
857
1011
  const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
@@ -910,7 +1064,7 @@ export function validateTenant(tenantDir, opts = {}) {
910
1064
  if (error) {
911
1065
  findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
912
1066
  } else if (name === "home.json") {
913
- findings.push(...validateHomeDoc(value, "content/home.json"));
1067
+ findings.push(...validateHomeDoc(value, "content/home.json", activeTenantId));
914
1068
  } else if (name === "chrome.json") {
915
1069
  findings.push(...findDuplicateCtaIds(value, "content/chrome.json"));
916
1070
  if (opts.previousChromeJson != null) {