@tokenoftrust/storefront-runner 2.2.89 → 2.2.91

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.
@@ -235,11 +235,13 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
235
235
  // The tenant as it renders in THIS environment: its record with the declared
236
236
  // `.tot/config.json` fields applied (compliance, siteType, capabilities,
237
237
  // features). Everything below — static bundle, notices, gates, features,
238
- // capabilities — reads this, never the bare record.
238
+ // capabilities — reads this, never the bare record. A candidate-pinned render
239
+ // takes the candidate version's declaration, so a reviewer sees the notices the
240
+ // change under review declares.
239
241
  const tenant = await withDeclaredTenantConfig(
240
242
  tenantRecord,
241
243
  kv ? (key: string) => kv.get(key) : async () => null,
242
- customization.env,
244
+ { env: customization.env, ...(candidatePin ? { pinnedVersionId: candidatePin.versionId } : {}) },
243
245
  );
244
246
  const publicationTimestamp = publicationSource.status === "selected"
245
247
  ? publicationSource.publicationTimestamp
@@ -441,16 +443,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
441
443
  if (hit) return await stampResponseNonce(hit, freshNonce());
442
444
  }
443
445
 
444
- // Catalog source selection (docs/architecture/product-catalog.md).
445
- // A store's product source graduates over its life: JSON fixtures (dev default) ->
446
- // D1 edge replica (staging) -> hosted ToT catalog (go-live). The read backend is
447
- // chosen by the CALLER: the control plane resolves the D1 edge replica when
448
- // USE_D1_CATALOG=1; the public branch never does, so `createToTClient` falls back
449
- // to the bundled JSON fixture snapshot. The hosted catalog graduates the SAME
450
- // canonical records into the D1 read model (emitCatalogSql), so JSON/D1/hosted
451
- // render identically — pinned by lib/tot/sourceSelection.test.ts.
452
- const tot = await createToTClient(tenant, await input.readCatalogD1?.());
453
-
454
446
  const proto = url.protocol === "http:" ? "http" : "https";
455
447
  // Canonical base uses the tenant's canonical domain in prod; for local dev we
456
448
  // keep the actual host (plus any /<domain> base path) so links resolve.
@@ -468,7 +460,19 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
468
460
  // pointer, the artifact is resolved through the immutable site version
469
461
  // (channel -> versionId -> manifest -> content). Tenants with no channel
470
462
  // pointer fall back to the legacy per-path selection — no behavior change.
471
- const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
463
+ // Request-scoped read memo: every content read resolves the same pointer and
464
+ // version manifest, and a catalog render reads one artifact per catalog file.
465
+ const kvReads = new Map<string, Promise<string | null>>();
466
+ const kvGet = kv
467
+ ? (key: string) => {
468
+ let read = kvReads.get(key);
469
+ if (!read) {
470
+ read = kv.get(key);
471
+ kvReads.set(key, read);
472
+ }
473
+ return read;
474
+ }
475
+ : async () => null;
472
476
 
473
477
  // Self-heal a DANGLING channel pointer: the pointer for this env's channel may
474
478
  // name a version that was never built (or whose manifest was evicted).
@@ -600,7 +604,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
600
604
  ? await resolveTenantCommerce(tenant)
601
605
  : null;
602
606
  locals.commerce = commerce ?? undefined;
603
- locals.tot = tot;
604
607
  // Resolve the shared root adapter first. An opened publication version then
605
608
  // becomes authoritative for selected content while a mounted space may still
606
609
  // reuse root-owned chrome. Neither source falls through to another space.
@@ -624,6 +627,12 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
624
627
  kvGet,
625
628
  );
626
629
  }
630
+ // Catalog serving is declared per tenant (`catalog.serving`, ADR 0033 and
631
+ // docs/architecture/product-catalog.md). The store's own `content/catalog/` files
632
+ // are read through the SAME content source just resolved, so they follow the
633
+ // request's selected version exactly as pages and the blog do. Only the control
634
+ // plane supplies the D1 projection; the public branch never reads D1.
635
+ locals.tot = await createToTClient(tenant, await input.readCatalogD1?.(), locals.content);
627
636
  locals.themeStyle = themeToCssVars(theme);
628
637
  locals.canonicalBase = canonicalBase;
629
638
  locals.customizationEnv = customization.env;
@@ -745,7 +754,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
745
754
  // exactly the placements that rendered on THIS page, never a wildcard.
