@tokenoftrust/cli 2.0.21 → 2.0.23

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 +174 -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.23",
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,22 @@ 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",
324
+ "block-palette@6",
321
325
  ]);
326
+ export const FEATURED_PRODUCT_PRESENTATIONS = Object.freeze(["grid", "carousel"]);
327
+ const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
322
328
  const KNOWN_BLOCKS = new Set([
323
329
  "hero",
324
330
  "promo_tiles",
331
+ "trust_strip",
325
332
  "featured_collections",
326
333
  "featured_products",
327
334
  "editorial",
@@ -341,6 +348,7 @@ const KNOWN_BLOCKS = new Set([
341
348
  const REQUIRED_BLOCK_PROPS = {
342
349
  hero: ["headline"],
343
350
  promo_tiles: ["tiles"],
351
+ trust_strip: ["items"],
344
352
  featured_collections: ["handles"],
345
353
  editorial: ["title"],
346
354
  newsletter: ["title"],
@@ -368,6 +376,37 @@ const PRODUCT_SOURCE_KEYS = new Set([
368
376
  "recentlyViewed",
369
377
  ]);
370
378
  const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
379
+ export const PROMO_TILE_PRESENTATIONS = Object.freeze(["overlay", "portrait-cards", "editorial-cards", "logo-strip"]);
380
+ const PROMO_TILE_PRESENTATION_SET = new Set(PROMO_TILE_PRESENTATIONS);
381
+
382
+ export function isSafeRehostedAssetPath(value) {
383
+ if (typeof value !== "string" || !value.startsWith("/")) return false;
384
+ if (value.includes("\\") || value.includes("?") || value.includes("#")) return false;
385
+ let decoded;
386
+ try { decoded = decodeURIComponent(value); } catch { return false; }
387
+ if (decoded !== value || decoded.includes("..") || decoded.includes("//")) return false;
388
+ return /^\/(?:tenants\/[^/]+|shared)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value);
389
+ }
390
+
391
+ export function isSafeRehostedAssetPathForTenant(value, tenantId) {
392
+ if (!isSafeRehostedAssetPath(value) || typeof tenantId !== "string" || !tenantId) return false;
393
+ return value.startsWith("/shared/") || value.startsWith(`/tenants/${tenantId}/`);
394
+ }
395
+
396
+ export function isSafeStorefrontHref(value) {
397
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return false;
398
+ if (value.includes("\\") || value.includes("?") || value.includes("#") || /[\u0000-\u001f\u007f]/.test(value)) return false;
399
+ try {
400
+ const decoded = decodeURIComponent(value);
401
+ if (decoded !== value || decoded.includes("\\") || /[\u0000-\u001f\u007f]/.test(decoded)) return false;
402
+ const segments = value.split("/");
403
+ return !segments.some((segment, index) =>
404
+ index > 0 && (segment === "." || segment === ".." || (segment === "" && index < segments.length - 1))
405
+ );
406
+ } catch {
407
+ return false;
408
+ }
409
+ }
371
410
 
372
411
  function validateProductSource(value, file, at) {
373
412
  const out = [];
@@ -403,7 +442,121 @@ function validateProductSource(value, file, at) {
403
442
  return out;
404
443
  }
405
444
 
406
- function validateHomeDoc(doc, file) {
445
+ function validateVisualAsset(value, file, at, out, tenantId) {
446
+ const safe = tenantId
447
+ ? isSafeRehostedAssetPathForTenant(value, tenantId)
448
+ : isSafeRehostedAssetPath(value);
449
+ if (!safe) {
450
+ out.push(mk(ERROR, "asset-path", file, `${at} must be a /shared/... asset or belong to the active tenant`));
451
+ }
452
+ }
453
+
454
+ function validatePromoTiles(block, file, at, out, tenantId) {
455
+ if (block.presentation !== undefined && !PROMO_TILE_PRESENTATION_SET.has(block.presentation)) {
456
+ out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be overlay, portrait-cards, editorial-cards, or logo-strip`));
457
+ }
458
+ if (!Array.isArray(block.tiles)) {
459
+ out.push(mk(ERROR, "block-prop", file, `${at}.tiles must be an array`));
460
+ return;
461
+ }
462
+ block.tiles.forEach((tile, index) => {
463
+ const tileAt = `${at}.tiles[${index}]`;
464
+ if (tile == null || typeof tile !== "object" || Array.isArray(tile)) {
465
+ out.push(mk(ERROR, "block-prop", file, `${tileAt} must be an object`));
466
+ return;
467
+ }
468
+ for (const prop of ["label", "href"]) {
469
+ if (typeof tile[prop] !== "string" || !tile[prop].trim()) {
470
+ out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a non-empty string`));
471
+ }
472
+ }
473
+ for (const prop of ["imageAlt", "meta"]) {
474
+ if (tile[prop] !== undefined && typeof tile[prop] !== "string") {
475
+ out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a string`));
476
+ }
477
+ }
478
+ if (tile.image !== undefined) validateVisualAsset(tile.image, file, `${tileAt}.image`, out, tenantId);
479
+ if (typeof tile.href === "string" && !isSafeStorefrontHref(tile.href)) {
480
+ out.push(mk(ERROR, "navigation-href", file, `${tileAt}.href must be a same-storefront root-relative path`));
481
+ }
482
+ if ((block.presentation ?? "overlay") !== "overlay" && tile.image === undefined) {
483
+ out.push(mk(ERROR, "block-required", file, `${tileAt}.image is required for ${block.presentation}`));
484
+ }
485
+ });
486
+ if ((block.ctaLabel === undefined) !== (block.ctaHref === undefined)) {
487
+ out.push(mk(ERROR, "block-required", file, `${at}.ctaLabel and ${at}.ctaHref must be supplied together`));
488
+ }
489
+ for (const prop of ["ctaLabel", "ctaHref"]) {
490
+ if (block[prop] !== undefined && (typeof block[prop] !== "string" || !block[prop].trim())) {
491
+ out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a non-empty string`));
492
+ }
493
+ }
494
+ if (typeof block.ctaHref === "string" && !isSafeStorefrontHref(block.ctaHref)) {
495
+ out.push(mk(ERROR, "navigation-href", file, `${at}.ctaHref must be a same-storefront root-relative path`));
496
+ }
497
+ }
498
+
499
+ function validateHeroSlides(slides, file, at, out, tenantId) {
500
+ if (!Array.isArray(slides) || slides.length === 0) { out.push(mk(ERROR, "block-prop", file, `${at}.slides must be a non-empty array`)); return; }
501
+ slides.forEach((slide, index) => {
502
+ const slideAt = `${at}.slides[${index}]`;
503
+ if (slide == null || typeof slide !== "object" || Array.isArray(slide)) { out.push(mk(ERROR, "block-prop", file, `${slideAt} must be an object`)); return; }
504
+ for (const prop of ["image", "imageAlt"]) if (typeof slide[prop] !== "string" || !slide[prop].trim()) out.push(mk(ERROR, "block-required", file, `${slideAt}.${prop} must be a non-empty string`));
505
+ validateVisualAsset(slide.image, file, `${slideAt}.image`, out, tenantId);
506
+ for (const prop of ["headline", "ctaLabel", "ctaHref"]) if (slide[prop] !== undefined && (typeof slide[prop] !== "string" || !slide[prop].trim())) out.push(mk(ERROR, "block-prop", file, `${slideAt}.${prop} must be a non-empty string`));
507
+ if ((slide.ctaLabel === undefined) !== (slide.ctaHref === undefined)) out.push(mk(ERROR, "block-required", file, `${slideAt}.ctaLabel and ${slideAt}.ctaHref must be supplied together`));
508
+ if (typeof slide.ctaHref === "string" && !isSafeStorefrontHref(slide.ctaHref)) out.push(mk(ERROR, "navigation-href", file, `${slideAt}.ctaHref must be a same-storefront root-relative path`));
509
+ });
510
+ }
511
+
512
+ function validateTrustStrip(block, file, at, out, tenantId) {
513
+ if (!Array.isArray(block.items)) {
514
+ out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
515
+ return;
516
+ }
517
+ block.items.forEach((item, index) => {
518
+ const itemAt = `${at}.items[${index}]`;
519
+ if (item == null || typeof item !== "object" || Array.isArray(item) || !["claim", "rating"].includes(item.kind)) {
520
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.kind must be claim or rating`));
521
+ return;
522
+ }
523
+ if (item.kind === "claim") {
524
+ if (typeof item.label !== "string" || !item.label.trim()) {
525
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.label must be a non-empty string`));
526
+ }
527
+ if (item.icon !== undefined) validateVisualAsset(item.icon, file, `${itemAt}.icon`, out, tenantId);
528
+ if (item.iconAlt !== undefined && typeof item.iconAlt !== "string") {
529
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.iconAlt must be a string`));
530
+ }
531
+ } else if (
532
+ typeof item.average !== "number" || item.average <= 0 || item.average > 5 ||
533
+ typeof item.count !== "number" || item.count <= 0
534
+ ) {
535
+ out.push(mk(ERROR, "block-prop", file, `${itemAt} requires average 1–5 and a positive count`));
536
+ }
537
+ });
538
+ }
539
+
540
+ function validateFaq(block, file, at, out) {
541
+ if (!Array.isArray(block.items)) {
542
+ out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
543
+ return;
544
+ }
545
+ block.items.forEach((item, index) => {
546
+ const itemAt = `${at}.items[${index}]`;
547
+ if (item == null || typeof item !== "object" || Array.isArray(item)) {
548
+ out.push(mk(ERROR, "block-prop", file, `${itemAt} must be an object`));
549
+ return;
550
+ }
551
+ for (const prop of ["q", "a"]) {
552
+ if (typeof item[prop] !== "string" || !item[prop].trim()) {
553
+ out.push(mk(ERROR, "block-prop", file, `${itemAt}.${prop} must be a non-empty string`));
554
+ }
555
+ }
556
+ });
557
+ }
558
+
559
+ function validateHomeDoc(doc, file, tenantId) {
407
560
  const out = [];
408
561
  if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
409
562
  return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
@@ -433,6 +586,9 @@ function validateHomeDoc(doc, file) {
433
586
  for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
434
587
  if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
435
588
  }
589
+ if (component === "featured_collections" && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
590
+ out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
591
+ }
436
592
  if (component === "featured_products") {
437
593
  if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
438
594
  if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
@@ -441,7 +597,21 @@ function validateHomeDoc(doc, file) {
441
597
  if (block.limit !== undefined && typeof block.limit !== "number") {
442
598
  out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
443
599
  }
600
+ if (block.presentation !== undefined && !FEATURED_PRODUCT_PRESENTATIONS.includes(block.presentation)) out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be grid or carousel`));
601
+ }
602
+ if (component === "hero" && block.image !== undefined) {
603
+ validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
604
+ }
605
+ if (component === "hero" && block.slides !== undefined) validateHeroSlides(block.slides, file, at, out, tenantId);
606
+ if (component === "editorial" && block.image !== undefined) {
607
+ validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
608
+ if (typeof block.imageAlt !== "string") {
609
+ out.push(mk(ERROR, "block-required", file, `${at}.imageAlt is required when ${at}.image is supplied`));
610
+ }
444
611
  }
