@tokenoftrust/storefront-runner 2.4.24 → 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.
@@ -62,8 +62,23 @@ export class PublicationSpaceContentProvider implements ContentProvider {
62
62
  constructor(
63
63
  private readonly sharedRoot: ContentProvider,
64
64
  private readonly publication: OpenPublicationArtifactV1,
65
+ /**
66
+ * A discovery document (sitemap, feeds, search index, llms.txt) lists every page of the space,
67
+ * so it reads any of them; a page request reads only the page its plan selected.
68
+ */
69
+ private readonly wholeSpace = false,
65
70
  ) {}
66
71
 
72
+ /** The space-relative slug a page request may read, or null. `""` is the space's home. */
73
+ private readableSlug(slug: string): string | null {
74
+ if (!this.wholeSpace) return selectedPageSlug(this.plan, slug);
75
+ const requested = stripSlashes(slug);
76
+ const mount = stripSlashes(this.plan.manifest.routes.canonicalMount);
77
+ if (!mount) return requested;
78
+ if (requested === mount) return "";
79
+ return requested.startsWith(`${mount}/`) ? requested.slice(mount.length + 1) : null;
80
+ }
81
+
67
82
  private get plan(): PublicationRouteRenderPlanV1 {
68
83
  return this.publication.selection;
69
84
  }
@@ -86,7 +101,7 @@ export class PublicationSpaceContentProvider implements ContentProvider {
86
101
  }
87
102
 
88
103
  async getPageHtml(slug: string): Promise<string | null> {
89
- const selected = selectedPageSlug(this.plan, slug);
104
+ const selected = this.readableSlug(slug);
90
105
  if (selected === null) return null;
91
106
  const raw = this.publication.readContent(
92
107
  selected ? `pages-html/${selected}.html` : "home.html",
@@ -122,10 +137,11 @@ export class PublicationSpaceContentProvider implements ContentProvider {
122
137
  }
123
138
 
124
139
  async getPage(slug: string): Promise<EditorialPage | null> {
125
- const selected = selectedPageSlug(this.plan, slug);
140
+ const selected = this.readableSlug(slug);
126
141
  if (!selected) return null;
127
142
  const page = (await this.sourcePages()).find((candidate) => candidate.slug === selected);
128
- return page ? rebasePage(this.plan, page, stripSlashes(this.plan.canonicalPath)) : null;
143
+ if (!page) return null;
144
+ return this.wholeSpace ? rebasePage(this.plan, page) : rebasePage(this.plan, page, stripSlashes(this.plan.canonicalPath));
129
145
  }
130
146
 
131
147
  async listPages(): Promise<EditorialPage[]> {
@@ -152,8 +168,9 @@ export class PublicationSpaceContentProvider implements ContentProvider {
152
168
  export function bindPublicationSpaceContentProviderV1(
153
169
  sharedRoot: ContentProvider,
154
170
  publication?: OpenPublicationArtifactV1,
171
+ options: { wholeSpace?: boolean } = {},
155
172
  ): ContentProvider {
156
173
  return publication
157
- ? new PublicationSpaceContentProvider(sharedRoot, publication)
174
+ ? new PublicationSpaceContentProvider(sharedRoot, publication, options.wholeSpace ?? false)
158
175
  : sharedRoot;
159
176
  }
@@ -25,7 +25,7 @@ import { getTenantResolver } from "@/config/resolver";
25
25
  import { readEnv, readKv, readR2 } from "@/lib/runtimeEnv";
26
26
  import { resolveCdnForwardRouting, resolveTenantRouting } from "@/lib/tenantRouting";
27
27
  import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/cdnForward";
28
- import { compileRouteTierIndexV1, type RouteTierIndexV1 } from "@tot/public-runtime";
28
+ import { compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 } from "@tot/public-runtime";
29
29
  import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
30
30
  import { DEV_DRAFT_COOKIE } from "@/lib/dev/devDraft";
31
31
  import { applySecurityHeaders } from "@/lib/securityHeaders";
@@ -39,6 +39,7 @@ import { HEALTH_PATH, healthResponse } from "@/lib/health";
39
39
  import { observeDeployVersion } from "@/lib/activity/deployVersion";
40
40
  import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
41
41
  import {
42
+ isPlatformPublicDiscoveryPath,
42
43
  platformStaticRootLocationV1,
43
44
  publicationRedirectLocationV1,
44
45
  resolveMiddlewareBranchV1,
@@ -314,6 +315,14 @@ async function serveRequest(
314
315
  return asset;
315
316
  }
316
317
 
318
+ // A tenant's discovery documents (sitemap, feeds, search index, llms.txt, robots) are the root's:
319
+ // when the root is a publication space, they render from its selected version, as its pages do.
320
+ const discoveryRoot = isPlatformPublicDiscoveryPath(renderPath)
321
+ ? resolvePublicationRoutePlanV1(routeTierIndexFor(tenant), "/")
322
+ : null;
323
+ const publicationSourceRoute = discoveryRoot?.action === "render" ? discoveryRoot : publication;
324
+ const publicationSourcePath = discoveryRoot?.action === "render" ? "/" : renderPath;
325
+
317
326
  if (branch === "public-render") {
318
327
  return await servePublicRender({
319
328
  context,
@@ -326,9 +335,11 @@ async function serveRequest(
326
335
  cache,
327
336
  kv,
328
337
  routeTierIndex: routeTierIndexFor(tenant),
329
- publicationRequestPath: renderPath,
330
- publicationRoute: publication,
331
- publicationBucket,
338
+ publicationRequestPath: publicationSourcePath,
339
+ publicationRoute: publicationSourceRoute,
340
+ publicationBucket: discoveryRoot?.action === "render"
341
+ ? await readR2("TENANT_ASSETS") as unknown as PublicationArtifactBucketV1 | undefined
342
+ : publicationBucket,
332
343
  });
333
344
  }
334
345
 
@@ -14,6 +14,7 @@ import { mergeTheme, themeToCssVars } from "@/themes/schema";
14
14
  import { createToTClient } from "@/lib/tot/ToTClient";
15
15
  import { readEnv, readR2 } from "@/lib/runtimeEnv";
16
16
  import { tryServeStaticBundle } from "./staticBundle.js";
17
+ import { isPlatformPublicDiscoveryPath } from "./routeBranch.js";
17
18
  import { resolveCandidateContentPin, type CandidateContentPin } from "./candidatePin.js";
18
19
  import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
19
20
  import { bindPublicationSpaceContentProviderV1 } from "@/lib/publication/contentProvider.js";
@@ -82,6 +83,7 @@ import {
82
83
  HTML_CACHE_POLICY_CAPTURE_HEADER,
83
84
  HTML_CACHE_POLICY_CAPTURE_PROBE,
84
85
  } from "@tot/public-runtime";
86
+ import { publicationContentRedirectLocationV1 } from "@tot/public-runtime/publication-content-redirects";
85
87
  import { loadTenantIntegrationDeclarations } from "@/lib/integration/tenantDeclarations";
86
88
  import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
87
89
  import {
@@ -236,6 +238,21 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
236
238
  if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
237
239
  return response;
238
240
  }
241
+ // The owning space's content-declared redirects answer before anything renders, as the CloudFront
242
+ // redirect plane does for the same declarations: a same-origin target keeps the path-routing prefix
243
+ // and the query; an off-site target (the root space only) is sent as declared.
244
+ if (publicationSource.status === "selected") {
245
+ const location = publicationContentRedirectLocationV1(publicationSource.artifact, input.publicationRequestPath);
246
+ if (location) {
247
+ const response = new Response(null, {
248
+ status: 308,
249
+ headers: { location: location.startsWith("/") ? `${url.origin}${basePath}${location}${url.search}` : location },
250
+ });
251
+ applySecurityHeaders(response.headers);
252
+ if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
253
+ return response;
254
+ }
255
+ }
239
256
 
240
257
  // The tenant as it renders in THIS environment: its record with the declared
241
258
  // `.tot/config.json` fields applied (compliance, siteType, capabilities,
@@ -613,6 +630,8 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
613
630
  locals.content = bindPublicationSpaceContentProviderV1(
614
631
  sharedRootContent,
615
632
  publicationSource.status === "selected" ? publicationSource.artifact : undefined,
633
+ // A discovery document lists the whole space it is bound to, not one selected page.
634
+ { wholeSpace: isPlatformPublicDiscoveryPath(renderPath) },
616
635
  );
617
636
  // Developer-draft content overlay — serves the AI-edited artifacts to the draft
618
637
  // owner only; every other viewer keeps the published/base chain above.
@@ -17,6 +17,10 @@
17
17
  * Preview sessions also consult the image-capture rewrite overlay so rewritten
18
18
  * `public/*.css` (and similar text assets) serve `/_a/<sha>` references.
19
19
  *
20
+ * A tenant whose ROOT is a publication space serves these paths from that space's selected version
21
+ * (its public asset lane, `<namespace>/<path>`), the same URL a whole-site tenant serves them at;
22
+ * otherwise the legacy asset pointer below answers.
23
+ *
20
24
  * Version selection, in order: a candidate/revision pin (`tot_preview_version` +
21
25
  * token cookie); else, for a shared-preview viewer (preview host, or a valid token
22
26
  * for this tenant), the version the content `preview` channel serves — what the
@@ -24,11 +28,18 @@
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";
33
44
  import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
34
45
  import { readR2, readKv, readEnv } from "@/lib/env";
@@ -68,6 +79,42 @@ async function tryServePreviewRewriteOverlay(input: {
68
79
  });
69
80
  }
70
81
 
82
+ /** The root publication space's asset, or null when the tenant's root is not a selected space. */
83
+ async function tryServePublicationRootAsset(input: {
84
+ /** The tenant the request resolved to (`Astro.locals.tenant`). */
85
+ tenant: TenantConfig | undefined;
86
+ bucket: PublicationArtifactReadBucketV1;
87
+ kvGet: (key: string) => Promise<string | null>;
88
+ tenantId: string;
89
+ pathname: string;
90
+ channel: "live" | "preview";
91
+ ifNoneMatch: string | null;
92
+ }): Promise<Response | null> {
93
+ const parsed = parseTenantAssetPath(input.pathname);
94
+ if (!parsed || parsed.tenantId !== input.tenantId) return null;
95
+ const tenant = input.tenant;
96
+ if (!tenant || tenant.tenant_id !== input.tenantId ||
97
+ !tenant.publicationSpaces?.some((space) => space.routes.canonicalMount === "/")) return null;
98
+ const stateJson = await input.kvGet(publicationVersionStateKey(input.tenantId));
99
+ if (stateJson === null) return null;
100
+ const state = parsePublicationVersionStateV1Json(stateJson);
101
+ if (!state.ok) return null;
102
+ const read = await readPublicationRootAssetV1({
103
+ index: compileRouteTierIndexV1(tenant), state: state.value, channel: input.channel,
104
+ bucket: input.bucket, relativePath: parsed.rel,
105
+ });
106
+ if (read.status === "legacy-root") return null;
107
+ if (read.status === "absent") {
108
+ return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
109
+ }
110
+ const etag = `"${read.sha256}"`;
111
+ const headers = { "content-type": assetContentType(parsed.rel), "cache-control": ASSET_CACHE_CONTROL, etag };
112
+ if (input.ifNoneMatch && input.ifNoneMatch.split(",").some((value) => value.trim().replace(/^W\//, "") === etag)) {
113
+ return new Response(null, { status: 304, headers });
114
+ }
115
+ return new Response(read.bytes as Uint8Array<ArrayBuffer>, { status: 200, headers });
116
+ }
117
+
71
118
  export async function GET(context: APIContext): Promise<Response> {
72
119
  const [bucket, kv] = await Promise.all([
73
120
  readR2("TENANT_ASSETS"),
@@ -106,6 +153,17 @@ export async function GET(context: APIContext): Promise<Response> {
106
153
  const previewVersionId = pinnedVersionId ?? sharedPreview.versionId;
107
154
 
108
155
  const pathname = url.pathname;
156
+ const fromPublication = await tryServePublicationRootAsset({
157
+ tenant: context.locals.tenant,
158
+ bucket: bucket as unknown as PublicationArtifactReadBucketV1,
159
+ kvGet: (key) => kv.get(key),
160
+ tenantId: urlTenant,
161
+ pathname,
162
+ channel: sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live",
163
+ ifNoneMatch: context.request.headers.get("if-none-match"),
164
+ });
165
+ if (fromPublication) return fromPublication;
166
+
109
167
  if (previewVersionId) {
110
168
  const overlay = await tryServePreviewRewriteOverlay({
111
169
  kvGet: (key) => kv.get(key),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.24",
3
+ "version": "2.4.25",
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,7 @@
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",
23
24
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
24
25
  "./declared-tenant-config": "./src/declared-tenant-config.mjs",
25
26
  "./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
  },
@@ -42,8 +42,8 @@ export interface PublicationArtifactBundleExpectationV1 {
42
42
 
43
43
  /** Locale-independent canonical order for immutable publication files. */
44
44
  export function comparePublicationArtifactFilesV1(
45
- left: PublicationArtifactFileV1,
46
- right: PublicationArtifactFileV1,
45
+ left: Pick<PublicationArtifactFileV1, "lane" | "path">,
46
+ right: Pick<PublicationArtifactFileV1, "lane" | "path">,
47
47
  ): number {
48
48
  const leftIdentity = `${left.lane}\0${left.path}`;
49
49
  const rightIdentity = `${right.lane}\0${right.path}`;
@@ -96,10 +96,28 @@ export function publicationArtifactFileBytesV1(file: PublicationArtifactFileV1):
96
96
  }
97
97
  }
98
98
 
99
+ /** The identity fields a bundle or its index must carry for `expected`. */
100
+ function identityMatches(
101
+ value: Omit<PublicationArtifactBundleV1, "files">,
102
+ expected: PublicationArtifactBundleExpectationV1,
103
+ ): boolean {
104
+ return value.tenant === expected.tenant && value.spaceId === expected.spaceId &&
105
+ value.versionId === expected.versionId && value.sourceSha === expected.sourceSha &&
106
+ /^[0-9a-f]{40}$/.test(value.sourceSha) && value.artifactDigest === expected.artifactDigest &&
107
+ /^[0-9a-f]{64}$/.test(value.artifactDigest) && typeof value.sourceLock === "string" &&
108
+ /^[0-9a-f]{64}$/.test(value.sourceLock) &&
109
+ ["public", "internal"].includes(value.visibility) && typeof value.assetNamespace === "string" &&
110
+ !!value.assetNamespace && ["public", "authorized"].includes(value.assetAccess) &&
111
+ (expected.visibility === undefined || value.visibility === expected.visibility) &&
112
+ (expected.assetNamespace === undefined || value.assetNamespace === expected.assetNamespace) &&
113
+ (expected.assetAccess === undefined || value.assetAccess === expected.assetAccess);
114
+ }
115
+
99
116
  /**
100
- * Canonical marker admission shared by the writer-side activation gate and the
101
- * renderer. It validates shape, identity, source provenance/lock, and recomputes
102
- * the complete file-list digest; a marker cannot define its own admission facts.
117
+ * Full in-memory bundle admission: the producer side (a freshly built version, a CI composition)
118
+ * holds every file's bytes. It validates shape, identity and source lock, and recomputes the digest;
119
+ * a bundle cannot define its own admission facts. A stored artifact is admitted by its index
120
+ * (`validatePublicationArtifactIndexV1`).
103
121
  */
104
122
  export async function validatePublicationArtifactBundleV1(
105
123
  value: unknown,
@@ -109,17 +127,7 @@ export async function validatePublicationArtifactBundleV1(
109
127
  const bundle = value as PublicationArtifactBundleV1;
110
128
  if (!hasExactKeys(bundle, ["tenant", "spaceId", "versionId", "sourceSha", "artifactDigest",
111
129
  "sourceLock", "visibility", "assetNamespace", "assetAccess", "files"]) ||
112
- bundle.tenant !== expected.tenant || bundle.spaceId !== expected.spaceId ||
113
- bundle.versionId !== expected.versionId || bundle.sourceSha !== expected.sourceSha ||
114
- !/^[0-9a-f]{40}$/.test(bundle.sourceSha) || bundle.artifactDigest !== expected.artifactDigest ||
115
- !/^[0-9a-f]{64}$/.test(bundle.artifactDigest) || typeof bundle.sourceLock !== "string" ||
116
- !/^[0-9a-f]{64}$/.test(bundle.sourceLock) ||
117
- !["public", "internal"].includes(bundle.visibility) || typeof bundle.assetNamespace !== "string" ||
118
- !bundle.assetNamespace ||
119
- !["public", "authorized"].includes(bundle.assetAccess) || !Array.isArray(bundle.files) ||
120
- (expected.visibility !== undefined && bundle.visibility !== expected.visibility) ||
121
- (expected.assetNamespace !== undefined && bundle.assetNamespace !== expected.assetNamespace) ||
122
- (expected.assetAccess !== undefined && bundle.assetAccess !== expected.assetAccess)) return { ok: false };
130
+ !identityMatches(bundle, expected) || !Array.isArray(bundle.files)) return { ok: false };
123
131
  const paths = new Set<string>();
124
132
  for (const file of bundle.files) {
125
133
  if (!validPublicationArtifactFileV1(file) || !publicationArtifactFileBytesV1(file)) {
@@ -148,23 +156,114 @@ export async function validatePublicationArtifactBundleV1(
148
156
  }
149
157
  }
150
158
 
159
+ /**
160
+ * One file as the artifact INDEX records it: its identity and the sha256 of its bytes, never the
161
+ * bytes themselves. The artifact digest is computed over exactly these rows, so an index proves
162
+ * its version without carrying any content.
163
+ */
164
+ export interface PublicationArtifactIndexFileV1 {
165
+ lane: PublicationArtifactFileV1["lane"];
166
+ path: string;
167
+ encoding: PublicationArtifactFileV1["encoding"];
168
+ byteSize: number;
169
+ sha256: string;
170
+ }
171
+
172
+ /**
173
+ * The stored marker for one tenant+space+version: the bundle's identity and its file index. It grows
174
+ * with the number of files, never with their bytes; each file's bytes are their own object, addressed
175
+ * by sha256 (`publicationArtifactObjectKeyV1`), and a reader fetches only the files it serves.
176
+ */
177
+ export interface PublicationArtifactIndexV1 extends Omit<PublicationArtifactBundleV1, "files"> {
178
+ files: PublicationArtifactIndexFileV1[];
179
+ }
180
+
181
+ /** The digest over index rows: the one formula the writer, the index validator and readers share. */
182
+ async function publicationArtifactIndexDigestV1(files: readonly PublicationArtifactIndexFileV1[]): Promise<string> {
183
+ const rows = [...files].sort(comparePublicationArtifactFilesV1)
184
+ .map((file) => `${file.lane}\0${file.path}\0${file.encoding}\0${file.byteSize}\0${file.sha256}`);
185
+ return sha256Hex(rows.join("\n"));
186
+ }
187
+
188
+ /** The index row for one complete file. */
189
+ export async function publicationArtifactIndexFileV1(
190
+ file: PublicationArtifactFileV1,
191
+ ): Promise<PublicationArtifactIndexFileV1> {
192
+ const bytes = publicationArtifactFileBytesV1(file);
193
+ if (!bytes) throw new Error("publication artifact file is malformed");
194
+ return { lane: file.lane, path: file.path, encoding: file.encoding, byteSize: file.byteSize,
195
+ sha256: await sha256Hex(bytes) };
196
+ }
197
+
151
198
  /** Canonical content digest shared by the trusted writer and renderer readback. */
152
199
  export async function publicationArtifactDigestV1(
153
200
  files: readonly PublicationArtifactFileV1[],
154
201
  ): Promise<string> {
155
202
  if (!Array.isArray(files)) throw new Error("publication artifact file list is malformed");
156
- const ordered = [...files].sort(comparePublicationArtifactFilesV1);
157
203
  const paths = new Set<string>();
158
- const rows = await Promise.all(ordered.map(async (file) => {
204
+ const rows = await Promise.all(files.map(async (file) => {
159
205
  if (!validPublicationArtifactFileV1(file) || paths.has(file.path)) {
160
206
  throw new Error("publication artifact file is malformed or duplicated");
161
207
  }
162
208
  paths.add(file.path);
163
- const bytes = publicationArtifactFileBytesV1(file);
164
- if (!bytes) throw new Error("publication artifact file is malformed");
165
- return `${file.lane}\0${file.path}\0${file.encoding}\0${file.byteSize}\0${await sha256Hex(bytes)}`;
209
+ return publicationArtifactIndexFileV1(file);
166
210
  }));
167
- return sha256Hex(rows.join("\n"));
211
+ return publicationArtifactIndexDigestV1(rows);
212
+ }
213
+
214
+ /** The index a complete bundle stores as its marker. */
215
+ export async function publicationArtifactIndexV1(
216
+ bundle: PublicationArtifactBundleV1,
217
+ ): Promise<PublicationArtifactIndexV1> {
218
+ const { files, ...identity } = bundle;
219
+ const rows = await Promise.all([...files].sort(comparePublicationArtifactFilesV1)
220
+ .map((file) => publicationArtifactIndexFileV1(file)));
221
+ return { ...identity, files: rows };
222
+ }
223
+
224
+ function validIndexFile(value: unknown): value is PublicationArtifactIndexFileV1 {
225
+ if (!value || typeof value !== "object" || !hasExactKeys(value,
226
+ ["lane", "path", "encoding", "byteSize", "sha256"])) return false;
227
+ const file = value as PublicationArtifactIndexFileV1;
228
+ return ["content", "public-asset", "authorized-asset"].includes(file.lane) &&
229
+ ["utf8", "base64"].includes(file.encoding) && typeof file.path === "string" &&
230
+ safeArtifactPath(file.path) && Number.isSafeInteger(file.byteSize) && file.byteSize >= 0 &&
231
+ typeof file.sha256 === "string" && /^[0-9a-f]{64}$/.test(file.sha256);
232
+ }
233
+
234
+ /**
235
+ * Admission for a stored index, shared by review admission, the renderer and every off-path reader.
236
+ * It validates shape and identity, recomputes the artifact digest from the rows and the source lock
237
+ * from that digest: an index cannot define its own admission facts, exactly as a full bundle could not.
238
+ */
239
+ export async function validatePublicationArtifactIndexV1(
240
+ value: unknown,
241
+ expected: PublicationArtifactBundleExpectationV1,
242
+ ): Promise<{ ok: true; value: PublicationArtifactIndexV1 } | { ok: false }> {
243
+ if (!value || typeof value !== "object") return { ok: false };
244
+ const index = value as PublicationArtifactIndexV1;
245
+ if (!hasExactKeys(index, ["tenant", "spaceId", "versionId", "sourceSha", "artifactDigest",
246
+ "sourceLock", "visibility", "assetNamespace", "assetAccess", "files"]) ||
247
+ !identityMatches(index, expected) || !Array.isArray(index.files)) return { ok: false };
248
+ const paths = new Set<string>();
249
+ for (const file of index.files) {
250
+ if (!validIndexFile(file)) return { ok: false };
251
+ if ((file.lane === "public-asset" && index.assetAccess !== "public") ||
252
+ (file.lane === "authorized-asset" && index.assetAccess !== "authorized")) return { ok: false };
253
+ if (paths.has(file.path)) return { ok: false };
254
+ paths.add(file.path);
255
+ }
256
+ try {
257
+ const digest = await publicationArtifactIndexDigestV1(index.files);
258
+ if (digest !== expected.artifactDigest) return { ok: false };
259
+ const sourceLock = await publicationArtifactSourceLockV1({
260
+ tenant: expected.tenant, spaceId: expected.spaceId, forge: expected.forge, repo: expected.repo,
261
+ sourceSha: expected.sourceSha, versionId: expected.versionId, artifactDigest: digest,
262
+ });
263
+ return sourceLock === index.sourceLock ? { ok: true, value: index } : { ok: false };
264
+ } catch {
265
+ return { ok: false };
266
+ }
168
267
  }
169
268
 
170
269
  /** Canonical source lock binds one immutable artifact to its configured source. */
@@ -183,41 +282,174 @@ export function publicationArtifactSourceLockV1(input: {
183
282
  ].join("\0"));
184
283
  }
185
284
 
186
- /** Physical immutable namespace. Logical artifact paths are always relative. */
187
- export function publicationArtifactKeyV1(
188
- tenant: string,
189
- spaceId: string,
190
- versionId: string,
191
- lane: PublicationArtifactFileV1["lane"],
192
- relativePath: string,
193
- ): string {
285
+ function assertSafeIdentity(tenant: string, spaceId: string, versionId: string): void {
194
286
  if (!PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(tenant) ||
195
287
  !PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(spaceId) ||
196
288
  !PUBLICATION_ARTIFACT_VERSION_RE.test(versionId)) {
197
289
  throw new Error("publication artifact identity is unsafe");
198
290
  }
199
- if (!["content", "public-asset", "authorized-asset"].includes(lane) ||
200
- !relativePath || relativePath.startsWith("/") || relativePath.includes("\\") ||
201
- relativePath.includes("\0") ||
202
- relativePath.split("/").some((part) => !part || part === "." || part === "..")) {
203
- throw new Error("publication artifact path is unsafe");
204
- }
205
- return `publications/${tenant}/${spaceId}/${versionId}/${lane}/${relativePath}`;
206
291
  }
207
292
 
208
- /** The largest `bundle.json` marker review admission reads; a larger one is never built. */
209
- export const PUBLICATION_ARTIFACT_MARKER_MAX_BYTES = 16 * 1024 * 1024;
293
+ /**
294
+ * Physical address of one file's bytes: its sha256 under the version. A tenant's file names never
295
+ * reach an object key, so a key is ASCII by construction and no tool between writer and reader can
296
+ * re-encode it (a store CLI percent-encodes a non-ASCII key, which strands the object under a name
297
+ * the reader never asks for).
298
+ */
299
+ export function publicationArtifactObjectKeyV1(
300
+ tenant: string,
301
+ spaceId: string,
302
+ versionId: string,
303
+ sha256: string,
304
+ ): string {
305
+ assertSafeIdentity(tenant, spaceId, versionId);
306
+ if (!/^[0-9a-f]{64}$/.test(sha256)) throw new Error("publication artifact object address is unsafe");
307
+ return `publications/${tenant}/${spaceId}/${versionId}/objects/${sha256}`;
308
+ }
210
309
 
211
- /** Marker address for one immutable tenant+space+version bundle. */
212
- export function publicationArtifactBundleKeyV1(
310
+ /**
311
+ * The largest index review admission and readers accept. An index row costs about 200 bytes, so this
312
+ * admits tens of thousands of files whatever their size.
313
+ */
314
+ export const PUBLICATION_ARTIFACT_INDEX_MAX_BYTES = 16 * 1024 * 1024;
315
+
316
+ /** Index (marker) address for one immutable tenant+space+version artifact. */
317
+ export function publicationArtifactIndexKeyV1(
213
318
  tenant: string,
214
319
  spaceId: string,
215
320
  versionId: string,
216
321
  ): string {
217
- if (!PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(tenant) ||
218
- !PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(spaceId) ||
219
- !PUBLICATION_ARTIFACT_VERSION_RE.test(versionId)) {
220
- throw new Error("publication artifact identity is unsafe");
322
+ assertSafeIdentity(tenant, spaceId, versionId);
323
+ return `publications/${tenant}/${spaceId}/${versionId}/index.json`;
324
+ }
325
+
326
+ /**
327
+ * Every object one bundle stores, in write order: one per distinct file content (addressed by
328
+ * sha256), then its index. The single definition of the stored layout, shared by the writer and
329
+ * every reader's test fixtures.
330
+ */
331
+ export async function publicationArtifactObjectsV1(
332
+ bundle: PublicationArtifactBundleV1,
333
+ ): Promise<Array<{ key: string; bytes: Uint8Array }>> {
334
+ const index = await publicationArtifactIndexV1(bundle);
335
+ const objects = new Map<string, Uint8Array>();
336
+ for (const file of bundle.files) {
337
+ const bytes = publicationArtifactFileBytesV1(file);
338
+ if (!bytes) throw new Error("publication bundle contained malformed file bytes");
339
+ const key = publicationArtifactObjectKeyV1(bundle.tenant, bundle.spaceId, bundle.versionId, await sha256Hex(bytes));
340
+ if (!objects.has(key)) objects.set(key, bytes);
341
+ }
342
+ return [...[...objects].map(([key, bytes]) => ({ key, bytes })), {
343
+ key: publicationArtifactIndexKeyV1(bundle.tenant, bundle.spaceId, bundle.versionId),
344
+ bytes: new TextEncoder().encode(JSON.stringify(index)),
345
+ }];
346
+ }
347
+
348
+ /** One stored object, as R2 and test stores both expose it. */
349
+ export interface PublicationArtifactStoredObjectV1 {
350
+ text(): Promise<string>;
351
+ arrayBuffer(): Promise<ArrayBuffer>;
352
+ size?: number;
353
+ }
354
+
355
+ /** Provider-neutral object-store read surface. */
356
+ export interface PublicationArtifactReadBucketV1 {
357
+ get(key: string): Promise<PublicationArtifactStoredObjectV1 | null>;
358
+ }
359
+
360
+ export type PublicationArtifactIndexReadV1 =
361
+ | { ok: true; index: PublicationArtifactIndexV1 }
362
+ | { ok: false; code: "publication_artifact_marker_missing" | "publication_artifact_marker_malformed" |
363
+ "publication_artifact_source_unavailable" };
364
+
365
+ /** Read and admit the index for `expected`. Never reads a file's bytes. */
366
+ export async function readPublicationArtifactIndexV1(
367
+ bucket: PublicationArtifactReadBucketV1,
368
+ expected: PublicationArtifactBundleExpectationV1,
369
+ ): Promise<PublicationArtifactIndexReadV1> {
370
+ let stored: PublicationArtifactStoredObjectV1 | null;
371
+ try {
372
+ stored = await bucket.get(publicationArtifactIndexKeyV1(expected.tenant, expected.spaceId, expected.versionId));
373
+ } catch {
374
+ return { ok: false, code: "publication_artifact_source_unavailable" };
375
+ }
376
+ if (!stored) return { ok: false, code: "publication_artifact_marker_missing" };
377
+ if (typeof stored.size === "number" && stored.size > PUBLICATION_ARTIFACT_INDEX_MAX_BYTES) {
378
+ return { ok: false, code: "publication_artifact_marker_malformed" };
379
+ }
380
+ let value: unknown;
381
+ try {
382
+ const text = await stored.text();
383
+ if (new TextEncoder().encode(text).byteLength > PUBLICATION_ARTIFACT_INDEX_MAX_BYTES) {
384
+ return { ok: false, code: "publication_artifact_marker_malformed" };
385
+ }
386
+ value = JSON.parse(text);
387
+ } catch {
388
+ return { ok: false, code: "publication_artifact_marker_malformed" };
389
+ }
390
+ const checked = await validatePublicationArtifactIndexV1(value, expected);
391
+ return checked.ok ? { ok: true, index: checked.value } : { ok: false, code: "publication_artifact_marker_malformed" };
392
+ }
393
+
394
+ export type PublicationArtifactFileReadV1 =
395
+ | { ok: true; bytes: Uint8Array }
396
+ | { ok: false; code: "publication_artifact_file_missing" | "publication_artifact_file_mismatch" |
397
+ "publication_artifact_source_unavailable" };
398
+
399
+ /** Fetch one indexed file's bytes and prove them against its row (length and sha256). */
400
+ export async function readPublicationArtifactFileV1(
401
+ bucket: PublicationArtifactReadBucketV1,
402
+ index: Pick<PublicationArtifactIndexV1, "tenant" | "spaceId" | "versionId">,
403
+ file: PublicationArtifactIndexFileV1,
404
+ ): Promise<PublicationArtifactFileReadV1> {
405
+ let stored: PublicationArtifactStoredObjectV1 | null;
406
+ try {
407
+ stored = await bucket.get(publicationArtifactObjectKeyV1(index.tenant, index.spaceId, index.versionId, file.sha256));
408
+ } catch {
409
+ return { ok: false, code: "publication_artifact_source_unavailable" };
410
+ }
411
+ if (!stored) return { ok: false, code: "publication_artifact_file_missing" };
412
+ let bytes: Uint8Array;
413
+ try {
414
+ bytes = new Uint8Array(await stored.arrayBuffer());
415
+ } catch {
416
+ return { ok: false, code: "publication_artifact_file_missing" };
417
+ }
418
+ if (bytes.byteLength !== file.byteSize || await sha256Hex(bytes) !== file.sha256) {
419
+ return { ok: false, code: "publication_artifact_file_mismatch" };
420
+ }
421
+ return { ok: true, bytes };
422
+ }
423
+
424
+ function bytesToBase64(bytes: Uint8Array): string {
425
+ let binary = "";
426
+ for (let offset = 0; offset < bytes.byteLength; offset += 0x8000) {
427
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
428
+ }
429
+ return btoa(binary);
430
+ }
431
+
432
+ /**
433
+ * The files of an admitted index that `include` selects, with their proven bytes, as a bundle whose
434
+ * `files` list is exactly that selection. Off-path readers (review evidence, original staging) select
435
+ * the text they scan and the originals they stage instead of loading a whole site into memory.
436
+ * Null when any selected file is missing or does not match its row.
437
+ */
438
+ export async function readPublicationArtifactSelectionV1(
439
+ bucket: PublicationArtifactReadBucketV1,
440
+ index: PublicationArtifactIndexV1,
441
+ include: (file: PublicationArtifactIndexFileV1) => boolean,
442
+ ): Promise<PublicationArtifactBundleV1 | null> {
443
+ const files: PublicationArtifactFileV1[] = [];
444
+ for (const row of index.files) {
445
+ if (!include(row)) continue;
446
+ const read = await readPublicationArtifactFileV1(bucket, index, row);
447
+ if (!read.ok) return null;
448
+ const content = row.encoding === "utf8"
449
+ ? new TextDecoder("utf-8", { fatal: true }).decode(read.bytes)
450
+ : bytesToBase64(read.bytes);
451
+ files.push({ lane: row.lane, path: row.path, encoding: row.encoding, content, byteSize: row.byteSize });
221
452
  }
222
- return `publications/${tenant}/${spaceId}/${versionId}/bundle.json`;
453
+ const { files: _rows, ...identity } = index;
454
+ return { ...identity, files };
223
455
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A publication space's content-declared redirects on the Worker: `content/redirects.json` and every
3
+ * `content/<collection>/redirects.json` of the version the request opened, matched by the redirect
4
+ * contract's own resolver. The same set the aggregate writes to the CloudFront redirect plane, so
5
+ * the two CDNs answer one request the same way.
6
+ *
7
+ * Only the space that OWNS the request path is consulted (its artifact is the one the request
8
+ * opened), which is the composition's precedence rule: a mount owns every path under it. Only the
9
+ * root space may send a visitor to another origin; a mounted space's off-site entry never applies.
10
+ */
11
+ import {
12
+ buildRedirectSet,
13
+ COLLECTION_REDIRECTS_PATH,
14
+ declaredRedirectsFromContent,
15
+ redirectResolver,
16
+ } from "./publication-redirects.mjs";
17
+ import type { OpenPublicationArtifactV1 } from "./publication-reader.js";
18
+
19
+ type Resolve = (path: string) => string | null;
20
+
21
+ /** Resolvers per immutable version; a version's declarations never change once stored. */
22
+ const resolvers = new Map<string, Resolve>();
23
+ const MAX_CACHED_VERSIONS = 64;
24
+
25
+ function compile(artifact: OpenPublicationArtifactV1): Resolve {
26
+ const entries: { path: string; json: unknown }[] = [];
27
+ const read = (relative: string) => {
28
+ const text = artifact.readContent(relative);
29
+ if (text !== null) entries.push({ path: `content/${relative}`, json: JSON.parse(text) as unknown });
30
+ };
31
+ try {
32
+ read("redirects.json");
33
+ for (const relative of artifact.listContentPaths()) {
34
+ if (COLLECTION_REDIRECTS_PATH.test(`content/${relative}`)) read(relative);
35
+ }
36
+ const set = buildRedirectSet(declaredRedirectsFromContent(entries));
37
+ // A declaration composition would refuse publishes nothing here either.
38
+ if (!set.ok) return () => null;
39
+ const isRoot = artifact.selection.manifest.routes.canonicalMount === "/";
40
+ const resolve = redirectResolver(set.redirects.filter(({ to }) => isRoot || to.startsWith("/")));
41
+ return (path) => resolve(path)?.location ?? null;
42
+ } catch {
43
+ return () => null;
44
+ }
45
+ }
46
+
47
+ /** Where `requestPath` redirects within its own space's declarations, or null. */
48
+ export function publicationContentRedirectLocationV1(
49
+ artifact: OpenPublicationArtifactV1,
50
+ requestPath: string,
51
+ ): string | null {
52
+ const { tenant, spaceId, versionId, artifactDigest } = artifact.index;
53
+ const key = `${tenant}\0${spaceId}\0${versionId}\0${artifactDigest}`;
54
+ let resolve = resolvers.get(key);
55
+ if (!resolve) {
56
+ resolve = compile(artifact);
57
+ if (resolvers.size >= MAX_CACHED_VERSIONS) resolvers.delete(resolvers.keys().next().value!);
58
+ resolvers.set(key, resolve);
59
+ }
60
+ return resolve(requestPath);
61
+ }
@@ -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
+ }
@@ -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,