746
755
  const widgetCsp = appWidgetCspAdditions(locals.activeWidgetPlacements ?? []);
747
756
  // Third-party marketing embeds (e.g. a Pipedrive form) the tenant opted into
748
- // via .tot/config.json `embeds:`. Only scan the HTML body when the tenant
757
+ // via its published embeds.json. Only scan the HTML body when the tenant
749
758
  // declared at least one embed — zero cost for everyone else — and add each
750
759
  // provider's origins ONLY if its marker is actually on THIS page.
751
760
  // Prefer an opt-in the render handler resolved for a tenant the URL router
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.89",
3
+ "version": "2.2.91",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The `.tot/config.json` keys the platform reads, and the refusal of everything
3
+ * else. Shared by `tot validate` (src/validate.mjs) and the monorepo/dev-loop
4
+ * validator (scripts/tenant/validate.mjs), so both refuse the same keys.
5
+ *
6
+ * Mirrors packages/public-runtime/src/declared-tenant-config.mjs (this package ships
7
+ * to npm on its own); test/declared-config-parity.test.mjs pins the two together, so
8
+ * a field the platform honors can never be refused here, and a key no surface reads
9
+ * can never pass. Dependency-free: the storefront runner copies this file as-is.
10
+ */
11
+
12
+ export const TOT_CONFIG_KEYS = [
13
+ "$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
14
+ "siteType", "compliance", "capabilities", "features",
15
+ ];
16
+ // Keys other tooling has written that the platform does NOT read, with the key it does.
17
+ const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
18
+ export const COMPLIANCE_KEYS = [
19
+ "ruleProfile", "minAge", "nicotineWarning", "shippingRestriction", "pactAct",
20
+ "adultSignature", "prop65", "purchaseLimit", "stateEligibility", "exciseTax",
21
+ ];
22
+ export const FEATURE_KEYS = [
23
+ "catalog", "productSearch", "savedItems", "quickView", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
24
+ ];
25
+ export const SITE_TYPES = ["commerce", "marketing"];
26
+
27
+ const ERROR = "error";
28
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
29
+
30
+ /** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
31
+ function mk(level, rule, file, message, fix) {
32
+ return { level, rule, file, message, fix };
33
+ }
34
+
35
+ /**
36
+ * Refuse what the platform would silently ignore: an unknown top-level key, an
37
+ * unknown `compliance`/`features` key, or a declared field of the wrong shape. Each
38
+ * one passes JSON parsing and renders nothing — on a regulated store that is a
39
+ * mandated notice that never appears.
40
+ * @param {Record<string, any>} config parsed `.tot/config.json` (an object)
41
+ * @param {string} [file]
42
+ * @returns {Finding[]}
43
+ */
44
+ export function validateDeclaredConfig(config, file = ".tot/config.json") {
45
+ const out = [];
46
+ for (const key of Object.keys(config)) {
47
+ if (!TOT_CONFIG_KEYS.includes(key)) {
48
+ const renamed = RENAMED_CONFIG_KEYS[/** @type {keyof typeof RENAMED_CONFIG_KEYS} */ (key)];
49
+ out.push(mk(ERROR, "config-unknown-key", file,
50
+ `\`${key}\` is not a .tot/config.json key — the platform never reads it`,
51
+ renamed ? `use \`${renamed}\`` : `known keys: ${TOT_CONFIG_KEYS.join(", ")}`));
52
+ }
53
+ }
54
+ if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
55
+ out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
56
+ }
57
+ /** @type {Array<[string, string[]]>} */
58
+ const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
59
+ for (const [field, known] of objectFields) {
60
+ const value = config[field];
61
+ if (value === undefined) continue;
62
+ if (!isObject(value)) {
63
+ out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
64
+ continue;
65
+ }
66
+ for (const key of Object.keys(value)) {
67
+ if (!known.includes(key)) {
68
+ out.push(mk(ERROR, `config-${field}-unknown-key`, `${file} ${field}`,
69
+ `\`${field}.${key}\` is not a ${field} key — nothing renders from it`,
70
+ `known keys: ${known.join(", ")}`));
71
+ }
72
+ }
73
+ }
74
+ if (config.capabilities !== undefined && !isObject(config.capabilities)) {
75
+ out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
76
+ }
77
+ return out;
78
+ }
@@ -17,6 +17,7 @@
17
17
  "./image-preview-rewrite-overlay": "./src/image-preview-rewrite-overlay.ts",
