@tokenoftrust/storefront-runner 4.12.2 → 4.13.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.
@@ -2,9 +2,16 @@
2
2
  * Worker-safe serve of revision-bound captured originals.
3
3
  *
4
4
  * Does not import Node capture — R2 + Web Crypto only. Bytes are served only
5
- * when the sealed revision manifest names the address.
5
+ * when the sealed revision manifest names the address, within the tenant's image tier
6
+ * (`imageTier`, resolved by the caller from the server-side tenant record).
7
+ *
8
+ * Where the runtime has `crypto.DigestStream` (workerd), the R2 body is streamed to the client
9
+ * through a verifier that counts and hashes it and holds back the last chunk until both match, so a
10
+ * wrong or truncated object never reaches a client complete. Elsewhere the object is read whole,
11
+ * bounded by the manifest's byte length, and verified before the first byte.
6
12
  */
7
13
  import { sha256Hex } from "@tot/public-runtime";
14
+ import { imageLimitsFor, type ImageTier } from "@tot/public-runtime/image-limits";
8
15
  import {
9
16
  PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE, PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION, sealedMediaTypeAllowed,
10
17
  } from "@tot/public-runtime/hosted-image-media-types";
@@ -58,7 +65,9 @@ async function readManifestEntries(
58
65
  bucket: CapturedOriginalBucket,
59
66
  tenantId: string,
60
67
  revisionId: string,
68
+ imageTier: ImageTier | undefined,
61
69
  ): Promise<ManifestEntry[] | null> {
70
+ const maxEntryBytes = imageLimitsFor(imageTier).maxEncodedBytes;
62
71
  if (!isSafeTenantId(tenantId) || !isSafeRevisionId(revisionId)) return null;
63
72
  const object = await bucket.get(originalImageManifestObjectKey(tenantId, revisionId));
64
73
  if (!object || typeof object !== "object") return null;
@@ -79,7 +88,7 @@ async function readManifestEntries(
79
88
  if (!raw || typeof raw !== "object") return null;
80
89
  const entry = raw as Partial<ManifestEntry>;
81
90
  if (typeof entry.address !== "string" || !ADDRESS_RE.test(entry.address)) return null;
82
- if (!Number.isSafeInteger(entry.byteLength) || entry.byteLength! < 1 || entry.byteLength! > 8 * 1024 * 1024 ||
91
+ if (!Number.isSafeInteger(entry.byteLength) || entry.byteLength! < 1 || entry.byteLength! > maxEntryBytes ||
83
92
  !sealedMediaTypeAllowed(entry.provenance?.kind, entry.mediaType)) return null;
84
93
  entries.push({
85
94
  address: entry.address,
@@ -101,6 +110,8 @@ export async function readDurableCapturedEntry(input: {
101
110
  tenantId: string;
102
111
  provider: string | null | undefined;
103
112
  address: string;
113
+ /** The tenant's image tier from its server-resolved record (`resolveImageTier`); absent is standard. */
114
+ imageTier?: ImageTier;
104
115
  }): Promise<ManifestEntry | null> {
105
116
  if (!isSafeTenantId(input.tenantId) || !ADDRESS_RE.test(input.address)) return null;
106
117
  const required = durableRequiredStores(input.provider);
@@ -111,7 +122,7 @@ export async function readDurableCapturedEntry(input: {
111
122
  if (typeof designation.entry !== "string" || !designation.entry.startsWith(`${input.address}.`) ||
112
123
  !Array.isArray(designation.verifiedStores) || !required.every((store) => designation.verifiedStores.includes(store)) ||
113
124
  typeof designation.designatedRevision !== "string" || !isSafeRevisionId(designation.designatedRevision)) continue;
114
- const entries = await readManifestEntries(input.bucket, input.tenantId, durableCaptureRevisionId(designation.designatedRevision));
125
+ const entries = await readManifestEntries(input.bucket, input.tenantId, durableCaptureRevisionId(designation.designatedRevision), input.imageTier);
115
126
  const entry = entries?.find((entry) => entry.address === input.address &&
116
127
  designation.entry === `${entry.address}${PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE[entry.mediaType]}`);
117
128
  if (entry) return entry;
@@ -130,6 +141,8 @@ export async function tryServeCapturedOriginal(input: {
130
141
  durable?: { kv: { get(key: string): Promise<string | null> }; provider: string | null | undefined };
131
142
  pathname: string;
132
143
  method?: string;
144
+ /** The tenant's image tier from its server-resolved record (`resolveImageTier`); absent is standard. */
145
+ imageTier?: ImageTier;
133
146
  }): Promise<Response | null> {
134
147
  const parsed = parsePinnedImagePath(input.pathname);
135
148
  if (!parsed) return null;
@@ -137,18 +150,21 @@ export async function tryServeCapturedOriginal(input: {
137
150
  return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
138
151
  }
139
152
 
140
- const entries = input.revisionId ? await readManifestEntries(input.bucket, input.tenantId, input.revisionId).catch(() => null) : null;
153
+ const entries = input.revisionId
154
+ ? await readManifestEntries(input.bucket, input.tenantId, input.revisionId, input.imageTier).catch(() => null)
155
+ : null;
141
156
  let entry = entries?.find((item) => item.address === parsed.address);
142
157
  let durable = false;
143
158
  if (!entry && input.revisionId) {
144
- const declared = await readManifestEntries(input.bucket, input.tenantId, durableCaptureRevisionId(input.revisionId)).catch(() => null);
159
+ const declared = await readManifestEntries(input.bucket, input.tenantId, durableCaptureRevisionId(input.revisionId), input.imageTier)
160
+ .catch(() => null);
145
161
  entry = declared?.find((item) => item.address === parsed.address);
146
162
  durable = !!entry;
147
163
  }
148
164
  if (!entry && input.durable) {
149
165
  try {
150
166
  entry = await readDurableCapturedEntry({ bucket: input.bucket, tenantId: input.tenantId,
151
- address: parsed.address, ...input.durable }) ?? undefined;
167
+ address: parsed.address, ...input.durable, ...(input.imageTier ? { imageTier: input.imageTier } : {}) }) ?? undefined;
152
168
  durable = !!entry;
153
169
  } catch { /* Unreadable durable authority fails closed. */ }
154
170
  }
@@ -162,29 +178,80 @@ export async function tryServeCapturedOriginal(input: {
162
178
  if (!object || typeof object !== "object") {
163
179
  return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
164
180
  }
165
- const body = object as { arrayBuffer?: () => Promise<ArrayBuffer> };
166
- if (typeof body.arrayBuffer !== "function") {
167
- return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
168
- }
169
- const bytes = new Uint8Array(await body.arrayBuffer());
170
- if (bytes.byteLength !== entry.byteLength) {
181
+ const stored = object as StoredObject;
182
+ const notFound = async () => {
183
+ await stored.body?.cancel?.().catch(() => {});
171
184
  return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
172
- }
173
- const digest = await sha256Hex(bytes);
174
- if (digest !== parsed.address) {
175
- return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
176
- }
177
- const contentType = EXT_TYPE[parsed.ext.toLowerCase()] ?? entry.mediaType;
185
+ };
186
+ if (typeof stored.size === "number" && stored.size !== entry.byteLength) return notFound();
187
+ const checksum = stored.checksums?.sha256;
188
+ if (checksum && hex(checksum) !== parsed.address) return notFound();
189
+ const method = (input.method ?? "GET").toUpperCase();
178
190
  const headers: Record<string, string> = {
179
- "content-type": contentType,
191
+ "content-type": EXT_TYPE[parsed.ext.toLowerCase()] ?? entry.mediaType,
180
192
  "cache-control": durable ? "no-store" : "public, max-age=31536000, immutable",
181
193
  "x-tot-original-image": durable ? "durable" : "revision-bound",
182
- "content-length": String(bytes.byteLength),
194
+ "content-length": String(entry.byteLength),
183
195
  ...POOL_ASSET_SECURITY_HEADERS,
184
196
  };
185
- const method = (input.method ?? "GET").toUpperCase();
197
+ const Digest = digestStreamClass();
198
+ if (Digest && stored.body && typeof stored.size === "number") {
199
+ if (method === "HEAD") {
200
+ await stored.body.cancel?.().catch(() => {});
201
+ return new Response(null, { status: 200, headers });
202
+ }
203
+ return new Response(verifiedBody(stored.body, entry.byteLength, parsed.address, Digest), { status: 200, headers });
204
+ }
205
+ if (typeof stored.arrayBuffer !== "function") return notFound();
206
+ const bytes = new Uint8Array(await stored.arrayBuffer());
207
+ if (bytes.byteLength !== entry.byteLength || await sha256Hex(bytes) !== parsed.address) return notFound();
186
208
  return new Response(method === "HEAD" ? null : bytes, { status: 200, headers });
187
209
  } catch {
188
210
  return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
189
211
  }
190
212
  }
213
+
214
+ type StoredObject = {
215
+ body?: (ReadableStream<Uint8Array> & { cancel?: () => Promise<void> }) | null;
216
+ size?: number;
217
+ arrayBuffer?: () => Promise<ArrayBuffer>;
218
+ /** R2 keeps the sha256 an upload declared; capture declares it when it streams an original in. */
219
+ checksums?: { sha256?: ArrayBuffer };
220
+ };
221
+
222
+ type DigestStreamClass = new (algorithm: string) => WritableStream<Uint8Array> & { digest: Promise<ArrayBuffer> };
223
+
224
+ function digestStreamClass(): DigestStreamClass | null {
225
+ const found = (globalThis.crypto as unknown as { DigestStream?: DigestStreamClass } | undefined)?.DigestStream;
226
+ return typeof found === "function" ? found : null;
227
+ }
228
+
229
+ function hex(buffer: ArrayBuffer): string {
230
+ return [...new Uint8Array(buffer)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
231
+ }
232
+
233
+ /**
234
+ * The R2 body, passed through as it is read: counted and hashed on the way, with the last chunk held
235
+ * back until the whole body has matched its length and address. An overrun, a short body or a
236
+ * wrong hash errors the stream instead, so a client never receives a complete wrong object.
237
+ */
238
+ function verifiedBody(body: ReadableStream<Uint8Array>, length: number, address: string, Digest: DigestStreamClass): ReadableStream<Uint8Array> {
239
+ const digest = new Digest("SHA-256");
240
+ const writer = digest.getWriter();
241
+ let seen = 0;
242
+ let held: Uint8Array | null = null;
243
+ return body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
244
+ async transform(chunk, controller) {
245
+ seen += chunk.byteLength;
246
+ if (seen > length) throw new Error("captured original is longer than its manifest entry");
247
+ await writer.write(chunk);
248
+ if (held) controller.enqueue(held);
249
+ held = chunk;
250
+ },
251
+ async flush(controller) {
252
+ await writer.close();
253
+ if (seen !== length || hex(await digest.digest) !== address) throw new Error("captured original does not match its address");
254
+ if (held) controller.enqueue(held);
255
+ },
256
+ }));
257
+ }
@@ -20,8 +20,8 @@ import {
20
20
  } from "@tot/public-runtime/durable-assets";
21
21
  import { GC_LEASE_KEY, awaitGcQuiescence } from "@tot/public-runtime/retention-holds";
22
22
  import { authorizeAssetReference, type AssetRegistryStore, type AssetWrite } from "@tot/public-runtime/asset-registry";
23
+ import { imageLimitsFor, type ImageTier } from "@tot/public-runtime/image-limits";
23
24
 
24
- const MAX_IMAGE = 8 * 1024 * 1024;
25
25
  const MAX_MANIFEST = 1024 * 1024;
26
26
  const POOL_ENTRY_RE = /^([0-9a-f]{64})(\.[A-Za-z0-9]+)$/;
27
27
  const IMMUTABLE = "public, max-age=31536000, immutable";
@@ -99,7 +99,8 @@ export function awaitR2Quiescence(bucket: R2BucketLike, waiter: QuiescenceWaiter
99
99
  export async function ensurePoolObject(bucket: R2BucketLike, key: string, bytes: Uint8Array, mediaType: string): Promise<void> {
100
100
  const address = POOL_ENTRY_RE.exec(key.replace(/^assets\//, ""))?.[1];
101
101
  if (!address) throw new R2StagingError("pool-key");
102
- const existing = await readBounded(bucket, key, MAX_IMAGE);
102
+ // Bounded by the bytes being staged: an object of any other size cannot hold this address.
103
+ const existing = await readBounded(bucket, key, bytes.byteLength);
103
104
  if (existing && await sha256Hex(existing) !== address) throw new R2StagingError("store-mismatch");
104
105
  if (!existing) {
105
106
  if (!bucket.put) throw new R2StagingError("store-unwritable");
@@ -108,7 +109,7 @@ export async function ensurePoolObject(bucket: R2BucketLike, key: string, bytes:
108
109
  onlyIf: new Headers({ "If-None-Match": "*" }),
109
110
  });
110
111
  }
111
- const readback = await readBounded(bucket, key, MAX_IMAGE);
112
+ const readback = await readBounded(bucket, key, bytes.byteLength);
112
113
  if (!readback || readback.byteLength !== bytes.byteLength || await sha256Hex(readback) !== address) {
113
114
  throw new R2StagingError("store-mismatch");
114
115
  }
@@ -131,11 +132,11 @@ function mediaTypeOf(entry: string): string {
131
132
  return mediaType;
132
133
  }
133
134
 
134
- /** The exact captured bytes for `<sha256><ext>` from the tenant's capture store. */
135
- async function capturedBytes(captureBucket: R2BucketLike, tenant: string, entry: string): Promise<Uint8Array> {
135
+ /** The exact captured bytes for `<sha256><ext>` from the tenant's capture store, within its tier. */
136
+ async function capturedBytes(captureBucket: R2BucketLike, tenant: string, entry: string, imageTier: ImageTier | undefined): Promise<Uint8Array> {
136
137
  const address = POOL_ENTRY_RE.exec(entry)?.[1];
137
138
  if (!address) throw new R2StagingError("pool-key");
138
- const bytes = await readBounded(captureBucket, `original-images/${tenant}/objects/${address}`, MAX_IMAGE);
139
+ const bytes = await readBounded(captureBucket, `original-images/${tenant}/objects/${address}`, imageLimitsFor(imageTier).maxEncodedBytes);
139
140
  if (!bytes || bytes.byteLength < 1 || await sha256Hex(bytes) !== address) throw new R2StagingError("original-missing");
140
141
  return bytes;
141
142
  }
@@ -149,6 +150,8 @@ async function capturedBytes(captureBucket: R2BucketLike, tenant: string, entry:
149
150
  * it (ADR 0038 §11). Each entry must be registered to this tenant in the asset registry, or be in the
150
151
  * tenant's own capture store (which registers it); otherwise nothing is staged and the go-live
151
152
  * refuses `asset-unauthorized`.
153
+ *
154
+ * Originals are read one at a time, within the tenant's image tier, and never held across entries.
152
155
  */
153
156
  export async function stageCapturedOriginalsR2(input: {
154
157
  tenant: string;
@@ -156,22 +159,24 @@ export async function stageCapturedOriginalsR2(input: {
156
159
  captureBucket: R2BucketLike;
157
160
  publicBucket: R2BucketLike;
158
161
  registry?: AssetRegistryStore;
162
+ /** The tenant's image tier from its server-resolved record (`resolveImageTier`); absent is standard. */
163
+ imageTier?: ImageTier;
159
164
  }): Promise<number> {
160
165
  const registry = input.registry ? await input.registry.read().catch(() => null) : null;
161
- const owned = new Map<string, Uint8Array>();
166
+ const owned = new Map<string, number>();
162
167
  const unauthorized: string[] = [];
163
168
  for (const entry of input.entries) {
164
169
  if (authorizeAssetReference([registry], input.tenant, entry).ok) continue;
165
- const own = await capturedBytes(input.captureBucket, input.tenant, entry).catch((error: unknown) => {
170
+ const own = await capturedBytes(input.captureBucket, input.tenant, entry, input.imageTier).catch((error: unknown) => {
166
171
  if (error instanceof R2StagingError) return null;
167
172
  throw error;
168
173
  });
169
- if (own) owned.set(entry, own);
174
+ if (own) owned.set(entry, own.byteLength);
170
175
  else unauthorized.push(entry);
171
176
  }
172
177
  if (input.registry && owned.size > 0) {
173
- const writes: AssetWrite[] = [...owned].map(([entry, bytes]) => ({
174
- entry, tenant: input.tenant, mediaType: mediaTypeOf(entry), size: bytes.byteLength,
178
+ const writes: AssetWrite[] = [...owned].map(([entry, size]) => ({
179
+ entry, tenant: input.tenant, mediaType: mediaTypeOf(entry), size,
175
180
  }));
176
181
  await input.registry.upsert(writes).catch(() => "lost");
177
182
  }
@@ -179,10 +184,10 @@ export async function stageCapturedOriginalsR2(input: {
179
184
  let staged = 0;
180
185
  for (const entry of input.entries) {
181
186
  const key = `assets/${entry}`;
182
- const present = await readBounded(input.publicBucket, key, MAX_IMAGE);
187
+ const present = await readBounded(input.publicBucket, key, imageLimitsFor(input.imageTier).maxEncodedBytes);
183
188
  const address = POOL_ENTRY_RE.exec(entry)?.[1];
184
189
  if (present && address && await sha256Hex(present) === address) continue;
185
- await ensurePoolObject(input.publicBucket, key, owned.get(entry) ?? await capturedBytes(input.captureBucket, input.tenant, entry), mediaTypeOf(entry));
190
+ await ensurePoolObject(input.publicBucket, key, await capturedBytes(input.captureBucket, input.tenant, entry, input.imageTier), mediaTypeOf(entry));
186
191
  staged++;
187
192
  }
188
193
  return staged;
@@ -231,6 +236,8 @@ export async function designateDurableR2(input: {
231
236
  awaitQuiescence?: QuiescenceWaiter;
232
237
  /** The pool's asset registry: each verified designation is recorded as a transition to `durable`. */
233
238
  registry?: AssetRegistryStore;
239
+ /** The tenant's image tier from its server-resolved record (`resolveImageTier`); absent is standard. */
240
+ imageTier?: ImageTier;
234
241
  }): Promise<DurableDesignationResult> {
235
242
  if (input.images.length === 0) return { ok: true, designated: [] };
236
243
  const revision = durableCaptureRevisionId(input.revision);
@@ -274,7 +281,7 @@ export async function designateDurableR2(input: {
274
281
  for (const item of prepared) {
275
282
  await createHold(input.publicBucket, item.holdKey, item.holdBody);
276
283
  await ensurePoolObject(input.publicBucket, `assets/${item.entry}`,
277
- await capturedBytes(input.captureBucket, input.tenant, item.entry), item.mediaType);
284
+ await capturedBytes(input.captureBucket, input.tenant, item.entry, input.imageTier), item.mediaType);
278
285
  }
279
286
  await recordDurableStatusKv(input.kv, input.tenant, { revision: input.revision, at: now().toISOString(), declared,
280
287
  designated: prepared.map((item) => ({ entry: item.entry, name: item.image.name, source: item.image.source, verifiedStores: ["r2"] })) });
@@ -299,6 +306,8 @@ export function makeWorkerR2PinnedOriginalStager(deps: {
299
306
  kv: StatusKvLike;
300
307
  /** The pool's asset registry (`STATIC_BUNDLES` `registry/assets.json`). */
301
308
  registry?: AssetRegistryStore;
309
+ /** The tier of the tenant this Worker resolves for `tenantId`, from its server-side record. */
310
+ imageTier?: (tenantId: string) => Promise<ImageTier>;
302
311
  now?: () => Date;
303
312
  awaitQuiescence?: QuiescenceWaiter;
304
313
  }) {
@@ -315,15 +324,17 @@ export function makeWorkerR2PinnedOriginalStager(deps: {
315
324
  if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string" || !POOL_ENTRY_RE.test(entry))) {
316
325
  throw new R2StagingError("manifest-invalid");
317
326
  }
327
+ const imageTier = deps.imageTier ? await deps.imageTier(tenantId) : undefined;
318
328
  await awaitR2Quiescence(deps.publicBucket, deps.awaitQuiescence);
319
329
  await stageCapturedOriginalsR2({ tenant: tenantId, entries: entries as string[],
320
- captureBucket: deps.captureBucket, publicBucket: deps.publicBucket, ...(deps.registry ? { registry: deps.registry } : {}) });
330
+ captureBucket: deps.captureBucket, publicBucket: deps.publicBucket, ...(deps.registry ? { registry: deps.registry } : {}),
331
+ ...(imageTier ? { imageTier } : {}) });
321
332
  const images = manifest.durableAssets?.images ?? [];
322
333
  if (images.length > 0) {
323
334
  if (typeof manifest.sourceSha !== "string") throw new R2StagingError("manifest-invalid");
324
335
  const result = await designateDurableR2({ tenant: tenantId, revision: manifest.sourceSha, images,
325
336
  captureBucket: deps.captureBucket, publicBucket: deps.publicBucket, kv: deps.kv,
326
- ...(deps.registry ? { registry: deps.registry } : {}),
337
+ ...(deps.registry ? { registry: deps.registry } : {}), ...(imageTier ? { imageTier } : {}),
327
338
  ...(deps.now ? { now: deps.now } : {}), ...(deps.awaitQuiescence ? { awaitQuiescence: deps.awaitQuiescence } : {}) });
328
339
  if (!result.ok) throw new R2StagingError(result.reason);
329
340
  }
@@ -7,8 +7,10 @@
7
7
  import { readKv, readR2 } from "@/lib/publication/shared/env";
8
8
  import { makeWorkerR2PinnedOriginalStager, R2StagingError, type R2BucketLike, type StatusKvLike } from "./durableR2.js";
9
9
  import { createR2AssetRegistry, type AssetRegistryBucket } from "./assetRegistryR2.js";
10
+ import type { ImageTier } from "@tot/public-runtime/image-limits";
10
11
 
11
- export async function workerR2PinnedOriginalStagerFromEnv(): Promise<
12
+ /** `imageTier` resolves a tenant's tier from its server-side record; the caller owns that lookup. */
13
+ export async function workerR2PinnedOriginalStagerFromEnv(imageTier: (tenantId: string) => Promise<ImageTier>): Promise<
12
14
  (args: { tenantId: string; versionId: string; treeDigest?: string | null }) => Promise<void>
13
15
  > {
14
16
  const [publicBucket, productImages, tenantAssets, kv] = await Promise.all([
@@ -29,5 +31,6 @@ export async function workerR2PinnedOriginalStagerFromEnv(): Promise<
29
31
  captureBucket: captureBucket as unknown as R2BucketLike,
30
32
  kv: kv as unknown as StatusKvLike,
31
33
  registry: createR2AssetRegistry(publicBucket as unknown as AssetRegistryBucket),
34
+ imageTier,
32
35
  });
33
36
  }
@@ -16,6 +16,7 @@ import {
16
16
  versionIndependentAssetKey,
17
17
  } from "@tot/public-runtime";
18
18
  import type { StaticBundleR2BucketLike, TenantConfig } from "@tot/public-runtime";
19
+ import { resolveImageTier } from "@tot/public-runtime/image-limits";
19
20
 
20
21
  export interface StaticBundleAttemptInput {
21
22
  request: Request;
@@ -94,6 +95,7 @@ export async function tryServeStaticBundle(
94
95
  revisionId: input.capturedOriginal.revisionId,
95
96
  pathname: assetPath,
96
97
  method,
98
+ imageTier: resolveImageTier(tenant),
97
99
  ...(kv ? { durable: { kv, provider: tenant.serving?.provider } } : {}),
98
100
  });
99
101
  if (captured) return captured;
@@ -107,6 +109,7 @@ export async function tryServeStaticBundle(
107
109
  revisionId: input.capturedOriginal.revisionId,
108
110
  pathname: assetPath,
109
111
  method,
112
+ imageTier: resolveImageTier(tenant),
110
113
  ...(kv ? { durable: { kv, provider: tenant.serving?.provider } } : {}),
111
114
  });
112
115
  if (captured) return captured;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.12.2",
3
+ "version": "4.13.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",
@@ -24,6 +24,7 @@
24
24
  "./asset-registry": "./src/asset-registry.ts",
25
25
  "./responsive-renditions": "./src/responsive-renditions.ts",
26
26
  "./svg-sanitize": "./src/svg-sanitize.ts",
27
+ "./image-limits": "./src/image-limits.mjs",
27
28
  "./image-release-gate": "./src/image-release-gate.ts",
28
29
  "./pool-asset-headers": "./src/pool-asset-headers.ts",
29
30
  "./catalog-files": "./src/catalog-files.ts",
@@ -0,0 +1,18 @@
1
+ export type ImageTier = "standard" | "large";
2
+
3
+ export interface ImageLimits {
4
+ readonly maxEncodedBytes: number;
5
+ readonly maxPixels: number;
6
+ readonly maxDimension: number;
7
+ }
8
+
9
+ export declare const IMAGE_TIERS: Readonly<Record<ImageTier, ImageLimits>>;
10
+ export declare const IMAGE_LIMITS_CEILING: ImageLimits;
11
+ export declare const LARGE_IMAGES_ADDON_NAME: string;
12
+ export declare function imageLimitsFor(tier: unknown): ImageLimits;
13
+ export declare function resolveImageTier(tenant: unknown): ImageTier;
14
+ export declare function imageLimitsForTenant(tenant: unknown): ImageLimits;
15
+ export declare function imageLimitSummary(tier: ImageTier): string;
16
+ export declare function withinImagePixels(
17
+ width: number, height: number, frames: number, limits: Pick<ImageLimits, "maxPixels" | "maxDimension">,
18
+ ): boolean;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The one source of storefront image size limits. Every capture, upload, publication and serving
3
+ * path takes its byte, pixel and dimension caps from `imageLimitsFor(resolveImageTier(tenant))`;
4
+ * no path restates a literal (`image-limits-contract.test.mjs` scans the tree for one).
5
+ *
6
+ * Tiers are per tenant. `large` is the High Fidelity / Large Images add-on, read only from the
7
+ * server-resolved tenant record (`addOns.largeImages.mode === "enabled"`); every other value,
8
+ * including absent or malformed, is `standard` (fail closed, the same contract as
9
+ * `storefront.mode`).
10
+ *
11
+ * Pixels are counted as width x height x frames. No path decodes pixels inside the Worker:
12
+ * dimensions are read from a bounded header prefix, and display sizes are produced by the CDN.
13
+ *
14
+ * Resize paths and their input limits (measured 2026-11-03 against the tree and official docs):
15
+ *
16
+ * | path | used by | max input bytes | max pixels | max side |
17
+ * | ------------------------------------------------- | --------------------------------------- | --------------- | ---------- | --------- |
18
+ * | R2 original + cdn-cgi image transform (cf.image) | Cloudflare-served display sizes | 100 MB | 100 MP | 12,000 px |
19
+ * | node-side sharp renditions (responsive-images.mjs)| CloudFront/S3 publication renditions | none (stream) | 268 MP (limitInputPixels default) | none |
20
+ * | Cloudflare Images stored / Images binding .input()| NOT USED (no binding or upload in tree) | 10 MB / 20 MB | 100 MP | 12,000 px |
21
+ * | Worker isolate | capture and serve (streamed, no decode) | 128 MB memory | n/a | n/a |
22
+ *
23
+ * Animated inputs over 50 MP pass through cf.image untransformed; the large tier's 25 MP total
24
+ * frame-area cap keeps every accepted image below that. If a consumer ever moves to Cloudflare
25
+ * Images storage or the Images binding, cap `large` at that path's byte limit.
26
+ */
27
+
28
+ const MiB = 1024 * 1024;
29
+
30
+ /** @type {Readonly<Record<"standard" | "large", Readonly<{ maxEncodedBytes: number, maxPixels: number, maxDimension: number }>>>} */
31
+ export const IMAGE_TIERS = Object.freeze({
32
+ standard: Object.freeze({ maxEncodedBytes: 8 * MiB, maxPixels: 2 ** 23, maxDimension: 8192 }),
33
+ large: Object.freeze({ maxEncodedBytes: 30 * MiB, maxPixels: 25_000_000, maxDimension: 8192 }),
34
+ });
35
+
36
+ /** The highest limit any tenant can have: for clients that cannot know the tenant's tier. */
37
+ export const IMAGE_LIMITS_CEILING = IMAGE_TIERS.large;
38
+
39
+ export const LARGE_IMAGES_ADDON_NAME = "High Fidelity / Large Images";
40
+
41
+ /** @param {unknown} tier */
42
+ export function imageLimitsFor(tier) {
43
+ return tier === "large" ? IMAGE_TIERS.large : IMAGE_TIERS.standard;
44
+ }
45
+
46
+ /** @param {unknown} tenant */
47
+ export function resolveImageTier(tenant) {
48
+ if (!tenant || typeof tenant !== "object") return "standard";
49
+ const addOns = /** @type {Record<string, unknown>} */ (tenant).addOns;
50
+ if (!addOns || typeof addOns !== "object") return "standard";
51
+ const largeImages = /** @type {Record<string, unknown>} */ (addOns).largeImages;
52
+ if (!largeImages || typeof largeImages !== "object") return "standard";
53
+ return /** @type {Record<string, unknown>} */ (largeImages).mode === "enabled" ? "large" : "standard";
54
+ }
55
+
56
+ /** @param {unknown} tenant */
57
+ export function imageLimitsForTenant(tenant) {
58
+ return imageLimitsFor(resolveImageTier(tenant));
59
+ }
60
+
61
+ const mb = (bytes) => `${Math.round((bytes / MiB) * 10) / 10} MB`;
62
+ const mp = (pixels) => `${Math.round((pixels / 1_000_000) * 10) / 10} MP`;
63
+
64
+ /**
65
+ * The tier-aware refusal sentence for an image over its tenant's limit.
66
+ * @param {"standard" | "large"} tier
67
+ */
68
+ export function imageLimitSummary(tier) {
69
+ const limits = imageLimitsFor(tier);
70
+ const base = `${mb(limits.maxEncodedBytes)}, ${mp(limits.maxPixels)} or ${limits.maxDimension}px on a side`;
71
+ if (tier === "large") return `This store's ${LARGE_IMAGES_ADDON_NAME} limit is ${base}.`;
72
+ const large = IMAGE_TIERS.large;
73
+ return `This store's image limit is ${base}; the ${LARGE_IMAGES_ADDON_NAME} add-on raises it to `
74
+ + `${mb(large.maxEncodedBytes)} and ${mp(large.maxPixels)}.`;
75
+ }
76
+
77
+ /**
78
+ * @param {number} width
79
+ * @param {number} height
80
+ * @param {number} frames
81
+ * @param {{ maxPixels: number, maxDimension: number }} limits
82
+ */
83
+ export function withinImagePixels(width, height, frames, limits) {
84
+ if (![width, height, frames].every((n) => Number.isSafeInteger(n) && n > 0)) return false;
85
+ if (width > limits.maxDimension || height > limits.maxDimension) return false;
86
+ const pixels = width * height * frames;
87
+ return Number.isSafeInteger(pixels) && pixels <= limits.maxPixels;
88
+ }
@@ -739,6 +739,15 @@ export interface TenantConfig {
739
739
  */
740
740
  mode?: StorefrontMode;
741
741
  };
742
+ /**
743
+ * Per-store add-ons. `largeImages` is the High Fidelity / Large Images add-on: `mode ===
744
+ * "enabled"` raises the store's image limits to the large tier (`image-limits.mjs`
745
+ * `resolveImageTier`); any other value, or absent, is the standard tier. `updatedBy` is an
746
+ * opaque actor id, never an email or other personal detail.
747
+ */
748
+ addOns?: {
749
+ largeImages?: { mode: "enabled" | "disabled"; updatedAt?: string; updatedBy?: string };
750
+ };
742
751
  /**
743
752
  * Internal test/e2e tenant — never a real client. A tenant carrying this
744
753
  * marker (or an `.e2e.test` appDomain) is excluded from real/prospect-facing