@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.
- package/apps/storefront/src/lib/publication/contentProvider.ts +21 -4
- package/apps/storefront/src/middleware/index.ts +15 -4
- package/apps/storefront/src/middleware/renderChain.ts +19 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +58 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +13 -0
- package/packages/public-runtime/src/publication-artifact.ts +278 -46
- package/packages/public-runtime/src/publication-content-redirects.ts +61 -0
- package/packages/public-runtime/src/publication-reader.ts +89 -77
- package/packages/public-runtime/src/publication-space.ts +76 -1
|
@@ -62,8 +62,23 @@ export class PublicationSpaceContentProvider implements ContentProvider {
|
|
|
62
62
|
constructor(
|
|
63
63
|
private readonly sharedRoot: ContentProvider,
|
|
64
64
|
private readonly publication: OpenPublicationArtifactV1,
|
|
65
|
+
/**
|
|
66
|
+
* A discovery document (sitemap, feeds, search index, llms.txt) lists every page of the space,
|
|
67
|
+
* so it reads any of them; a page request reads only the page its plan selected.
|
|
68
|
+
*/
|
|
69
|
+
private readonly wholeSpace = false,
|
|
65
70
|
) {}
|
|
66
71
|
|
|
72
|
+
/** The space-relative slug a page request may read, or null. `""` is the space's home. */
|
|
73
|
+
private readableSlug(slug: string): string | null {
|
|
74
|
+
if (!this.wholeSpace) return selectedPageSlug(this.plan, slug);
|
|
75
|
+
const requested = stripSlashes(slug);
|
|
76
|
+
const mount = stripSlashes(this.plan.manifest.routes.canonicalMount);
|
|
77
|
+
if (!mount) return requested;
|
|
78
|
+
if (requested === mount) return "";
|
|
79
|
+
return requested.startsWith(`${mount}/`) ? requested.slice(mount.length + 1) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
67
82
|
private get plan(): PublicationRouteRenderPlanV1 {
|
|
68
83
|
return this.publication.selection;
|
|
69
84
|
}
|
|
@@ -86,7 +101,7 @@ export class PublicationSpaceContentProvider implements ContentProvider {
|
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
async getPageHtml(slug: string): Promise<string | null> {
|
|
89
|
-
const selected =
|
|
104
|
+
const selected = this.readableSlug(slug);
|
|
90
105
|
if (selected === null) return null;
|
|
91
106
|
const raw = this.publication.readContent(
|
|
92
107
|
selected ? `pages-html/${selected}.html` : "home.html",
|
|
@@ -122,10 +137,11 @@ export class PublicationSpaceContentProvider implements ContentProvider {
|
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
async getPage(slug: string): Promise<EditorialPage | null> {
|
|
125
|
-
const selected =
|
|
140
|
+
const selected = this.readableSlug(slug);
|
|
126
141
|
if (!selected) return null;
|
|
127
142
|
const page = (await this.sourcePages()).find((candidate) => candidate.slug === selected);
|
|
128
|
-
|
|
143
|
+
if (!page) return null;
|
|
144
|
+
return this.wholeSpace ? rebasePage(this.plan, page) : rebasePage(this.plan, page, stripSlashes(this.plan.canonicalPath));
|
|
129
145
|
}
|
|
130
146
|
|
|
131
147
|
async listPages(): Promise<EditorialPage[]> {
|
|
@@ -152,8 +168,9 @@ export class PublicationSpaceContentProvider implements ContentProvider {
|
|
|
152
168
|
export function bindPublicationSpaceContentProviderV1(
|
|
153
169
|
sharedRoot: ContentProvider,
|
|
154
170
|
publication?: OpenPublicationArtifactV1,
|
|
171
|
+
options: { wholeSpace?: boolean } = {},
|
|
155
172
|
): ContentProvider {
|
|
156
173
|
return publication
|
|
157
|
-
? new PublicationSpaceContentProvider(sharedRoot, publication)
|
|
174
|
+
? new PublicationSpaceContentProvider(sharedRoot, publication, options.wholeSpace ?? false)
|
|
158
175
|
: sharedRoot;
|
|
159
176
|
}
|
|
@@ -25,7 +25,7 @@ import { getTenantResolver } from "@/config/resolver";
|
|
|
25
25
|
import { readEnv, readKv, readR2 } from "@/lib/runtimeEnv";
|
|
26
26
|
import { resolveCdnForwardRouting, resolveTenantRouting } from "@/lib/tenantRouting";
|
|
27
27
|
import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/cdnForward";
|
|
28
|
-
import { compileRouteTierIndexV1, type RouteTierIndexV1 } from "@tot/public-runtime";
|
|
28
|
+
import { compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 } from "@tot/public-runtime";
|
|
29
29
|
import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
|
|
30
30
|
import { DEV_DRAFT_COOKIE } from "@/lib/dev/devDraft";
|
|
31
31
|
import { applySecurityHeaders } from "@/lib/securityHeaders";
|
|
@@ -39,6 +39,7 @@ import { HEALTH_PATH, healthResponse } from "@/lib/health";
|
|
|
39
39
|
import { observeDeployVersion } from "@/lib/activity/deployVersion";
|
|
40
40
|
import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
|
|
41
41
|
import {
|
|
42
|
+
isPlatformPublicDiscoveryPath,
|
|
42
43
|
platformStaticRootLocationV1,
|
|
43
44
|
publicationRedirectLocationV1,
|
|
44
45
|
resolveMiddlewareBranchV1,
|
|
@@ -314,6 +315,14 @@ async function serveRequest(
|
|
|
314
315
|
return asset;
|
|
315
316
|
}
|
|
316
317
|
|
|
318
|
+
// A tenant's discovery documents (sitemap, feeds, search index, llms.txt, robots) are the root's:
|
|
319
|
+
// when the root is a publication space, they render from its selected version, as its pages do.
|
|
320
|
+
const discoveryRoot = isPlatformPublicDiscoveryPath(renderPath)
|
|
321
|
+
? resolvePublicationRoutePlanV1(routeTierIndexFor(tenant), "/")
|
|
322
|
+
: null;
|
|
323
|
+
const publicationSourceRoute = discoveryRoot?.action === "render" ? discoveryRoot : publication;
|
|
324
|
+
const publicationSourcePath = discoveryRoot?.action === "render" ? "/" : renderPath;
|
|
325
|
+
|
|
317
326
|
if (branch === "public-render") {
|
|
318
327
|
return await servePublicRender({
|
|
319
328
|
context,
|
|
@@ -326,9 +335,11 @@ async function serveRequest(
|
|
|
326
335
|
cache,
|
|
327
336
|
kv,
|
|
328
337
|
routeTierIndex: routeTierIndexFor(tenant),
|
|
329
|
-
publicationRequestPath:
|
|
330
|
-
publicationRoute:
|
|
331
|
-
publicationBucket
|
|
338
|
+
publicationRequestPath: publicationSourcePath,
|
|
339
|
+
publicationRoute: publicationSourceRoute,
|
|
340
|
+
publicationBucket: discoveryRoot?.action === "render"
|
|
341
|
+
? await readR2("TENANT_ASSETS") as unknown as PublicationArtifactBucketV1 | undefined
|
|
342
|
+
: publicationBucket,
|
|
332
343
|
});
|
|
333
344
|
}
|
|
334
345
|
|
|
@@ -14,6 +14,7 @@ import { mergeTheme, themeToCssVars } from "@/themes/schema";
|
|
|
14
14
|
import { createToTClient } from "@/lib/tot/ToTClient";
|
|
15
15
|
import { readEnv, readR2 } from "@/lib/runtimeEnv";
|
|
16
16
|
import { tryServeStaticBundle } from "./staticBundle.js";
|
|
17
|
+
import { isPlatformPublicDiscoveryPath } from "./routeBranch.js";
|
|
17
18
|
import { resolveCandidateContentPin, type CandidateContentPin } from "./candidatePin.js";
|
|
18
19
|
import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
|
|
19
20
|
import { bindPublicationSpaceContentProviderV1 } from "@/lib/publication/contentProvider.js";
|
|
@@ -82,6 +83,7 @@ import {
|
|
|
82
83
|
HTML_CACHE_POLICY_CAPTURE_HEADER,
|
|
83
84
|
HTML_CACHE_POLICY_CAPTURE_PROBE,
|
|
84
85
|
} from "@tot/public-runtime";
|
|
86
|
+
import { publicationContentRedirectLocationV1 } from "@tot/public-runtime/publication-content-redirects";
|
|
85
87
|
import { loadTenantIntegrationDeclarations } from "@/lib/integration/tenantDeclarations";
|
|
86
88
|
import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
|
|
87
89
|
import {
|
|
@@ -236,6 +238,21 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
236
238
|
if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
|
|
237
239
|
return response;
|
|
238
240
|
}
|
|
241
|
+
// The owning space's content-declared redirects answer before anything renders, as the CloudFront
|
|
242
|
+
// redirect plane does for the same declarations: a same-origin target keeps the path-routing prefix
|
|
243
|
+
// and the query; an off-site target (the root space only) is sent as declared.
|
|
244
|
+
if (publicationSource.status === "selected") {
|
|
245
|
+
const location = publicationContentRedirectLocationV1(publicationSource.artifact, input.publicationRequestPath);
|
|
246
|
+
if (location) {
|
|
247
|
+
const response = new Response(null, {
|
|
248
|
+
status: 308,
|
|
249
|
+
headers: { location: location.startsWith("/") ? `${url.origin}${basePath}${location}${url.search}` : location },
|
|
250
|
+
});
|
|
251
|
+
applySecurityHeaders(response.headers);
|
|
252
|
+
if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
|
|
253
|
+
return response;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
239
256
|
|
|
240
257
|
// The tenant as it renders in THIS environment: its record with the declared
|
|
241
258
|
// `.tot/config.json` fields applied (compliance, siteType, capabilities,
|
|
@@ -613,6 +630,8 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
613
630
|
locals.content = bindPublicationSpaceContentProviderV1(
|
|
614
631
|
sharedRootContent,
|
|
615
632
|
publicationSource.status === "selected" ? publicationSource.artifact : undefined,
|
|
633
|
+
// A discovery document lists the whole space it is bound to, not one selected page.
|
|
634
|
+
{ wholeSpace: isPlatformPublicDiscoveryPath(renderPath) },
|
|
616
635
|
);
|
|
617
636
|
// Developer-draft content overlay — serves the AI-edited artifacts to the draft
|
|
618
637
|
// owner only; every other viewer keeps the published/base chain above.
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* Preview sessions also consult the image-capture rewrite overlay so rewritten
|
|
18
18
|
* `public/*.css` (and similar text assets) serve `/_a/<sha>` references.
|
|
19
19
|
*
|
|
20
|
+
* A tenant whose ROOT is a publication space serves these paths from that space's selected version
|
|
21
|
+
* (its public asset lane, `<namespace>/<path>`), the same URL a whole-site tenant serves them at;
|
|
22
|
+
* otherwise the legacy asset pointer below answers.
|
|
23
|
+
*
|
|
20
24
|
* Version selection, in order: a candidate/revision pin (`tot_preview_version` +
|
|
21
25
|
* token cookie); else, for a shared-preview viewer (preview host, or a valid token
|
|
22
26
|
* for this tenant), the version the content `preview` channel serves — what the
|
|
@@ -24,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.
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
209
|
-
|
|
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
|
-
/**
|
|
212
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type
|
|
7
|
-
type
|
|
2
|
+
readPublicationArtifactFileV1,
|
|
3
|
+
readPublicationArtifactIndexV1,
|
|
4
|
+
type PublicationArtifactIndexFileV1,
|
|
5
|
+
type PublicationArtifactIndexV1,
|
|
6
|
+
type PublicationArtifactReadBucketV1,
|
|
7
|
+
type PublicationArtifactStoredObjectV1,
|
|
8
8
|
} from "./publication-artifact.js";
|
|
9
9
|
import {
|
|
10
|
+
resolvePublicationRoutePlanV1,
|
|
10
11
|
selectPublicationRouteVersionV1,
|
|
11
12
|
type PublicationRouteFailureCodeV1,
|
|
12
13
|
type PublicationRoutePlanV1,
|
|
@@ -18,15 +19,10 @@ import type {
|
|
|
18
19
|
} from "./publication-version.js";
|
|
19
20
|
import type { RouteTierIndexV1 } from "./route-tier.js";
|
|
20
21
|
|
|
21
|
-
export
|
|
22
|
-
text(): Promise<string>;
|
|
23
|
-
arrayBuffer(): Promise<ArrayBuffer>;
|
|
24
|
-
}
|
|
22
|
+
export type PublicationStoredObjectV1 = PublicationArtifactStoredObjectV1;
|
|
25
23
|
|
|
26
24
|
/** Provider-neutral object-store read surface; R2 and test stores both satisfy it. */
|
|
27
|
-
export
|
|
28
|
-
get(key: string): Promise<PublicationStoredObjectV1 | null>;
|
|
29
|
-
}
|
|
25
|
+
export type PublicationArtifactBucketV1 = PublicationArtifactReadBucketV1;
|
|
30
26
|
|
|
31
27
|
export type PublicationArtifactOpenFailureCodeV1 =
|
|
32
28
|
| PublicationRouteFailureCodeV1
|
|
@@ -38,13 +34,17 @@ export type PublicationArtifactOpenFailureCodeV1 =
|
|
|
38
34
|
|
|
39
35
|
export interface OpenPublicationArtifactV1 {
|
|
40
36
|
selection: SelectedPublicationRouteV1;
|
|
41
|
-
|
|
37
|
+
/** The admitted file index of the selected version. */
|
|
38
|
+
index: PublicationArtifactIndexV1;
|
|
42
39
|
/** Read a UTF-8 artifact relative to the selected space's content/ root. */
|
|
43
40
|
readContent(relativePath: string): string | null;
|
|
44
41
|
/** List paths relative to the selected space's content/ root. */
|
|
45
42
|
listContentPaths(prefix?: string): string[];
|
|
46
|
-
/**
|
|
47
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Read bytes relative to this space's declared asset namespace. Fetched on demand and proven
|
|
45
|
+
* against the index, so opening a version never reads its assets.
|
|
46
|
+
*/
|
|
47
|
+
readAsset(relativePath: string): Promise<Uint8Array | null>;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export type OpenPublicationArtifactResultV1 =
|
|
@@ -57,11 +57,6 @@ function safeRelativePath(path: string, allowEmpty = false): boolean {
|
|
|
57
57
|
path.split("/").every((part) => !!part && part !== "." && part !== "..");
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
61
|
-
return left.byteLength === right.byteLength &&
|
|
62
|
-
left.every((value, index) => value === right[index]);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
60
|
/**
|
|
66
61
|
* Open exactly one selected publication version and validate every physical
|
|
67
62
|
* object before rendering from it. A route/state/provenance/object failure is
|
|
@@ -91,25 +86,7 @@ async function openSelectedPublicationArtifactV1(
|
|
|
91
86
|
bucket: PublicationArtifactBucketV1,
|
|
92
87
|
): Promise<OpenPublicationArtifactResultV1> {
|
|
93
88
|
const { manifest, publicationVersion } = selection;
|
|
94
|
-
|
|
95
|
-
try {
|
|
96
|
-
marker = await bucket.get(publicationArtifactBundleKeyV1(
|
|
97
|
-
manifest.tenant,
|
|
98
|
-
manifest.id,
|
|
99
|
-
publicationVersion.versionId,
|
|
100
|
-
));
|
|
101
|
-
} catch {
|
|
102
|
-
return { ok: false, code: "publication_artifact_source_unavailable" };
|
|
103
|
-
}
|
|
104
|
-
if (!marker) return { ok: false, code: "publication_artifact_marker_missing" };
|
|
105
|
-
|
|
106
|
-
let markerValue: unknown;
|
|
107
|
-
try {
|
|
108
|
-
markerValue = JSON.parse(await marker.text());
|
|
109
|
-
} catch {
|
|
110
|
-
return { ok: false, code: "publication_artifact_marker_malformed" };
|
|
111
|
-
}
|
|
112
|
-
const checked = await validatePublicationArtifactBundleV1(markerValue, {
|
|
89
|
+
const admitted = await readPublicationArtifactIndexV1(bucket, {
|
|
113
90
|
tenant: manifest.tenant,
|
|
114
91
|
spaceId: manifest.id,
|
|
115
92
|
versionId: publicationVersion.versionId,
|
|
@@ -121,55 +98,38 @@ async function openSelectedPublicationArtifactV1(
|
|
|
121
98
|
assetNamespace: manifest.assets.namespace,
|
|
122
99
|
assetAccess: manifest.assets.access,
|
|
123
100
|
});
|
|
124
|
-
if (!
|
|
101
|
+
if (!admitted.ok) return { ok: false, code: admitted.code };
|
|
102
|
+
const { index } = admitted;
|
|
125
103
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
file.lane,
|
|
137
|
-
file.path,
|
|
138
|
-
));
|
|
139
|
-
} catch {
|
|
140
|
-
return { ok: false, code: "publication_artifact_source_unavailable" };
|
|
141
|
-
}
|
|
142
|
-
if (!stored) return { ok: false, code: "publication_artifact_file_missing" };
|
|
143
|
-
let actual: Uint8Array;
|
|
104
|
+
// The renderer reads content synchronously, so the content lane is loaded and proven up front.
|
|
105
|
+
// Assets are not: a page never needs every image its space carries.
|
|
106
|
+
const contentFiles = index.files.filter(
|
|
107
|
+
(file): file is PublicationArtifactIndexFileV1 & { encoding: "utf8" } =>
|
|
108
|
+
file.lane === "content" && file.encoding === "utf8" && file.path.startsWith("content/"),
|
|
109
|
+
);
|
|
110
|
+
const content = new Map<string, string>();
|
|
111
|
+
for (const file of contentFiles) {
|
|
112
|
+
const read = await readPublicationArtifactFileV1(bucket, index, file);
|
|
113
|
+
if (!read.ok) return { ok: false, code: read.code };
|
|
144
114
|
try {
|
|
145
|
-
|
|
115
|
+
content.set(file.path, new TextDecoder("utf-8", { fatal: true }).decode(read.bytes));
|
|
146
116
|
} catch {
|
|
147
|
-
return { ok: false, code: "publication_artifact_file_missing" };
|
|
148
|
-
}
|
|
149
|
-
if (!equalBytes(actual, expected)) {
|
|
150
117
|
return { ok: false, code: "publication_artifact_file_mismatch" };
|
|
151
118
|
}
|
|
152
|
-
physical.set(`${file.lane}\0${file.path}`, actual);
|
|
153
119
|
}
|
|
154
|
-
|
|
155
|
-
const contentFiles = checked.value.files.filter(
|
|
156
|
-
(file): file is PublicationArtifactFileV1 & { encoding: "utf8" } =>
|
|
157
|
-
file.lane === "content" && file.encoding === "utf8" && file.path.startsWith("content/"),
|
|
158
|
-
);
|
|
159
120
|
const assetLane = manifest.assets.access === "public" ? "public-asset" : "authorized-asset";
|
|
160
121
|
const assetPrefix = `${manifest.assets.namespace}/`;
|
|
122
|
+
const assets = new Map(index.files
|
|
123
|
+
.filter((file) => file.lane === assetLane && file.path.startsWith(assetPrefix))
|
|
124
|
+
.map((file) => [file.path.slice(assetPrefix.length), file]));
|
|
161
125
|
return {
|
|
162
126
|
ok: true,
|
|
163
127
|
value: {
|
|
164
128
|
selection,
|
|
165
|
-
|
|
129
|
+
index,
|
|
166
130
|
readContent(relativePath) {
|
|
167
131
|
if (!safeRelativePath(relativePath)) return null;
|
|
168
|
-
|
|
169
|
-
const file = contentFiles.find((candidate) => candidate.path === path);
|
|
170
|
-
if (!file) return null;
|
|
171
|
-
const bytes = physical.get(`content\0${path}`);
|
|
172
|
-
return bytes ? new TextDecoder().decode(bytes) : null;
|
|
132
|
+
return content.get(`content/${relativePath}`) ?? null;
|
|
173
133
|
},
|
|
174
134
|
listContentPaths(prefix = "") {
|
|
175
135
|
if (!safeRelativePath(prefix.replace(/\/$/, ""), true)) return [];
|
|
@@ -178,10 +138,62 @@ async function openSelectedPublicationArtifactV1(
|
|
|
178
138
|
.filter((path) => path.startsWith(prefix))
|
|
179
139
|
.sort();
|
|
180
140
|
},
|
|
181
|
-
readAsset(relativePath) {
|
|
141
|
+
async readAsset(relativePath) {
|
|
182
142
|
if (!safeRelativePath(relativePath)) return null;
|
|
183
|
-
|
|
143
|
+
const file = assets.get(relativePath);
|
|
144
|
+
if (!file) return null;
|
|
145
|
+
const read = await readPublicationArtifactFileV1(bucket, index, file);
|
|
146
|
+
return read.ok ? read.bytes : null;
|
|
184
147
|
},
|
|
185
148
|
},
|
|
186
149
|
};
|
|
187
150
|
}
|
|
151
|
+
|
|
152
|
+
export type PublicationRootAssetReadV1 =
|
|
153
|
+
| { status: "legacy-root" }
|
|
154
|
+
| { status: "found"; bytes: Uint8Array; sha256: string }
|
|
155
|
+
| { status: "absent" };
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One file of the ROOT space's public asset lane, by its path under the namespace — what a
|
|
159
|
+
* whole-site tenant serves at `/tenants/<tenant>/<path>`. Reads the selected version's index and
|
|
160
|
+
* that one object, never the content lane. `legacy-root` when the tenant's root is not a public
|
|
161
|
+
* publication space this channel selected, so the legacy asset pointer still answers.
|
|
162
|
+
*/
|
|
163
|
+
export async function readPublicationRootAssetV1(input: {
|
|
164
|
+
index: RouteTierIndexV1;
|
|
165
|
+
state: PublicationVersionStateV1;
|
|
166
|
+
channel: PublicationChannelV1;
|
|
167
|
+
bucket: PublicationArtifactBucketV1;
|
|
168
|
+
relativePath: string;
|
|
169
|
+
}): Promise<PublicationRootAssetReadV1> {
|
|
170
|
+
const plan = resolvePublicationRoutePlanV1(input.index, "/");
|
|
171
|
+
if (plan.action !== "render" || plan.manifest.routes.canonicalMount !== "/" ||
|
|
172
|
+
plan.manifest.visibility !== "public" || plan.manifest.assets.access !== "public") {
|
|
173
|
+
return { status: "legacy-root" };
|
|
174
|
+
}
|
|
175
|
+
const selected = selectPublicationRouteVersionV1(input.index, "/", plan, input.state, input.channel);
|
|
176
|
+
if (!selected.ok) {
|
|
177
|
+
return selected.code === "publication_channel_unselected" ? { status: "legacy-root" } : { status: "absent" };
|
|
178
|
+
}
|
|
179
|
+
if (!safeRelativePath(input.relativePath)) return { status: "absent" };
|
|
180
|
+
const { manifest, publicationVersion } = selected.value;
|
|
181
|
+
const admitted = await readPublicationArtifactIndexV1(input.bucket, {
|
|
182
|
+
tenant: manifest.tenant,
|
|
183
|
+
spaceId: manifest.id,
|
|
184
|
+
versionId: publicationVersion.versionId,
|
|
185
|
+
sourceSha: publicationVersion.source.commitSha,
|
|
186
|
+
artifactDigest: publicationVersion.artifactDigest,
|
|
187
|
+
forge: manifest.source.forge,
|
|
188
|
+
repo: manifest.source.repo,
|
|
189
|
+
visibility: manifest.visibility,
|
|
190
|
+
assetNamespace: manifest.assets.namespace,
|
|
191
|
+
assetAccess: manifest.assets.access,
|
|
192
|
+
});
|
|
193
|
+
if (!admitted.ok) return { status: "absent" };
|
|
194
|
+
const path = `${manifest.assets.namespace}/${input.relativePath}`;
|
|
195
|
+
const row = admitted.index.files.find((file) => file.lane === "public-asset" && file.path === path);
|
|
196
|
+
if (!row) return { status: "absent" };
|
|
197
|
+
const read = await readPublicationArtifactFileV1(input.bucket, admitted.index, row);
|
|
198
|
+
return read.ok ? { status: "found", bytes: read.bytes, sha256: row.sha256 } : { status: "absent" };
|
|
199
|
+
}
|
|
@@ -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,
|
|
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,
|