@tokenoftrust/storefront-runner 2.2.96 → 2.2.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.98",
|
|
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,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.tot/config.json` mapping resolution — the ONE reading of a store repo's
|
|
3
|
+
* declared layout, shared by every consumer: the hosted reconcile (preview,
|
|
4
|
+
* candidate, aggregate, publication), `tot dev`'s checkout graft, `tot validate`,
|
|
5
|
+
* the platform's materialize / asset-repair scripts, and this kit's
|
|
6
|
+
* `repair-tenant-config.mjs`. The platform imports this file from the kit.
|
|
7
|
+
*
|
|
8
|
+
* A mapping is `{ workspace, repo, kind }`:
|
|
9
|
+
* - `workspace` — where the files physically live in the store repo. It is the
|
|
10
|
+
* only source of truth for location; nothing assumes a `tenants/<id>/` layout
|
|
11
|
+
* inside the store's own repo.
|
|
12
|
+
* - `repo` — the storefront artifact target, `tenants/<segment>/<rel>`. Only
|
|
13
|
+
* `<rel>` (the tenant-relative path: `theme.json`, `content/`, `public/`, …)
|
|
14
|
+
* is read from it. The tenant namespace always comes from the CANONICAL
|
|
15
|
+
* tenant id — the store's registered id hosted, the config's `tenant` locally
|
|
16
|
+
* — never from `<segment>`, so a config whose segment disagrees with its
|
|
17
|
+
* tenant id resolves identically on every surface.
|
|
18
|
+
*
|
|
19
|
+
* Pure and dependency-free so kit tools, platform `.mjs` scripts and TypeScript
|
|
20
|
+
* consumers (types in the adjacent `.d.mts`) all import it. The published CLI
|
|
21
|
+
* installs without the kit, so it ships a byte-identical copy that its own test
|
|
22
|
+
* suite holds equal to this file.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Platform test tenants nest one level deeper: `tenants/e2e/<id>.e2e.test/`. */
|
|
26
|
+
const TARGET_RE = /^tenants\/(?:e2e\/([^/]+\.e2e\.test)|([^/]+))(?:\/(.*))?$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Split a declared mapping target into the tenant segment it names and the
|
|
30
|
+
* tenant-relative path it addresses. Null when the target is not under
|
|
31
|
+
* `tenants/<segment>/`, or when its relative part is not a clean path (empty,
|
|
32
|
+
* `.` or `..` segments) — such a target could address outside the tenant.
|
|
33
|
+
* A trailing `/` (tree target) is preserved on `rel`; the tenant root is `""`.
|
|
34
|
+
* @param {unknown} repo
|
|
35
|
+
* @returns {{ segment: string, rel: string } | null}
|
|
36
|
+
*/
|
|
37
|
+
export function parseMappingTarget(repo) {
|
|
38
|
+
if (typeof repo !== "string") return null;
|
|
39
|
+
const m = TARGET_RE.exec(repo);
|
|
40
|
+
if (!m) return null;
|
|
41
|
+
const segment = m[1] ?? m[2];
|
|
42
|
+
const rel = m[3] ?? "";
|
|
43
|
+
const parts = rel.replace(/\/$/, "").split("/");
|
|
44
|
+
if (rel && parts.some((p) => !p || p === "." || p === "..")) return null;
|
|
45
|
+
return { segment, rel };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The canonical artifact root for a tenant: `tenants/<tenantId>/`. */
|
|
49
|
+
export function tenantArtifactRoot(tenantId) {
|
|
50
|
+
return `tenants/${tenantId}/`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function asDir(path) {
|
|
54
|
+
return path === "" || path.endsWith("/") ? path : `${path}/`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mappingsOf(config) {
|
|
58
|
+
return Array.isArray(config?.mappings) ? config.mappings : [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Map a tenant-repo path to its tenant-relative path (e.g. `content/home.json`).
|
|
63
|
+
* First match wins; a `file` mapping matches exactly, a `tree` mapping by prefix.
|
|
64
|
+
* Null when no mapping covers the path or the covering mapping's target is not
|
|
65
|
+
* a valid tenant target.
|
|
66
|
+
* @param {{ mappings?: unknown }} config
|
|
67
|
+
* @param {string} workspacePath
|
|
68
|
+
* @returns {string | null}
|
|
69
|
+
*/
|
|
70
|
+
export function mapWorkspaceToTenantRelative(config, workspacePath) {
|
|
71
|
+
for (const m of mappingsOf(config)) {
|
|
72
|
+
if (!m || typeof m !== "object" || typeof m.workspace !== "string") continue;
|
|
73
|
+
if (m.kind === "file") {
|
|
74
|
+
if (workspacePath !== m.workspace) continue;
|
|
75
|
+
const target = parseMappingTarget(m.repo);
|
|
76
|
+
return target ? target.rel : null;
|
|
77
|
+
}
|
|
78
|
+
if (m.kind === "tree") {
|
|
79
|
+
const ws = asDir(m.workspace);
|
|
80
|
+
if (!workspacePath.startsWith(ws)) continue;
|
|
81
|
+
const target = parseMappingTarget(m.repo);
|
|
82
|
+
return target ? asDir(target.rel) + workspacePath.slice(ws.length) : null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Map a tenant-repo path to its storefront artifact path under the CANONICAL
|
|
90
|
+
* tenant root (`tenants/<tenantId>/<rel>`). Null when unmapped.
|
|
91
|
+
* @param {{ mappings?: unknown }} config
|
|
92
|
+
* @param {string} tenantId
|
|
93
|
+
* @param {string} workspacePath
|
|
94
|
+
* @returns {string | null}
|
|
95
|
+
*/
|
|
96
|
+
export function mapWorkspaceToArtifact(config, tenantId, workspacePath) {
|
|
97
|
+
const rel = mapWorkspaceToTenantRelative(config, workspacePath);
|
|
98
|
+
return rel === null ? null : tenantArtifactRoot(tenantId) + rel;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The inverse: where a tenant-relative path (`theme.json`, `content`, `public`,
|
|
103
|
+
* `content/home.json`) lives in the tenant repo, per the declared mappings.
|
|
104
|
+
* Unmapped paths resolve to themselves — the flat checkout layout — so a
|
|
105
|
+
* consumer still finds files the mappings do not cover (and the config-coverage
|
|
106
|
+
* check reports the gap).
|
|
107
|
+
* @param {{ mappings?: unknown }} config
|
|
108
|
+
* @param {string} relPath tenant-relative, no leading `/`
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function workspacePathForTenantRelative(config, relPath) {
|
|
112
|
+
const bare = relPath.replace(/\/$/, "");
|
|
113
|
+
for (const m of mappingsOf(config)) {
|
|
114
|
+
if (!m || typeof m !== "object" || typeof m.workspace !== "string") continue;
|
|
115
|
+
const target = parseMappingTarget(m.repo);
|
|
116
|
+
if (!target) continue;
|
|
117
|
+
const targetRel = target.rel.replace(/\/$/, "");
|
|
118
|
+
const ws = m.workspace.replace(/\/$/, "");
|
|
119
|
+
if (m.kind === "file") {
|
|
120
|
+
if (targetRel === bare) return ws;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (m.kind !== "tree") continue;
|
|
124
|
+
if (targetRel === bare) return ws;
|
|
125
|
+
const prefix = asDir(targetRel);
|
|
126
|
+
if (bare.startsWith(prefix)) {
|
|
127
|
+
const rest = bare.slice(prefix.length);
|
|
128
|
+
return ws ? `${ws}/${rest}` : rest;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return bare;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Mappings whose declared target names a tenant segment other than the
|
|
136
|
+
* canonical tenant id. They still resolve correctly (the segment is never
|
|
137
|
+
* read); this exists so validators can tell the author the config disagrees
|
|
138
|
+
* with itself.
|
|
139
|
+
* @param {{ mappings?: unknown }} config
|
|
140
|
+
* @param {string} tenantId
|
|
141
|
+
* @returns {{ workspace: string, repo: string, segment: string }[]}
|
|
142
|
+
*/
|
|
143
|
+
export function foreignTenantSegments(config, tenantId) {
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const m of mappingsOf(config)) {
|
|
146
|
+
if (!m || typeof m !== "object") continue;
|
|
147
|
+
const target = parseMappingTarget(m.repo);
|
|
148
|
+
if (target && target.segment !== tenantId) {
|
|
149
|
+
out.push({ workspace: String(m.workspace ?? ""), repo: m.repo, segment: target.segment });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Read-only closure checks for the optional visual-parity migration module.
|
|
3
|
-
* The tenant checkout remains portable: this reads only content/, public
|
|
4
|
-
* .tot/visual-parity.json, never a preview host or a delivery
|
|
3
|
+
* The tenant checkout remains portable: this reads only content/, public/ and theme.json (where
|
|
4
|
+
* `.tot/config.json` maps them) and .tot/visual-parity.json, never a preview host or a delivery
|
|
5
|
+
* provider.
|
|
5
6
|
*/
|
|
6
7
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
8
|
import { spawnSync } from "node:child_process";
|
|
8
9
|
import { extname, join, relative } from "node:path";
|
|
10
|
+
import { workspacePathForTenantRelative } from "./tot-repo-mappings.mjs";
|
|
9
11
|
|
|
10
12
|
const ERROR = "error";
|
|
11
13
|
// Public routes implemented by the renderer. Authentication and cart URLs are
|
|
@@ -59,10 +61,10 @@ function parseConfig(tenantDir) {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
/** The checkout's `.tot/config.json`, or null when absent or unreadable. */
|
|
65
|
+
function readTotConfig(tenantDir) {
|
|
63
66
|
try {
|
|
64
|
-
|
|
65
|
-
return typeof value?.tenant === "string" ? value.tenant : null;
|
|
67
|
+
return JSON.parse(readFileSync(join(tenantDir, ".tot", "config.json"), "utf8"));
|
|
66
68
|
} catch { return null; }
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -278,7 +280,11 @@ export function validateVisualParityClosure(tenantDir, options = {}) {
|
|
|
278
280
|
const findings = [];
|
|
279
281
|
const { value: config, findings: configFindings } = parseConfig(tenantDir);
|
|
280
282
|
findings.push(...configFindings);
|
|
281
|
-
|
|
283
|
+
// Files are found where the checkout's `.tot/config.json` mappings declare them, as `tot validate`
|
|
284
|
+
// and the hosted reconcile find them.
|
|
285
|
+
const totConfig = readTotConfig(tenantDir);
|
|
286
|
+
const located = (rel) => join(tenantDir, workspacePathForTenantRelative(totConfig, rel));
|
|
287
|
+
const pagesDir = located("content/pages");
|
|
282
288
|
const files = walk(pagesDir).filter((file) => file.endsWith(".json")).sort();
|
|
283
289
|
if (files.length === 0) findings.push(finding("parity-pages-missing", "content/pages/", "no page JSON files define the visual-parity route scope", "add the paired authored page records under content/pages/ before closing parity"));
|
|
284
290
|
const pages = [];
|
|
@@ -297,17 +303,17 @@ export function validateVisualParityClosure(tenantDir, options = {}) {
|
|
|
297
303
|
const routes = new Set(["/"]);
|
|
298
304
|
for (const page of pages) routes.add(normalizeRoute(page.value.slug));
|
|
299
305
|
/** @type {{findings:ParityFinding[], tenantId:string|null, publicDir:string, routes:Set<string>, allowlist:string[], decodeAssets:DecodeAsset[]}} */
|
|
300
|
-
const context = { findings, tenantId: tenantId ??
|
|
306
|
+
const context = { findings, tenantId: tenantId ?? (typeof totConfig?.tenant === "string" ? totConfig.tenant : null), publicDir: located("public"), routes, allowlist: Array.isArray(config.externalNavigationAllowlist) ? config.externalNavigationAllowlist : [], decodeAssets: [] };
|
|
301
307
|
if (!context.tenantId) findings.push(finding("parity-tenant-id", ".tot/config.json", "tenant id is required to verify /tenants/<tenant>/ assets", "set tenant in .tot/config.json or pass a resolved tenant id"));
|
|
302
308
|
for (const page of pages) inspectValue(page.value, page.file, [], context);
|
|
303
309
|
for (const name of ["home.json", "chrome.json"]) {
|
|
304
|
-
const path =
|
|
310
|
+
const path = located(`content/${name}`);
|
|
305
311
|
if (existsSync(path)) {
|
|
306
312
|
const value = readStructured(path, `content/${name}`, findings);
|
|
307
313
|
if (value != null) inspectValue(value, `content/${name}`, [], context);
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
|
-
const themePath =
|
|
316
|
+
const themePath = located("theme.json");
|
|
311
317
|
if (existsSync(themePath)) {
|
|
312
318
|
const value = readStructured(themePath, "theme.json", findings);
|
|
313
319
|
if (value != null) inspectValue(value, "theme.json", [], context);
|
|
@@ -44,6 +44,7 @@ import { assetContentType } from "./tenant-assets.js";
|
|
|
44
44
|
import { formatHtmlCacheControl } from "./html-cache-policy.js";
|
|
45
45
|
import { NATIVE_GA4_SANDBOX_ASSET_PATH } from "./integration/native-ga4-sandbox-asset.mjs";
|
|
46
46
|
import { CONTENT_NAMED_ASSET_PREFIXES } from "./content-named-asset-prefixes.mjs";
|
|
47
|
+
import { resolveRedirect } from "./publication-redirects.mjs";
|
|
47
48
|
|
|
48
49
|
// ---------------------------------------------------------------------------
|
|
49
50
|
// R2 seam — same structural subset tenant-assets.ts uses (no workers-types dep)
|
|
@@ -189,41 +190,15 @@ function isManifestRedirectRule(value: string): boolean {
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* `publication-redirects.mjs` and the CloudFront function — the three are held together by
|
|
195
|
-
* `tests/fixtures/redirect-match-cases.json`.
|
|
196
|
-
*/
|
|
197
|
-
function ruleLocation(to: string, capture: string): string | null {
|
|
198
|
-
const location = to.endsWith("/*") ? `${to.slice(0, -1)}${capture}` : to;
|
|
199
|
-
return location.startsWith("/") && !location.startsWith("//") && !location.includes("\\")
|
|
200
|
-
? location
|
|
201
|
-
: null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* The redirect the manifest declares for `pathname`, as a location, or null. An exact entry wins
|
|
206
|
-
* outright; otherwise the rule with the longest directory that the trailing-slash form of the path
|
|
207
|
-
* starts with decides — and when its location is unusable there is no redirect.
|
|
193
|
+
* The redirect the manifest declares for `pathname`, as a location, or null — the redirect
|
|
194
|
+
* contract's own matcher (`publication-redirects.mjs`), so this plane cannot answer differently.
|
|
208
195
|
*/
|
|
209
196
|
function staticBundleRedirectLocation(
|
|
210
197
|
redirects: StaticBundleManifest["redirects"],
|
|
211
198
|
pathname: string,
|
|
212
199
|
): string | null {
|
|
213
200
|
if (!redirects?.length) return null;
|
|
214
|
-
|
|
215
|
-
? pathname
|
|
216
|
-
: pathname.endsWith("/") ? pathname : `${pathname}/`;
|
|
217
|
-
let rule: { from: string; to: string } | null = null;
|
|
218
|
-
for (const redirect of redirects) {
|
|
219
|
-
if (redirect.from.endsWith("/*")) {
|
|
220
|
-
const dir = redirect.from.slice(0, -1);
|
|
221
|
-
if (path.startsWith(dir) && (rule === null || dir.length > rule.from.length - 1)) rule = redirect;
|
|
222
|
-
} else if (redirect.from === pathname || redirect.from === path) {
|
|
223
|
-
return redirect.to;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return rule === null ? null : ruleLocation(rule.to, path.slice(rule.from.length - 1));
|
|
201
|
+
return resolveRedirect(redirects, pathname)?.location ?? null;
|
|
227
202
|
}
|
|
228
203
|
|
|
229
204
|
function isManifestRedirectDestination(value: string): boolean {
|
|
@@ -1386,21 +1386,24 @@ if (isMain) {
|
|
|
1386
1386
|
})()
|
|
1387
1387
|
: baseline;
|
|
1388
1388
|
const { ok, findings } = result;
|
|
1389
|
+
// exitCode, never exit(): a report written to a pipe is flushed asynchronously, and exit() cuts a
|
|
1390
|
+
// large one off mid-JSON for whoever parses it.
|
|
1389
1391
|
if (asJson) {
|
|
1390
1392
|
console.log(JSON.stringify({ ok, findings }, null, 2));
|
|
1391
|
-
process.
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1393
|
+
process.exitCode = ok ? 0 : 1;
|
|
1394
|
+
} else {
|
|
1395
|
+
const errors = findings.filter((f) => f.level === ERROR);
|
|
1396
|
+
const warns = findings.filter((f) => f.level === WARN);
|
|
1397
|
+
console.log(`\ntot validate${profile ? ` --profile ${profile}` : ""} — ${tenantId ?? dir}\n`);
|
|
1398
|
+
// Named, grouped callouts FIRST — see printLoudAdvisories above.
|
|
1399
|
+
printLoudAdvisories(findings, { log: (s) => console.log(s) });
|
|
1400
|
+
for (const f of findings) {
|
|
1401
|
+
const tag = f.level === ERROR ? "✗" : "⚠";
|
|
1402
|
+
console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
|
|
1403
|
+
}
|
|
1404
|
+
console.log(
|
|
1405
|
+
`\n${errors.length === 0 ? "✔" : "✖"} ${errors.length} error(s), ${warns.length} warning(s).\n`,
|
|
1406
|
+
);
|
|
1407
|
+
process.exitCode = errors.length === 0 ? 0 : 1;
|
|
1408
|
+
}
|
|
1406
1409
|
}
|