@tokenoftrust/storefront-runner 2.4.24 → 2.4.26

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.
@@ -62,8 +62,23 @@ export class PublicationSpaceContentProvider implements ContentProvider {
62
62
  constructor(
63
63
  private readonly sharedRoot: ContentProvider,
64
64
  private readonly publication: OpenPublicationArtifactV1,
65
+ /**
66
+ * A discovery document (sitemap, feeds, search index, llms.txt) lists every page of the space,
67
+ * so it reads any of them; a page request reads only the page its plan selected.
68
+ */
69
+ private readonly wholeSpace = false,
65
70
  ) {}
66
71
 
72
+ /** The space-relative slug a page request may read, or null. `""` is the space's home. */
73
+ private readableSlug(slug: string): string | null {
74
+ if (!this.wholeSpace) return selectedPageSlug(this.plan, slug);
75
+ const requested = stripSlashes(slug);
76
+ const mount = stripSlashes(this.plan.manifest.routes.canonicalMount);
77
+ if (!mount) return requested;
78
+ if (requested === mount) return "";
79
+ return requested.startsWith(`${mount}/`) ? requested.slice(mount.length + 1) : null;
80
+ }
81
+
67
82
  private get plan(): PublicationRouteRenderPlanV1 {
68
83
  return this.publication.selection;
69
84
  }
@@ -86,7 +101,7 @@ export class PublicationSpaceContentProvider implements ContentProvider {
86
101
  }
87
102
 
88
103
  async getPageHtml(slug: string): Promise<string | null> {
89
- const selected = selectedPageSlug(this.plan, slug);
104
+ const selected = this.readableSlug(slug);
90
105
  if (selected === null) return null;
91
106
  const raw = this.publication.readContent(
92
107
  selected ? `pages-html/${selected}.html` : "home.html",
@@ -122,10 +137,11 @@ export class PublicationSpaceContentProvider implements ContentProvider {
122
137
  }
123
138
 
124
139
  async getPage(slug: string): Promise<EditorialPage | null> {
125
- const selected = selectedPageSlug(this.plan, slug);
140
+ const selected = this.readableSlug(slug);
126
141
  if (!selected) return null;
127
142
  const page = (await this.sourcePages()).find((candidate) => candidate.slug === selected);
128
- return page ? rebasePage(this.plan, page, stripSlashes(this.plan.canonicalPath)) : null;
143
+ if (!page) return null;
144
+ return this.wholeSpace ? rebasePage(this.plan, page) : rebasePage(this.plan, page, stripSlashes(this.plan.canonicalPath));
129
145
  }
130
146
 
131
147
  async listPages(): Promise<EditorialPage[]> {
@@ -152,8 +168,9 @@ export class PublicationSpaceContentProvider implements ContentProvider {
152
168
  export function bindPublicationSpaceContentProviderV1(
153
169
  sharedRoot: ContentProvider,
154
170
  publication?: OpenPublicationArtifactV1,
171
+ options: { wholeSpace?: boolean } = {},
155
172
  ): ContentProvider {
156
173
  return publication
157
- ? new PublicationSpaceContentProvider(sharedRoot, publication)
174
+ ? new PublicationSpaceContentProvider(sharedRoot, publication, options.wholeSpace ?? false)
158
175
  : sharedRoot;
159
176
  }
@@ -28,6 +28,7 @@ import {
28
28
  PUBLICATION_DISCOVERY_ENTRY_KINDS_V1,
29
29
  type PublicationDiscoveryEntryKindV1,
30
30
  } from "@tot/public-runtime";
31
+ import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
31
32
  import { rawPageCanonicalUrl } from "../rawMarketingHtml.js";
32
33
 
33
34
  export type DiscoverySurfaceV1 = "sitemap" | "feeds" | "publicSearch" | "llms";
@@ -135,14 +136,18 @@ export async function readMountedPublicationDiscoveryV1(input: {
135
136
  if (mounts.size === 0 || !input.store) return [];
136
137
  let projectionJson: string | null;
137
138
  let stateJson: string | null;
139
+ let recoveryJson: string | null;
138
140
  try {
139
- [projectionJson, stateJson] = await Promise.all([
141
+ [projectionJson, stateJson, recoveryJson] = await Promise.all([
140
142
  input.store.get(publicationMountedDiscoveryKeyV1(input.tenant.tenant_id)),
141
143
  input.store.get(publicationVersionStateKey(input.tenant.tenant_id)),
144
+ input.store.get(publicationRecoveryKeyV1(input.tenant.tenant_id)),
142
145
  ]);
143
146
  } catch {
144
147
  return [];
145
148
  }
149
+ // Under a whole-site recovery the mounts serve the whole-site release, which lists itself.
150
+ if (publicationRecoveryActiveV1(recoveryJson)) return [];
146
151
  if (!projectionJson || !stateJson || projectionJson.length > MAX_PROJECTION_CHARS) return [];
147
152
  const state = parsePublicationVersionStateV1Json(stateJson);
148
153
  if (!state.ok) return [];
@@ -12,6 +12,7 @@ import {
12
12
  type PublicationRoutePlanV1,
13
13
  type RouteTierIndexV1,
14
14
  } from "@tot/public-runtime";
15
+ import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
15
16
 
16
17
  export interface PublicationStateStoreV1 {
17
18
  get(key: string): Promise<string | null>;
@@ -110,7 +111,9 @@ export type PublicationRuntimeSourceV1 =
110
111
  * Resolve one live request from the public KV state projection into its exact
111
112
  * immutable artifact. A carried plan is checked against current route authority
112
113
  * before state or object-store I/O. Only an unselected root keeps the legacy
113
- * adapter; a mounted space never falls through to root content.
114
+ * adapter; a mounted space never falls through to root content — except under a
115
+ * whole-site recovery (`publicationRecoveryKeyV1`), when every public space serves
116
+ * the tenant's whole-site release on `live`, exactly as before its spaces went live.
114
117
  */
115
118
  export async function resolvePublicationRuntimeSourceV1(input: {
116
119
  index: RouteTierIndexV1;
@@ -132,11 +135,18 @@ export async function resolvePublicationRuntimeSourceV1(input: {
132
135
  }
133
136
 
134
137
  let stateJson: string | null;
138
+ let recoveryJson: string | null = null;
135
139
  try {
136
- stateJson = await input.store.get(publicationVersionStateKey(input.plan.manifest.tenant));
140
+ [stateJson, recoveryJson] = await Promise.all([
141
+ input.store.get(publicationVersionStateKey(input.plan.manifest.tenant)),
142
+ input.channel === "live" && input.plan.manifest.visibility === "public"
143
+ ? input.store.get(publicationRecoveryKeyV1(input.plan.manifest.tenant))
144
+ : Promise.resolve(null),
145
+ ]);
137
146
  } catch {
138
147
  return { status: "unavailable", code: "publication_artifact_source_unavailable" };
139
148
  }
149
+ if (publicationRecoveryActiveV1(recoveryJson)) return { status: "legacy-root" };
140
150
  if (stateJson === null) {
141
151
  return isRoot
142
152
  ? { status: "legacy-root" }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Whether a tenant's ROOT is served by a publication space, which decides what a whole-site
3
+ * promotion (ship, rollback, hotfix, re-publish, platform reconcile) can do for it.
4
+ *
5
+ * While the root public space is live and no whole-site recovery holds, a whole-site promotion
6
+ * cannot reach visitors: on Cloudflare the selected root space shadows the whole-site live channel
7
+ * (the job would report success while visitors kept the space), and on CloudFront the aggregate
8
+ * pointer refuses a whole-site publish after minutes of rendering. So it refuses up front with
9
+ * `publication_root_is_space`, naming the root space and how its content goes live instead.
10
+ *
11
+ * Behaviour follows the declared topology and the editorial state, never the tenant id.
12
+ */
13
+ import type { PublicationVersionStateV1, TenantConfig } from "@tot/public-runtime";
14
+ import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
15
+
16
+ export const PUBLICATION_ROOT_IS_SPACE = "publication_root_is_space";
17
+
18
+ export type PublicationServingModeV1 =
19
+ | { mode: "whole-site"; reason: "no-root-space" | "root-not-live" | "recovered" }
20
+ | { mode: "spaces"; root: { spaceId: string; versionId: string; revision: number; source: string } };
21
+
22
+ export async function publicationServingModeV1(input: {
23
+ tenant: Pick<TenantConfig, "tenant_id" | "publicationSpaces">;
24
+ readState(): Promise<PublicationVersionStateV1>;
25
+ kvGet(key: string): Promise<string | null>;
26
+ }): Promise<PublicationServingModeV1> {
27
+ const root = input.tenant.publicationSpaces?.find((space) => space.tenant === input.tenant.tenant_id &&
28
+ space.visibility === "public" && space.routes.canonicalMount === "/");
29
+ if (!root) return { mode: "whole-site", reason: "no-root-space" };
30
+ if (publicationRecoveryActiveV1(await input.kvGet(publicationRecoveryKeyV1(input.tenant.tenant_id)))) {
31
+ return { mode: "whole-site", reason: "recovered" };
32
+ }
33
+ const pointer = (await input.readState()).pointers.find((candidate) =>
34
+ candidate.tenant === input.tenant.tenant_id && candidate.spaceId === root.id && candidate.channel === "live");
35
+ if (!pointer) return { mode: "whole-site", reason: "root-not-live" };
36
+ return { mode: "spaces", root: { spaceId: root.id, versionId: pointer.versionId, revision: pointer.revision,
37
+ source: `${root.source.forge}:${root.source.repo}` } };
38
+ }
39
+
40
+ /** The refusal a whole-site promotion gets while the root is a live publication space. */
41
+ export function publicationRootIsSpaceRefusalV1(
42
+ tenantId: string,
43
+ mode: Extract<PublicationServingModeV1, { mode: "spaces" }>,
44
+ ): string {
45
+ const { spaceId, versionId, revision, source } = mode.root;
46
+ return `${PUBLICATION_ROOT_IS_SPACE}: ${tenantId}'s root is served by publication space "${spaceId}" ` +
47
+ `(source ${source}, live ${versionId} at revision ${revision}), so a whole-site release would never reach visitors. ` +
48
+ `Put root content live by activating a root-space version: build it ` +
49
+ `(scripts/publish/build-publication-space-version.ts --tenant ${tenantId} --space ${spaceId} --source-sha <sha>), then ` +
50
+ `POST /api/publication-spaces/${spaceId}/review and /api/publication-spaces/${spaceId}/activate. ` +
51
+ `To serve the whole site instead, recover it first (scripts/publish/recover-publication-tenant.mjs recover).`;
52
+ }
@@ -25,7 +25,7 @@ import { getTenantResolver } from "@/config/resolver";
25
25
  import { readEnv, readKv, readR2 } from "@/lib/runtimeEnv";
26
26
  import { resolveCdnForwardRouting, resolveTenantRouting } from "@/lib/tenantRouting";
27
27
  import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/cdnForward";
28
- import { compileRouteTierIndexV1, type RouteTierIndexV1 } from "@tot/public-runtime";
28
+ import { compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 } from "@tot/public-runtime";
29
29
  import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
30
30
  import { DEV_DRAFT_COOKIE } from "@/lib/dev/devDraft";
31
31
  import { applySecurityHeaders } from "@/lib/securityHeaders";
@@ -39,6 +39,7 @@ import { HEALTH_PATH, healthResponse } from "@/lib/health";
39
39
  import { observeDeployVersion } from "@/lib/activity/deployVersion";
40
40
  import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
41
41
  import {
42
+ isPlatformPublicDiscoveryPath,
42
43
  platformStaticRootLocationV1,
43
44
  publicationRedirectLocationV1,
44
45
  resolveMiddlewareBranchV1,
@@ -314,6 +315,14 @@ async function serveRequest(
314
315
  return asset;
315
316
  }
316
317
 
318
+ // A tenant's discovery documents (sitemap, feeds, search index, llms.txt, robots) are the root's:
319
+ // when the root is a publication space, they render from its selected version, as its pages do.
320
+ const discoveryRoot = isPlatformPublicDiscoveryPath(renderPath)
321
+ ? resolvePublicationRoutePlanV1(routeTierIndexFor(tenant), "/")
322
+ : null;
323
+ const publicationSourceRoute = discoveryRoot?.action === "render" ? discoveryRoot : publication;
324
+ const publicationSourcePath = discoveryRoot?.action === "render" ? "/" : renderPath;
325
+
317
326
  if (branch === "public-render") {
318
327
  return await servePublicRender({
319
328
  context,
@@ -326,9 +335,11 @@ async function serveRequest(
326
335
  cache,
327
336
  kv,
328
337
  routeTierIndex: routeTierIndexFor(tenant),
329
- publicationRequestPath: renderPath,
330
- publicationRoute: publication,
331
- publicationBucket,
338
+ publicationRequestPath: publicationSourcePath,
339
+ publicationRoute: publicationSourceRoute,
340
+ publicationBucket: discoveryRoot?.action === "render"
341
+ ? await readR2("TENANT_ASSETS") as unknown as PublicationArtifactBucketV1 | undefined
342
+ : publicationBucket,
332
343
  });
333
344
  }
334
345
 
@@ -14,6 +14,7 @@ import { mergeTheme, themeToCssVars } from "@/themes/schema";
14
14
  import { createToTClient } from "@/lib/tot/ToTClient";
15
15
  import { readEnv, readR2 } from "@/lib/runtimeEnv";
16
16
  import { tryServeStaticBundle } from "./staticBundle.js";
17
+ import { isPlatformPublicDiscoveryPath } from "./routeBranch.js";
17
18
  import { resolveCandidateContentPin, type CandidateContentPin } from "./candidatePin.js";
18
19
  import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
19
20
  import { bindPublicationSpaceContentProviderV1 } from "@/lib/publication/contentProvider.js";
@@ -82,6 +83,7 @@ import {
82
83
  HTML_CACHE_POLICY_CAPTURE_HEADER,
83
84
  HTML_CACHE_POLICY_CAPTURE_PROBE,
84
85
  } from "@tot/public-runtime";
86
+ import { publicationContentRedirectLocationV1 } from "@tot/public-runtime/publication-content-redirects";
85
87
  import { loadTenantIntegrationDeclarations } from "@/lib/integration/tenantDeclarations";
86
88
  import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
87
89
  import {
@@ -236,6 +238,21 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
236
238
  if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
237
239
  return response;
238
240
  }
241
+ // The owning space's content-declared redirects answer before anything renders, as the CloudFront
242
+ // redirect plane does for the same declarations: a same-origin target keeps the path-routing prefix
243
+ // and the query; an off-site target (the root space only) is sent as declared.
244
+ if (publicationSource.status === "selected") {
245
+ const location = publicationContentRedirectLocationV1(publicationSource.artifact, input.publicationRequestPath);
246
+ if (location) {
247
+ const response = new Response(null, {
248
+ status: 308,
249
+ headers: { location: location.startsWith("/") ? `${url.origin}${basePath}${location}${url.search}` : location },
250
+ });
251
+ applySecurityHeaders(response.headers);
252
+ if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
253
+ return response;
254
+ }
255
+ }
239
256
 
240
257
  // The tenant as it renders in THIS environment: its record with the declared
241
258
  // `.tot/config.json` fields applied (compliance, siteType, capabilities,
@@ -613,6 +630,8 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
613
630
  locals.content = bindPublicationSpaceContentProviderV1(
614
631
  sharedRootContent,
615
632
  publicationSource.status === "selected" ? publicationSource.artifact : undefined,
633
+ // A discovery document lists the whole space it is bound to, not one selected page.
634
+ { wholeSpace: isPlatformPublicDiscoveryPath(renderPath) },
616
635
  );
617
636
  // Developer-draft content overlay — serves the AI-edited artifacts to the draft
618
637
  // owner only; every other viewer keeps the published/base chain above.
@@ -17,6 +17,10 @@
17
17
  * Preview sessions also consult the image-capture rewrite overlay so rewritten
18
18
  * `public/*.css` (and similar text assets) serve `/_a/<sha>` references.
19
19
  *
20
+ * A tenant whose ROOT is a publication space serves these paths from that space's selected version
21
+ * (its public asset lane, `<namespace>/<path>`), the same URL a whole-site tenant serves them at;
22
+ * otherwise the legacy asset pointer below answers.
23
+ *
20
24
  * Version selection, in order: a candidate/revision pin (`tot_preview_version` +
21
25
  * token cookie); else, for a shared-preview viewer (preview host, or a valid token
22
26
  * for this tenant), the version the content `preview` channel serves — what the
@@ -24,12 +28,20 @@
24
28
  */
25
29
  import type { APIContext } from "astro";
26
30
  import {
31
+ ASSET_CACHE_CONTROL,
27
32
  assetContentType,
33
+ compileRouteTierIndexV1,
34
+ parsePublicationVersionStateV1Json,
28
35
  parseTenantAssetPath,
36
+ publicationVersionStateKey,
37
+ readPublicationRootAssetV1,
29
38
  resolveChannelVersion,
30
39
  serveTenantAsset,
40
+ type PublicationArtifactReadBucketV1,
31
41
  type R2BucketLike,
42
+ type TenantConfig,
32
43
  } from "@tot/public-runtime";
44
+ import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
33
45
  import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
34
46
  import { readR2, readKv, readEnv } from "@/lib/env";
35
47
  import {
@@ -68,6 +80,46 @@ async function tryServePreviewRewriteOverlay(input: {
68
80
  });
69
81
  }
70
82
 
83
+ /** The root publication space's asset, or null when the tenant's root is not a selected space. */
84
+ async function tryServePublicationRootAsset(input: {
85
+ /** The tenant the request resolved to (`Astro.locals.tenant`). */
86
+ tenant: TenantConfig | undefined;
87
+ bucket: PublicationArtifactReadBucketV1;
88
+ kvGet: (key: string) => Promise<string | null>;
89
+ tenantId: string;
90
+ pathname: string;
91
+ channel: "live" | "preview";
92
+ ifNoneMatch: string | null;
93
+ }): Promise<Response | null> {
94
+ const parsed = parseTenantAssetPath(input.pathname);
95
+ if (!parsed || parsed.tenantId !== input.tenantId) return null;
96
+ const tenant = input.tenant;
97
+ if (!tenant || tenant.tenant_id !== input.tenantId ||
98
+ !tenant.publicationSpaces?.some((space) => space.routes.canonicalMount === "/")) return null;
99
+ const [stateJson, recoveryJson] = await Promise.all([
100
+ input.kvGet(publicationVersionStateKey(input.tenantId)),
101
+ input.channel === "live" ? input.kvGet(publicationRecoveryKeyV1(input.tenantId)) : Promise.resolve(null),
102
+ ]);
103
+ // A whole-site recovery serves the whole-site release's assets, through the legacy pointer.
104
+ if (stateJson === null || publicationRecoveryActiveV1(recoveryJson)) return null;
105
+ const state = parsePublicationVersionStateV1Json(stateJson);
106
+ if (!state.ok) return null;
107
+ const read = await readPublicationRootAssetV1({
108
+ index: compileRouteTierIndexV1(tenant), state: state.value, channel: input.channel,
109
+ bucket: input.bucket, relativePath: parsed.rel,
110
+ });
111
+ if (read.status === "legacy-root") return null;
112
+ if (read.status === "absent") {
113
+ return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
114
+ }
115
+ const etag = `"${read.sha256}"`;
116
+ const headers = { "content-type": assetContentType(parsed.rel), "cache-control": ASSET_CACHE_CONTROL, etag };
117
+ if (input.ifNoneMatch && input.ifNoneMatch.split(",").some((value) => value.trim().replace(/^W\//, "") === etag)) {
118
+ return new Response(null, { status: 304, headers });
119
+ }
120
+ return new Response(read.bytes as Uint8Array<ArrayBuffer>, { status: 200, headers });
121
+ }
122
+
71
123
  export async function GET(context: APIContext): Promise<Response> {
72
124
  const [bucket, kv] = await Promise.all([
73
125
  readR2("TENANT_ASSETS"),
@@ -106,6 +158,17 @@ export async function GET(context: APIContext): Promise<Response> {
106
158
  const previewVersionId = pinnedVersionId ?? sharedPreview.versionId;
107
159
 
108
160
  const pathname = url.pathname;
161
+ const fromPublication = await tryServePublicationRootAsset({
162
+ tenant: context.locals.tenant,
163
+ bucket: bucket as unknown as PublicationArtifactReadBucketV1,
164
+ kvGet: (key) => kv.get(key),
165
+ tenantId: urlTenant,
166
+ pathname,
167
+ channel: sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live",
168
+ ifNoneMatch: context.request.headers.get("if-none-match"),
169
+ });
170
+ if (fromPublication) return fromPublication;
171
+
109
172
  if (previewVersionId) {
110
173
  const overlay = await tryServePreviewRewriteOverlay({
111
174
  kvGet: (key) => kv.get(key),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.24",
3
+ "version": "2.4.26",
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",
@@ -20,6 +20,8 @@
20
20
  "./catalog-files": "./src/catalog-files.ts",
21
21
  "./embed-catalog": "./src/embed-catalog.mjs",
22
22
  "./retention-holds": "./src/retention-holds.ts",
23
+ "./publication-content-redirects": "./src/publication-content-redirects.ts",
24
+ "./publication-recovery": "./src/publication-recovery.ts",
23
25
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
24
26
  "./declared-tenant-config": "./src/declared-tenant-config.mjs",
25
27
  "./compliance-coherence": "./src/compliance-coherence.mjs",
@@ -85,6 +85,19 @@
85
85
  "blog": { "$ref": "#/$defs/collectionBase" },
86
86
  "the-build": { "$ref": "#/$defs/collectionBase" }
87
87
  }
88
+ },
89
+ "externalRedirects": {
90
+ "description": "Exact paths the ROOT space sends to another origin; a mounted space declares none.",
91
+ "type": "array",
92
+ "items": {
93
+ "type": "object",
94
+ "additionalProperties": false,
95
+ "required": ["from", "to"],
96
+ "properties": {
97
+ "from": { "$ref": "#/$defs/path" },
98
+ "to": { "type": "string", "pattern": "^https://[^/?#@\\s]+(?:/[^?#\\s]*)?$" }
99
+ }
100
+ }
88
101
  }
89
102
  }
90
103
  },