@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.
- package/agency-kit/tools/lib/catalog-model/files.d.mts +48 -0
- package/agency-kit/tools/lib/catalog-model/files.mjs +263 -0
- package/agency-kit/tools/lib/catalog-model/model.mjs +282 -0
- package/agency-kit/tools/lib/tot-repo-mappings.mjs +153 -0
- package/apps/storefront/src/config/declaredTenantConfig.ts +9 -3
- package/apps/storefront/src/config/devTenantSeed.ts +7 -7
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +1 -0
- package/apps/storefront/src/config/resolver.ts +19 -1
- package/apps/storefront/src/lib/storyblok/provider.ts +25 -1
- package/apps/storefront/src/lib/tot/ToTClient.ts +63 -18
- package/apps/storefront/src/lib/tot/tenantCatalog.ts +126 -0
- package/apps/storefront/src/middleware/controlPlane.ts +5 -5
- package/apps/storefront/src/middleware/renderChain.ts +24 -15
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +78 -0
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/catalog-files.ts +51 -0
- package/packages/public-runtime/src/csp.ts +2 -2
- package/packages/public-runtime/src/tenant.ts +27 -2
- package/scripts/dev/standalone-graft-plan.mjs +34 -12
- package/scripts/tenant/materialize-tenant-content.mjs +8 -1
- package/scripts/tenant/validate.mjs +72 -31
- package/scripts/tot-dev.mjs +18 -18
|
@@ -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
|
-
|
|
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 .
|
|
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.
|
|
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
|
|
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.
|
|
@@ -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
|
|
@@ -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
|
|
5
|
-
* each is a file or a directory tree. Decision-only — scripts/tot-dev.mjs
|
|
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 {
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
27
|
-
//
|
|
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(
|
|
32
|
-
plan.push({ workspace:
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
-
* (`
|
|
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
|
|
131
|
-
// knows "file" vs tree — a missing repo or
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
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 =
|
|
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",
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
...[
|
|
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
|
-
|
|
1204
|
-
|
|
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) {
|