@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.
@@ -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
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * A whole-site recovery: a tenant served by its public publication spaces is put back on a
3
+ * whole-site release, and later resumed onto its spaces, each in one reviewed action
4
+ * (scripts/publish/recover-publication-tenant.mjs).
5
+ *
6
+ * The Worker holds the record under `publicationRecoveryKeyV1(tenant)`. While it is present every
7
+ * public space of the tenant serves through the whole-site path instead (`legacy-root`), whatever
8
+ * its live pointer selects; the editorial state is untouched, so a resume serves exactly what was
9
+ * live before plus anything activated during the recovery. On CloudFront the same fence rides in
10
+ * the KVS `live` value (scripts/publish/lib/cloudfront-live-pointer.mjs).
11
+ *
12
+ * Every recover and resume also writes an immutable audit record under
13
+ * `publicationRecoveryAuditKeyV1`, which is never deleted.
14
+ */
15
+ import type { PublicationSpaceManifestV1 } from "./publication-space.js";
16
+ import type { PublicationVersionStateV1 } from "./publication-version.js";
17
+
18
+ export const PUBLICATION_RECOVERY_ID_RE = /^rcv_[a-z0-9][a-z0-9-]{6,78}[a-z0-9]$/;
19
+ const SHA40_RE = /^[0-9a-f]{40}$/;
20
+ const DIGEST_RE = /^[A-Za-z0-9_-]{1,128}$/;
21
+ const PRINCIPAL_RE = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/;
22
+ const INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
23
+
24
+ /** One public space as it was serving when the recovery replaced it. */
25
+ export interface PublicationRecoverySpaceV1 {
26
+ spaceId: string;
27
+ revision: number;
28
+ versionId: string;
29
+ }
30
+
31
+ export interface PublicationRecoveryV1 {
32
+ schemaVersion: 1;
33
+ tenant: string;
34
+ recoveryId: string;
35
+ /**
36
+ * The whole-site release now served: its version (a tenant commit sha) and bundle digest, each
37
+ * null when the tenant has none on that plane (a tenant whose whole site is its committed
38
+ * baseline has neither).
39
+ */
40
+ wholeSite: { versionId: string | null; digest: string | null };
41
+ /** The public spaces' live pointers it replaced, and their generation (the sum of revisions). */
42
+ fromSpaces: { generation: number; spaces: PublicationRecoverySpaceV1[] };
43
+ principal: string;
44
+ at: string;
45
+ }
46
+
47
+ export type PublicationRecoveryEventV1 = "recover" | "resume";
48
+
49
+ export function publicationRecoveryKeyV1(tenant: string): string {
50
+ return `cust:publication-recovery:${tenant}`;
51
+ }
52
+
53
+ export function publicationRecoveryAuditKeyV1(tenant: string, recoveryId: string, event: PublicationRecoveryEventV1): string {
54
+ if (!PUBLICATION_RECOVERY_ID_RE.test(recoveryId)) throw new Error("publication recovery id is malformed");
55
+ return `cust:publication-recovery-audit:${tenant}:${recoveryId}:${event}`;
56
+ }
57
+
58
+ function exactKeys(value: object, keys: readonly string[]): boolean {
59
+ const actual = Object.keys(value).sort();
60
+ const expected = [...keys].sort();
61
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
62
+ }
63
+
64
+ function nullable(value: unknown, re: RegExp): value is string | null {
65
+ return value === null || (typeof value === "string" && re.test(value));
66
+ }
67
+
68
+ export function validatePublicationRecoveryV1(value: unknown): value is PublicationRecoveryV1 {
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
70
+ const record = value as Record<string, unknown>;
71
+ if (!exactKeys(record, ["schemaVersion", "tenant", "recoveryId", "wholeSite", "fromSpaces", "principal", "at"])) return false;
72
+ const wholeSite = record.wholeSite as Record<string, unknown> | null;
73
+ const fromSpaces = record.fromSpaces as Record<string, unknown> | null;
74
+ if (record.schemaVersion !== 1 || typeof record.tenant !== "string" || !record.tenant ||
75
+ typeof record.recoveryId !== "string" || !PUBLICATION_RECOVERY_ID_RE.test(record.recoveryId) ||
76
+ typeof record.principal !== "string" || !PRINCIPAL_RE.test(record.principal) ||
77
+ typeof record.at !== "string" || !INSTANT_RE.test(record.at) || new Date(record.at).toISOString() !== record.at ||
78
+ !wholeSite || typeof wholeSite !== "object" || !exactKeys(wholeSite, ["versionId", "digest"]) ||
79
+ !nullable(wholeSite.versionId, SHA40_RE) || !nullable(wholeSite.digest, DIGEST_RE) ||
80
+ !fromSpaces || typeof fromSpaces !== "object" || !exactKeys(fromSpaces, ["generation", "spaces"]) ||
81
+ !Array.isArray(fromSpaces.spaces) || fromSpaces.spaces.length === 0 ||
82
+ !Number.isSafeInteger(fromSpaces.generation)) return false;
83
+ let sum = 0;
84
+ const ids = new Set<string>();
85
+ for (const space of fromSpaces.spaces as unknown[]) {
86
+ if (!space || typeof space !== "object" || !exactKeys(space, ["spaceId", "revision", "versionId"])) return false;
87
+ const { spaceId, revision, versionId } = space as Record<string, unknown>;
88
+ if (typeof spaceId !== "string" || !spaceId || ids.has(spaceId) || typeof versionId !== "string" || !versionId ||
89
+ !Number.isSafeInteger(revision) || (revision as number) < 1) return false;
90
+ ids.add(spaceId);
91
+ sum += revision as number;
92
+ }
93
+ return sum === fromSpaces.generation;
94
+ }
95
+
96
+ export function parsePublicationRecoveryV1Json(json: string | null): PublicationRecoveryV1 | null {
97
+ if (json === null) return null;
98
+ try {
99
+ const value = JSON.parse(json) as unknown;
100
+ return validatePublicationRecoveryV1(value) ? value : null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The tenant's public spaces as they serve on `live`, sorted by space, and their generation. This
108
+ * is what a recovery replaces and what its caller must name (a compare-and-set): for a CloudFront
109
+ * tenant it is the aggregate generation its publish wrote.
110
+ */
111
+ export function publicationServingSpacesV1(
112
+ tenant: { tenant_id: string; publicationSpaces?: readonly PublicationSpaceManifestV1[] },
113
+ state: PublicationVersionStateV1,
114
+ ): { generation: number; spaces: PublicationRecoverySpaceV1[] } {
115
+ const spaces: PublicationRecoverySpaceV1[] = [];
116
+ for (const manifest of [...(tenant.publicationSpaces ?? [])].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) {
117
+ if (manifest.visibility !== "public" || manifest.tenant !== tenant.tenant_id) continue;
118
+ const pointer = state.pointers.find((candidate) =>
119
+ candidate.tenant === tenant.tenant_id && candidate.spaceId === manifest.id && candidate.channel === "live");
120
+ if (pointer) spaces.push({ spaceId: manifest.id, revision: pointer.revision, versionId: pointer.versionId });
121
+ }
122
+ return { generation: spaces.reduce((sum, space) => sum + space.revision, 0), spaces };
123
+ }
124
+
125
+ /**
126
+ * Whether the tenant is recovered to its whole site, read fail-safe: a record that exists but does
127
+ * not validate still counts as recovered, because serving the whole site is the direction a
128
+ * recovery exists to guarantee. Only an absent record means the spaces serve.
129
+ */
130
+ export function publicationRecoveryActiveV1(json: string | null): boolean {
131
+ return json !== null;
132
+ }
@@ -12,6 +12,7 @@ import {
12
12
  publicationCollectionBaseV1,
13
13
  type PublicationCollectionV1,
14
14
  } from "./blog-visibility.mjs";
15
+ import { normalizeRedirectPath } from "./publication-redirects.mjs";
15
16
  import {
16
17
  ANNOTATION_KEYS,
17
18
  type ContractResult,
@@ -113,6 +114,14 @@ export interface PublicationSpaceAliasV1 {
113
114
  to?: string;
114
115
  }
115
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
+
116
125
  export interface PublicationSpaceManifestV1 {
117
126
  version: typeof PUBLICATION_SPACE_MANIFEST_VERSION;
118
127
  id: string;
@@ -127,6 +136,11 @@ export interface PublicationSpaceManifestV1 {
127
136
  * it by `publicationCollectionPathV1`.
128
137
  */
129
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[];
130
144
  };
131
145
  visibility: PublicationSpaceVisibilityV1;
132
146
  authorization:
@@ -500,6 +514,62 @@ function validateCollections(
500
514
  return Object.keys(out).length ? out : undefined;
501
515
  }
502
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
+
503
573
  /** Validate, close, and normalize one serialized publication-space manifest. */
504
574
  export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult<PublicationSpaceManifestV1> {
505
575
  const v = new Violations();
@@ -519,8 +589,10 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
519
589
  let canonicalMount: string | undefined;
520
590
  let aliases: PublicationSpaceAliasV1[] | undefined;
521
591
  let collections: PublicationSpaceManifestV1["routes"]["collections"];
592
+ let externalRedirects: PublicationSpaceExternalRedirectV1[] | undefined;
522
593
  if (routes) {
523
- rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases", "collections"], v, "routes");
594
+ rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases", "collections", "externalRedirects"], v,
595
+ "routes");
524
596
  const rawMount = reqString(routes, "canonicalMount", v, "routes");
525
597
  canonicalMount = rawMount
526
598
  ? append(v, "routes.canonicalMount", normalizePublicationPath(rawMount, { mode: "mount" }))
@@ -530,6 +602,8 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
530
602
  }
531
603
  aliases = validateAliases(routes["aliases"], canonicalMount, v, "routes.aliases");
532
604
  collections = validateCollections(routes["collections"], canonicalMount, v, "routes.collections");
605
+ externalRedirects = validateExternalRedirects(routes["externalRedirects"], canonicalMount, v,
606
+ "routes.externalRedirects");
533
607
  }
534
608
  const visibility = reqEnum<PublicationSpaceVisibilityV1>(root, "visibility", ["public", "internal"], v, "");
535
609
  const authorization = validateAuthorization(root["authorization"], v, "authorization");
@@ -581,6 +655,7 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
581
655
  canonicalMount,
582
656
  ...(aliases === undefined ? {} : { aliases }),
583
657
  ...(collections === undefined ? {} : { collections }),
658
+ ...(externalRedirects === undefined ? {} : { externalRedirects }),
584
659
  },
585
660
  visibility,
586
661
  authorization,