@tokenoftrust/cli 3.0.5 → 3.0.7

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": "3.0.5",
3
+ "version": "3.0.7",
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",
@@ -5,10 +5,13 @@
5
5
  * catalog images, and the source store is never shut down before that check passes. This decides,
6
6
  * from a listing of the store's `public/` tree, whether the repo is carrying binaries it must not.
7
7
  *
8
- * Three findings, each naming its remedy:
8
+ * Findings, each naming its remedy:
9
9
  *
10
- * - `catalog-images-in-repo` — any file under `public/catalog-images/`, the directory a catalog
11
- * image download writes. Decided from paths alone, so a caller without sizes still refuses it.
10
+ * - one per entry of {@link FORBIDDEN_PUBLIC_DIRS}: any file under a directory a media download
11
+ * writes (`public/catalog-images/` is `catalog-images-in-repo`, `public/blog/wp-content/uploads/`
12
+ * is `blog-media-in-repo`). Decided from paths alone, so a caller without sizes still refuses
13
+ * it. This list is the one source the validators and the reconcile read; the Gitea
14
+ * pre-receive size guard names the same directories.
12
15
  * - `public-binary-oversized` — one raster or media file over {@link PUBLIC_BINARY_LIMITS}
13
16
  * `maxFileBytes`.
14
17
  * - `public-tree-over-budget` — the whole `public/` tree over `maxTotalBytes`.
@@ -28,13 +31,25 @@ export const PUBLIC_BINARY_LIMITS = Object.freeze({
28
31
  maxTotalBytes: 32 * 1024 * 1024,
29
32
  });
30
33
 
31
- /** The directory under `public/` a catalog image download writes. */
32
- export const CATALOG_IMAGES_DIR = "catalog-images/";
34
+ /**
35
+ * The directories under `public/` a media download writes. No file under one may be committed.
36
+ *
37
+ * @type {ReadonlyArray<Readonly<{ dir: string, code: string, noun: string }>>}
38
+ */
39
+ export const FORBIDDEN_PUBLIC_DIRS = Object.freeze([
40
+ Object.freeze({ dir: "catalog-images/", code: "catalog-images-in-repo", noun: "catalog image" }),
41
+ Object.freeze({ dir: "blog/wp-content/uploads/", code: "blog-media-in-repo", noun: "blog media" }),
42
+ ]);
33
43
 
34
- /** The finding code for files under {@link CATALOG_IMAGES_DIR}. */
35
- export const CATALOG_IMAGES_FINDING_CODE = "catalog-images-in-repo";
44
+ /** The finding codes decided from paths alone. */
45
+ export const PATH_DECIDED_FINDING_CODES = Object.freeze(new Set(FORBIDDEN_PUBLIC_DIRS.map((d) => d.code)));
46
+
47
+ /** True for a path (relative to `public/`, `/` separators) under a forbidden directory. */
48
+ export function isForbiddenPublicPath(rel) {
49
+ return FORBIDDEN_PUBLIC_DIRS.some((d) => rel.startsWith(d.dir));
50
+ }
36
51
 
37
- /** The kit command that moves rehosted catalog image references back to their source URLs. */
52
+ /** The kit command that moves downloaded catalog images and blog media back to their source URLs. */
38
53
  export const UNHOST_IMAGES_COMMAND = "node agency-kit/tools/import-catalog.mjs --store <checkout> --unhost-images --write";
39
54
 
