@tokenoftrust/storefront-runner 4.12.1 → 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.
- package/apps/storefront/src/lib/access/auth/internalContentAuthority.ts +1 -0
- package/apps/storefront/src/lib/access/auth/totAccessClient.ts +2 -0
- package/apps/storefront/src/lib/access/dashboard/staffVendorAccess.ts +8 -1
- package/apps/storefront/src/lib/access/shared/accessExplain.ts +2 -0
- package/apps/storefront/src/lib/access/shared/developerInvites.ts +12 -3
- package/apps/storefront/src/lib/access/shared/invitePreflight.ts +2 -1
- package/apps/storefront/src/lib/access/shared/principalGrants.ts +10 -3
- package/apps/storefront/src/lib/assets/captured-original-serve.ts +88 -21
- package/apps/storefront/src/lib/assets/durableR2.ts +27 -16
- package/apps/storefront/src/lib/assets/durableR2Bindings.ts +4 -1
- package/apps/storefront/src/middleware/staticBundle.ts +3 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/image-limits.d.mts +18 -0
- package/packages/public-runtime/src/image-limits.mjs +88 -0
- package/packages/public-runtime/src/tenant.ts +9 -0
|
@@ -98,6 +98,7 @@ export async function authorizeInternalContentRequest(
|
|
|
98
98
|
try {
|
|
99
99
|
response = await (input.fetchImpl ?? fetch)(`${coreUrl}/api/broker/appDomains`, {
|
|
100
100
|
method: "GET",
|
|
101
|
+
redirect: "manual",
|
|
101
102
|
headers: {
|
|
102
103
|
accept: "application/json",
|
|
103
104
|
"X-ToT-Broker-Assertion": assertion,
|
|
@@ -298,6 +298,7 @@ export class HttpTotAccessClient implements TotAccessClient {
|
|
|
298
298
|
} catch {
|
|
299
299
|
return { ok: false, reason: "error" };
|
|
300
300
|
}
|
|
301
|
+
if (res.status >= 300 && res.status < 400) return { ok: false, reason: "error" };
|
|
301
302
|
let body: unknown;
|
|
302
303
|
try {
|
|
303
304
|
body = await res.json();
|
|
@@ -329,6 +330,7 @@ export class HttpTotAccessClient implements TotAccessClient {
|
|
|
329
330
|
}
|
|
330
331
|
return { ok: false, reason: "error" };
|
|
331
332
|
}
|
|
333
|
+
if (res.status >= 300 && res.status < 400) return { ok: false, reason: "error" };
|
|
332
334
|
let body: unknown = "(unread)";
|
|
333
335
|
try {
|
|
334
336
|
body = await res.json();
|
|
@@ -98,13 +98,14 @@ export interface HttpTransport {
|
|
|
98
98
|
/** Default transport: JSON `fetch` GET/POST. */
|
|
99
99
|
export class FetchHttpTransport implements HttpTransport {
|
|
100
100
|
async get(url: string, headers: Record<string, string>) {
|
|
101
|
-
return fetch(url, { method: "GET", headers: { accept: "application/json", ...headers } });
|
|
101
|
+
return fetch(url, { method: "GET", headers: { accept: "application/json", ...headers }, redirect: "manual" });
|
|
102
102
|
}
|
|
103
103
|
async post(url: string, headers: Record<string, string>, body: unknown) {
|
|
104
104
|
return fetch(url, {
|
|
105
105
|
method: "POST",
|
|
106
106
|
headers: { "content-type": "application/json", accept: "application/json", ...headers },
|
|
107
107
|
body: JSON.stringify(body),
|
|
108
|
+
redirect: "manual",
|
|
108
109
|
});
|
|
109
110
|
}
|
|
110
111
|
}
|
|
@@ -191,6 +192,9 @@ export class HttpStaffVendorAccessClient implements StaffVendorAccessClient {
|
|
|
191
192
|
message: "staffVendorAccess: transport failure searching vendors",
|
|
192
193
|
};
|
|
193
194
|
}
|
|
195
|
+
if (res.status >= 300 && res.status < 400) {
|
|
196
|
+
return { ok: false, status: res.status, reason: "error", message: "staffVendorAccess: redirects are not accepted" };
|
|
197
|
+
}
|
|
194
198
|
let parsed: unknown;
|
|
195
199
|
try {
|
|
196
200
|
parsed = await res.json();
|
|
@@ -229,6 +233,9 @@ export class HttpStaffVendorAccessClient implements StaffVendorAccessClient {
|
|
|
229
233
|
message: "staffVendorAccess: transport failure granting access",
|
|
230
234
|
};
|
|
231
235
|
}
|
|
236
|
+
if (res.status >= 300 && res.status < 400) {
|
|
237
|
+
return { ok: false, status: res.status, reason: "error", message: "staffVendorAccess: redirects are not accepted" };
|
|
238
|
+
}
|
|
232
239
|
let parsed: unknown;
|
|
233
240
|
try {
|
|
234
241
|
parsed = await res.json();
|
|
@@ -161,10 +161,12 @@ export class FetchExplainTransport implements ExplainTransport {
|
|
|
161
161
|
try {
|
|
162
162
|
const res = await fetch(url, {
|
|
163
163
|
method: "POST",
|
|
164
|
+
redirect: "manual",
|
|
164
165
|
headers: { "content-type": "application/json", ...headers },
|
|
165
166
|
body: JSON.stringify(body),
|
|
166
167
|
signal: ctrl.signal,
|
|
167
168
|
});
|
|
169
|
+
if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
|
|
168
170
|
return { status: res.status, json: () => res.json() };
|
|
169
171
|
} finally {
|
|
170
172
|
clearTimeout(timer);
|
|
@@ -318,8 +318,9 @@ export interface HttpTransport {
|
|
|
318
318
|
/** Default transport: JSON `fetch` POST/GET/DELETE. */
|
|
319
319
|
export class FetchHttpTransport implements HttpTransport {
|
|
320
320
|
async post(url: string, headers: Record<string, string>, body: unknown) {
|
|
321
|
-
|
|
321
|
+
const res = await fetch(url, {
|
|
322
322
|
method: "POST",
|
|
323
|
+
redirect: "manual",
|
|
323
324
|
headers: {
|
|
324
325
|
"content-type": "application/json",
|
|
325
326
|
accept: "application/json",
|
|
@@ -327,18 +328,26 @@ export class FetchHttpTransport implements HttpTransport {
|
|
|
327
328
|
},
|
|
328
329
|
body: JSON.stringify(body),
|
|
329
330
|
});
|
|
331
|
+
if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
|
|
332
|
+
return res;
|
|
330
333
|
}
|
|
331
334
|
async get(url: string, headers: Record<string, string>) {
|
|
332
|
-
|
|
335
|
+
const res = await fetch(url, {
|
|
333
336
|
method: "GET",
|
|
337
|
+
redirect: "manual",
|
|
334
338
|
headers: { accept: "application/json", ...headers },
|
|
335
339
|
});
|
|
340
|
+
if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
|
|
341
|
+
return res;
|
|
336
342
|
}
|
|
337
343
|
async delete(url: string, headers: Record<string, string>) {
|
|
338
|
-
|
|
344
|
+
const res = await fetch(url, {
|
|
339
345
|
method: "DELETE",
|
|
346
|
+
redirect: "manual",
|
|
340
347
|
headers: { accept: "application/json", ...headers },
|
|
341
348
|
});
|
|
349
|
+
if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
|
|
350
|
+
return res;
|
|
342
351
|
}
|
|
343
352
|
}
|
|
344
353
|
|
|
@@ -41,7 +41,8 @@ export class FetchPreflightTransport implements PreflightTransport {
|
|
|
41
41
|
const ctrl = new AbortController();
|
|
42
42
|
const timer = setTimeout(() => ctrl.abort(), PREFLIGHT_TIMEOUT_MS);
|
|
43
43
|
try {
|
|
44
|
-
const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal });
|
|
44
|
+
const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal, redirect: "manual" });
|
|
45
|
+
if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
|
|
45
46
|
return { status: res.status, json: () => res.json() };
|
|
46
47
|
} finally {
|
|
47
48
|
clearTimeout(timer);
|
|
@@ -287,17 +287,24 @@ export interface HttpTransport {
|
|
|
287
287
|
|
|
288
288
|
export class FetchHttpTransport implements HttpTransport {
|
|
289
289
|
async post(url: string, headers: Record<string, string>, body: unknown) {
|
|
290
|
-
|
|
290
|
+
const res = await fetch(url, {
|
|
291
291
|
method: "POST",
|
|
292
|
+
redirect: "manual",
|
|
292
293
|
headers: { "content-type": "application/json", accept: "application/json", ...headers },
|
|
293
294
|
body: JSON.stringify(body),
|
|
294
295
|
});
|
|
296
|
+
if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
|
|
297
|
+
return res;
|
|
295
298
|
}
|
|
296
299
|
async get(url: string, headers: Record<string, string>) {
|
|
297
|
-
|
|
300
|
+
const res = await fetch(url, { method: "GET", headers: { accept: "application/json", ...headers }, redirect: "manual" });
|
|
301
|
+
if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
|
|
302
|
+
return res;
|
|
298
303
|
}
|
|
299
304
|
async delete(url: string, headers: Record<string, string>) {
|
|
300
|
-
|
|
305
|
+
const res = await fetch(url, { method: "DELETE", headers: { accept: "application/json", ...headers }, redirect: "manual" });
|
|
306
|
+
if (res.status >= 300 && res.status < 400) throw new Error("authenticated transport refused redirect");
|
|
307
|
+
return res;
|
|
301
308
|
}
|
|
302
309
|
}
|
|
303
310
|
|
|
@@ -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! >
|
|
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
|
|
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)
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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":
|
|
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(
|
|
194
|
+
"content-length": String(entry.byteLength),
|
|
183
195
|
...POOL_ASSET_SECURITY_HEADERS,
|
|
184
196
|
};
|
|
185
|
-
const
|
|
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
|
-
|
|
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,
|
|
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}`,
|
|
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,
|
|
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,
|
|
174
|
-
entry, tenant: input.tenant, mediaType: mediaTypeOf(entry), size
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|