612
+ if (component === "promo_tiles") validatePromoTiles(block, file, at, out, tenantId);
613
+ if (component === "trust_strip") validateTrustStrip(block, file, at, out, tenantId);
614
+ if (component === "faq") validateFaq(block, file, at, out);
445
615
  });
446
616
  return out;
447
617
  }
@@ -852,6 +1022,7 @@ export function validateTenant(tenantDir, opts = {}) {
852
1022
  );
853
1023
  }
854
1024
  const scope = opts.scope || config?.scope;
1025
+ const activeTenantId = opts.tenantId || scope || config?.tenant;
855
1026
  // platform-route awareness: which framework routes the host platform owns for this tenant
856
1027
  const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
857
1028
  const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
@@ -910,7 +1081,7 @@ export function validateTenant(tenantDir, opts = {}) {
910
1081
  if (error) {
911
1082
  findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
912
1083
  } else if (name === "home.json") {
913
- findings.push(...validateHomeDoc(value, "content/home.json"));
1084
+ findings.push(...validateHomeDoc(value, "content/home.json", activeTenantId));
914
1085
  } else if (name === "chrome.json") {
915
1086
  findings.push(...findDuplicateCtaIds(value, "content/chrome.json"));
916
1087
  if (opts.previousChromeJson != null) {