@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.
- package/apps/storefront/src/lib/publication/contentProvider.ts +21 -4
- package/apps/storefront/src/lib/publication/mountedDiscovery.ts +6 -1
- package/apps/storefront/src/lib/publication/runtimeSource.ts +12 -2
- package/apps/storefront/src/lib/publication/servingMode.ts +52 -0
- package/apps/storefront/src/middleware/index.ts +15 -4
- package/apps/storefront/src/middleware/renderChain.ts +19 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +63 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +13 -0
- package/packages/public-runtime/src/publication-artifact.ts +278 -46
- package/packages/public-runtime/src/publication-content-redirects.ts +61 -0
- package/packages/public-runtime/src/publication-reader.ts +89 -77
- package/packages/public-runtime/src/publication-recovery.ts +132 -0
- package/packages/public-runtime/src/publication-space.ts +76 -1
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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:
|
|
330
|
-
publicationRoute:
|
|
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.
|
|
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
|
},
|