@tokenoftrust/cli 3.0.3 → 3.0.5

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.3",
3
+ "version": "3.0.5",
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",
@@ -22,6 +22,9 @@ export const DEFAULT_CURRENCY: "USD";
22
22
  export const CURRENCY_CODES: readonly string[];
23
23
  export function resolveStoreCurrency(declared: unknown): string;
24
24
  export function currencyDeclarationProblems(currency: unknown): string[];
25
+ export const SHIPPING_KEYS: readonly string[];
26
+ export const MAX_SHIPPING_AMOUNT: number;
27
+ export function shippingDeclarationProblems(shipping: unknown): string[];
25
28
  export const COMPLIANCE_NOTICES: Record<string, string>;
26
29
  export const MANDATED_NOTICE_KEYS: string[];
27
30
  export function nearestKey(key: string, known: readonly string[]): string | null;
@@ -19,7 +19,7 @@
19
19
 
20
20
  export const TOT_CONFIG_KEYS = [
21
21
  "$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
22
- "displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo", "currency",
22
+ "displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo", "currency", "shipping",
23
23
  ];
24
24
  // Keys other tooling has written that the platform does NOT read, with the key it does.
25
25
  const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
@@ -196,6 +196,46 @@ export function seoDeclarationProblems(seo) {
196
196
  return problems;
197
197
  }
198
198
 
