@tokenoftrust/storefront-runner 2.4.24 → 2.4.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/src/lib/publication/contentProvider.ts +21 -4
- package/apps/storefront/src/lib/publication/mountedDiscovery.ts +6 -1
- package/apps/storefront/src/lib/publication/runtimeSource.ts +12 -2
- package/apps/storefront/src/lib/publication/servingMode.ts +52 -0
- 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 +63 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -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-recovery.ts +132 -0
- package/packages/public-runtime/src/publication-space.ts +76 -1
|
@@ -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
|
+
}
|