@tokenoftrust/storefront-runner 2.4.23 → 2.4.25
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/components/blog/BlogIndexView.astro +10 -9
- package/apps/storefront/src/components/blog/PostGrid.astro +6 -5
- package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -8
- package/apps/storefront/src/config/publicationSpaceConfig.ts +6 -1
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +1 -0
- package/apps/storefront/src/config/publicationSpaceTopology.ts +22 -1
- package/apps/storefront/src/lib/blog/collectionPaths.ts +46 -0
- package/apps/storefront/src/lib/blog/pagination.ts +6 -16
- package/apps/storefront/src/lib/publication/contentProvider.ts +21 -4
- package/apps/storefront/src/lib/publication/mountedDiscovery.ts +4 -1
- package/apps/storefront/src/lib/publication/rootDiscovery.ts +7 -4
- package/apps/storefront/src/lib/the-build/pagination.ts +3 -8
- package/apps/storefront/src/middleware/index.ts +19 -7
- package/apps/storefront/src/middleware/renderChain.ts +19 -0
- package/apps/storefront/src/pages/blog/[slug].astro +14 -12
- package/apps/storefront/src/pages/blog/author/[author].astro +8 -6
- package/apps/storefront/src/pages/blog/category/[category].astro +6 -4
- package/apps/storefront/src/pages/blog/tag/[tag].astro +6 -4
- package/apps/storefront/src/pages/llms.txt.ts +6 -4
- package/apps/storefront/src/pages/sitemap.xml.ts +12 -9
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +58 -0
- package/apps/storefront/src/pages/the-build/[slug].astro +9 -7
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +25 -0
- package/packages/public-runtime/src/blog-visibility.d.mts +20 -0
- package/packages/public-runtime/src/blog-visibility.mjs +87 -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-routing.ts +15 -1
- package/packages/public-runtime/src/publication-space.ts +145 -2
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type
|
|
7
|
-
type
|
|
2
|
+
readPublicationArtifactFileV1,
|
|
3
|
+
readPublicationArtifactIndexV1,
|
|
4
|
+
type PublicationArtifactIndexFileV1,
|
|
5
|
+
type PublicationArtifactIndexV1,
|
|
6
|
+
type PublicationArtifactReadBucketV1,
|
|
7
|
+
type PublicationArtifactStoredObjectV1,
|
|
8
8
|
} from "./publication-artifact.js";
|
|
9
9
|
import {
|
|
10
|
+
resolvePublicationRoutePlanV1,
|
|
10
11
|
selectPublicationRouteVersionV1,
|
|
11
12
|
type PublicationRouteFailureCodeV1,
|
|
12
13
|
type PublicationRoutePlanV1,
|
|
@@ -18,15 +19,10 @@ import type {
|
|
|
18
19
|
} from "./publication-version.js";
|
|
19
20
|
import type { RouteTierIndexV1 } from "./route-tier.js";
|
|
20
21
|
|
|
21
|
-
export
|
|
22
|
-
text(): Promise<string>;
|
|
23
|
-
arrayBuffer(): Promise<ArrayBuffer>;
|
|
24
|
-
}
|
|
22
|
+
export type PublicationStoredObjectV1 = PublicationArtifactStoredObjectV1;
|
|
25
23
|
|
|
26
24
|
/** Provider-neutral object-store read surface; R2 and test stores both satisfy it. */
|
|
27
|
-
export
|
|
28
|
-
get(key: string): Promise<PublicationStoredObjectV1 | null>;
|
|
29
|
-
}
|
|
25
|
+
export type PublicationArtifactBucketV1 = PublicationArtifactReadBucketV1;
|
|
30
26
|
|
|
31
27
|
export type PublicationArtifactOpenFailureCodeV1 =
|
|
32
28
|
| PublicationRouteFailureCodeV1
|
|
@@ -38,13 +34,17 @@ export type PublicationArtifactOpenFailureCodeV1 =
|
|
|
38
34
|
|
|
39
35
|
export interface OpenPublicationArtifactV1 {
|
|
40
36
|
selection: SelectedPublicationRouteV1;
|
|
41
|
-
|
|
37
|
+
/** The admitted file index of the selected version. */
|
|
38
|
+
index: PublicationArtifactIndexV1;
|
|
42
39
|
/** Read a UTF-8 artifact relative to the selected space's content/ root. */
|
|
43
40
|
readContent(relativePath: string): string | null;
|
|
44
41
|
/** List paths relative to the selected space's content/ root. */
|
|
45
42
|
listContentPaths(prefix?: string): string[];
|
|
46
|
-
/**
|
|
47
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Read bytes relative to this space's declared asset namespace. Fetched on demand and proven
|
|
45
|
+
* against the index, so opening a version never reads its assets.
|
|
46
|
+
*/
|
|
47
|
+
readAsset(relativePath: string): Promise<Uint8Array | null>;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export type OpenPublicationArtifactResultV1 =
|
|
@@ -57,11 +57,6 @@ function safeRelativePath(path: string, allowEmpty = false): boolean {
|
|
|
57
57
|
path.split("/").every((part) => !!part && part !== "." && part !== "..");
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
61
|
-
return left.byteLength === right.byteLength &&
|
|
62
|
-
left.every((value, index) => value === right[index]);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
60
|
/**
|
|
66
61
|
* Open exactly one selected publication version and validate every physical
|
|
67
62
|
* object before rendering from it. A route/state/provenance/object failure is
|
|
@@ -91,25 +86,7 @@ async function openSelectedPublicationArtifactV1(
|
|
|
91
86
|
bucket: PublicationArtifactBucketV1,
|
|
92
87
|
): Promise<OpenPublicationArtifactResultV1> {
|
|
93
88
|
const { manifest, publicationVersion } = selection;
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
marker = await bucket.get(publicationArtifactBundleKeyV1(
|
|
97
|
-
manifest.tenant,
|
|
98
|
-
manifest.id,
|
|
99
|
-
publicationVersion.versionId,
|
|
100
|
-
));
|
|
101
|
-
} catch {
|
|
102
|
-
return { ok: false, code: "publication_artifact_source_unavailable" };
|
|
103
|
-
}
|
|
104
|
-
if (!marker) return { ok: false, code: "publication_artifact_marker_missing" };
|
|
105
|
-
|
|
106
|
-
let markerValue: unknown;
|
|
107
|
-
try {
|
|
108
|
-
markerValue = JSON.parse(await marker.text());
|
|
109
|
-
} catch {
|
|
110
|
-
return { ok: false, code: "publication_artifact_marker_malformed" };
|
|
111
|
-
}
|
|
112
|
-
const checked = await validatePublicationArtifactBundleV1(markerValue, {
|
|
89
|
+
const admitted = await readPublicationArtifactIndexV1(bucket, {
|
|
113
90
|
tenant: manifest.tenant,
|
|
114
91
|
spaceId: manifest.id,
|
|
115
92
|
versionId: publicationVersion.versionId,
|
|
@@ -121,55 +98,38 @@ async function openSelectedPublicationArtifactV1(
|
|
|
121
98
|
assetNamespace: manifest.assets.namespace,
|
|
122
99
|
assetAccess: manifest.assets.access,
|
|
123
100
|
});
|
|
124
|
-
if (!
|
|
101
|
+
if (!admitted.ok) return { ok: false, code: admitted.code };
|
|
102
|
+
const { index } = admitted;
|
|
125
103
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
file.lane,
|
|
137
|
-
file.path,
|
|
138
|
-
));
|
|
139
|
-
} catch {
|
|
140
|
-
return { ok: false, code: "publication_artifact_source_unavailable" };
|
|
141
|
-
}
|
|
142
|
-
if (!stored) return { ok: false, code: "publication_artifact_file_missing" };
|
|
143
|
-
let actual: Uint8Array;
|
|
104
|
+
// The renderer reads content synchronously, so the content lane is loaded and proven up front.
|
|
105
|
+
// Assets are not: a page never needs every image its space carries.
|
|
106
|
+
const contentFiles = index.files.filter(
|
|
107
|
+
(file): file is PublicationArtifactIndexFileV1 & { encoding: "utf8" } =>
|
|
108
|
+
file.lane === "content" && file.encoding === "utf8" && file.path.startsWith("content/"),
|
|
109
|
+
);
|
|
110
|
+
const content = new Map<string, string>();
|
|
111
|
+
for (const file of contentFiles) {
|
|
112
|
+
const read = await readPublicationArtifactFileV1(bucket, index, file);
|
|
113
|
+
if (!read.ok) return { ok: false, code: read.code };
|
|
144
114
|
try {
|
|
145
|
-
|
|
115
|
+
content.set(file.path, new TextDecoder("utf-8", { fatal: true }).decode(read.bytes));
|
|
146
116
|
} catch {
|
|
147
|
-
return { ok: false, code: "publication_artifact_file_missing" };
|
|
148
|
-
}
|
|
149
|
-
if (!equalBytes(actual, expected)) {
|
|
150
117
|
return { ok: false, code: "publication_artifact_file_mismatch" };
|
|
151
118
|
}
|
|
152
|
-
physical.set(`${file.lane}\0${file.path}`, actual);
|
|
153
119
|
}
|
|
154
|
-
|
|
155
|
-
const contentFiles = checked.value.files.filter(
|
|
156
|
-
(file): file is PublicationArtifactFileV1 & { encoding: "utf8" } =>
|
|
157
|
-
file.lane === "content" && file.encoding === "utf8" && file.path.startsWith("content/"),
|
|
158
|
-
);
|
|
159
120
|
const assetLane = manifest.assets.access === "public" ? "public-asset" : "authorized-asset";
|
|
160
121
|
const assetPrefix = `${manifest.assets.namespace}/`;
|
|
122
|
+
const assets = new Map(index.files
|
|
123
|
+
.filter((file) => file.lane === assetLane && file.path.startsWith(assetPrefix))
|
|
124
|
+
.map((file) => [file.path.slice(assetPrefix.length), file]));
|
|
161
125
|
return {
|
|
162
126
|
ok: true,
|
|
163
127
|
value: {
|
|
164
128
|
selection,
|
|
165
|
-
|
|
129
|
+
index,
|
|
166
130
|
readContent(relativePath) {
|
|
167
131
|
if (!safeRelativePath(relativePath)) return null;
|
|
168
|
-
|
|
169
|
-
const file = contentFiles.find((candidate) => candidate.path === path);
|
|
170
|
-
if (!file) return null;
|
|
171
|
-
const bytes = physical.get(`content\0${path}`);
|
|
172
|
-
return bytes ? new TextDecoder().decode(bytes) : null;
|
|
132
|
+
return content.get(`content/${relativePath}`) ?? null;
|
|
173
133
|
},
|
|
174
134
|
listContentPaths(prefix = "") {
|
|
175
135
|
if (!safeRelativePath(prefix.replace(/\/$/, ""), true)) return [];
|
|
@@ -178,10 +138,62 @@ async function openSelectedPublicationArtifactV1(
|
|
|
178
138
|
.filter((path) => path.startsWith(prefix))
|
|
179
139
|
.sort();
|
|
180
140
|
},
|
|
181
|
-
readAsset(relativePath) {
|
|
141
|
+
async readAsset(relativePath) {
|
|
182
142
|
if (!safeRelativePath(relativePath)) return null;
|
|
183
|
-
|
|
143
|
+
const file = assets.get(relativePath);
|
|
144
|
+
if (!file) return null;
|
|
145
|
+
const read = await readPublicationArtifactFileV1(bucket, index, file);
|
|
146
|
+
return read.ok ? read.bytes : null;
|
|
184
147
|
},
|
|
185
148
|
},
|
|
186
149
|
};
|
|
187
150
|
}
|
|
151
|
+
|
|
152
|
+
export type PublicationRootAssetReadV1 =
|
|
153
|
+
| { status: "legacy-root" }
|
|
154
|
+
| { status: "found"; bytes: Uint8Array; sha256: string }
|
|
155
|
+
| { status: "absent" };
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One file of the ROOT space's public asset lane, by its path under the namespace — what a
|
|
159
|
+
* whole-site tenant serves at `/tenants/<tenant>/<path>`. Reads the selected version's index and
|
|
160
|
+
* that one object, never the content lane. `legacy-root` when the tenant's root is not a public
|
|
161
|
+
* publication space this channel selected, so the legacy asset pointer still answers.
|
|
162
|
+
*/
|
|
163
|
+
export async function readPublicationRootAssetV1(input: {
|
|
164
|
+
index: RouteTierIndexV1;
|
|
165
|
+
state: PublicationVersionStateV1;
|
|
166
|
+
channel: PublicationChannelV1;
|
|
167
|
+
bucket: PublicationArtifactBucketV1;
|
|
168
|
+
relativePath: string;
|
|
169
|
+
}): Promise<PublicationRootAssetReadV1> {
|
|
170
|
+
const plan = resolvePublicationRoutePlanV1(input.index, "/");
|
|
171
|
+
if (plan.action !== "render" || plan.manifest.routes.canonicalMount !== "/" ||
|
|
172
|
+
plan.manifest.visibility !== "public" || plan.manifest.assets.access !== "public") {
|
|
173
|
+
return { status: "legacy-root" };
|
|
174
|
+
}
|
|
175
|
+
const selected = selectPublicationRouteVersionV1(input.index, "/", plan, input.state, input.channel);
|
|
176
|
+
if (!selected.ok) {
|
|
177
|
+
return selected.code === "publication_channel_unselected" ? { status: "legacy-root" } : { status: "absent" };
|
|
178
|
+
}
|
|
179
|
+
if (!safeRelativePath(input.relativePath)) return { status: "absent" };
|
|
180
|
+
const { manifest, publicationVersion } = selected.value;
|
|
181
|
+
const admitted = await readPublicationArtifactIndexV1(input.bucket, {
|
|
182
|
+
tenant: manifest.tenant,
|
|
183
|
+
spaceId: manifest.id,
|
|
184
|
+
versionId: publicationVersion.versionId,
|
|
185
|
+
sourceSha: publicationVersion.source.commitSha,
|
|
186
|
+
artifactDigest: publicationVersion.artifactDigest,
|
|
187
|
+
forge: manifest.source.forge,
|
|
188
|
+
repo: manifest.source.repo,
|
|
189
|
+
visibility: manifest.visibility,
|
|
190
|
+
assetNamespace: manifest.assets.namespace,
|
|
191
|
+
assetAccess: manifest.assets.access,
|
|
192
|
+
});
|
|
193
|
+
if (!admitted.ok) return { status: "absent" };
|
|
194
|
+
const path = `${manifest.assets.namespace}/${input.relativePath}`;
|
|
195
|
+
const row = admitted.index.files.find((file) => file.lane === "public-asset" && file.path === path);
|
|
196
|
+
if (!row) return { status: "absent" };
|
|
197
|
+
const read = await readPublicationArtifactFileV1(input.bucket, admitted.index, row);
|
|
198
|
+
return read.ok ? { status: "found", bytes: read.bytes, sha256: row.sha256 } : { status: "absent" };
|
|
199
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type RouteTierIndexV1,
|
|
23
23
|
} from "./route-tier.js";
|
|
24
24
|
import type { PublicationSpaceManifestV1 } from "./publication-space.js";
|
|
25
|
+
import { publicationCollectionOfV1 } from "./blog-visibility.mjs";
|
|
25
26
|
|
|
26
27
|
export const PUBLICATION_ROUTE_PLAN_VERSION = 1 as const;
|
|
27
28
|
|
|
@@ -42,8 +43,14 @@ export interface PublicationRouteRenderPlanV1 {
|
|
|
42
43
|
requestPath: string;
|
|
43
44
|
/** Route relative to the selected publication source. Always absolute. */
|
|
44
45
|
contentPath: string;
|
|
45
|
-
/** Canonical tenant-relative route used by links
|
|
46
|
+
/** Canonical tenant-relative route used by links. */
|
|
46
47
|
canonicalPath: string;
|
|
48
|
+
/**
|
|
49
|
+
* The platform route Astro dispatches: a path in one of the space's declared collections maps
|
|
50
|
+
* to that collection's platform route (`/blog/...`, `/the-build/...`), whatever its base inside
|
|
51
|
+
* the mount; any other path dispatches at its canonical path.
|
|
52
|
+
*/
|
|
53
|
+
renderPath: string;
|
|
47
54
|
assetNamespace: string;
|
|
48
55
|
}
|
|
49
56
|
|
|
@@ -91,6 +98,12 @@ function appendSuffix(mount: string, suffix: string): string {
|
|
|
91
98
|
return `${mount}${suffix}`;
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/** The platform collection route for a mount-relative path, or null when it is in no collection. */
|
|
102
|
+
function collectionRenderPath(manifest: PublicationSpaceManifestV1, contentPath: string): string | null {
|
|
103
|
+
const match = publicationCollectionOfV1(manifest, contentPath);
|
|
104
|
+
return match ? `/${match.collection}${match.rest}` : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
94
107
|
/** Resolve one request through the already-compiled tenant mount authority. */
|
|
95
108
|
export function resolvePublicationRoutePlanV1(
|
|
96
109
|
index: RouteTierIndexV1,
|
|
@@ -143,6 +156,7 @@ export function resolvePublicationRoutePlanV1(
|
|
|
143
156
|
requestPath: route.path,
|
|
144
157
|
contentPath: suffix || "/",
|
|
145
158
|
canonicalPath: appendSuffix(mount.canonicalMount, suffix),
|
|
159
|
+
renderPath: collectionRenderPath(manifest, suffix || "/") ?? appendSuffix(mount.canonicalMount, suffix),
|
|
146
160
|
assetNamespace: manifest.assets.namespace,
|
|
147
161
|
};
|
|
148
162
|
}
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* normalized mounts emitted here rather than decode or canonicalize paths again.
|
|
7
7
|
*/
|
|
8
8
|
import type { TenantConfig } from "./tenant.js";
|
|
9
|
+
import {
|
|
10
|
+
isPublicationCollectionBaseV1,
|
|
11
|
+
PUBLICATION_COLLECTIONS_V1,
|
|
12
|
+
publicationCollectionBaseV1,
|
|
13
|
+
type PublicationCollectionV1,
|
|
14
|
+
} from "./blog-visibility.mjs";
|
|
15
|
+
import { normalizeRedirectPath } from "./publication-redirects.mjs";
|
|
9
16
|
import {
|
|
10
17
|
ANNOTATION_KEYS,
|
|
11
18
|
type ContractResult,
|
|
@@ -107,6 +114,14 @@ export interface PublicationSpaceAliasV1 {
|
|
|
107
114
|
to?: string;
|
|
108
115
|
}
|
|
109
116
|
|
|
117
|
+
/** One exact path the root space sends to another origin. */
|
|
118
|
+
export interface PublicationSpaceExternalRedirectV1 {
|
|
119
|
+
/** Normalized exact request path (`/my-account/`), never a rule. */
|
|
120
|
+
from: string;
|
|
121
|
+
/** Absolute `https:` destination without credentials, query, or fragment. */
|
|
122
|
+
to: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
110
125
|
export interface PublicationSpaceManifestV1 {
|
|
111
126
|
version: typeof PUBLICATION_SPACE_MANIFEST_VERSION;
|
|
112
127
|
id: string;
|
|
@@ -115,6 +130,17 @@ export interface PublicationSpaceManifestV1 {
|
|
|
115
130
|
routes: {
|
|
116
131
|
canonicalMount: string;
|
|
117
132
|
aliases?: PublicationSpaceAliasV1[];
|
|
133
|
+
/**
|
|
134
|
+
* Base path of each post collection inside the mount. Absent means the collection's own name
|
|
135
|
+
* (`blog`, `the-build`); `""` serves it at the mount root. Every collection URL is built from
|
|
136
|
+
* it by `publicationCollectionPathV1`.
|
|
137
|
+
*/
|
|
138
|
+
collections?: Partial<Record<PublicationCollectionV1, string>>;
|
|
139
|
+
/**
|
|
140
|
+
* Exact paths sent off the site, as a whole-site publish does. Only the ROOT space may declare
|
|
141
|
+
* them: a mounted space owns only its mount, and its redirects stay on the tenant's origin.
|
|
142
|
+
*/
|
|
143
|
+
externalRedirects?: PublicationSpaceExternalRedirectV1[];
|
|
118
144
|
};
|
|
119
145
|
visibility: PublicationSpaceVisibilityV1;
|
|
120
146
|
authorization:
|
|
@@ -450,6 +476,100 @@ function validateRenderer(
|
|
|
450
476
|
return contractVersion && themeContractVersion ? { contractVersion, themeContractVersion } : undefined;
|
|
451
477
|
}
|
|
452
478
|
|
|
479
|
+
/**
|
|
480
|
+
* A space's declared collection bases: known collections only, each `""` or lowercase segments,
|
|
481
|
+
* no two collections sharing a base, and none at the site root (the root space's home owns `/`).
|
|
482
|
+
*/
|
|
483
|
+
function validateCollections(
|
|
484
|
+
raw: unknown,
|
|
485
|
+
canonicalMount: string | undefined,
|
|
486
|
+
v: Violations,
|
|
487
|
+
path: string,
|
|
488
|
+
): PublicationSpaceManifestV1["routes"]["collections"] {
|
|
489
|
+
if (raw === undefined) return undefined;
|
|
490
|
+
const collections = reqObject(raw, v, path);
|
|
491
|
+
if (!collections) return undefined;
|
|
492
|
+
rejectPublicationUnknownKeys(collections, [...PUBLICATION_COLLECTIONS_V1], v, path);
|
|
493
|
+
const out: Partial<Record<PublicationCollectionV1, string>> = {};
|
|
494
|
+
for (const collection of PUBLICATION_COLLECTIONS_V1) {
|
|
495
|
+
const value = collections[collection];
|
|
496
|
+
if (value === undefined) continue;
|
|
497
|
+
if (!isPublicationCollectionBaseV1(value)) {
|
|
498
|
+
v.add(`${path}.${collection}`, "publication_collection_base_malformed",
|
|
499
|
+
"A collection base is empty (the mount root) or lowercase path segments.");
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (value === "" && canonicalMount === "/") {
|
|
503
|
+
v.add(`${path}.${collection}`, "publication_collection_at_site_root",
|
|
504
|
+
"A collection cannot take the site root; the root space's home serves it.");
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
out[collection] = value;
|
|
508
|
+
}
|
|
509
|
+
const bases = PUBLICATION_COLLECTIONS_V1.map((collection) => publicationCollectionBaseV1({ routes: { collections: out } },
|
|
510
|
+
collection));
|
|
511
|
+
if (new Set(bases).size !== bases.length) {
|
|
512
|
+
v.add(path, "publication_collection_base_conflict", "Two collections cannot share a base path.");
|
|
513
|
+
}
|
|
514
|
+
return Object.keys(out).length ? out : undefined;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function isExternalDestination(value: string): boolean {
|
|
518
|
+
try {
|
|
519
|
+
const url = new URL(value);
|
|
520
|
+
return url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash &&
|
|
521
|
+
url.toString().replace(/\/$/, url.pathname === "/" ? "/" : "") === value;
|
|
522
|
+
} catch {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The root space's exact off-site redirects; a mounted space declaring any is refused. */
|
|
528
|
+
function validateExternalRedirects(
|
|
529
|
+
raw: unknown,
|
|
530
|
+
canonicalMount: string | undefined,
|
|
531
|
+
v: Violations,
|
|
532
|
+
path: string,
|
|
533
|
+
): PublicationSpaceExternalRedirectV1[] | undefined {
|
|
534
|
+
if (raw === undefined) return undefined;
|
|
535
|
+
if (!Array.isArray(raw)) {
|
|
536
|
+
v.add(path, "type", "Expected externalRedirects to be an array.");
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
if (canonicalMount !== undefined && canonicalMount !== "/") {
|
|
540
|
+
v.add(path, "publication_external_redirect_on_mount",
|
|
541
|
+
"Only the root space may redirect off the site; a mounted space owns only its mount.");
|
|
542
|
+
}
|
|
543
|
+
const out: PublicationSpaceExternalRedirectV1[] = [];
|
|
544
|
+
const seen = new Set<string>();
|
|
545
|
+
raw.forEach((item, index) => {
|
|
546
|
+
const at = `${path}[${index}]`;
|
|
547
|
+
const entry = reqObject(item, v, at);
|
|
548
|
+
if (!entry) return;
|
|
549
|
+
rejectPublicationUnknownKeys(entry, ["from", "to"], v, at);
|
|
550
|
+
const from = reqString(entry, "from", v, at);
|
|
551
|
+
const to = reqString(entry, "to", v, at);
|
|
552
|
+
if (from !== undefined && (normalizeRedirectPath(from) !== from || from === "/")) {
|
|
553
|
+
v.add(`${at}.from`, "publication_external_redirect_source",
|
|
554
|
+
"An off-site redirect source is one normalized exact path, never the site root or a rule.");
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (to !== undefined && !isExternalDestination(to)) {
|
|
558
|
+
v.add(`${at}.to`, "publication_external_redirect_target",
|
|
559
|
+
"An off-site redirect target is an absolute https URL without credentials, query, or fragment.");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (from === undefined || to === undefined) return;
|
|
563
|
+
if (seen.has(from)) {
|
|
564
|
+
v.add(`${at}.from`, "publication_external_redirect_duplicate", "An off-site redirect source is declared twice.");
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
seen.add(from);
|
|
568
|
+
out.push({ from, to });
|
|
569
|
+
});
|
|
570
|
+
return out.length ? out.sort((left, right) => left.from < right.from ? -1 : left.from > right.from ? 1 : 0) : undefined;
|
|
571
|
+
}
|
|
572
|
+
|
|
453
573
|
/** Validate, close, and normalize one serialized publication-space manifest. */
|
|
454
574
|
export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult<PublicationSpaceManifestV1> {
|
|
455
575
|
const v = new Violations();
|
|
@@ -468,8 +588,11 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
|
|
|
468
588
|
const routes = reqObject(root["routes"], v, "routes");
|
|
469
589
|
let canonicalMount: string | undefined;
|
|
470
590
|
let aliases: PublicationSpaceAliasV1[] | undefined;
|
|
591
|
+
let collections: PublicationSpaceManifestV1["routes"]["collections"];
|
|
592
|
+
let externalRedirects: PublicationSpaceExternalRedirectV1[] | undefined;
|
|
471
593
|
if (routes) {
|
|
472
|
-
rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases"
|
|
594
|
+
rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases", "collections", "externalRedirects"], v,
|
|
595
|
+
"routes");
|
|
473
596
|
const rawMount = reqString(routes, "canonicalMount", v, "routes");
|
|
474
597
|
canonicalMount = rawMount
|
|
475
598
|
? append(v, "routes.canonicalMount", normalizePublicationPath(rawMount, { mode: "mount" }))
|
|
@@ -478,6 +601,9 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
|
|
|
478
601
|
v.add("routes.canonicalMount", "publication_reserved_mount", "Canonical mount is platform-reserved.");
|
|
479
602
|
}
|
|
480
603
|
aliases = validateAliases(routes["aliases"], canonicalMount, v, "routes.aliases");
|
|
604
|
+
collections = validateCollections(routes["collections"], canonicalMount, v, "routes.collections");
|
|
605
|
+
externalRedirects = validateExternalRedirects(routes["externalRedirects"], canonicalMount, v,
|
|
606
|
+
"routes.externalRedirects");
|
|
481
607
|
}
|
|
482
608
|
const visibility = reqEnum<PublicationSpaceVisibilityV1>(root, "visibility", ["public", "internal"], v, "");
|
|
483
609
|
const authorization = validateAuthorization(root["authorization"], v, "authorization");
|
|
@@ -525,7 +651,12 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
|
|
|
525
651
|
id,
|
|
526
652
|
tenant,
|
|
527
653
|
source,
|
|
528
|
-
routes:
|
|
654
|
+
routes: {
|
|
655
|
+
canonicalMount,
|
|
656
|
+
...(aliases === undefined ? {} : { aliases }),
|
|
657
|
+
...(collections === undefined ? {} : { collections }),
|
|
658
|
+
...(externalRedirects === undefined ? {} : { externalRedirects }),
|
|
659
|
+
},
|
|
529
660
|
visibility,
|
|
530
661
|
authorization,
|
|
531
662
|
discovery,
|
|
@@ -787,6 +918,18 @@ export function validatePublicationSpaceRegistryV1(
|
|
|
787
918
|
);
|
|
788
919
|
}
|
|
789
920
|
}
|
|
921
|
+
// Precedence: a mount owns every path under it. An alias one space declares inside ANOTHER
|
|
922
|
+
// space's mount (the root's `/blog/page` once a space is mounted at `/blog`) is refused, so no
|
|
923
|
+
// route-tier or edge redirect can shadow the mounted space's own pages.
|
|
924
|
+
for (const alias of entries.filter((entry) => entry.kind === "alias")) {
|
|
925
|
+
const owner = entries.find((entry) => entry.kind === "canonical" && entry.tenant === alias.tenant &&
|
|
926
|
+
entry.spaceId !== alias.spaceId && entry.mount !== "/" &&
|
|
927
|
+
(alias.mount === entry.mount || alias.mount.startsWith(`${entry.mount}/`)));
|
|
928
|
+
if (owner) {
|
|
929
|
+
addRegistryViolation(violations, `spaces.${alias.spaceId}.routes.aliases`, "publication_alias_under_mount",
|
|
930
|
+
`Alias ${alias.mount} lies under the mount ${owner.mount} that space ${owner.spaceId} owns.`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
790
933
|
detectAliasLoops(entries, violations);
|
|
791
934
|
if (violations.length > 0) return { ok: false, violations };
|
|
792
935
|
|