18
18
  "./hosted-image-media-types": "./src/hosted-image-media-types.ts",
19
19
  "./durable-assets": "./src/durable-assets.ts",
20
+ "./catalog-files": "./src/catalog-files.ts",
20
21
  "./retention-holds": "./src/retention-holds.ts",
21
22
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
22
23
  "./declared-tenant-config": "./src/declared-tenant-config.mjs",
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A store's own catalog files — the cold content artifact of ADR 0033 — typed for the platform.
3
+ *
4
+ * The layout, the validator and the parser live once, in the agency kit's pure catalog module
5
+ * (`agency-kit/tools/lib/catalog-model/files.mjs`), which the kit's own tools, the renderer, the
6
+ * reconcile validator and the migration engine all import. This module only narrows its records
7
+ * to the canonical types in `./product.ts`; the kit's model is pinned to those types field for
8
+ * field by `scripts/lib/agency-kit-catalog-drift.test.mjs`.
9
+ */
10
+ import {
11
+ readCatalogFile as readKitCatalogFile,
12
+ readCatalogFiles as readKitCatalogFiles,
13
+ type CatalogStore,
14
+ } from "../../../agency-kit/tools/lib/catalog-model/files.mjs";
15
+ import type { CatalogCollection, CatalogProduct } from "./product.js";
16
+
17
+ export {
18
+ CATALOG_ARTIFACT_PREFIX,
19
+ CATALOG_COLLECTIONS_PREFIX,
20
+ CATALOG_CONTENT_DIRS,
21
+ CATALOG_HANDLE_RE,
22
+ CATALOG_PRODUCTS_PREFIX,
23
+ CatalogFileError,
24
+ catalogFileIssues,
25
+ classifyCatalogPath,
26
+ declaredAvailability,
27
+ planCatalogFiles,
28
+ type CatalogFileKind,
29
+ type CatalogStore,
30
+ type InventoryProvider,
31
+ } from "../../../agency-kit/tools/lib/catalog-model/files.mjs";
32
+
33
+ export type CatalogFileRecord =
34
+ | { kind: "product"; record: CatalogProduct }
35
+ | { kind: "collection"; record: CatalogCollection };
36
+
37
+ /** One validated catalog file as its canonical record. Throws `CatalogFileError`. */
38
+ export function readCatalogFile(relative: string, raw: string, store: CatalogStore): CatalogFileRecord {
39
+ return readKitCatalogFile(relative, raw, store) as unknown as CatalogFileRecord;
40
+ }
41
+
42
+ /** A whole validated catalog. Throws `CatalogFileError` naming every failing file. */
43
+ export function readCatalogFiles(
44
+ entries: Iterable<{ path: string; raw: string | null }>,
45
+ store: CatalogStore,
46
+ ): { products: CatalogProduct[]; collections: CatalogCollection[] } {
47
+ return readKitCatalogFiles(entries, store) as unknown as {
48
+ products: CatalogProduct[];
49
+ collections: CatalogCollection[];
50
+ };
51
+ }
@@ -103,7 +103,7 @@ export const STOREFRONT_CSP_ALLOWLIST = {
103
103
 
104
104
  /**
105
105
  * Curated catalog of known third-party embeds a tenant may opt into. Keyed by a
106
- * stable provider slug the tenant lists in `.tot/config.json` `embeds:`; the
106
+ * stable provider slug the tenant lists in its `embeds.json`; the
107
107
  * platform (never the tenant) owns the exact origins each provider needs, so a
108
108
  * tenant can enable "pipedrive" without ever hand-writing a CSP origin — and a
109
109
  * novel provider requires a reviewed catalog entry here, not a config edit.
@@ -170,7 +170,7 @@ export interface EmbedCspAdditions {
170
170
  * CSP additions for the third-party embeds ACTUALLY rendered on this page.
171
171
  *
172
172
  * An origin is added only when BOTH hold: (1) the tenant opted the provider in
173
- * via `.tot/config.json` `embeds:` (audit trail + platform review of novel
173
+ * via its `embeds.json` (audit trail + platform review of novel
174
174
  * providers), and (2) the provider's marker appears in the rendered HTML (so a
175
175
  * page that doesn't embed the form never carries the origin). This keeps the
176
176
  * strict policy tenant- AND page-scoped — never a blanket global relaxation.
@@ -503,10 +503,16 @@ export interface TenantConfig {
503
503
  * the pages that actually contain it (see `embedCspAdditions`). A provider not
504
504
  * in the catalog needs a reviewed platform addition, not a config edit — so
505
505
  * this stays an auditable allow-list, never an escape hatch to arbitrary hosts.
506
- * Declared in the tenant's platform config alongside `siteType`/`capabilities`.
506
+ * The platform default comes from tenant-embed-defaults.mjs; a tenant opts in
507
+ * itself by committing `embeds.json` (see `resolveEffectiveEmbeds`).
507
508
  */
508
509
  embeds?: string[];
509
- /** Regulated-commerce affordances; undefined for unregulated tenants. */
510
+ /**
511
+ * Regulated-commerce affordances; undefined for unregulated tenants. A tenant
512
+ * declares these in its `.tot/config.json`; every render reads them through
513
+ * `applyDeclaredTenantConfig` (declared-tenant-config.mjs), never off the bare
514
+ * tenant record alone.
515
+ */
510
516
  compliance?: ComplianceConfig;
511
517
  /**
512
518
  * Per-tenant newsletter compliance/sender config. Sourced when a digest is
@@ -622,6 +628,25 @@ export interface TenantConfig {
622
628
  */
623
629
  htmlCachePolicy?: HtmlCachePolicy;
624
630
  };
