@tokenoftrust/storefront-runner 2.2.90 → 2.2.92

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.
@@ -46,3 +46,11 @@ export function readCatalogFiles(
46
46
  entries: Iterable<{ path: string; raw: string | null }>,
47
47
  store: CatalogStore,
48
48
  ): { products: CatalogRecord[]; collections: CatalogRecord[] };
49
+
50
+ export const CATALOG_LISTING_VERSION: number;
51
+ export const LISTING_DESCRIPTION_CHARS: number;
52
+ export function catalogListingProjection(entries: Iterable<{ path: string; raw: string | null }>): string | null;
53
+ export function readCatalogListing(
54
+ raw: string,
55
+ store: CatalogStore,
56
+ ): { products: CatalogRecord[]; collections: CatalogRecord[] };
@@ -261,3 +261,96 @@ export function readCatalogFiles(entries, { tenantScope, inventoryProvider }) {
261
261
  if (failures.length > 0) throw new CatalogFileError(failures);
262
262
  return { products, collections };
263
263
  }
264
+
265
+ // --- The listing projection ------------------------------------------------------------------
266
+ //
267
+ // A large catalog cannot be read file by file on every request where each read is a separate
268
+ // storage call (the hosted preview). The projection is one derived document built once per
269
+ // reconciled version: every product as a listing needs it (no `description_html`, no
270
+ // `metafields`, `description_text` cut to LISTING_DESCRIPTION_CHARS) plus every collection whole,
271
+ // or, when any file fails validation, the failures. A product page reads its full file.
272
+
273
+ /** Version of the projection document; a reader refuses any other. */
274
+ export const CATALOG_LISTING_VERSION = 1;
275
+ /** Characters of `description_text` a listing keeps: cards show 180, search weighs the rest lightly. */
276
+ export const LISTING_DESCRIPTION_CHARS = 400;
277
+
278
+ /** @param {Record<string, any>} record */
279
+ function listingProduct(record) {
280
+ const { description_html: _html, metafields: _metafields, ...rest } = record;
281
+ return {
282
+ ...rest,
283
+ description_text:
284
+ typeof rest.description_text === "string"
285
+ ? rest.description_text.slice(0, LISTING_DESCRIPTION_CHARS)
286
+ : rest.description_text,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Build the listing projection from a store's catalog files, validating each one exactly as the
292
+ * reader does. Records are kept as their files hold them (identity and availability are stamped
293
+ * when the projection is read). Returns the document's text, or null when there are no catalog
294
+ * files.
295
+ *
296
+ * @param {Iterable<{ path: string, raw: string | null }>} entries `content/`-relative paths
297
+ * @returns {string | null}
298
+ */
299
+ export function catalogListingProjection(entries) {
300
+ const products = [];
301
+ const collections = [];
302
+ const failures = [];
303
+ let any = false;
304
+ for (const { path, raw } of entries) {
305
+ if (classifyCatalogPath(path) === null) continue;
306
+ any = true;
307
+ if (raw === null) {
308
+ failures.push({ path, issues: ["listed in the catalog but unreadable"] });
309
+ continue;
310
+ }
311
+ const parsed = parse(path, raw, "", { available: true });
312
+ if (parsed.issues.length > 0) {
313
+ failures.push({ path, issues: parsed.issues });
314
+ continue;
315
+ }
316
+ const doc = JSON.parse(raw);
317
+ if (parsed.kind === "product") products.push(listingProduct(catalogFileRecord("product", doc)));
318
+ else collections.push(doc);
319
+ }
320
+ if (!any) return null;
321
+ return JSON.stringify(
322
+ failures.length > 0
323
+ ? { version: CATALOG_LISTING_VERSION, failures }
324
+ : { version: CATALOG_LISTING_VERSION, products, collections },
325
+ );
326
+ }
327
+
328
+ /**
329
+ * Read a listing projection for the store being read, records stamped exactly as file reads
330
+ * are. Throws `CatalogFileError` with the failures the projection recorded, or when the document
331
+ * is not a projection this module wrote.
332
+ *
333
+ * @param {string} raw
334
+ * @param {{ tenantScope: string, inventoryProvider?: string }} store
335
+ * @returns {{ products: object[], collections: object[] }}
336
+ */
337
+ export function readCatalogListing(raw, { tenantScope, inventoryProvider }) {
338
+ const availability = declaredAvailability(inventoryProvider);
339
+ /** @type {any} */
340
+ let doc = null;
341
+ try {
342
+ doc = JSON.parse(raw);
343
+ } catch {
344
+ doc = null;
345
+ }
346
+ if (!isObject(doc) || doc.version !== CATALOG_LISTING_VERSION) {
347
+ throw new CatalogFileError([
348
+ { path: "catalog/", issues: ["the catalog listing projection is unreadable; reconcile the preview again"] },
349
+ ]);
350
+ }
351
+ if (Array.isArray(doc.failures)) throw new CatalogFileError(doc.failures);
352
+ return {
353
+ products: doc.products.map((/** @type {unknown} */ p) => stamp("product", p, tenantScope, availability)),
354
+ collections: doc.collections.map((/** @type {unknown} */ c) => stamp("collection", c, tenantScope, availability)),
355
+ };
356
+ }
@@ -80,15 +80,21 @@ export async function versionDeclaredTenantConfig(
80
80
  }
81
81
  }
82
82
 
83
- /** The tenant as it renders in `env`: its record with every declaration applied. */
83
+ /**
84
+ * The tenant as it renders: its record with every declaration applied. A render
85
+ * pinned to one version (a candidate under review) takes that version's
86
+ * declaration; otherwise the environment's channel supplies it.
87
+ */
84
88
  export async function withDeclaredTenantConfig(
85
89
  tenant: TenantConfig,
86
90
  kvGet: (key: string) => Promise<string | null>,
87
- env: CustomizationEnv,
91
+ source: { env: CustomizationEnv; pinnedVersionId?: string },
88
92
  ): Promise<TenantConfig> {
89
93
  const fromCheckout = applyDeclaredTenantConfig(tenant, checkoutDeclaredTenantConfig(tenant.tenant_id));
90
94
  return applyDeclaredTenantConfig(
91
95
  fromCheckout,
92
- await publishedDeclaredTenantConfig(kvGet, tenant.tenant_id, env),
96
+ source.pinnedVersionId
97
+ ? await versionDeclaredTenantConfig(kvGet, tenant.tenant_id, source.pinnedVersionId)
98
+ : await publishedDeclaredTenantConfig(kvGet, tenant.tenant_id, source.env),
93
99
  );
94
100
  }
@@ -22,10 +22,13 @@ import type { ChromeConfig } from "../chrome/model.js";
22
22
  import type { CustomizationEnv, KvGet, TenantConfig } from "@tot/public-runtime";
23
23
  import {
24
24
  contentArtifactPath,
25
+ derivedArtifactPath,
25
26
  listPublishedArtifactPaths,
26
27
  listVersionArtifactPaths,
27
28
  readPublishedArtifact,
28
29
  readVersionArtifact,
30
+ resolvePublishedArtifactKey,
31
+ resolveVersionArtifactKey,
29
32
  tenantDirRelative,
30
33
  tenantDirSegment,
31
34
  } from "@tot/public-runtime";
@@ -86,6 +89,17 @@ export interface ContentProvider {
86
89
  * providers), so a caller that reads many artifacts in one request budgets against it.
87
90
  */
88
91
  readonly perRequestArtifactReads?: boolean;
92
+ /**
93
+ * A document the reconcile derived for the selected version (`derivedArtifactPath`), as its
94
+ * immutable KV key plus a reader, or null when the version has none. The key names
95
+ * content-addressed bytes, so a caller may keep what it parsed from them under it.
96
+ */
97
+ derivedArtifact?(name: string): Promise<DerivedArtifact | null>;
98
+ }
99
+
100
+ export interface DerivedArtifact {
101
+ key: string;
102
+ read(): Promise<string | null>;
89
103
  }
