@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.
Files changed (32) hide show
  1. package/apps/storefront/src/components/blog/BlogIndexView.astro +10 -9
  2. package/apps/storefront/src/components/blog/PostGrid.astro +6 -5
  3. package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -8
  4. package/apps/storefront/src/config/publicationSpaceConfig.ts +6 -1
  5. package/apps/storefront/src/config/publicationSpaceRegistry.ts +1 -0
  6. package/apps/storefront/src/config/publicationSpaceTopology.ts +22 -1
  7. package/apps/storefront/src/lib/blog/collectionPaths.ts +46 -0
  8. package/apps/storefront/src/lib/blog/pagination.ts +6 -16
  9. package/apps/storefront/src/lib/publication/contentProvider.ts +21 -4
  10. package/apps/storefront/src/lib/publication/mountedDiscovery.ts +4 -1
  11. package/apps/storefront/src/lib/publication/rootDiscovery.ts +7 -4
  12. package/apps/storefront/src/lib/the-build/pagination.ts +3 -8
  13. package/apps/storefront/src/middleware/index.ts +19 -7
  14. package/apps/storefront/src/middleware/renderChain.ts +19 -0
  15. package/apps/storefront/src/pages/blog/[slug].astro +14 -12
  16. package/apps/storefront/src/pages/blog/author/[author].astro +8 -6
  17. package/apps/storefront/src/pages/blog/category/[category].astro +6 -4
  18. package/apps/storefront/src/pages/blog/tag/[tag].astro +6 -4
  19. package/apps/storefront/src/pages/llms.txt.ts +6 -4
  20. package/apps/storefront/src/pages/sitemap.xml.ts +12 -9
  21. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +58 -0
  22. package/apps/storefront/src/pages/the-build/[slug].astro +9 -7
  23. package/package.json +1 -1
  24. package/packages/public-runtime/package.json +1 -0
  25. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +25 -0
  26. package/packages/public-runtime/src/blog-visibility.d.mts +20 -0
  27. package/packages/public-runtime/src/blog-visibility.mjs +87 -0
  28. package/packages/public-runtime/src/publication-artifact.ts +278 -46
  29. package/packages/public-runtime/src/publication-content-redirects.ts +61 -0
  30. package/packages/public-runtime/src/publication-reader.ts +89 -77
  31. package/packages/public-runtime/src/publication-routing.ts +15 -1
  32. package/packages/public-runtime/src/publication-space.ts +145 -2
@@ -1,12 +1,13 @@
1
1
  import {
2
- publicationArtifactBundleKeyV1,
3
- publicationArtifactFileBytesV1,
4
- publicationArtifactKeyV1,
5
- validatePublicationArtifactBundleV1,
6
- type PublicationArtifactBundleV1,
7
- type PublicationArtifactFileV1,
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 interface PublicationStoredObjectV1 {
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 interface PublicationArtifactBucketV1 {
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
- bundle: PublicationArtifactBundleV1;
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
- /** Read bytes relative to this space's declared asset namespace. */
47
- readAsset(relativePath: string): Uint8Array | null;
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
- let marker: PublicationStoredObjectV1 | null;
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 (!checked.ok) return { ok: false, code: "publication_artifact_marker_malformed" };
101
+ if (!admitted.ok) return { ok: false, code: admitted.code };
102
+ const { index } = admitted;
125
103
 
126
- const physical = new Map<string, Uint8Array>();
127
- for (const file of checked.value.files) {
128
- const expected = publicationArtifactFileBytesV1(file);
129
- if (!expected) return { ok: false, code: "publication_artifact_marker_malformed" };
130
- let stored: PublicationStoredObjectV1 | null;
131
- try {
132
- stored = await bucket.get(publicationArtifactKeyV1(
133
- manifest.tenant,
134
- manifest.id,
135
- publicationVersion.versionId,
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
- actual = new Uint8Array(await stored.arrayBuffer());
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
- bundle: checked.value,
129
+ index,
166
130
  readContent(relativePath) {
167
131
  if (!safeRelativePath(relativePath)) return null;
168
- const path = `content/${relativePath}`;
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
- return physical.get(`${assetLane}\0${assetPrefix}${relativePath}`) ?? null;
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 and Astro dispatch. */
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"], v, "routes");
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: aliases === undefined ? { canonicalMount } : { canonicalMount, aliases },
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