@tokenoftrust/storefront-runner 4.7.0 → 4.7.1

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.
@@ -0,0 +1,113 @@
1
+ // @ts-check
2
+ /**
3
+ * Which editorial page slugs the hosted storefront router can serve.
4
+ *
5
+ * A page under `content/pages/<file>.json` is served at `/<slug>/`, where `<slug>` is the page's
6
+ * own JSON `slug` (never the file name), and a raw page `content/pages-html/<path>.html` is served
7
+ * at `/<path>/`. Both reach the platform's catch-all route, which every first-class platform route
8
+ * outranks. So a slug is servable only when:
9
+ *
10
+ * - it is a string of one or more `/`-separated segments, each starting with a letter or digit
11
+ * and holding only letters, digits, `-` and `_` (no leading/trailing or doubled slash, no `.`:
12
+ * both CDNs treat a dotted last segment as a file, never a page);
13
+ * - its first segment is not a route root the platform owns (`products`, `collections`, `blog`,
14
+ * `api`, …) — the platform route answers first and the page is never reached;
15
+ * - no other page, JSON or raw, claims the same slug — the router serves one of them.
16
+ *
17
+ * Pure: callers pass what they read, so the kit validator and `tot validate` apply one rule.
18
+ * `packages/cli/src/page-slugs.mjs` is a byte-identical copy (a test pins it).
19
+ */
20
+
21
+ /**
22
+ * First path segments the platform's own routes answer before the editorial catch-all.
23
+ * Pinned against the storefront's route tree by scripts/lib/page-slug-reserved.test.mjs.
24
+ */
25
+ export const RESERVED_PAGE_ROOTS = Object.freeze([
26
+ "_sitemap",
27
+ "account",
28
+ "admin",
29
+ "api",
30
+ "auth",
31
+ "blog",
32
+ "capabilities",
33
+ "cart",
34
+ "cockpit",
35
+ "collections",
36
+ "dashboard",
37
+ "diag",
38
+ "preview",
39
+ "products",
40
+ "saved",
41
+ "search",
42
+ "shared",
43
+ "style-guide",
44
+ "tenants",
45
+ "the-build",
46
+ ]);
47
+
48
+ const SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
49
+
50
+ /**
51
+ * Why `slug` cannot be served by the hosted router, or null when it can.
52
+ * @param {unknown} slug
53
+ * @returns {string | null}
54
+ */
55
+ export function unservableSlugReason(slug) {
56
+ if (typeof slug !== "string" || slug.length === 0) return "has no string `slug`";
57
+ const segments = slug.split("/");
58
+ if (segments.some((segment) => !SEGMENT_RE.test(segment))) {
59
+ return `slug "${slug}" is not a page path: each "/"-separated segment must start with a letter or digit and hold only letters, digits, "-" and "_" (no leading, trailing or doubled "/", no ".")`;
60
+ }
61
+ const root = /** @type {string} */ (segments[0]);
62
+ if (RESERVED_PAGE_ROOTS.includes(root)) {
63
+ return `slug "${slug}" starts with "${root}/", a route the platform serves itself — the page is never reached`;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * @typedef {Object} PageSlugFinding
70
+ * @property {string} code `page-slug-unservable` | `page-slug-duplicate`
71
+ * @property {string} path the declaring file, store-relative
72
+ * @property {string} message
73
+ * @property {string} remedy
74
+ */
75
+
76
+ /**
77
+ * Every slug the hosted router cannot serve, across a store's JSON and raw pages.
78
+ * @param {{ pages?: ReadonlyArray<{ path: string, slug: unknown }>, rawPages?: ReadonlyArray<{ path: string, slug: string }> }} input
79
+ * `pages`: each `content/pages/*.json` with its parsed `slug`; `rawPages`: each
80
+ * `content/pages-html/**.html` with the slug its path encodes.
81
+ * @returns {PageSlugFinding[]}
82
+ */
83
+ export function pageSlugFindings({ pages = [], rawPages = [] }) {
84
+ /** @type {PageSlugFinding[]} */
85
+ const findings = [];
86
+ /** @type {Map<string, string>} */
87
+ const claimed = new Map();
88
+ for (const { path, slug } of [...pages, ...rawPages]) {
89
+ const reason = unservableSlugReason(slug);
90
+ if (reason) {
91
+ findings.push({
92
+ code: "page-slug-unservable",
93
+ path,
94
+ message: `${reason}, so the hosted store answers 404 for it`,
95
+ remedy: "give the page a slug of plain path segments outside the platform's routes, and add a content/redirects.json entry from any old URL",
96
+ });
97
+ continue;
98
+ }
99
+ const key = /** @type {string} */ (slug);
100
+ const first = claimed.get(key);
101
+ if (first !== undefined) {
102
+ findings.push({
103
+ code: "page-slug-duplicate",
104
+ path,
105
+ message: `slug "${slug}" is also claimed by ${first} — the hosted store serves only one of them`,
106
+ remedy: "give each page its own slug",
107
+ });
108
+ continue;
109
+ }
110
+ claimed.set(key, path);
111
+ }
112
+ return findings;
113
+ }
@@ -261,6 +261,23 @@ async function readRawDevFile(scope: string, relative: string): Promise<string |
261
261
  }
262
262
  }