90
104
 
91
105
  // ---------------------------------------------------------------------------
@@ -695,6 +709,14 @@ export class PublishedContentProvider implements ContentProvider {
695
709
  return this.publishedRaw(relative);
696
710
  }
697
711
 
712
+ async derivedArtifact(name: string): Promise<DerivedArtifact | null> {
713
+ const get = this.cust.get;
714
+ const key = await resolvePublishedArtifactKey(
715
+ get, this.tenantId, this.cust.env, derivedArtifactPath(this.tenantId, name),
716
+ );
717
+ return key ? { key, read: () => get(key) } : null;
718
+ }
719
+
698
720
  async listArtifactPaths(prefix = ""): Promise<string[] | null> {
699
721
  const root = contentArtifactPath(this.tenantId, "");
700
722
  const paths = await listPublishedArtifactPaths(
@@ -801,6 +823,14 @@ export class VersionPinnedContentProvider implements ContentProvider {
801
823
  async listArtifactPaths(prefix = ""): Promise<string[] | null> {
802
824
  return listVersionArtifactPaths(this.get, this.tenantId, this.versionId, prefix);
803
825
  }
826
+
827
+ async derivedArtifact(name: string): Promise<DerivedArtifact | null> {
828
+ const get = this.get;
829
+ const key = await resolveVersionArtifactKey(
830
+ get, this.tenantId, this.versionId, derivedArtifactPath(this.tenantId, name),
831
+ );
832
+ return key ? { key, read: () => get(key) } : null;
833
+ }
804
834
  }
805
835
 
806
836
  // ---------------------------------------------------------------------------
@@ -865,6 +895,12 @@ export class DevDraftContentProvider implements ContentProvider {
865
895
  listArtifactPaths(prefix?: string): Promise<string[] | null> {
866
896
  return this.base.listArtifactPaths(prefix);
867
897
  }
898
+ get perRequestArtifactReads(): boolean | undefined {
899
+ return this.base.perRequestArtifactReads;
900
+ }
901
+ derivedArtifact(name: string): Promise<DerivedArtifact | null> {
902
+ return this.base.derivedArtifact?.(name) ?? Promise.resolve(null);
903
+ }
868
904
  }
869
905
 
870
906
  const PLACEHOLDER_TOKEN_RE = /PLACEHOLDER|^$/i;
@@ -46,6 +46,8 @@ export class FixtureToTClient implements ToTClient {
46
46
  private readonly collections: CatalogCollection[],
47
47
  private readonly scope: string,
48
48
  private readonly reviews: Record<string, Review[]> = {},
49
+ /** The full record for a product page, when the products above are listing records. */
50
+ private readonly detail?: (handle: string) => Promise<CatalogProduct | null>,
49
51
  ) {}
50
52
 
51
53
  async getReviews(handle: string): Promise<Review[]> {
@@ -75,6 +77,7 @@ export class FixtureToTClient implements ToTClient {
75
77
 
76
78
  async getProductByHandle(handle: string): Promise<CatalogProduct | null> {
77
79
  // NOTE: resolves drafts too (preview/direct links). Callers gate visibility.
80
+ if (this.detail) return this.detail(handle);
78
81
  return this.scoped().find((p) => p.handle === handle) ?? null;
79
82
  }
80
83
 
@@ -214,7 +217,9 @@ export async function createToTClient(
214
217
  return new DeferredToTClient(async () => {
215
218
  const files = await readTenantCatalogFiles(tenant, content);
216
219
  if (files) {
217
- return new FixtureToTClient(files.products, files.collections, tenant.tot_data_scope);
220
+ return new FixtureToTClient(
221
+ files.products, files.collections, tenant.tot_data_scope, {}, files.productDetail,
222
+ );
218
223
  }
219
224
  const { products, collections, reviews } = await loadFixtures(tenant.tot_data_scope);
220
225
  return new FixtureToTClient(products, collections, tenant.tot_data_scope, reviews);
@@ -4,7 +4,9 @@
4
4
  * uses (`ContentProvider.readArtifact` / `listArtifactPaths`), so it follows the store's
5
5
  * `.tot/config.json` content mapping and whichever version the request selected:
6
6
  *
7
- * - hosted preview (Worker): the reconciled version manifest in KV, one read per file;
7
+ * - hosted preview (Worker): the reconciled version in KV. Listings, search and the
8
+ * sitemap read the version's derived listing projection (one read, parsed once per
9
+ * isolate and version); a product page reads that product's full file;
8
10
  * - static publish (both CDNs) and `tot dev`: the store's checkout as materialized
9
11
  * into the build (`LocalContentProvider`'s build-time globs).
10
12
  *
@@ -13,31 +15,41 @@
13
15
  * silently missing.
14
16
  */
15
17
  import type { CatalogCollection, CatalogProduct, TenantConfig } from "@tot/public-runtime";
18
+ import { CATALOG_LISTING_ARTIFACT } from "@tot/public-runtime";
16
19
  import {
17
20
  CATALOG_ARTIFACT_PREFIX,
21
+ CATALOG_PRODUCTS_PREFIX,
22
+ CATALOG_HANDLE_RE,
18
23
  CatalogFileError,
24
+ readCatalogFile,
19
25
  readCatalogFiles,
26
+ readCatalogListing,
20
27
  type CatalogStore,
21
28
  } from "@tot/public-runtime/catalog-files";
22
29
  import { LocalContentProvider, type ContentProvider } from "../storyblok/provider.js";
23
30
 
24
31
  export type CatalogArtifactSource = Pick<
25
32
  ContentProvider,
26
- "readArtifact" | "listArtifactPaths" | "perRequestArtifactReads"
33
+ "readArtifact" | "listArtifactPaths" | "perRequestArtifactReads" | "derivedArtifact"
27
34
  >;
28
35
 
29
36
  export interface TenantCatalogFiles {
30
37
  products: CatalogProduct[];
31
38
  collections: CatalogCollection[];
39
+ /**
40
+ * The full record for a product page, when `products` are listing records (the listing
41
+ * projection drops `description_html` and `metafields`). Absent: `products` are full.
42
+ */
43
+ productDetail?: (handle: string) => Promise<CatalogProduct | null>;
32
44
  }
33
45
 
34
46
  /**
35
- * The most catalog files one request may read from a per-request source. A Worker
36
- * invocation may make 1,000 calls to KV and other bindings, and each artifact read is
37
- * one of them; the rest of the budget is left to the page's other reads. Measured
38
- * against a 2,959-product, 357-collection catalog (3,316 files, 44.6 MB at 13.4 KB per
39
- * product, ~290 ms to validate): that store cannot be read file by file per request on
40
- * the hosted preview, while `tot dev` and the static publish read it from the checkout.
47
+ * The most catalog files one request may read from a per-request source that has no listing
48
+ * projection (a version reconciled before projections existed). Each artifact read is its own
49
+ * KV call, and the rest of the page needs its own. Measured 2026-10-06 on giantvapes.com's
50
+ * catalog (2,959 products, 357 collections, 27.2 MB of files): read file by file it is 3,316
51
+ * calls and ~40 MB of parsed heap per request; its listing projection is one 11.4 MB read
52
+ * (1.55 MB gzipped), 18 MB of heap, parsed in ~20 ms once per isolate and version.
41
53
  */
42
54
  export const CATALOG_REQUEST_READ_BUDGET = 800;
43
55
 
@@ -94,10 +106,32 @@ function readBuildCatalog(
94
106
  return { products, collections };
95
107
  }
96
108
 
109
+ /** Listing projections parsed in this isolate, by immutable KV key and the store reading them. */
110
+ const parsedListings = new Map<string, { products: CatalogProduct[]; collections: CatalogCollection[] }>();
111
+ const PARSED_LISTINGS_KEPT = 2;
112
+
113
+ async function readListing(
114
+ listing: { key: string; read(): Promise<string | null> },
115
+ store: CatalogStore,
116
+ ): Promise<{ products: CatalogProduct[]; collections: CatalogCollection[] }> {
117
+ const cacheKey = `${listing.key}\0${store.tenantScope}\0${store.inventoryProvider ?? ""}`;
118
+ const hit = parsedListings.get(cacheKey);
119
+ if (hit) return hit;
120
+ const raw = await listing.read();
121
+ if (raw === null) {
122
+ throw new CatalogFileError([{ path: "catalog/", issues: ["the catalog listing projection is unreadable; reconcile the preview again"] }]);
123
+ }
124
+ const parsed = readCatalogListing(raw, store);
125
+ parsedListings.set(cacheKey, parsed);
126
+ while (parsedListings.size > PARSED_LISTINGS_KEPT) parsedListings.delete(parsedListings.keys().next().value!);
127
+ return parsed;
128
+ }
129
+
97
130
  /**
98
131
  * The store's catalog files, or null when it has none. Throws `CatalogFileError` naming
99
- * every file that fails validation, and refuses a per-request source holding more files
100
- * than `CATALOG_REQUEST_READ_BUDGET` before reading any of them.
132
+ * every file that fails validation. A per-request source serves its version's listing
133
+ * projection; one without a projection reads its files only up to
134
+ * `CATALOG_REQUEST_READ_BUDGET`, refusing above it before reading any of them.
101
135
  */
102
136
  export async function readTenantCatalogFiles(
103
137
  tenant: TenantConfig,
@@ -106,19 +140,32 @@ export async function readTenantCatalogFiles(
106
140
  const { source, paths } = await catalogPaths(tenant, content);
107
141
  if (paths.length === 0) return null;
108
142
 
143
+ const store: CatalogStore = {
144
+ tenantScope: tenant.tot_data_scope,
145
+ inventoryProvider: tenant.inventory?.provider,
146
+ };
147
+
148
+ const listing = await source.derivedArtifact?.(CATALOG_LISTING_ARTIFACT);
149
+ if (listing) {
150
+ const { products, collections } = await readListing(listing, store);
151
+ const productDetail = async (handle: string): Promise<CatalogProduct | null> => {
152
+ if (!CATALOG_HANDLE_RE.test(handle)) return null;
153
+ const path = `${CATALOG_PRODUCTS_PREFIX}${handle}.json`;
154
+ const raw = await source.readArtifact(path);
155
+ if (raw === null) return null;
156
+ const read = readCatalogFile(path, raw, store);
157
+ return read.kind === "product" ? read.record : null;
158
+ };
159
+ return { products, collections, productDetail };
160
+ }
161
+
109
162
  if (source.perRequestArtifactReads && paths.length > CATALOG_REQUEST_READ_BUDGET) {
110
163
  throw new Error(
111
- `catalog: ${tenant.tenant_id} has ${paths.length} files in content/catalog/, and the hosted ` +
112
- `preview reads at most ${CATALOG_REQUEST_READ_BUDGET} catalog files per request (one read ` +
113
- `each, inside the Worker's per-request limit). \`tot dev\` and the published site render ` +
114
- `this catalog from the checkout.`,
164
+ `catalog: ${tenant.tenant_id} has ${paths.length} files in content/catalog/ and this preview ` +
165
+ `version has no catalog listing; reconcile the preview again to build it.`,
115
166
  );
116
167
  }
117
168
 
118
- const store: CatalogStore = {
119
- tenantScope: tenant.tot_data_scope,
120
- inventoryProvider: tenant.inventory?.provider,
121
- };
122
169
  const entries = await Promise.all(paths.map(async (path) => ({ path, raw: await source.readArtifact(path) })));
123
170
  return source.perRequestArtifactReads
124
171
  ? readCatalogFiles(entries, store)
@@ -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
@@ -752,7 +754,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
752
754
  // exactly the placements that rendered on THIS page, never a wildcard.
753
755
  const widgetCsp = appWidgetCspAdditions(locals.activeWidgetPlacements ?? []);
754
756
  // Third-party marketing embeds (e.g. a Pipedrive form) the tenant opted into
755
- // 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
756
758
  // declared at least one embed — zero cost for everyone else — and add each
757
759
  // provider's origins ONLY if its marker is actually on THIS page.
758
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.90",
3
+ "version": "2.2.92",
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
+ }
@@ -10,6 +10,7 @@
10
10
  import {
11
11
  readCatalogFile as readKitCatalogFile,
12
12
  readCatalogFiles as readKitCatalogFiles,
13
+ readCatalogListing as readKitCatalogListing,
13
14
  type CatalogStore,
14
15
  } from "../../../agency-kit/tools/lib/catalog-model/files.mjs";
15
16
  import type { CatalogCollection, CatalogProduct } from "./product.js";
@@ -22,6 +23,7 @@ export {
22
23
  CATALOG_PRODUCTS_PREFIX,
23
24
  CatalogFileError,
24
25
  catalogFileIssues,
26
+ catalogListingProjection,
25
27
  classifyCatalogPath,
26
28
  declaredAvailability,
27
29
  planCatalogFiles,
@@ -49,3 +51,18 @@ export function readCatalogFiles(
49
51
  collections: CatalogCollection[];
50
52
  };
51
53
  }
54
+
55
+ /**
56
+ * The derived listing projection's catalog: listing records (no `description_html`, no
57
+ * `metafields`, `description_text` shortened) for every product, collections whole. Throws
58
+ * `CatalogFileError` with the failures the reconcile recorded.
59
+ */
60
+ export function readCatalogListing(
61
+ raw: string,
62
+ store: CatalogStore,
63
+ ): { products: CatalogProduct[]; collections: CatalogCollection[] } {
64
+ return readKitCatalogListing(raw, store) as unknown as {
65
+ products: CatalogProduct[];
66
+ collections: CatalogCollection[];
67
+ };
68
+ }
@@ -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.
@@ -67,6 +67,18 @@ export function contentArtifactPath(tenantId: string, relative: string): string
67
67
  return `tenants/${tenantId}/content/${relative}`;
68
68
  }
69
69
 
70
+ /**
71
+ * Canonical artifact path for a document the reconcile DERIVES from a tenant's files (never one
72
+ * the tenant writes): `tenants/<id>/.derived/<name>`, outside `content/` so no repo file can
73
+ * collide with it. Recorded in the same immutable site version as the files it derives from.
74
+ */
75
+ export function derivedArtifactPath(tenantId: string, name: string): string {
76
+ return `tenants/${tenantId}/.derived/${name}`;
77
+ }
78
+
79
+ /** The derived catalog listing projection (`catalogListingProjection` in the kit's catalog module). */
80
+ export const CATALOG_LISTING_ARTIFACT = "catalog-listing.json";
81
+
70
82
  async function readPointer(
71
83
  get: KvGet,
72
84
  tenantId: string,
@@ -270,6 +282,21 @@ export async function readPublishedArtifact(
270
282
  tenantId: string,
271
283
  env: CustomizationEnv,
272
284
  path: string,
285
+ ): Promise<string | null> {
286
+ const key = await resolvePublishedArtifactKey(get, tenantId, env, path);
287
+ return key ? get(key) : null;
288
+ }
289
+
290
+ /**
291
+ * The KV key holding the published artifact for (tenant, env, path), resolved exactly as
292
+ * {@link readPublishedArtifact} resolves it, or null. The key names immutable, content-addressed
293
+ * bytes, so a caller may cache what it parsed from them under the key.
294
+ */
295
+ export async function resolvePublishedArtifactKey(
296
+ get: KvGet,
297
+ tenantId: string,
298
+ env: CustomizationEnv,
299
+ path: string,
273
300
  ): Promise<string | null> {
274
301
  const pointer = await readPointer(get, tenantId);
275
302
  if (!pointer) return null;
@@ -277,13 +304,25 @@ export async function readPublishedArtifact(
277
304
  const channel = channelFromEnv(env);
278
305
  if (pointer.channels?.[channel]) {
279
306
  // Versioned model engaged for this channel — authoritative.
280
- return readVersionedArtifact(get, tenantId, channel, path);
307
+ const { versionId } = await resolveServableChannelVersion(get, tenantId, channel);
308
+ return versionId ? resolveVersionArtifactKey(get, tenantId, versionId, path) : null;
281
309
  }
282
310
 
283
311
  // Legacy per-path selection.
284
312
  const versionId = pointer?.[env]?.[path];
285
- if (!versionId) return null;
286
- return get(customizationArtifactKey(tenantId, versionId));
313
+ return versionId ? customizationArtifactKey(tenantId, versionId) : null;
314
+ }
315
+
316
+ /** The KV key of one artifact in an immutable site version, or null when the version lacks it. */
317
+ export async function resolveVersionArtifactKey(
318
+ get: KvGet,
319
+ tenantId: string,
320
+ versionId: string,
321
+ path: string,
322
+ ): Promise<string | null> {
323
+ const manifest = await readSiteVersion(get, tenantId, versionId);
324
+ const contentHash = manifest?.artifacts?.[path];
325
+ return contentHash ? customizationArtifactKey(tenantId, contentHash) : null;
287
326
  }
288
327
 
289
328
  /**
@@ -299,10 +338,8 @@ export async function readVersionArtifact(
299
338
  versionId: string,
300
339
  path: string,
301
340
  ): Promise<string | null> {
302
- const manifest = await readSiteVersion(get, tenantId, versionId);
303
- const contentHash = manifest?.artifacts?.[path];
304
- if (!contentHash) return null;
305
- return get(customizationArtifactKey(tenantId, contentHash));
341
+ const key = await resolveVersionArtifactKey(get, tenantId, versionId, path);
342
+ return key ? get(key) : null;
306
343
  }
307
344
 
308
345
  /**
@@ -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
@@ -45,8 +45,8 @@ export function standaloneGraftPlan(tenant, mappings) {
45
45
  // `.tot/config.json` is never itself one of the declared `mappings` (those map
46
46
  // content/public/theme.json) but IS the tenant's own declared platform config
47
47
  // — siteType/compliance/capabilities/features (see
48
- // apps/storefront/src/config/devTenantSeed.ts TOT_CONFIG_TENANT_FIELDS and
49
- // 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
50
50
  // scripts/tenant/materialize-tenant-content.mjs copies for a Gitea-sourced
51
51
  // tenant. Graft it unconditionally so a regulated store's mandated notices
52
52
  // resolve from the SAME declared config locally as they would once published.
@@ -44,6 +44,7 @@ import {
44
44
  workspacePathForTenantRelative,
45
45
  } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
46
46
  import { validateVisualParityClosure } from "../../packages/cli/src/visual-parity.mjs";
47
+ import { validateDeclaredConfig } from "../../packages/cli/src/declared-config.mjs";
47
48
 
48
49
  /** Catalogued embed slugs a tenant may opt into (mirror of EMBED_PROVIDERS). */
49
50
  const KNOWN_EMBED_SLUGS = new Set(KNOWN_EMBEDS.map((p) => p.slug));
@@ -115,6 +116,8 @@ export function validateConfigShape(config, file = ".tot/config.json") {
115
116
  if (typeof config.tenant !== "string" || !config.tenant) {
116
117
  out.push(mk(ERROR, "config-tenant", file, "`tenant` must be a non-empty string"));
117
118
  }
119
+ // The same refusal `tot validate` applies: a key no surface reads never passes.
120
+ out.push(...validateDeclaredConfig(config, file));
118
121
  if (!Array.isArray(config.mappings)) {
119
122
  out.push(mk(ERROR, "config-mappings", file, "`mappings` must be an array", "seed shape: [{workspace,repo,kind}]"));
120
123
  return out; // nothing more to check without the array
@@ -1,72 +0,0 @@
1
- /**
2
- * Force-injectable compliance markup for the raw-HTML serve path.
3
- *
4
- * `rawHtmlResponse` (rawMarketingHtml.ts) bypasses Astro's Layout, so these are
5
- * the platform-owned anchors that CAN be guaranteed on a raw document
6
- * independent of its content: the FDA nicotine warning, the shipping/
7
- * jurisdiction notice, and the excise-tax disclosure. Each mirrors the exact
8
- * markup its Layout.astro sibling renders (NicotineWarning.astro "bar" variant,
9
- * ShippingRestrictionNotice.astro "footer" variant via ComplianceNotice.astro,
10
- * and Layout's own excise-tax <p>) so a raw and a block-composed page present
11
- * these anchors identically. See `rawHtmlPolicy` for which anchors this does
12
- * and doesn't cover (notably: NOT `age-gate`).
13
- */
14
-
15
- function escapeHtml(value: string): string {
16
- return value
17
- .replace(/&/g, "&amp;")
18
- .replace(/</g, "&lt;")
19
- .replace(/>/g, "&gt;")
20
- .replace(/"/g, "&quot;")
21
- .replace(/'/g, "&#39;");
22
- }
23
-
24
- const NICOTINE_WARNING_TEXT =
25
- "WARNING: This product contains nicotine. Nicotine is an addictive chemical.";
26
-
27
- /** Mirrors NicotineWarning.astro's "bar" variant. */
28
- export function nicotineWarningBarHtml(): string {
29
- return (
30
- '<div class="border-b-2 border-[var(--color-text)] bg-[var(--color-text)] text-[var(--color-bg)]" role="note" aria-label="Health warning" data-compliance="nicotine-warning">' +
31
- '<p class="container-prose py-2 text-center text-[0.72rem] font-semibold uppercase tracking-[0.06em] sm:text-xs">' +
32
- NICOTINE_WARNING_TEXT +
33
- "</p></div>"
34
- );
35
- }
36
-
37
- /** Mirrors ShippingRestrictionNotice.astro's "footer" variant (via ComplianceNotice.astro). Empty when `text` is absent — same as the Astro component. */
38
- export function shippingRestrictionNoticeHtml(text: string | undefined): string {
39
- if (!text) return "";
40
- return (
41
- '<div class="border-t border-[var(--color-border)] bg-[var(--color-bg)]" data-compliance="shipping-restriction">' +
42
- '<p class="container-prose py-3 text-center text-sm text-[var(--color-muted)]" role="note" aria-label="Shipping restriction notice">' +
43
- escapeHtml(text) +
44
- "</p></div>"
45
- );
46
- }
47
-
48
- /** Mirrors Layout.astro's excise-tax disclosure <p>. */
49
- export function exciseTaxNoticeHtml(): string {
50
- return (
51
- '<p class="border-t border-[var(--color-border)] bg-[var(--color-bg)] py-2.5 text-center text-xs text-[var(--color-muted)]" data-capability="excise-tax">' +
52
- "Applicable excise tax is calculated at checkout.</p>"
53
- );
54
- }
55
-
56
- export interface RawComplianceChromeInput {
57
- nicotineWarning?: boolean;
58
- shippingRestriction?: string;
59
- exciseTaxEnabled?: boolean;
60
- }
61
-
62
- /** The markup to inject at `rawHtmlResponse`'s body-start boundary. */
63
- export function renderRawComplianceTop(input: RawComplianceChromeInput): string {
64
- return input.nicotineWarning ? nicotineWarningBarHtml() : "";
65
- }
66
-
67
- /** The markup to inject at `rawHtmlResponse`'s body-deferred boundary. */
68
- export function renderRawComplianceBottom(input: RawComplianceChromeInput): string {
69
- let out = shippingRestrictionNoticeHtml(input.shippingRestriction);
70
- if (input.exciseTaxEnabled) out += exciseTaxNoticeHtml();
71
- return out;
72
- }