@tokenoftrust/cli 2.2.12 → 2.2.14

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": "2.2.12",
3
+ "version": "2.2.14",
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",
@@ -51,6 +51,7 @@ import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
51
51
  import { streamDevLogs } from "../dev-logs.mjs";
52
52
  import { cockpitUrlFrom } from "../banner.mjs";
53
53
  import { recordDiagnostic } from "../diagnostics.mjs";
54
+ import { lockRendererDir, unlockRendererDir, rendererDirInUse } from "../renderer-lock.mjs";
54
55
 
55
56
  // Kept for the internal `tot start` migration path. `tot dev` itself is native-only.
56
57
  const DEFAULT_DEV_IMAGE =
@@ -914,8 +915,19 @@ export function rendererArtifactComplete(runnerDir) {
914
915
  * runner tree per version forever. Only touches `public-*` dirs: the entitled
915
916
  * path caches by bare version and sample tarball runs by `sample-*`, both left
916
917
  * alone. Best-effort — a failure here never breaks `tot dev`.
918
+ *
919
+ * NEVER deletes a dir a running `tot dev` session is still serving files out
920
+ * of (per renderer-lock.mjs's cross-process lock) — resolving a newer runner
921
+ * in one terminal must not crash a DIFFERENT, still-running session in
922
+ * another with ENOENT reading its own astro templates mid-session
923
+ * (fb-1791304494289-ussj8l). That dir is simply left for a later prune once
924
+ * nothing holds it; `log` reports the deferral instead of pruning silently.
917
925
  */
918
- function prunePublicRunnerCache(cacheRoot, keepVersion) {
926
+ export function prunePublicRunnerCache(
927
+ cacheRoot,
928
+ keepVersion,
929
+ { log = (/** @type {string} */ _message) => {}, pidAlive = processAlive, lockDir = undefined } = {},
930
+ ) {
919
931
  try {
920
932
  if (!cacheRoot || !existsSync(cacheRoot)) return;
921
933
  // keepVersion == null → keep NOTHING (drop every public-* dir); used when a
@@ -923,7 +935,12 @@ function prunePublicRunnerCache(cacheRoot, keepVersion) {
923
935
  const keep = keepVersion == null ? null : `public-${keepVersion}`;
924
936
  for (const name of readdirSync(cacheRoot)) {
925
937
  if (!name.startsWith("public-") || name === keep) continue;
926
- rmSync(join(cacheRoot, name), { recursive: true, force: true });
938
+ const dir = join(cacheRoot, name);
939
+ if (rendererDirInUse(dir, { pidAlive, ...(lockDir ? { dir: lockDir } : {}) })) {
940
+ log(`~ keeping ${name} cached — a running \`tot dev\` session is still serving from it.`);
941
+ continue;
942
+ }
943
+ rmSync(dir, { recursive: true, force: true });
927
944
  }
928
945
  } catch {
929
946
  /* best-effort cache hygiene */
@@ -1044,21 +1061,21 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
1044
1061
  `~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
1045
1062
  );
1046
1063
  setRunnerVersion(wantVersion);
1047
- prunePublicRunnerCache(cacheRoot, wantVersion);
1064
+ prunePublicRunnerCache(cacheRoot, wantVersion, { log: console.error });
1048
1065
  return exact;
1049
1066
  }
1050
1067
  // Same version string, different published contents — a corrected
1051
1068
  // republish. Fall through to resolution + a fresh install (which also
1052
1069
  // verifies the new bytes against the new integrity).
1053
1070
  console.error(`~ renderer: ${wantVersion} was republished with different contents — refetching the corrected artifact`);
1054
- prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none matches what npm now publishes
1071
+ prunePublicRunnerCache(cacheRoot, null, { log: console.error }); // drop ALL public-* — none matches what npm now publishes
1055
1072
  } else {
1056
1073
  console.error(
1057
1074
  rendererCacheHealthy(exact)
1058
1075
  ? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
1059
1076
  : `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
1060
1077
  );
1061
- prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
1078
+ prunePublicRunnerCache(cacheRoot, null, { log: console.error }); // drop ALL public-* — none is trustworthy
1062
1079
  }
1063
1080
  }
1064
1081
  }
@@ -1096,7 +1113,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
1096
1113
  setRunnerVersion(pub.version); // telemetry: the runner version running this session
1097
1114
  // The pinned version is now installed under public-<version> — drop any other
1098
1115
  // public-* dirs (e.g. the stale public-0.1.0) so they can never be reused.
1099
- prunePublicRunnerCache(cacheRoot, pub.version);
1116
+ prunePublicRunnerCache(cacheRoot, pub.version, { log: console.error });
1100
1117
  return dir;
1101
1118
  }
1102
1119
 
@@ -1715,14 +1732,21 @@ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit",
1715
1732
  [script, "--workspace", workspace, "--port", port],
1716
1733
  { cwd: runnerDir, stdio: stdioArr, env: mergedEnv },
1717
1734
  );