199
+ /** Every `shipping` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
200
+ export const SHIPPING_KEYS = Object.freeze(["flatRate", "freeAtOrAbove"]);
201
+
202
+ /** Largest shipping amount a declaration may state, in the cart's currency. */
203
+ export const MAX_SHIPPING_AMOUNT = 100000;
204
+
205
+ /** @param {unknown} value */
206
+ const isShippingAmount = (value) =>
207
+ typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_SHIPPING_AMOUNT
208
+ && Math.abs(value * 100 - Math.round(value * 100)) < 1e-6;
209
+
210
+ /**
211
+ * What is wrong with a declared `shipping` value, one sentence per problem; `[]` when it is
212
+ * valid. A policy with any problem is refused whole, so checkout never charges half of one.
213
+ *
214
+ * @param {unknown} shipping
215
+ * @returns {string[]}
216
+ */
217
+ export function shippingDeclarationProblems(shipping) {
218
+ if (!isPlainObject(shipping)) return ["`shipping` must be an object"];
219
+ const problems = [];
220
+ const source = /** @type {Record<string, unknown>} */ (shipping);
221
+ for (const key of Object.keys(source)) {
222
+ if (!SHIPPING_KEYS.includes(key)) problems.push(`\`shipping.${key}\` is not a shipping key (${SHIPPING_KEYS.join(", ")})`);
223
+ }
224
+ if (!isShippingAmount(source.flatRate)) {
225
+ problems.push(`\`shipping.flatRate\` must be an amount from 0 to ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
226
+ }
227
+ if (source.freeAtOrAbove !== undefined && !(isShippingAmount(source.freeAtOrAbove) && /** @type {number} */ (source.freeAtOrAbove) > 0)) {
228
+ problems.push(`\`shipping.freeAtOrAbove\` must be an amount above 0 and at most ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
229
+ }
230
+ return problems;
231
+ }
232
+
233
+ /** The owner-facing sentence for an invalid `shipping` declaration. */
234
+ function shippingProblemMessage(problems) {
235
+ return `\`shipping\` is not a valid shipping policy (${problems.join("; ")}). ` +
236
+ "Checkout charges no shipping until it is fixed.";
237
+ }
238
+
199
239
 
200
240
  /** What each compliance key puts on the page — the thing that is missing when it is. */
201
241
  export const COMPLIANCE_NOTICES = {
@@ -568,6 +608,8 @@ export function declarationFindings(current, previous) {
568
608
  if (warning.enabled && warning.problems.length > 0) {
569
609
  out.push(`CONFIG: ${nicotineWarningProblemMessage(warning.problems)}`);
570
610
  }
611
+ const shipping = current.shipping === undefined ? [] : shippingDeclarationProblems(current.shipping);
612
+ if (shipping.length > 0) out.push(`CONFIG: ${shippingProblemMessage(shipping)}`);
571
613
  }
572
614
  for (const { key, notice } of droppedMandatedNotices(previous, current)) {
573
615
  out.push(
@@ -656,6 +698,13 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
656
698
  out.push(mk(ERROR, "config-seo", `${file} seo`, `${problem} — the whole \`seo\` declaration is ignored until it is fixed`));
657
699
  }
658
700
  }
701
+ if (config.shipping !== undefined) {
702
+ const problems = shippingDeclarationProblems(config.shipping);
703
+ if (problems.length > 0) {
704
+ out.push(mk(ERROR, "config-shipping", `${file} shipping`, shippingProblemMessage(problems),
705
+ 'declare e.g. `"shipping": { "flatRate": 5.99, "freeAtOrAbove": 100 }`, or remove `shipping` to charge none'));
706
+ }
707
+ }
659
708
  return out;
660
709
  }
661
710
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tenant binaries never live in the tenant repo. The platform's content-addressed `/_a/<sha256>`
3
+ * pool owns them and the store's repo holds references only. Platform capture of catalog images
4
+ * is pending: until it owns them, an image stays at its source URL, go-live requires 0 uncaptured
5
+ * catalog images, and the source store is never shut down before that check passes. This decides,
6
+ * from a listing of the store's `public/` tree, whether the repo is carrying binaries it must not.
7
+ *
8
+ * Three findings, each naming its remedy:
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.
12
+ * - `public-binary-oversized` — one raster or media file over {@link PUBLIC_BINARY_LIMITS}
13
+ * `maxFileBytes`.
14
+ * - `public-tree-over-budget` — the whole `public/` tree over `maxTotalBytes`.
15
+ *
16
+ * The size findings need `bytes`; an entry without it is judged by path only.
17
+ *
18
+ * Budget basis (measured 2026-10-20 on three live store repos' `preview` branches): public/ held
19
+ * 17.2 MB (384 files, largest 0.9 MB), 16.5 MB (largest 0.4 MB) and 0.2 MB outside
20
+ * catalog-images/. No legitimate file exceeded 1 MB.
21
+ *
22
+ * Pure: no filesystem, no network, no imports, so it runs in Node and in the Worker.
23
+ */
24
+
25
+ /** @type {{ maxFileBytes: number, maxTotalBytes: number }} */
26
+ export const PUBLIC_BINARY_LIMITS = Object.freeze({
27
+ maxFileBytes: 1024 * 1024,
28
+ maxTotalBytes: 32 * 1024 * 1024,
29
+ });
30
+
31
+ /** The directory under `public/` a catalog image download writes. */
32
+ export const CATALOG_IMAGES_DIR = "catalog-images/";
33
+
34
+ /** The finding code for files under {@link CATALOG_IMAGES_DIR}. */
35
+ export const CATALOG_IMAGES_FINDING_CODE = "catalog-images-in-repo";
36
+
37
+ /** The kit command that moves rehosted catalog image references back to their source URLs. */
38
+ export const UNHOST_IMAGES_COMMAND = "node agency-kit/tools/import-catalog.mjs --store <checkout> --unhost-images --write";
39
+
40
+ 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;
41
+
42
+ const mb = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
43
+
44
+ /**
45
+ * @param {ReadonlyArray<{ rel: string, bytes?: number }>} entries files under `public/`, `rel`
46
+ * relative to it (`catalog-images/ab.jpg`)
47
+ * @param {{ maxFileBytes?: number, maxTotalBytes?: number }} [limits]
48
+ * @returns {Array<{ code: string, path: string, message: string, remedy: string }>}
49
+ */
50
+ export function publicBinaryFindings(entries, limits = {}) {
51
+ const maxFileBytes = limits.maxFileBytes ?? PUBLIC_BINARY_LIMITS.maxFileBytes;
52
+ const maxTotalBytes = limits.maxTotalBytes ?? PUBLIC_BINARY_LIMITS.maxTotalBytes;
53
+ 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);
57
+ 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; ` +
61
+ "images belong in the platform pool, not the repo; until platform capture owns them, keep each image at its source URL",
62
+ remedy: UNHOST_IMAGES_COMMAND,
63
+ });
64
+ }
65
+ for (const e of entries) {
66
+ if (e.rel.startsWith(CATALOG_IMAGES_DIR) || e.bytes === undefined) continue;
67
+ if (e.bytes > maxFileBytes && RASTER_OR_MEDIA.test(e.rel)) {
68
+ findings.push({
69
+ code: "public-binary-oversized",
70
+ path: `public/${e.rel}`,
71
+ message: `${mb(e.bytes)} raster/media file exceeds the ${mb(maxFileBytes)} per-file limit for the store repo; ` +
72
+ "images and media belong in the platform pool",
73
+ remedy: "reference the file by its source URL, or compress it under the limit",
74
+ });
75
+ }
76
+ }
77
+ if (entries.every((e) => e.bytes !== undefined)) {
78
+ const total = entries.reduce((n, e) => n + /** @type {number} */ (e.bytes), 0);
79
+ if (total > maxTotalBytes) {
80
+ findings.push({
81
+ code: "public-tree-over-budget",
82
+ path: "public/",
83
+ message: `public/ holds ${mb(total)} across ${entries.length} file(s), over the ${mb(maxTotalBytes)} store-repo budget; ` +
84
+ "images and media belong in the platform pool",
85
+ remedy: "reference large or bulk assets by their source URL instead of committing them",
86
+ });
87
+ }
88
+ }
89
+ return findings;
90
+ }
91
+
92
+ /** One line per finding, remedy included: `<path>: [<code>] <message> — fix: <remedy>`. */
93
+ export function describePublicBinaryFindings(findings) {
94
+ return findings.map((f) => `${f.path}: [${f.code}] ${f.message} — fix: ${f.remedy}`);
95
+ }
package/src/validate.mjs CHANGED
@@ -23,6 +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
27
 
27
28
  export {
28
29
  TOT_CONFIG_KEYS,
@@ -1350,6 +1351,13 @@ export function validateTenant(tenantDir, opts = {}) {
1350
1351
  // large public assets — advisory (never blocks); see LARGE_PUBLIC_ASSET_FIX.
1351
1352
  findings.push(...findLargePublicAssets(publicDir, rel));
1352
1353
 
1354
+ // Tenant binaries never live in the tenant repo — the same rule the hosted reconcile refuses.
1355
+ const publicEntries = walk(publicDir, () => true)
1356
+ .map((p) => ({ rel: relative(publicDir, p).split("\\").join("/"), bytes: statSync(p).size }));
1357
+ for (const f of publicBinaryFindings(publicEntries)) {
1358
+ findings.push(mk(ERROR, f.code, `${rel(publicDir)}/${f.path.slice("public/".length)}`, f.message, f.remedy));
1359
+ }
1360
+
1353
1361
  const ok = !findings.some((f) => f.level === ERROR);
1354
1362
  return { ok, findings };
1355
1363
  }
@@ -1426,6 +1434,8 @@ function checkAsset(src, file, publicDir, tenantId) {
1426
1434
  function findLargePublicAssets(publicDir, rel) {
1427
1435
  const out = [];
1428
1436
  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;
1429
1439
  const size = statSync(p).size;
1430
1440
  if (size >= LARGE_PUBLIC_ASSET_BYTES) {
1431
1441
  out.push(mk(WARN, "large-public-asset", rel(p), `${(size / 1024).toFixed(0)}KB`));