@tokenoftrust/storefront-runner 6.1.5 → 6.2.0

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, sealedMediaTypeAllowed,
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
- !sealedMediaTypeAllowed(entry.provenance?.kind, entry.mediaType)) return null;
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, sealedMediaTypeAllowed } from "@tot/public-runtime/hosted-image-media-types";
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. A {@link StreamSource} is streamed into R2 with its sha256, so R2 refuses (writes nothing)
208
- * on any other body; the existing object and the read-back are hashed as streams, never held whole.
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, source instanceof Uint8Array ? source : exactLength(await source.open(), size), {
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 hashed and copied as streams, one at a time, within the image ceiling: none is ever
272
- * held whole.
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
- if (present && address && present.sha256 === address) continue;
306
- await ensurePoolObject(input.publicBucket, key, await capturedSource(input.captureBucket, input.tenant, entry), mediaTypeOf(entry));
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
- !sealedMediaTypeAllowed(entry.provenance?.kind, entry.mediaType) ||
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.5",
3
+ "version": "6.2.0",
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",
@@ -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 `remote` or `upload` entry carry `image/svg+xml` (`sealedMediaTypeAllowed`).
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
- * An author upload or a remote capture may also be an SVG: both pass it through the sanitizer
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
- /** May a sealed original of provenance `kind` carry `mediaType`? Every reader of a manifest asks this. */
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" ? 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") 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
+ }