1735
+ // Claim this runnerDir for the lifetime of this child — prunePublicRunnerCache
1736
+ // consults this lock before deleting an un-pinned cached version, so resolving
1737
+ // a NEWER runner in one terminal can never delete the directory THIS session
1738
+ // is still serving files out of (fb-1791304494289-ussj8l).
1739
+ lockRendererDir(runnerDir, { pid: child.pid });
1718
1740
  const handle = { child, exited: false, done: /** @type {any} */ (null) };
1719
1741
  handle.done = new Promise((resolvePromise) => {
1720
1742
  child.on("exit", (code) => {
1721
1743
  handle.exited = true;
1744
+ unlockRendererDir({ pid: child.pid });
1722
1745
  resolvePromise(code ?? 0);
1723
1746
  });
1724
1747
  child.on("error", (e) => {
1725
1748
  handle.exited = true;
1749
+ unlockRendererDir({ pid: child.pid });
1726
1750
  console.error(fail(`could not start the native dev runner: ${e.message}`));
1727
1751
  resolvePromise(1);
1728
1752
  });
@@ -0,0 +1,74 @@
1
+ // @ts-check
2
+ /**
3
+ * Cross-process lock recording which PIDs are actively serving a `tot dev`
4
+ * native session out of which cached renderer directory (`~/.tot/cache/renderer/
5
+ * <version>` or `public-<version>`). `prunePublicRunnerCache` (src/commands/dev.mjs)
6
+ * reads it before deleting an un-pinned cached version, so resolving a NEWER
7
+ * runner in one terminal can never delete the directory a DIFFERENT, still-running
8
+ * `tot dev` session in another terminal is serving files out of — which used to
9
+ * crash the live session with ENOENT reading its own astro templates mid-session
10
+ * (fb-1791304494289-ussj8l).
11
+ *
12
+ * Lives OUTSIDE the renderer cache tree itself (not `<runnerDir>/.tot-dev-lock`)
13
+ * so a lock entry is never mistaken for cache/package content and never ships
14
+ * inside a published runner tarball.
15
+ */
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ export const RENDERER_LOCK_DIR = join(homedir(), ".tot", "run", "active-renderers");
21
+
22
+ /** Record that `pid` (default: this process) is actively serving from `runnerDir`. */
23
+ export function lockRendererDir(runnerDir, { pid = process.pid, dir = RENDERER_LOCK_DIR } = {}) {
24
+ try {
25
+ mkdirSync(dir, { recursive: true });
26
+ writeFileSync(join(dir, String(pid)), runnerDir);
27
+ } catch {
28
+ /* best-effort — a failed lock must never block `tot dev` from starting */
29
+ }
30
+ }
31
+
32
+ /** Release a lock (best-effort; safe to call more than once, e.g. on every exit path). */
33
+ export function unlockRendererDir({ pid = process.pid, dir = RENDERER_LOCK_DIR } = {}) {
34
+ try {
35
+ rmSync(join(dir, String(pid)), { force: true });
36
+ } catch {
37
+ /* best-effort */
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Is any LIVE process's lock pointing at `runnerDir`? Fails CLOSED (true) on any
43
+ * read error — an unreadable lock state must never read as "safe to delete",
44
+ * since that's precisely the failure this exists to prevent. A stale (dead-pid)
45
+ * lock is ignored so cache hygiene still reclaims disk once nothing holds it.
46
+ * @param {string} runnerDir
47
+ * @param {{ pidAlive: (pid:number)=>boolean, dir?: string }} deps
48
+ */
49
+ export function rendererDirInUse(runnerDir, { pidAlive, dir = RENDERER_LOCK_DIR }) {
50
+ try {
51
+ if (!existsSync(dir)) return false;
52
+ let inUse = false;
53
+ for (const name of readdirSync(dir)) {
54
+ const pid = Number(name);
55
+ if (!Number.isInteger(pid) || pid <= 0) continue;
56
+ let lockedDir;
57
+ try {
58
+ lockedDir = readFileSync(join(dir, name), "utf8");
59
+ } catch {
60
+ continue; // one unreadable lock entry must not block the rest of the scan
61
+ }
62
+ if (pidAlive(pid)) {
63
+ if (lockedDir === runnerDir) inUse = true;
64
+ } else {
65
+ // A crashed/killed session never ran its own exit handler — reap its
66
+ // lock opportunistically (best-effort) so entries don't accumulate.
67
+ try { rmSync(join(dir, name), { force: true }); } catch { /* best-effort */ }
68
+ }
69
+ }
70
+ return inUse;
71
+ } catch {
72
+ return true; // fail closed: an unreadable lock DIRECTORY must not read as "unused"
73
+ }
74
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `.tot/config.json` mapping resolution — the ONE reading of a store repo's
3
+ * declared layout, shared by every consumer: the hosted reconcile (preview,
4
+ * candidate, aggregate, publication), `tot dev`'s checkout graft, `tot validate`,
5
+ * the platform's materialize / asset-repair scripts, and this kit's
6
+ * `repair-tenant-config.mjs`. The platform imports this file from the kit.
7
+ *
8
+ * A mapping is `{ workspace, repo, kind }`:
9
+ * - `workspace` — where the files physically live in the store repo. It is the
10
+ * only source of truth for location; nothing assumes a `tenants/<id>/` layout
11
+ * inside the store's own repo.
12
+ * - `repo` — the storefront artifact target, `tenants/<segment>/<rel>`. Only
13
+ * `<rel>` (the tenant-relative path: `theme.json`, `content/`, `public/`, …)
14
+ * is read from it. The tenant namespace always comes from the CANONICAL
15
+ * tenant id — the store's registered id hosted, the config's `tenant` locally
16
+ * — never from `<segment>`, so a config whose segment disagrees with its
17
+ * tenant id resolves identically on every surface.
18
+ *
19
+ * Pure and dependency-free so kit tools, platform `.mjs` scripts and TypeScript
20
+ * consumers (types in the adjacent `.d.mts`) all import it. The published CLI
21
+ * installs without the kit, so it ships a byte-identical copy that its own test
22
+ * suite holds equal to this file.
23
+ */
24
+
25
+ /** Platform test tenants nest one level deeper: `tenants/e2e/<id>.e2e.test/`. */
26
+ const TARGET_RE = /^tenants\/(?:e2e\/([^/]+\.e2e\.test)|([^/]+))(?:\/(.*))?$/;
27
+
28
+ /**
29
+ * Split a declared mapping target into the tenant segment it names and the
30
+ * tenant-relative path it addresses. Null when the target is not under
31
+ * `tenants/<segment>/`, or when its relative part is not a clean path (empty,
32
+ * `.` or `..` segments) — such a target could address outside the tenant.
33
+ * A trailing `/` (tree target) is preserved on `rel`; the tenant root is `""`.
34
+ * @param {unknown} repo
35
+ * @returns {{ segment: string, rel: string } | null}
36
+ */
37
+ export function parseMappingTarget(repo) {
38
+ if (typeof repo !== "string") return null;
39
+ const m = TARGET_RE.exec(repo);
40
+ if (!m) return null;
41
+ const segment = m[1] ?? m[2];
42
+ const rel = m[3] ?? "";
43
+ const parts = rel.replace(/\/$/, "").split("/");
44
+ if (rel && parts.some((p) => !p || p === "." || p === "..")) return null;
45
+ return { segment, rel };
46
+ }
47
+
48
+ /** The canonical artifact root for a tenant: `tenants/<tenantId>/`. */
49
+ export function tenantArtifactRoot(tenantId) {
50
+ return `tenants/${tenantId}/`;
51
+ }
52
+
53
+ function asDir(path) {
54
+ return path === "" || path.endsWith("/") ? path : `${path}/`;
55
+ }
56
+
57
+ function mappingsOf(config) {
58
+ return Array.isArray(config?.mappings) ? config.mappings : [];
59
+ }
60
+
61
+ /**
62
+ * Map a tenant-repo path to its tenant-relative path (e.g. `content/home.json`).
63
+ * First match wins; a `file` mapping matches exactly, a `tree` mapping by prefix.
64
+ * Null when no mapping covers the path or the covering mapping's target is not
65
+ * a valid tenant target.
66
+ * @param {{ mappings?: unknown }} config
67
+ * @param {string} workspacePath
68
+ * @returns {string | null}
69
+ */
70
+ export function mapWorkspaceToTenantRelative(config, workspacePath) {
71
+ for (const m of mappingsOf(config)) {
72
+ if (!m || typeof m !== "object" || typeof m.workspace !== "string") continue;
73
+ if (m.kind === "file") {
74
+ if (workspacePath !== m.workspace) continue;
75
+ const target = parseMappingTarget(m.repo);
76
+ return target ? target.rel : null;
77
+ }
78
+ if (m.kind === "tree") {
79
+ const ws = asDir(m.workspace);
80
+ if (!workspacePath.startsWith(ws)) continue;
81
+ const target = parseMappingTarget(m.repo);
82
+ return target ? asDir(target.rel) + workspacePath.slice(ws.length) : null;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Map a tenant-repo path to its storefront artifact path under the CANONICAL
90
+ * tenant root (`tenants/<tenantId>/<rel>`). Null when unmapped.
91
+ * @param {{ mappings?: unknown }} config
92
+ * @param {string} tenantId
93
+ * @param {string} workspacePath
94
+ * @returns {string | null}
95
+ */
96
+ export function mapWorkspaceToArtifact(config, tenantId, workspacePath) {
97
+ const rel = mapWorkspaceToTenantRelative(config, workspacePath);
98
+ return rel === null ? null : tenantArtifactRoot(tenantId) + rel;
99
+ }
100
+
101
+ /**
102
+ * The inverse: where a tenant-relative path (`theme.json`, `content`, `public`,
103
+ * `content/home.json`) lives in the tenant repo, per the declared mappings.
104
+ * Unmapped paths resolve to themselves — the flat checkout layout — so a
105
+ * consumer still finds files the mappings do not cover (and the config-coverage
106
+ * check reports the gap).
107
+ * @param {{ mappings?: unknown }} config
108
+ * @param {string} relPath tenant-relative, no leading `/`
109
+ * @returns {string}
110
+ */
111
+ export function workspacePathForTenantRelative(config, relPath) {
112
+ const bare = relPath.replace(/\/$/, "");
113
+ for (const m of mappingsOf(config)) {
114
+ if (!m || typeof m !== "object" || typeof m.workspace !== "string") continue;
115
+ const target = parseMappingTarget(m.repo);
116
+ if (!target) continue;
117
+ const targetRel = target.rel.replace(/\/$/, "");
118
+ const ws = m.workspace.replace(/\/$/, "");
119
+ if (m.kind === "file") {
120
+ if (targetRel === bare) return ws;
121
+ continue;
122
+ }
123
+ if (m.kind !== "tree") continue;
124
+ if (targetRel === bare) return ws;
125
+ const prefix = asDir(targetRel);
126
+ if (bare.startsWith(prefix)) {
127
+ const rest = bare.slice(prefix.length);
128
+ return ws ? `${ws}/${rest}` : rest;
129
+ }
130
+ }
131
+ return bare;
132
+ }
133
+
134
+ /**
135
+ * Mappings whose declared target names a tenant segment other than the
136
+ * canonical tenant id. They still resolve correctly (the segment is never
137
+ * read); this exists so validators can tell the author the config disagrees
138
+ * with itself.
139
+ * @param {{ mappings?: unknown }} config
140
+ * @param {string} tenantId
141
+ * @returns {{ workspace: string, repo: string, segment: string }[]}
142
+ */
143
+ export function foreignTenantSegments(config, tenantId) {
144
+ const out = [];
145
+ for (const m of mappingsOf(config)) {
146
+ if (!m || typeof m !== "object") continue;
147
+ const target = parseMappingTarget(m.repo);
148
+ if (target && target.segment !== tenantId) {
149
+ out.push({ workspace: String(m.workspace ?? ""), repo: m.repo, segment: target.segment });
150
+ }
151
+ }
152
+ return out;
153
+ }
package/src/validate.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Design principle — "valid locally ≡ will reconcile in storefront": these checks
7
7
  * mirror the reconcile pipeline's own gate (@tot/private-controlplane
8
- * customization-reconcile.ts `mapToArtifact` / `validateRawHtml`). Kept in step
8
+ * customization-reconcile.ts `validateMappingShape` / `validateRawHtml`, and the shared mapping resolver `tot-repo-mappings.mjs`). Kept in step
9
9
  * with the in-repo source of truth `scripts/tenant/validate.mjs` (which the CI
10
10
  * regression test imports) — including its platform-route awareness (commerce
11
11
  * tenants own /collections, /products/<handle>, /search, /saved, /account, /cart,
@@ -17,6 +17,11 @@
17
17
  */
18
18
  import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
19
19
  import { join, relative } from "node:path";
20
+ import {
21
+ foreignTenantSegments,
22
+ parseMappingTarget,
23
+ workspacePathForTenantRelative,
24
+ } from "./tot-repo-mappings.mjs";
20
25
 
21
26
  const NUL = String.fromCharCode(0);
22
27
  export const ERROR = "error";
@@ -63,9 +68,74 @@ const hasScanExt = (p) => CONFLICT_SCAN_EXT.has((p.match(/\.[^./\\]+$/) || [""])
63
68
  const KNOWN_KINDS = new Set(["file", "tree"]);
64
69
  const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
65
70
 
71
+ // The keys the platform reads in `.tot/config.json`. Mirrors
72
+ // packages/public-runtime/src/declared-tenant-config.mjs (this package ships to npm
73
+ // on its own); test/declared-config-parity.test.mjs pins the two together, so a
74
+ // field the platform honors can never be refused here, and a key no surface reads
75
+ // can never pass.
76
+ export const TOT_CONFIG_KEYS = [
77
+ "$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
78
+ "siteType", "compliance", "capabilities", "features",
79
+ ];
80
+ // Keys other tooling has written that the platform does NOT read, with the key it does.
81
+ const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
82
+ export const COMPLIANCE_KEYS = [
83
+ "ruleProfile", "minAge", "nicotineWarning", "shippingRestriction", "pactAct",
84
+ "adultSignature", "prop65", "purchaseLimit", "stateEligibility", "exciseTax",
85
+ ];
86
+ export const FEATURE_KEYS = [
87
+ "catalog", "productSearch", "savedItems", "quickView", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
88
+ ];
89
+ export const SITE_TYPES = ["commerce", "marketing"];
90
+
91
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
92
+
93
+ /**
94
+ * Refuse what the platform would silently ignore: an unknown top-level key, an
95
+ * unknown `compliance`/`features` key, or a declared field of the wrong shape. Each
96
+ * one passes JSON parsing and renders nothing — on a regulated store that is a
97
+ * mandated notice that never appears.
98
+ * @returns {Finding[]}
99
+ */
100
+ export function validateDeclaredConfig(config, file = ".tot/config.json") {
101
+ const out = [];
102
+ for (const key of Object.keys(config)) {
103
+ if (!TOT_CONFIG_KEYS.includes(key)) {
104
+ const renamed = RENAMED_CONFIG_KEYS[key];
105
+ out.push(mk(ERROR, "config-unknown-key", file,
106
+ `\`${key}\` is not a .tot/config.json key — the platform never reads it`,
107
+ renamed ? `use \`${renamed}\`` : `known keys: ${TOT_CONFIG_KEYS.join(", ")}`));
108
+ }
109
+ }
110
+ if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
111
+ out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
112
+ }
113
+ /** @type {Array<[string, string[]]>} */
114
+ const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
115
+ for (const [field, known] of objectFields) {
116
+ const value = config[field];
117
+ if (value === undefined) continue;
118
+ if (!isObject(value)) {
119
+ out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
120
+ continue;
121
+ }
122
+ for (const key of Object.keys(value)) {
123
+ if (!known.includes(key)) {
124
+ out.push(mk(ERROR, `config-${field}-unknown-key`, `${file} ${field}`,
125
+ `\`${field}.${key}\` is not a ${field} key — nothing renders from it`,
126
+ `known keys: ${known.join(", ")}`));
127
+ }
128
+ }
129
+ }
130
+ if (config.capabilities !== undefined && !isObject(config.capabilities)) {
131
+ out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
132
+ }
133
+ return out;
134
+ }
135
+
66
136
  /**
67
137
  * Validate a parsed `.tot/config.json` against the shape the reconcile pipeline
68
- * (`mapToArtifact`) requires. Pure — no I/O.
138
+ * (`validateMappingShape`) requires. Pure — no I/O.
69
139
  * @returns {Finding[]}
70
140
  */
71
141
  export function validateConfigShape(config, file = ".tot/config.json") {
@@ -76,6 +146,7 @@ export function validateConfigShape(config, file = ".tot/config.json") {
76
146
  if (typeof config.tenant !== "string" || !config.tenant) {
77
147
  out.push(mk(ERROR, "config-tenant", file, "`tenant` must be a non-empty string"));
78
148
  }
149
+ out.push(...validateDeclaredConfig(config, file));
79
150
  if (!Array.isArray(config.mappings)) {
80
151
  out.push(mk(ERROR, "config-mappings", file, "`mappings` must be an array", "seed shape: [{workspace,repo,kind}]"));
81
152
  return out;
@@ -89,18 +160,29 @@ export function validateConfigShape(config, file = ".tot/config.json") {
89
160
  }
90
161
  if (typeof m.workspace !== "string" || !m.workspace) {
91
162
  out.push(mk(ERROR, "mapping-workspace", at, "`workspace` must be a non-empty string"));
92
- } else {
93
- seen.add(m.workspace.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
94
- seen.add(m.workspace);
95
163
  }
96
- // THE regression guard: reconcile's mapToArtifact dereferences m.repo and only
97
- // knows "file" vs tree — a missing repo or kind:"dir" makes it throw at runtime.
164
+ // THE regression guard: reconcile reads the tenant-relative target out of
165
+ // m.repo and only knows "file" vs tree — a missing/unparseable repo or
166
+ // kind:"dir" is refused there, so refuse it here first.
98
167
  if (typeof m.repo !== "string" || !m.repo) {
99
168
  out.push(
100
169
  mk(ERROR, "mapping-repo", at,
101
- `mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (reconcile mapToArtifact throws on undefined.repo)`,
170
+ `mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (the reconcile refuses it)`,
102
171
  `add "repo": "tenants/${config.tenant ?? "<id>"}/${(m.workspace ?? "").replace(/\/+$/, "")}${m.kind === "file" ? "" : "/"}"`),
103
172
  );
173
+ } else {
174
+ const target = parseMappingTarget(m.repo);
175
+ if (!target) {
176
+ out.push(
177
+ mk(ERROR, "mapping-repo", at,
178
+ `mapping for "${m.workspace ?? "?"}" targets "${m.repo}" — \`repo\` must be a clean path under tenants/<id>/ (the reconcile refuses it)`,
179
+ `e.g. "repo": "tenants/${config.tenant ?? "<id>"}/content/"`),
180
+ );
181
+ } else {
182
+ // Coverage is about WHAT is mapped (the target), not where it lives.
183
+ seen.add(target.rel.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
184
+ seen.add(target.rel);
185
+ }
104
186
  }
105
187
  if (!KNOWN_KINDS.has(m.kind)) {
106
188
  out.push(
@@ -1025,16 +1107,9 @@ export function validateTenant(tenantDir, opts = {}) {
1025
1107
  /** @type {Finding[]} */
1026
1108
  const findings = [];
1027
1109
  const rel = (p) => relative(tenantDir, p) || p;
1028
- const contentDir = join(tenantDir, "content");
1029
- const publicDir = join(tenantDir, "public");
1030
- const pagesDir = join(contentDir, "pages-html");
1031
1110
 
1032
- // 0. basic layout
1033
- if (!existsSync(contentDir)) {
1034
- findings.push(mk(ERROR, "layout", "content/", "no content/ directory"));
1035
- }
1036
-
1037
- // 1. .tot/config.json — shape (flow-back readiness)
1111
+ // 1. .tot/config.json — shape (flow-back readiness). Read first: its mappings
1112
+ // say where theme.json / content/ / public/ live in this tree.
1038
1113
  const configPath = join(tenantDir, ".tot", "config.json");
1039
1114
  let config = null;
1040
1115
  if (existsSync(configPath)) {
@@ -1045,14 +1120,46 @@ export function validateTenant(tenantDir, opts = {}) {
1045
1120
  config = value;
1046
1121
  findings.push(...validateConfigShape(value, ".tot/config.json"));
1047
1122
  }
1048
- } else {
1123
+ }
1124
+ const located = (tenantRel) => join(tenantDir, workspacePathForTenantRelative(config, tenantRel));
1125
+ const contentDir = located("content");
1126
+ const publicDir = located("public");
1127
+ const pagesDir = join(contentDir, "pages-html");
1128
+
1129
+ // 0. basic layout
1130
+ if (!existsSync(contentDir)) {
1131
+ findings.push(mk(ERROR, "layout", rel(contentDir) + "/", `no ${rel(contentDir)}/ directory`));
1132
+ }
1133
+
1134
+ if (!existsSync(configPath)) {
1049
1135
  findings.push(
1050
1136
  mk(WARN, "config-absent", ".tot/config.json",
1051
1137
  "no .tot/config.json — tenant can serve but is NOT flow-back-ready (reconcile has nothing to map)"),
1052
1138
  );
1053
1139
  }
1054
1140
  const scope = opts.scope || config?.scope;
1055
- const activeTenantId = opts.tenantId || scope || config?.tenant;
1141
+ // The canonical tenant id: the one the asset rule binds to and the one the
1142
+ // mappings' targets resolve under — never a directory name.
1143
+ const activeTenantId = opts.tenantId || config?.tenant || scope;
1144
+ if (config && activeTenantId) {
1145
+ for (const f of foreignTenantSegments(config, activeTenantId)) {
1146
+ findings.push(
1147
+ mk(WARN, "mapping-tenant-segment", ".tot/config.json",
1148
+ `mapping for "${f.workspace}" targets "${f.repo}", naming tenant "${f.segment}" — this store is "${activeTenantId}". ` +
1149
+ `It still resolves under tenants/${activeTenantId}/ (the segment is not read), and its assets are served at /tenants/${activeTenantId}/...`,
1150
+ `write "repo": "tenants/${activeTenantId}/${parseMappingTarget(f.repo)?.rel ?? ""}" so the config agrees with itself ` +
1151
+ `(the agency kit's tools/repair-tenant-config.mjs rewrites the whole config)`),
1152
+ );
1153
+ }
1154
+ }
1155
+ // Publication refuses a config whose `scope` is not the store's id.
1156
+ if (config && typeof config.tenant === "string" && config.scope !== config.tenant) {
1157
+ findings.push(
1158
+ mk(WARN, "config-scope-mismatch", ".tot/config.json",
1159
+ `"scope" is ${JSON.stringify(config.scope ?? null)} but "tenant" is "${config.tenant}" — publication refuses a config whose scope is not the store's id`,
1160
+ `set "scope" to "${config.tenant}" (the agency kit's tools/repair-tenant-config.mjs rewrites the whole config)`),
1161
+ );
1162
+ }
1056
1163
  // platform-route awareness: which framework routes the host platform owns for this tenant
1057
1164
  const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
1058
1165
  const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
@@ -1062,18 +1169,18 @@ export function validateTenant(tenantDir, opts = {}) {
1062
1169
  }
1063
1170
 
1064
1171
  // 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
1065
- const themePath = join(tenantDir, "theme.json");
1172
+ const themePath = located("theme.json");
1066
1173
  if (existsSync(themePath)) {
1067
1174
  const { error } = readJsonSafe(themePath);
1068
1175
  if (error) {
1069
1176
  findings.push(
1070
- mk(ERROR, "theme-parse", "theme.json",
1177
+ mk(ERROR, "theme-parse", rel(themePath),
1071
1178
  `invalid JSON: ${error}`, "theme.json is loaded by an eager Vite glob — a parse error fails the entire storefront build"),
1072
1179
  );
1073
1180
  }
1074
1181
  }
1075
1182
 
1076
- const capabilitiesPath = join(tenantDir, "capabilities.json");
1183
+ const capabilitiesPath = located("capabilities.json");
1077
1184
  if (existsSync(capabilitiesPath)) {
1078
1185
  const { value, error } = readJsonSafe(capabilitiesPath);
1079
1186
  if (error) {
@@ -1083,7 +1190,7 @@ export function validateTenant(tenantDir, opts = {}) {
1083
1190
  }
1084
1191
  }
1085
1192
 
1086
- const scriptsPath = join(tenantDir, "scripts.json");
1193
+ const scriptsPath = located("scripts.json");
1087
1194
  if (existsSync(scriptsPath)) {
1088
1195
  const { value, error } = readJsonSafe(scriptsPath);
1089
1196
  if (error) {
@@ -1093,7 +1200,7 @@ export function validateTenant(tenantDir, opts = {}) {
1093
1200
  }
1094
1201
  }
1095
1202
 
1096
- const embedsPath = join(tenantDir, "embeds.json");
1203
+ const embedsPath = located("embeds.json");
1097
1204
  if (existsSync(embedsPath)) {
1098
1205
  const { value, error } = readJsonSafe(embedsPath);
1099
1206
  if (error) {
@@ -1193,9 +1300,7 @@ export function validateTenant(tenantDir, opts = {}) {
1193
1300
  // content/ plus the root config files.
1194
1301
  const conflictScanFiles = [
1195
1302
  ...walk(contentDir, hasScanExt),
1196
- ...["theme.json", "capabilities.json", "scripts.json", join(".tot", "config.json")]
1197
- .map((f) => join(tenantDir, f))
1198
- .filter((p) => existsSync(p)),
1303
+ ...[themePath, capabilitiesPath, scriptsPath, configPath].filter((p) => existsSync(p)),
1199
1304
  ];
1200
1305
  for (const p of conflictScanFiles) {
1201
1306
  const lines = detectConflictMarkers(readFileSync(p, "utf8"));
@@ -1214,9 +1319,8 @@ export function validateTenant(tenantDir, opts = {}) {
1214
1319
  // origin, is blocking; advisory (info) when there's no embeds.json to compare.
1215
1320
  {
1216
1321
  let declaredEmbeds = null;
1217
- const embedsFile = join(tenantDir, "embeds.json");
1218
- if (existsSync(embedsFile)) {
1219
- const { value } = readJsonSafe(embedsFile);
1322
+ if (existsSync(embedsPath)) {
1323
+ const { value } = readJsonSafe(embedsPath);
1220
1324
  if (Array.isArray(value)) declaredEmbeds = new Set(value.filter((s) => typeof s === "string"));
1221
1325
  }
1222
1326
  for (const file of walk(contentDir, (p) => p.endsWith(".html"))) {