631
+ /**
632
+ * Catalog serving (ADR 0033). The store's own `content/catalog/` files are the
633
+ * system of record; this declares which artifact serves reads.
634
+ *
635
+ * `"files"` (the default): the store's catalog files, then the platform's
636
+ * grandfathered in-repo snapshot for the same data scope, then an empty catalog.
637
+ * `"d1"`: the derived D1 serving projection, read on the control plane only.
638
+ * Never inferred from a tenant id.
639
+ */
640
+ catalog?: {
641
+ serving?: "files" | "d1";
642
+ };
643
+ /**
644
+ * Availability (hot) provider (ADR 0033). Availability is never read from catalog
645
+ * content files. Absent ⇒ `"mirror"`, the only implemented provider.
646
+ */
647
+ inventory?: {
648
+ provider: "mirror" | "live-hydrate" | "tot-authoritative";
649
+ };
625
650
  /**
626
651
  * Storefront product/subscription state — the paywall the platform gates GO-LIVE
627
652
  * on. This is the storefront's
@@ -1,35 +1,57 @@
1
1
  // @ts-check
2
2
  /**
3
3
  * Pure planning for `tot dev --workspace` grafts: which checkout-relative paths
4
- * get copied into the in-tree `tenants/<id>/` layout the app globs, and whether
5
- * each is a file or a directory tree. Decision-only — scripts/tot-dev.mjs does
6
- * the actual fs copy/backup/watch effects per planned entry.
4
+ * get copied where in the in-tree `tenants/<id>/` layout the app globs, and
5
+ * whether each is a file or a directory tree. Decision-only — scripts/tot-dev.mjs
6
+ * does the actual fs copy/backup/watch effects per planned entry.
7
+ *
8
+ * A mapping's `workspace` is where the files live in the checkout; its `repo`
9
+ * names what they are, tenant-relative (`theme.json`, `content/`, …). They land
10
+ * under THIS tenant's directory — the same resolution the hosted reconcile uses
11
+ * (agency-kit/tools/lib/tot-repo-mappings.mjs), whatever tenant segment `repo`
12
+ * spells — so the dev server serves them at the same `/tenants/<tenant>/…` the
13
+ * hosted preview does.
7
14
  */
15
+ import { tenantDirRelative } from "../lib/tenant-dirs.mjs";
16
+ import { parseMappingTarget } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
17
+
18
+ const TOT_CONFIG = ".tot/config.json";
8
19
 
9
20
  /**
10
- * @param {Array<{workspace?: unknown, kind?: unknown}>} mappings the checkout's
11
- * own `.tot/config.json` `mappings` array (content/, public/, theme.json, …)
12
- * @returns {{ workspace: string, isDir: boolean }[]}
21
+ * @param {string} tenant canonical tenant id (the checkout config's `tenant`)
22
+ * @param {unknown} mappings the checkout's own `.tot/config.json` `mappings` array
23
+ * (content/, public/, theme.json, …)
24
+ * @returns {{ workspace: string, target: string | null, isDir: boolean }[]} checkout-relative
25
+ * `workspace` and repo-relative `target` (POSIX); `target` is null for a mapping whose `repo` is
26
+ * not a valid `tenants/<id>/<path>` target, which the caller skips
13
27
  */