40
55
  const RASTER_OR_MEDIA = /\.(?:jpe?g|png|gif|webp|avif|bmp|tiff?|heic|heif|ico|jxl|mp4|m4v|mov|webm|mkv|avi|mpe?g|mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
@@ -51,19 +66,20 @@ export function publicBinaryFindings(entries, limits = {}) {
51
66
  const maxFileBytes = limits.maxFileBytes ?? PUBLIC_BINARY_LIMITS.maxFileBytes;
52
67
  const maxTotalBytes = limits.maxTotalBytes ?? PUBLIC_BINARY_LIMITS.maxTotalBytes;
53
68
  const findings = [];
54
- const catalogImages = entries.filter((e) => e.rel.startsWith(CATALOG_IMAGES_DIR));
55
- if (catalogImages.length > 0) {
56
- const bytes = catalogImages.reduce((n, e) => n + (e.bytes ?? 0), 0);
69
+ for (const { dir, code, noun } of FORBIDDEN_PUBLIC_DIRS) {
70
+ const files = entries.filter((e) => e.rel.startsWith(dir));
71
+ if (files.length === 0) continue;
72
+ const bytes = files.reduce((n, e) => n + (e.bytes ?? 0), 0);
57
73
  findings.push({
58
- code: CATALOG_IMAGES_FINDING_CODE,
59
- path: `public/${CATALOG_IMAGES_DIR}`,
60
- message: `${catalogImages.length} catalog image file(s)${bytes ? ` (${mb(bytes)})` : ""} are committed to the store repo; ` +
74
+ code,
75
+ path: `public/${dir}`,
76
+ message: `${files.length} ${noun} file(s)${bytes ? ` (${mb(bytes)})` : ""} are committed to the store repo; ` +
61
77
  "images belong in the platform pool, not the repo; until platform capture owns them, keep each image at its source URL",
62
78
  remedy: UNHOST_IMAGES_COMMAND,
63
79
  });
64
80
  }
65
81
  for (const e of entries) {
66
- if (e.rel.startsWith(CATALOG_IMAGES_DIR) || e.bytes === undefined) continue;
82
+ if (isForbiddenPublicPath(e.rel) || e.bytes === undefined) continue;
67
83
  if (e.bytes > maxFileBytes && RASTER_OR_MEDIA.test(e.rel)) {
68
84
  findings.push({
69
85
  code: "public-binary-oversized",
package/src/validate.mjs CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  parseMappingTarget,
24
24
  workspacePathForTenantRelative,
25
25
  } from "./tot-repo-mappings.mjs";
26
- import { CATALOG_IMAGES_DIR, publicBinaryFindings } from "./public-binaries.mjs";
26
+ import { isForbiddenPublicPath, publicBinaryFindings } from "./public-binaries.mjs";
27
27
 
28
28
  export {
29
29
  TOT_CONFIG_KEYS,
@@ -459,6 +459,39 @@ export function isSafeRehostedAssetPathForTenant(value, tenantId) {
459
459
  return value.startsWith("/shared/") || value.startsWith(`/tenants/${tenantId}/`);
460
460
  }
461
461
 
462
+ export function isPlatformPoolImagePath(value) {
463
+ return typeof value === "string" && /^\/_a\/[0-9a-f]{64}(?:\.[a-z0-9]{1,8})?$/.test(value);
464
+ }
465
+
466
+ const IMAGE_SOURCE_DNS_NAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
467
+ const IMAGE_SOURCE_BLOCKED_NAME = /(?:^|\.)(?:localhost|localdomain|local|internal|intranet|lan|home|arpa)$/i;
468
+ const IMAGE_SOURCE_IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
469
+
470
+ export function imageSourceUrlRefusal(value) {
471
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) return "unsupported";
472
+ let url;
473
+ try { url = new URL(value); } catch { return "unsupported"; }
474
+ if (url.protocol !== "https:") return "unsafe-scheme";
475
+ if (url.username || url.password) return "userinfo";
476
+ if (url.port) return "nonstandard-port";
477
+ const hostname = url.hostname;
478
+ if (!hostname || hostname.includes("%")) return "blocked-address";
479
+ if (IMAGE_SOURCE_IPV4.test(hostname)) return "ip-literal";
480
+ const name = hostname.endsWith(".") ? hostname.slice(0, -1) : hostname;
481
+ if (IMAGE_SOURCE_BLOCKED_NAME.test(name) || !IMAGE_SOURCE_DNS_NAME.test(hostname)) return "blocked-address";
482
+ return null;
483
+ }
484
+
485
+ export function isPageBlockImageReference(value, tenantId) {
486
+ if (typeof value !== "string") return false;
487
+ if (tenantId === undefined || tenantId === null
488
+ ? isSafeRehostedAssetPath(value)
489
+ : isSafeRehostedAssetPathForTenant(value, tenantId)) return true;
490
+ if (isPlatformPoolImagePath(value)) return true;
491
+ if (!/^https:\/\//i.test(value) || /[\u0000-\u0020\u007f"'<>\\`]/.test(value)) return false;
492
+ return imageSourceUrlRefusal(value) === null;
493
+ }
494
+
462
495
  export function isSafeStorefrontHref(value) {
463
496
  if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return false;
464
497
  if (value.includes("\\") || value.includes("?") || value.includes("#") || /[\u0000-\u001f\u007f]/.test(value)) return false;
@@ -509,11 +542,8 @@ function validateProductSource(value, file, at) {
509
542
  }
510
543
 
511
544
  function validateVisualAsset(value, file, at, out, tenantId) {
512
- const safe = tenantId
513
- ? isSafeRehostedAssetPathForTenant(value, tenantId)
514
- : isSafeRehostedAssetPath(value);
515
- if (!safe) {
516
- out.push(mk(ERROR, "asset-path", file, `${at} must be a /shared/... asset or belong to the active tenant`));
545
+ if (!isPageBlockImageReference(value, tenantId || undefined)) {
546
+ out.push(mk(ERROR, "asset-path", file, `${at} must be a /shared/... or active-tenant asset, a /_a/<sha256> pool reference, or an https source URL on a public host (captured into the pool at preview and publish)`));
517
547
  }
518
548
  }
519
549
 
@@ -1434,8 +1464,8 @@ function checkAsset(src, file, publicDir, tenantId) {
1434
1464
  function findLargePublicAssets(publicDir, rel) {
1435
1465
  const out = [];
1436
1466
  for (const p of walk(publicDir, () => true)) {
1437
- // public/catalog-images/ is already an error (catalog-images-in-repo); one line, not thousands.
1438
- if (relative(publicDir, p).split("\\").join("/").startsWith(CATALOG_IMAGES_DIR)) continue;
1467
+ // a forbidden media directory is already an error (catalog-images-in-repo, blog-media-in-repo); one line, not thousands.
1468
+ if (isForbiddenPublicPath(relative(publicDir, p).split("\\").join("/"))) continue;
1439
1469
  const size = statSync(p).size;
1440
1470
  if (size >= LARGE_PUBLIC_ASSET_BYTES) {
1441
1471
  out.push(mk(WARN, "large-public-asset", rel(p), `${(size / 1024).toFixed(0)}KB`));