263
263
 
264
+ /**
265
+ * An editorial page is routed by its JSON `slug`, never by its file name (`pages/<name>.json`
266
+ * may hold `"slug": "policies/terms-of-service"`). The file named after the slug is read first
267
+ * because it is the common layout; any other match needs the enumeration.
268
+ */
269
+ async function pageBySlug(
270
+ slug: string,
271
+ read: (relative: string) => Promise<EditorialPage | null>,
272
+ list: () => Promise<EditorialPage[]>,
273
+ ): Promise<EditorialPage | null> {
274
+ const wanted = slug.replace(/^\/+|\/+$/g, "");
275
+ if (!wanted) return null;
276
+ const named = await read(`pages/${wanted}.json`);
277
+ if (named?.slug === wanted) return named;
278
+ return (await list()).find((page) => page.slug === wanted) ?? null;
279
+ }
280
+
264
281
  export class LocalContentProvider implements ContentProvider {
265
282
  constructor(private readonly scope: string = DEFAULT_SCOPE) {}
266
283
 
@@ -714,8 +731,11 @@ export class PublishedContentProvider implements ContentProvider {
714
731
  return config ?? ((await this.usesVersionedContent()) ? EMPTY_CHROME_CONFIG : this.base.getChrome());
715
732
  }
716
733
  async getPage(slug: string): Promise<EditorialPage | null> {
717
- const page = await this.published<EditorialPage>(`pages/${slug}.json`);
718
- return page ?? ((await this.usesVersionedContent()) ? null : this.base.getPage(slug));
734
+ if (!(await this.usesVersionedContent())) {
735
+ const legacy = await this.published<EditorialPage>(`pages/${slug}.json`);
736
+ return legacy?.slug === slug ? legacy : this.base.getPage(slug);
737
+ }
738
+ return pageBySlug(slug, (relative) => this.published<EditorialPage>(relative), () => this.listPages());
719
739
  }
720
740
  async listPages(): Promise<EditorialPage[]> {
721
741
  // Editorial enumeration follows its source, not the generic artifact fallback.
@@ -824,7 +844,7 @@ export class VersionPinnedContentProvider implements ContentProvider {
824
844
  return config ?? EMPTY_CHROME_CONFIG;
825
845
  }
826
846
  async getPage(slug: string): Promise<EditorialPage | null> {
827
- return this.pinned<EditorialPage>(`pages/${slug}.json`);
847
+ return pageBySlug(slug, (relative) => this.pinned<EditorialPage>(relative), () => this.listPages());
828
848
  }
829
849
  async listPages(): Promise<EditorialPage[]> {
830
850
  const paths = await this.listArtifactPaths("pages/");
@@ -70,6 +70,7 @@ import {
70
70
  MATERIALIZE_MODE_BINDING,
71
71
  verifyConsentSnapshotSig,
72
72
  resolveNativeGa4SandboxUrl,
73
+ CANDIDATE_CONTENT_PIN_PARAM,
73
74
  } from "@tot/public-runtime";
74
75
  import { readBakedManagedInstalls } from "@/lib/integrations/integration/bakedManagedInstalls";
75
76
  import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
@@ -274,13 +275,22 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
274
275
  // The owning space's content-declared redirects answer before anything renders, as the CloudFront
275
276
  // redirect plane does for the same declarations: a same-origin target keeps the path-routing prefix
276
277
  // and the query; an off-site target (the root space only) is sent as declared.
278
+ // A pinned render (staged/candidate revision) redirects inside the revision it reviews, and its
279
+ // one-shot pin token never reaches the browser.
280
+ const declaredRedirectHeaders = (location: string): HeadersInit => {
281
+ if (!location.startsWith("/")) return { location };
282
+ let search = url.search;
283
+ if (url.searchParams.has(CANDIDATE_CONTENT_PIN_PARAM)) {
284
+ const query = new URLSearchParams(url.search);
285
+ query.delete(CANDIDATE_CONTENT_PIN_PARAM);
286
+ search = query.toString() ? `?${query.toString()}` : "";
287
+ }
288
+ return { location: `${url.origin}${candidatePin ? candidatePin.basePath : basePath}${location}${search}` };
289
+ };
277
290
  if (publicationSource.status === "selected") {
278
291
  const location = publicationContentRedirectLocationV1(publicationSource.artifact, input.publicationRequestPath);
279
292
  if (location) {
280
- const response = new Response(null, {
281
- status: 308,
282
- headers: { location: location.startsWith("/") ? `${url.origin}${basePath}${location}${url.search}` : location },
283
- });
293
+ const response = new Response(null, { status: 308, headers: declaredRedirectHeaders(location) });
284
294
  applySecurityHeaders(response.headers);
285
295
  if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
286
296
  return response;
@@ -355,10 +365,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
355
365
  if (publicationSource.status === "legacy-root") {
356
366
  const location = await contentRedirectLocation(locals.content, renderPath);
357
367
  if (location) {
358
- const response = new Response(null, {
359
- status: 308,
360
- headers: { location: location.startsWith("/") ? `${url.origin}${basePath}${location}${url.search}` : location },
361
- });
368
+ const response = new Response(null, { status: 308, headers: declaredRedirectHeaders(location) });
362
369
  applySecurityHeaders(response.headers);
363
370
  return response;
364
371
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.7.0",
3
+ "version": "4.7.1",
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",
@@ -46,6 +46,7 @@ import {
46
46
  workspacePathForTenantRelative,
47
47
  } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
48
48
  import { isForbiddenPublicPath, publicBinaryFindings } from "../../agency-kit/tools/lib/public-binaries.mjs";
49
+ import { pageSlugFindings } from "../../agency-kit/tools/lib/page-slugs.mjs";
49
50
  import { validateVisualParityClosure } from "../../packages/cli/src/visual-parity.mjs";
50
51
  import { declaredObligations, isRegulatedCompliance, validateDeclaredConfig } from "../../packages/cli/src/declared-config.mjs";
51
52
 
@@ -1200,6 +1201,18 @@ export function validateTenant(tenantDir, opts = {}) {
1200
1201
  }
1201
1202
  }
1202
1203
 
1204
+ // 3b. editorial page slugs — every page must be reachable at its slug on the hosted router
1205
+ // (agency-kit/tools/lib/page-slugs.mjs, the rule `tot validate` applies).
1206
+ {
1207
+ const pages = walk(join(contentDir, "pages"), (x) => x.endsWith(".json")).sort().map((p) => {
1208
+ const { value, error } = readJsonSafe(p);
1209
+ return { path: rel(p), slug: error ? undefined : value?.slug };
1210
+ });
1211
+ const rawPages = walk(pagesDir, (x) => x.endsWith(".html")).sort()
1212
+ .map((p) => ({ path: rel(p), slug: relative(pagesDir, p).split("\\").join("/").replace(/\.html$/, "") }));
1213
+ for (const f of pageSlugFindings({ pages, rawPages })) findings.push(mk(ERROR, f.code, f.path, f.message, f.remedy));
1214
+ }
1215
+
1203
1216
  // 4. raw HTML documents — validate + portability + resolution
1204
1217
  const hasChrome = providesChrome(contentDir);
1205
1218
  const htmlFiles = walk(contentDir, (p) => p.endsWith(".html"));