@tokenoftrust/storefront-runner 6.1.5 → 6.2.1
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.
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import { sha256Hex } from "@tot/public-runtime";
|
|
15
15
|
import { IMAGE_LIMITS_CEILING } from "@tot/public-runtime/image-limits";
|
|
16
16
|
import {
|
|
17
|
-
PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE, PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION,
|
|
17
|
+
PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE, PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION,
|
|
18
|
+
sealedOriginalEntryAllowed, validSvgSanitizeReceipt, type SvgSanitizeReceipt,
|
|
18
19
|
} from "@tot/public-runtime/hosted-image-media-types";
|
|
19
20
|
import { durableAssetsStatusKey, durableCaptureRevisionId, durableRequiredStores, parseDurableAssetsStatus } from "@tot/public-runtime/durable-assets";
|
|
20
21
|
import { POOL_ASSET_SECURITY_HEADERS } from "@tot/public-runtime/pool-asset-headers";
|
|
@@ -49,6 +50,7 @@ export type ManifestEntry = {
|
|
|
49
50
|
byteLength: number;
|
|
50
51
|
mediaType: string;
|
|
51
52
|
provenance?: { kind: "local" | "remote" | "upload"; locator: string };
|
|
53
|
+
sanitized?: SvgSanitizeReceipt;
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
function objectKey(tenantId: string, address: string): string {
|
|
@@ -89,12 +91,14 @@ async function readManifestEntries(
|
|
|
89
91
|
const entry = raw as Partial<ManifestEntry>;
|
|
90
92
|
if (typeof entry.address !== "string" || !ADDRESS_RE.test(entry.address)) return null;
|
|
91
93
|
if (!Number.isSafeInteger(entry.byteLength) || entry.byteLength! < 1 || entry.byteLength! > maxEntryBytes ||
|
|
92
|
-
!
|
|
94
|
+
!sealedOriginalEntryAllowed(entry.provenance?.kind, entry.mediaType, entry.sanitized)) return null;
|
|
95
|
+
const sanitized = validSvgSanitizeReceipt(entry.sanitized);
|
|
93
96
|
entries.push({
|
|
94
97
|
address: entry.address,
|
|
95
98
|
byteLength: entry.byteLength!,
|
|
96
99
|
mediaType: entry.mediaType!,
|
|
97
100
|
provenance: entry.provenance,
|
|
101
|
+
...(sanitized ? { sanitized } : {}),
|
|
98
102
|
});
|
|
99
103
|
}
|
|
100
104
|
return entries;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Order, always: write the references (version manifest already in R2, or the holds), wait while a
|
|
8
8
|
* bundle-GC sweep holds its lease, then stage and verify every object those references name.
|
|
9
9
|
*/
|
|
10
|
-
import { PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION,
|
|
10
|
+
import { PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION, sealedOriginalEntryAllowed } from "@tot/public-runtime/hosted-image-media-types";
|
|
11
11
|
import {
|
|
12
12
|
durableAssetsStatusKey,
|
|
13
13
|
durableCaptureRevisionId,
|
|
@@ -19,8 +19,9 @@ import {
|
|
|
19
19
|
} from "@tot/public-runtime/durable-assets";
|
|
20
20
|
import { GC_LEASE_KEY, awaitGcQuiescence } from "@tot/public-runtime/retention-holds";
|
|
21
21
|
import { authorizeAssetReference, type AssetRegistryStore, type AssetWrite } from "@tot/public-runtime/asset-registry";
|
|
22
|
-
import { IMAGE_LIMITS_CEILING } from "@tot/public-runtime/image-limits";
|
|
22
|
+
import { IMAGE_LIMITS_CEILING, IMAGE_TIERS } from "@tot/public-runtime/image-limits";
|
|
23
23
|
import { incrementalSha256 } from "@tot/public-runtime/incremental-sha256";
|
|
24
|
+
import { sanitizeSvg } from "@tot/public-runtime/svg-sanitize";
|
|
24
25
|
|
|
25
26
|
const MAX_MANIFEST = 1024 * 1024;
|
|
26
27
|
const POOL_ENTRY_RE = /^([0-9a-f]{64})(\.[A-Za-z0-9]+)$/;
|
|
@@ -204,19 +205,35 @@ export function awaitR2Quiescence(bucket: R2BucketLike, waiter: QuiescenceWaiter
|
|
|
204
205
|
|
|
205
206
|
/**
|
|
206
207
|
* Present with exactly the source's bytes, writing it create-only when absent; refuses a mismatched
|
|
207
|
-
* object.
|
|
208
|
-
*
|
|
208
|
+
* object. Raster streams are put with their sha256; SVG sources are bounded, hashed and checked
|
|
209
|
+
* against the sanitizer before any existing-object shortcut, then those exact bytes are put.
|
|
210
|
+
* The existing object and the read-back are hashed as streams.
|
|
209
211
|
*/
|
|
210
212
|
export async function ensurePoolObject(bucket: R2BucketLike, key: string, source: Uint8Array | StreamSource, mediaType: string): Promise<void> {
|
|
211
213
|
const address = POOL_ENTRY_RE.exec(key.replace(/^assets\//, ""))?.[1];
|
|
212
214
|
if (!address) throw new R2StagingError("pool-key");
|
|
213
215
|
const size = source instanceof Uint8Array ? source.byteLength : source.size;
|
|
216
|
+
let verifiedSource = source;
|
|
217
|
+
if (mediaType === "image/svg+xml") {
|
|
218
|
+
// SVG bytes must already be sanitizer output, even when the shared pool has this address.
|
|
219
|
+
const limit = IMAGE_TIERS.standard.maxEncodedBytes;
|
|
220
|
+
if (!Number.isSafeInteger(size) || size < 1 || size > limit) throw new R2StagingError("unsupported-format");
|
|
221
|
+
const bytes = source instanceof Uint8Array ? source.slice()
|
|
222
|
+
: await readBounded({ get: async () => ({ size, body: await source.open() }) }, key, limit);
|
|
223
|
+
if (!bytes || bytes.byteLength !== size) throw new R2StagingError("original-missing");
|
|
224
|
+
const hash = incrementalSha256();
|
|
225
|
+
await hash.update(bytes);
|
|
226
|
+
if (await hash.hex() !== address) throw new R2StagingError("original-missing");
|
|
227
|
+
const inspected = sanitizeSvg(bytes, { maxBytes: limit });
|
|
228
|
+
if (!inspected.ok || inspected.changed) throw new R2StagingError("unsupported-format");
|
|
229
|
+
verifiedSource = bytes;
|
|
230
|
+
}
|
|
214
231
|
// Bounded by the size being staged: an object of any other size cannot hold this address.
|
|
215
232
|
const existing = await digestBounded(bucket, key, size);
|
|
216
233
|
if (existing && (existing.sha256 !== address || existing.size !== size)) throw new R2StagingError("store-mismatch");
|
|
217
234
|
if (!existing) {
|
|
218
235
|
if (!bucket.put) throw new R2StagingError("store-unwritable");
|
|
219
|
-
await bucket.put(key,
|
|
236
|
+
await bucket.put(key, verifiedSource instanceof Uint8Array ? verifiedSource : exactLength(await verifiedSource.open(), size), {
|
|
220
237
|
httpMetadata: { contentType: mediaType, cacheControl: IMMUTABLE },
|
|
221
238
|
onlyIf: new Headers({ "If-None-Match": "*" }),
|
|
222
239
|
sha256: address,
|
|
@@ -268,8 +285,8 @@ async function capturedSource(captureBucket: R2BucketLike, tenant: string, entry
|
|
|
268
285
|
* tenant's own capture store (which registers it); otherwise nothing is staged and the go-live
|
|
269
286
|
* refuses `asset-unauthorized`.
|
|
270
287
|
*
|
|
271
|
-
* Originals are
|
|
272
|
-
*
|
|
288
|
+
* Originals are staged one at a time within the image ceiling. Rasters stream; SVGs are held
|
|
289
|
+
* within the sanitizer's bound for fixed-point validation before staging.
|
|
273
290
|
*/
|
|
274
291
|
export async function stageCapturedOriginalsR2(input: {
|
|
275
292
|
tenant: string;
|
|
@@ -290,22 +307,26 @@ export async function stageCapturedOriginalsR2(input: {
|
|
|
290
307
|
if (own) owned.set(entry, own.size);
|
|
291
308
|
else unauthorized.push(entry);
|
|
292
309
|
}
|
|
293
|
-
if (input.registry && owned.size > 0) {
|
|
294
|
-
const writes: AssetWrite[] = [...owned].map(([entry, size]) => ({
|
|
295
|
-
entry, tenant: input.tenant, mediaType: mediaTypeOf(entry), size,
|
|
296
|
-
}));
|
|
297
|
-
await input.registry.upsert(writes).catch(() => "lost");
|
|
298
|
-
}
|
|
299
310
|
if (unauthorized.length > 0) throw new R2StagingError("asset-unauthorized");
|
|
300
311
|
let staged = 0;
|
|
301
312
|
for (const entry of input.entries) {
|
|
302
313
|
const key = `assets/${entry}`;
|
|
303
|
-
const present = await digestBounded(input.publicBucket, key, IMAGE_LIMITS_CEILING.maxEncodedBytes);
|
|
304
314
|
const address = POOL_ENTRY_RE.exec(entry)?.[1];
|
|
305
|
-
|
|
306
|
-
await
|
|
315
|
+
const mediaType = mediaTypeOf(entry);
|
|
316
|
+
const present = address ? await verifiedObjectSource(input.publicBucket, key, address, IMAGE_LIMITS_CEILING.maxEncodedBytes) : null;
|
|
317
|
+
if (present) {
|
|
318
|
+
if (mediaType === "image/svg+xml") await ensurePoolObject(input.publicBucket, key, present, mediaType);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
await ensurePoolObject(input.publicBucket, key, await capturedSource(input.captureBucket, input.tenant, entry), mediaType);
|
|
307
322
|
staged++;
|
|
308
323
|
}
|
|
324
|
+
if (input.registry && owned.size > 0) {
|
|
325
|
+
const writes: AssetWrite[] = [...owned].map(([entry, size]) => ({
|
|
326
|
+
entry, tenant: input.tenant, mediaType: mediaTypeOf(entry), size,
|
|
327
|
+
}));
|
|
328
|
+
await input.registry.upsert(writes).catch(() => "lost");
|
|
329
|
+
}
|
|
309
330
|
return staged;
|
|
310
331
|
}
|
|
311
332
|
|
|
@@ -360,7 +381,7 @@ export async function designateDurableR2(input: {
|
|
|
360
381
|
if (manifestBytes) {
|
|
361
382
|
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as {
|
|
362
383
|
version?: unknown; revisionId?: unknown;
|
|
363
|
-
entries?: Array<{ address?: unknown; byteLength?: unknown; mediaType?: unknown; provenance?: { kind?: unknown; locator?: unknown } }>;
|
|
384
|
+
entries?: Array<{ address?: unknown; byteLength?: unknown; mediaType?: unknown; sanitized?: unknown; provenance?: { kind?: unknown; locator?: unknown } }>;
|
|
364
385
|
};
|
|
365
386
|
if (manifest.version !== 1 || manifest.revisionId !== revision || !Array.isArray(manifest.entries)) {
|
|
366
387
|
throw new R2StagingError("manifest-invalid");
|
|
@@ -368,7 +389,7 @@ export async function designateDurableR2(input: {
|
|
|
368
389
|
for (const entry of manifest.entries) {
|
|
369
390
|
if (typeof entry?.address !== "string" || !/^[0-9a-f]{64}$/.test(entry.address) ||
|
|
370
391
|
typeof entry.byteLength !== "number" || typeof entry.mediaType !== "string" ||
|
|
371
|
-
!
|
|
392
|
+
!sealedOriginalEntryAllowed(entry.provenance?.kind, entry.mediaType, entry.sanitized) ||
|
|
372
393
|
typeof entry.provenance?.locator !== "string") throw new R2StagingError("manifest-invalid");
|
|
373
394
|
byLocator.set(entry.provenance.locator, { address: entry.address, byteLength: entry.byteLength, mediaType: entry.mediaType });
|
|
374
395
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "6.1
|
|
3
|
+
"version": "6.2.1",
|
|
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",
|
|
@@ -39,7 +39,7 @@ export type CaptureRewrite = {
|
|
|
39
39
|
/** A sealed capture-manifest entry, as `original-image-capture.ts` writes it. */
|
|
40
40
|
export interface CapturedManifestEntry {
|
|
41
41
|
/** Remote `locator` is query-stripped for display; remote entries are matched by `locatorDigest`. */
|
|
42
|
-
provenance: { kind: "local" | "remote" | "upload"; locator: string };
|
|
42
|
+
provenance: { kind: "local" | "remote" | "upload" | "owned-cdn"; locator: string };
|
|
43
43
|
address: string;
|
|
44
44
|
mediaType: string;
|
|
45
45
|
/** Remote entries: digest of the exact fetched locator, query included (`captureLocatorDigest`). */
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
* - CAPTURABLE_RASTER_MEDIA_TYPES: the raster formats capture reads by their magic
|
|
18
18
|
* bytes. An SVG is never stored as fetched: capture and upload pass it through the
|
|
19
19
|
* one sanitizer (`svg-sanitize.ts`) and store only its output, which is what lets a
|
|
20
|
-
* sealed
|
|
20
|
+
* sealed local, remote or upload entry carry `image/svg+xml`.
|
|
21
21
|
*/
|
|
22
|
+
import { SVG_SANITIZER_ID } from "@tot/public-runtime/svg-sanitize";
|
|
22
23
|
|
|
23
24
|
export const PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE: Readonly<Record<string, string>> = Object.freeze({
|
|
24
25
|
"image/png": ".png",
|
|
@@ -42,9 +43,7 @@ export const CAPTURABLE_RASTER_MEDIA_TYPES = Object.freeze([
|
|
|
42
43
|
export type CapturableRasterMediaType = (typeof CAPTURABLE_RASTER_MEDIA_TYPES)[number];
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
-
* (`svg-sanitize.ts`) and store only its output, so a sealed `upload` or `remote` entry may carry
|
|
47
|
-
* it. A `local` entry is a repo file capture reads by raster magic, never an SVG.
|
|
46
|
+
* Capture and author uploads may carry SVG only as the existing sanitizer's output.
|
|
48
47
|
*/
|
|
49
48
|
export const UPLOADABLE_MEDIA_TYPES = Object.freeze([...CAPTURABLE_RASTER_MEDIA_TYPES, "image/svg+xml"] as const);
|
|
50
49
|
|
|
@@ -53,8 +52,37 @@ export type UploadableMediaType = (typeof UPLOADABLE_MEDIA_TYPES)[number];
|
|
|
53
52
|
/** Comma-joined for a remote capture's HTTP `Accept` header -- derived, never a second literal. */
|
|
54
53
|
export const CAPTURE_ACCEPT_HEADER = UPLOADABLE_MEDIA_TYPES.join(",");
|
|
55
54
|
|
|
56
|
-
/**
|
|
55
|
+
/** Media-type lookup only; manifest readers must use sealedOriginalEntryAllowed for receipt policy. */
|
|
57
56
|
export function sealedMediaTypeAllowed(kind: unknown, mediaType: unknown): boolean {
|
|
58
|
-
const types: readonly string[] = kind === "upload" || kind === "remote" ? UPLOADABLE_MEDIA_TYPES : CAPTURABLE_RASTER_MEDIA_TYPES;
|
|
57
|
+
const types: readonly string[] = kind === "upload" || kind === "remote" || kind === "local" || kind === "owned-cdn" ? UPLOADABLE_MEDIA_TYPES : CAPTURABLE_RASTER_MEDIA_TYPES;
|
|
59
58
|
return typeof mediaType === "string" && types.includes(mediaType);
|
|
60
59
|
}
|
|
60
|
+
|
|
61
|
+
/** Provenance of the sanitize pass; it does not authorize or verify the stored object's bytes. */
|
|
62
|
+
export interface SvgSanitizeReceipt {
|
|
63
|
+
sanitizer: string;
|
|
64
|
+
removedElements: number;
|
|
65
|
+
removedAttributes: number;
|
|
66
|
+
source: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Normalize the supported sanitizer's receipt, discarding untrusted extra fields. */
|
|
70
|
+
export function validSvgSanitizeReceipt(raw: unknown): SvgSanitizeReceipt | null {
|
|
71
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
72
|
+
const receipt = raw as Partial<SvgSanitizeReceipt>;
|
|
73
|
+
if (receipt.sanitizer !== SVG_SANITIZER_ID || typeof receipt.source !== "string" || !/^[0-9a-f]{64}$/.test(receipt.source)) return null;
|
|
74
|
+
if (!Number.isSafeInteger(receipt.removedElements) || (receipt.removedElements as number) < 0) return null;
|
|
75
|
+
if (!Number.isSafeInteger(receipt.removedAttributes) || (receipt.removedAttributes as number) < 0) return null;
|
|
76
|
+
return {
|
|
77
|
+
sanitizer: receipt.sanitizer, removedElements: receipt.removedElements as number,
|
|
78
|
+
removedAttributes: receipt.removedAttributes as number, source: receipt.source,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A sealed capture SVG requires its receipt; upload provenance resides in the upload registry. */
|
|
83
|
+
export function sealedOriginalEntryAllowed(kind: unknown, mediaType: unknown, sanitized: unknown): boolean {
|
|
84
|
+
if (kind !== "local" && kind !== "remote" && kind !== "upload" && kind !== "owned-cdn") return false;
|
|
85
|
+
if (!sealedMediaTypeAllowed(kind, mediaType)) return false;
|
|
86
|
+
const needsReceipt = kind !== "upload" && mediaType === "image/svg+xml";
|
|
87
|
+
return needsReceipt ? validSvgSanitizeReceipt(sanitized) !== null : sanitized === undefined;
|
|
88
|
+
}
|