@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.
- package/agency-kit/tools/lib/catalog-model/files.d.mts +8 -0
- package/agency-kit/tools/lib/catalog-model/files.mjs +93 -0
- package/apps/storefront/src/config/declaredTenantConfig.ts +9 -3
- package/apps/storefront/src/lib/storyblok/provider.ts +36 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +6 -1
- package/apps/storefront/src/lib/tot/tenantCatalog.ts +65 -18
- package/apps/storefront/src/middleware/renderChain.ts +5 -3
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +78 -0
- package/packages/public-runtime/src/catalog-files.ts +17 -0
- package/packages/public-runtime/src/csp.ts +2 -2
- package/packages/public-runtime/src/customization-runtime.ts +44 -7
- package/packages/public-runtime/src/tenant.ts +8 -2
- package/scripts/dev/standalone-graft-plan.mjs +2 -2
- package/scripts/tenant/validate.mjs +3 -0
- package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +0 -72
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
100
|
-
*
|
|
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
|
|
112
|
-
`
|
|
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 .
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|
303
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
49
|
-
//
|
|
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, "&")
|
|
18
|
-
.replace(/</g, "<")
|
|
19
|
-
.replace(/>/g, ">")
|
|
20
|
-
.replace(/"/g, """)
|
|
21
|
-
.replace(/'/g, "'");
|
|
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
|
-
}
|