14
- export function standaloneGraftPlan(mappings) {
28
+ export function standaloneGraftPlan(tenant, mappings) {
29
+ const root = tenantDirRelative(tenant);
15
30
  const plan = [];
16
31
  const seen = new Set();
17
32
  for (const map of Array.isArray(mappings) ? mappings : []) {
18
33
  const workspace = String(map?.workspace ?? "").replace(/\/+$/, "");
19
34
  if (!workspace || seen.has(workspace)) continue;
20
35
  seen.add(workspace);
21
- plan.push({ workspace, isDir: map?.kind !== "file" });
36
+ if (workspace === TOT_CONFIG) {
37
+ plan.push({ workspace, target: `${root}/${TOT_CONFIG}`, isDir: false });
38
+ continue;
39
+ }
40
+ const declared = parseMappingTarget(map?.repo);
41
+ const rel = declared ? declared.rel.replace(/\/+$/, "") : null;
42
+ const target = rel === null ? null : rel ? `${root}/${rel}` : root;
43
+ plan.push({ workspace, target, isDir: map?.kind !== "file" });
22
44
  }
23
45
  // `.tot/config.json` is never itself one of the declared `mappings` (those map
24
46
  // content/public/theme.json) but IS the tenant's own declared platform config
25
47
  // — siteType/compliance/capabilities/features (see
26
- // apps/storefront/src/config/devTenantSeed.ts TOT_CONFIG_TENANT_FIELDS and
27
- // resolver.ts declaredConfigs()) — the same file
48
+ // packages/public-runtime/src/declared-tenant-config.mjs, applied by
49
+ // apps/storefront/src/config/declaredTenantConfig.ts) — the same file
28
50
  // scripts/tenant/materialize-tenant-content.mjs copies for a Gitea-sourced
29
51
  // tenant. Graft it unconditionally so a regulated store's mandated notices
30
52
  // resolve from the SAME declared config locally as they would once published.
31
- if (!seen.has(".tot/config.json")) {
32
- plan.push({ workspace: ".tot/config.json", isDir: false });
53
+ if (!seen.has(TOT_CONFIG)) {
54
+ plan.push({ workspace: TOT_CONFIG, target: `${root}/${TOT_CONFIG}`, isDir: false });
33
55
  }
34
56
  return plan;
35
57
  }
@@ -71,6 +71,7 @@ import { tmpdir } from "node:os";
71
71
  import { dirname, join, resolve, sep } from "node:path";
72
72
  import { fileURLToPath, pathToFileURL } from "node:url";
73
73
  import { tenantDirRelative } from "../lib/tenant-dirs.mjs";
74
+ import { parseMappingTarget } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
74
75
 
75
76
  const here = dirname(fileURLToPath(import.meta.url));
76
77
  const repoRoot = resolve(here, "..", "..");
@@ -153,7 +154,13 @@ function canonicalMappings(tenant) {
153
154
  */
154
155
  export function copyMapping({ checkoutDir, root, tenant, mapping, log = (/** @type {string} */ _m) => {} }) {
155
156
  const src = join(checkoutDir, mapping.workspace.replace(/\/+$/, ""));
156
- const target = resolveTargetPath(root, tenant, mapping.repo.replace(/\/+$/, ""));
157
+ // The mapping names WHAT the files are (its tenant-relative target); the
158
+ // tenant being materialized names WHOSE — never the target's tenant segment.
159
+ const declared = parseMappingTarget(mapping.repo);
160
+ if (!declared) {
161
+ throw new Error(`mapping target "${mapping.repo}" escapes tenants/${tenant}/ — refusing`);
162
+ }
163
+ const target = resolveTargetPath(root, tenant, `tenants/${tenant}/${declared.rel}`.replace(/\/+$/, ""));
157
164
  if (target === resolve(root, tenantDirRelative(tenant))) {
158
165
  throw new Error("mapping target cannot replace the tenant root");
159
166
  }
@@ -5,7 +5,7 @@
5
5
  * Catches, BEFORE a commit reaches the hosted storefront, the classes of tenant
6
6
  * defect that otherwise fail silently or as a cryptic runtime/reconcile error:
7
7
  * · `.tot/config.json` mapping-shape drift (the #24/#25 regression — a `kind:"dir"`
8
- * / missing-`repo` mapping makes the flow-back reconcile `mapToArtifact` throw)
8
+ * / missing-`repo` mapping makes the flow-back reconcile refuse it)
9
9
  * · malformed `theme.json` / `home.json` / `chrome.json` (an eager Vite glob turns
10
10
  * these into a WHOLE-APP BUILD FAILURE)
11
11
  * · raw HTML that isn't a servable document, or drops CSS under the CSP nonce
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Design principle — "valid locally ≡ will reconcile in storefront": the checks below
17
17
  * mirror the reconcile pipeline's own gate (`@tot/private-controlplane`
18
- * customization-reconcile.ts `mapToArtifact` / `validateRawHtml`). Keep them in step.
18
+ * customization-reconcile.ts `validateMappingShape` / `validateRawHtml`, and the shared mapping resolver `tot-repo-mappings.mjs`). Keep them in step.
19
19
  *
20
20
  * Dependency-free. Two modes (mirrors scripts/tot-dev.mjs):
21
21
  * node scripts/tenant/validate.mjs <tenant> # monorepo: tenants/<tenant>/
@@ -38,7 +38,13 @@ import {
38
38
  isSafeRehostedAssetPathForTenant,
39
39
  isSafeStorefrontHref,
40
40
  } from "../../packages/public-runtime/src/extension-contract-values.mjs";
41
+ import {
42
+ foreignTenantSegments,
43
+ parseMappingTarget,
44
+ workspacePathForTenantRelative,
45
+ } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
41
46
  import { validateVisualParityClosure } from "../../packages/cli/src/visual-parity.mjs";
47
+ import { validateDeclaredConfig } from "../../packages/cli/src/declared-config.mjs";
42
48
 
43
49
  /** Catalogued embed slugs a tenant may opt into (mirror of EMBED_PROVIDERS). */
44
50
  const KNOWN_EMBED_SLUGS = new Set(KNOWN_EMBEDS.map((p) => p.slug));
@@ -97,7 +103,7 @@ const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
97
103
 
98
104
  /**
99
105
  * Validate a parsed `.tot/config.json` against the shape the reconcile pipeline
100
- * (`mapToArtifact`) requires. Pure — no I/O — so the CI regression test can call it.
106
+ * (`validateMappingShape`) requires. Pure — no I/O — so the CI regression test can call it.
101
107
  * @param {any} config parsed JSON
102
108
  * @param {string} file path for messages
103
109
  * @returns {Finding[]}
@@ -110,6 +116,8 @@ export function validateConfigShape(config, file = ".tot/config.json") {
110
116
  if (typeof config.tenant !== "string" || !config.tenant) {
111
117
  out.push(mk(ERROR, "config-tenant", file, "`tenant` must be a non-empty string"));
112
118
  }
119
+ // The same refusal `tot validate` applies: a key no surface reads never passes.
120
+ out.push(...validateDeclaredConfig(config, file));
113
121
  if (!Array.isArray(config.mappings)) {
114
122
  out.push(mk(ERROR, "config-mappings", file, "`mappings` must be an array", "seed shape: [{workspace,repo,kind}]"));
115
123
  return out; // nothing more to check without the array
@@ -123,18 +131,29 @@ export function validateConfigShape(config, file = ".tot/config.json") {
123
131
  }
124
132
  if (typeof m.workspace !== "string" || !m.workspace) {
125
133
  out.push(mk(ERROR, "mapping-workspace", at, "`workspace` must be a non-empty string"));
126
- } else {
127
- seen.add(m.workspace.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
128
- seen.add(m.workspace);
129
134
  }
130
- // THE regression guard: reconcile's mapToArtifact dereferences m.repo and only
131
- // knows "file" vs tree — a missing repo or kind:"dir" makes it throw at runtime.
135
+ // THE regression guard: reconcile reads the tenant-relative target out of
136
+ // m.repo and only knows "file" vs tree — a missing/unparseable repo or
137
+ // kind:"dir" is refused there, so refuse it here first.
132
138
  if (typeof m.repo !== "string" || !m.repo) {
133
139
  out.push(
134
140
  mk(ERROR, "mapping-repo", at,
135
- `mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (reconcile mapToArtifact throws on undefined.repo)`,
141
+ `mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (the reconcile refuses it)`,
136
142
  `add "repo": "tenants/${config.tenant ?? "<id>"}/${(m.workspace ?? "").replace(/\/+$/, "")}${m.kind === "file" ? "" : "/"}"`),
137
143
  );
144
+ } else {
145
+ const target = parseMappingTarget(m.repo);
146
+ if (!target) {
147
+ out.push(
148
+ mk(ERROR, "mapping-repo", at,
149
+ `mapping for "${m.workspace ?? "?"}" targets "${m.repo}" — \`repo\` must be a clean path under tenants/<id>/ (the reconcile refuses it)`,
150
+ `e.g. "repo": "tenants/${config.tenant ?? "<id>"}/content/"`),
151
+ );
152
+ } else {
153
+ // Coverage is about WHAT is mapped (the target), not where it lives.
154
+ seen.add(target.rel.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
155
+ seen.add(target.rel);
156
+ }
138
157
  }
139
158
  if (!KNOWN_KINDS.has(m.kind)) {
140
159
  out.push(
@@ -998,16 +1017,9 @@ export function validateTenant(tenantDir, opts = {}) {
998
1017
  /** @type {Finding[]} */
999
1018
  const findings = [];
1000
1019
  const rel = (p) => relative(tenantDir, p) || p;
1001
- const contentDir = join(tenantDir, "content");
1002
- const publicDir = join(tenantDir, "public");
1003
- const pagesDir = join(contentDir, "pages-html");
1004
-
1005
- // 0. basic layout
1006
- if (!existsSync(contentDir)) {
1007
- findings.push(mk(ERROR, "layout", "content/", "no content/ directory"));
1008
- }
1009
1020
 
1010
- // 1. .tot/config.json — shape (flow-back readiness)
1021
+ // 1. .tot/config.json — shape (flow-back readiness). Read first: its mappings
1022
+ // say where theme.json / content/ / public/ live in this tree.
1011
1023
  const configPath = join(tenantDir, ".tot", "config.json");
1012
1024
  let config = null;
1013
1025
  if (existsSync(configPath)) {
@@ -1018,7 +1030,18 @@ export function validateTenant(tenantDir, opts = {}) {
1018
1030
  config = value;
1019
1031
  findings.push(...validateConfigShape(value, ".tot/config.json"));
1020
1032
  }
1021
- } else {
1033
+ }
1034
+ const located = (tenantRel) => join(tenantDir, workspacePathForTenantRelative(config, tenantRel));
1035
+ const contentDir = located("content");
1036
+ const publicDir = located("public");
1037
+ const pagesDir = join(contentDir, "pages-html");
1038
+
1039
+ // 0. basic layout
1040
+ if (!existsSync(contentDir)) {
1041
+ findings.push(mk(ERROR, "layout", rel(contentDir) + "/", `no ${rel(contentDir)}/ directory`));
1042
+ }
1043
+
1044
+ if (!existsSync(configPath)) {
1022
1045
  findings.push(
1023
1046
  mk(WARN, "config-absent", ".tot/config.json",
1024
1047
  "no .tot/config.json — tenant can serve but is NOT flow-back-ready (reconcile has nothing to map)",
@@ -1031,7 +1054,28 @@ export function validateTenant(tenantDir, opts = {}) {
1031
1054
  );
1032
1055
  }
1033
1056
  const scope = opts.scope || config?.scope;
1034
- const activeTenantId = opts.tenantId || scope || config?.tenant;
1057
+ // The canonical tenant id: the one the asset rule binds to and the one the
1058
+ // mappings' targets resolve under — never a directory name.
1059
+ const activeTenantId = opts.tenantId || config?.tenant || scope;
1060
+ if (config && activeTenantId) {
1061
+ for (const f of foreignTenantSegments(config, activeTenantId)) {
1062
+ findings.push(
1063
+ mk(WARN, "mapping-tenant-segment", ".tot/config.json",
1064
+ `mapping for "${f.workspace}" targets "${f.repo}", naming tenant "${f.segment}" — this store is "${activeTenantId}". ` +
1065
+ `It still resolves under tenants/${activeTenantId}/ (the segment is not read), and its assets are served at /tenants/${activeTenantId}/...`,
1066
+ `write "repo": "tenants/${activeTenantId}/${parseMappingTarget(f.repo)?.rel ?? ""}" so the config agrees with itself ` +
1067
+ `(the agency kit's tools/repair-tenant-config.mjs rewrites the whole config)`),
1068
+ );
1069
+ }
1070
+ }
1071
+ // Publication refuses a config whose `scope` is not the store's id.
1072
+ if (config && typeof config.tenant === "string" && config.scope !== config.tenant) {
1073
+ findings.push(
1074
+ mk(WARN, "config-scope-mismatch", ".tot/config.json",
1075
+ `"scope" is ${JSON.stringify(config.scope ?? null)} but "tenant" is "${config.tenant}" — publication refuses a config whose scope is not the store's id`,
1076
+ `set "scope" to "${config.tenant}" (the agency kit's tools/repair-tenant-config.mjs rewrites the whole config)`),
1077
+ );
1078
+ }
1035
1079
  // platform-route awareness: which framework routes the host platform owns for this tenant
1036
1080
  const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
1037
1081
  const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
@@ -1041,18 +1085,18 @@ export function validateTenant(tenantDir, opts = {}) {
1041
1085
  }
1042
1086
 
1043
1087
  // 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
1044
- const themePath = join(tenantDir, "theme.json");
1088
+ const themePath = located("theme.json");
1045
1089
  if (existsSync(themePath)) {
1046
1090
  const { error } = readJsonSafe(themePath);
1047
1091
  if (error) {
1048
1092
  findings.push(
1049
- mk(ERROR, "theme-parse", "theme.json",
1093
+ mk(ERROR, "theme-parse", rel(themePath),
1050
1094
  `invalid JSON: ${error}`, "theme.json is loaded by an eager Vite glob — a parse error fails the entire storefront build"),
1051
1095
  );
1052
1096
  }
1053
1097
  }
1054
1098
 
1055
- const capabilitiesPath = join(tenantDir, "capabilities.json");
1099
+ const capabilitiesPath = located("capabilities.json");
1056
1100
  if (existsSync(capabilitiesPath)) {
1057
1101
  const { value, error } = readJsonSafe(capabilitiesPath);
1058
1102
  if (error) {
@@ -1062,7 +1106,7 @@ export function validateTenant(tenantDir, opts = {}) {
1062
1106
  }
1063
1107
  }
1064
1108
 
1065
- const scriptsPath = join(tenantDir, "scripts.json");
1109
+ const scriptsPath = located("scripts.json");
1066
1110
  if (existsSync(scriptsPath)) {
1067
1111
  const { value, error } = readJsonSafe(scriptsPath);
1068
1112
  if (error) {
@@ -1072,7 +1116,7 @@ export function validateTenant(tenantDir, opts = {}) {
1072
1116
  }
1073
1117
  }
1074
1118
 
1075
- const embedsPath = join(tenantDir, "embeds.json");
1119
+ const embedsPath = located("embeds.json");
1076
1120
  if (existsSync(embedsPath)) {
1077
1121
  const { value, error } = readJsonSafe(embedsPath);
1078
1122
  if (error) {
@@ -1177,9 +1221,7 @@ export function validateTenant(tenantDir, opts = {}) {
1177
1221
  // content/ plus the root config files.
1178
1222
  const conflictScanFiles = [
1179
1223
  ...walk(contentDir, hasScanExt),
1180
- ...["theme.json", "capabilities.json", "scripts.json", join(".tot", "config.json")]
1181
- .map((f) => join(tenantDir, f))
1182
- .filter((p) => existsSync(p)),
1224
+ ...[themePath, capabilitiesPath, scriptsPath, configPath].filter((p) => existsSync(p)),
1183
1225
  ];
1184
1226
  for (const p of conflictScanFiles) {
1185
1227
  const lines = detectConflictMarkers(readFileSync(p, "utf8"));
@@ -1200,9 +1242,8 @@ export function validateTenant(tenantDir, opts = {}) {
1200
1242
  // compare against, since the opt-in may be the platform build-time default.
1201
1243
  if (existsSync(contentDir)) {
1202
1244
  let declaredEmbeds = null;
1203
- const embedsFile = join(tenantDir, "embeds.json");
1204
- if (existsSync(embedsFile)) {
1205
- const { value } = readJsonSafe(embedsFile);
1245
+ if (existsSync(embedsPath)) {
1246
+ const { value } = readJsonSafe(embedsPath);
1206
1247
  if (Array.isArray(value)) declaredEmbeds = value.filter((s) => typeof s === "string");
1207
1248
  }
1208
1249
  for (const f of lintTenantEmbeds({ dir: contentDir, declared: declaredEmbeds }).findings) {