@tokenoftrust/storefront-runner 4.13.0 → 4.14.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.
@@ -17,9 +17,10 @@
17
17
  */
18
18
  import type { TenantConfig, TenantResolver } from "@tot/public-runtime";
19
19
  import { withPlatformPolicy } from "@/config/tenants";
20
+ import { TENANT_RECORD_PREFIX } from "@/config/storedTenantRecord";
20
21
 
21
22
  const DOMAIN_PREFIX = "domain:";
22
- const TENANT_PREFIX = "tenant:";
23
+ const TENANT_PREFIX = TENANT_RECORD_PREFIX;
23
24
  const DEFAULT_KEY = "config:default_tenant";
24
25
  // Edge-cache KV reads briefly; tenant config changes are rare and propagate
25
26
  // within this window. Bump down if you need faster propagation after a write.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The `tenant:<id>` KV record exactly as stored, for an in-place edit of one field (the admin
3
+ * add-ons switch). Separate from kvTenantResolver so lib/pages can use it without importing the
4
+ * concrete resolver (depcruise `tenancy-resolver-fence`); the resolver takes its key prefix from here.
5
+ */
6
+ export const TENANT_RECORD_PREFIX = "tenant:";
7
+
8
+ /**
9
+ * Read with no edge cache (`cacheTtl`) and no platform-policy overlay. A read-modify-write must
10
+ * start here, never from `resolveStrict`, which can return a copy up to its cache TTL old and
11
+ * carries platform fields the record itself does not hold.
12
+ */
13
+ export async function getStoredTenantRecord(kv: KVNamespace, tenantId: string): Promise<string | null> {
14
+ return kv.get(TENANT_RECORD_PREFIX + tenantId);
15
+ }
16
+
17
+ /**
18
+ * Write ONLY the `tenant:<id>` record key. Unlike putTenant it never touches the domain index or
19
+ * the default-tenant key, so an in-place edit of one field cannot re-point a hostname.
20
+ */
21
+ export async function putStoredTenantRecord(
22
+ kv: KVNamespace,
23
+ tenantId: string,
24
+ record: Record<string, unknown>,
25
+ ): Promise<void> {
26
+ await kv.put(TENANT_RECORD_PREFIX + tenantId, JSON.stringify(record));
27
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Client island for `/admin/addons` (ADR 0013): each add-on card's button POSTs the mode it names to
3
+ * `/api/admin/addons` and redraws the card from the confirmed state the API returns. A no-op when
4
+ * the page rendered no cards (unauthorized viewer).
5
+ */
6
+ export function initAddOnsPage(apiPath: string): void {
7
+ for (const button of document.querySelectorAll<HTMLButtonElement>("[data-addon-toggle]")) {
8
+ button.addEventListener("click", () => void toggle(apiPath, button));
9
+ }
10
+ }
11
+
12
+ async function toggle(apiPath: string, button: HTMLButtonElement): Promise<void> {
13
+ const card = button.closest<HTMLElement>("[data-addon-id]");
14
+ const id = card?.dataset.addonId;
15
+ const mode = button.dataset.addonNextMode;
16
+ const status = card?.querySelector<HTMLElement>("[data-addon-status]");
17
+ if (!card || !id || !mode) return;
18
+
19
+ button.disabled = true;
20
+ if (status) {
21
+ status.textContent = "Saving…";
22
+ status.dataset.tone = "";
23
+ }
24
+ try {
25
+ const res = await fetch(apiPath, {
26
+ method: "POST",
27
+ headers: { "content-type": "application/json" },
28
+ body: JSON.stringify({ id, mode }),
29
+ });
30
+ let body: { ok?: boolean; state?: { mode?: string }; status?: string };
31
+ try {
32
+ body = (await res.json()) as typeof body;
33
+ } catch {
34
+ throw new Error(`HTTP ${res.status}`);
35
+ }
36
+ if (!res.ok || !body.ok || !body.state?.mode) throw new Error(body.status || `HTTP ${res.status}`);
37
+ render(card, button, body.state.mode);
38
+ if (status) {
39
+ status.textContent = body.state.mode === "enabled" ? "Enabled." : "Disabled.";
40
+ status.dataset.tone = "ok";
41
+ }
42
+ } catch (error) {
43
+ if (status) {
44
+ status.textContent = `Could not save (${error instanceof Error ? error.message : "error"}).`;
45
+ status.dataset.tone = "error";
46
+ }
47
+ } finally {
48
+ button.disabled = false;
49
+ }
50
+ }
51
+
52
+ function render(card: HTMLElement, button: HTMLButtonElement, mode: string): void {
53
+ const enabled = mode === "enabled";
54
+ card.dataset.addonMode = mode;
55
+ const chip = card.querySelector<HTMLElement>("[data-addon-chip]");
56
+ if (chip) chip.textContent = enabled ? "Enabled" : "Off";
57
+ button.dataset.addonNextMode = enabled ? "disabled" : "enabled";
58
+ button.textContent = enabled ? "Turn off" : "Turn on";
59
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The add-ons a store owner can turn on or off from `/admin/addons`. Static on purpose: an add-on
3
+ * id or mode a request names is valid only if it appears here, so the API refuses anything else.
4
+ *
5
+ * State lives only in the tenant record (`addOns.<id>.mode`); nothing here reads the environment.
6
+ * High Fidelity / Large Images is read back by `resolveImageTier`, which treats every value but
7
+ * `"enabled"` as the standard tier.
8
+ */
9
+ import { IMAGE_TIERS, LARGE_IMAGES_ADDON_NAME } from "@tot/public-runtime/image-limits";
10
+
11
+ export const ADDON_MODES = ["enabled", "disabled"] as const;
12
+ export type AddOnMode = (typeof ADDON_MODES)[number];
13
+
14
+ export const ADDON_IDS = ["largeImages"] as const;
15
+ export type AddOnId = (typeof ADDON_IDS)[number];
16
+
17
+ export interface AddOnDefinition {
18
+ id: AddOnId;
19
+ name: string;
20
+ description: string;
21
+ modes: readonly AddOnMode[];
22
+ }
23
+
24
+ const MiB = 1024 * 1024;
25
+ const mb = (bytes: number) => `${Math.round((bytes / MiB) * 10) / 10} MB`;
26
+ const mp = (pixels: number) => `${Math.round((pixels / 1_000_000) * 10) / 10} MP`;
27
+ const limits = (tier: { maxEncodedBytes: number; maxPixels: number; maxDimension: number }) =>
28
+ `${mb(tier.maxEncodedBytes)}, ${mp(tier.maxPixels)}, ${tier.maxDimension}px on a side`;
29
+
30
+ export const ADDONS: readonly AddOnDefinition[] = Object.freeze([
31
+ {
32
+ id: "largeImages",
33
+ name: LARGE_IMAGES_ADDON_NAME,
34
+ description:
35
+ `Raises this store's image limit from the standard ${limits(IMAGE_TIERS.standard)} ` +
36
+ `to ${limits(IMAGE_TIERS.large)}. Takes effect on the next capture or upload.`,
37
+ modes: ADDON_MODES,
38
+ },
39
+ ]);
40
+
41
+ export function isAddOnId(value: unknown): value is AddOnId {
42
+ return typeof value === "string" && (ADDON_IDS as readonly string[]).includes(value);
43
+ }
44
+
45
+ export function isAddOnMode(value: unknown): value is AddOnMode {
46
+ return typeof value === "string" && (ADDON_MODES as readonly string[]).includes(value);
47
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Read and write a tenant's add-on state (`addOns.<id>` on its KV record).
3
+ *
4
+ * The write is a read-modify-write of the RAW stored record: read `tenant:<id>` with no edge cache
5
+ * and no platform-policy overlay, change only `addOns.<id>`, and write back only that key. A cached
6
+ * read (`resolveStrict` caches 300 s) would clobber a field written since, and an overlaid read would
7
+ * persist platform policy into the record. A missing record is refused, never created: a tenant
8
+ * served from the static fallback has no record to edit.
9
+ *
10
+ * KV read-modify-write is not atomic; two simultaneous toggles can lose one. Acceptable for an
11
+ * owner switch, and no other field is at risk because both writers spread the same fresh read.
12
+ */
13
+ import { getStoredTenantRecord, putStoredTenantRecord } from "@/config/storedTenantRecord";
14
+ import { ADDONS, isAddOnId, isAddOnMode, type AddOnId, type AddOnMode } from "./catalog";
15
+
16
+ export interface AddOnState {
17
+ mode: AddOnMode;
18
+ updatedAt?: string;
19
+ updatedBy?: string;
20
+ }
21
+
22
+ export type AddOnsState = Record<AddOnId, AddOnState>;
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
26
+ }
27
+
28
+ /** Every catalog add-on's state on `record`. Anything but `mode: "enabled"` reads as disabled. */
29
+ export function readAddOns(record: unknown): AddOnsState {
30
+ const stored = isRecord(record) && isRecord(record.addOns) ? record.addOns : {};
31
+ const state = {} as AddOnsState;
32
+ for (const addOn of ADDONS) {
33
+ const entry = stored[addOn.id];
34
+ const out: AddOnState = { mode: isRecord(entry) && entry.mode === "enabled" ? "enabled" : "disabled" };
35
+ if (isRecord(entry) && typeof entry.updatedAt === "string") out.updatedAt = entry.updatedAt;
36
+ if (isRecord(entry) && typeof entry.updatedBy === "string") out.updatedBy = entry.updatedBy;
37
+ state[addOn.id] = out;
38
+ }
39
+ return state;
40
+ }
41
+
42
+ function parseStored(raw: string | null): Record<string, unknown> | null | "unreadable" {
43
+ if (raw === null) return null;
44
+ try {
45
+ const parsed: unknown = JSON.parse(raw);
46
+ return isRecord(parsed) ? parsed : "unreadable";
47
+ } catch {
48
+ return "unreadable";
49
+ }
50
+ }
51
+
52
+ /** The stored record's add-on state, or null when the tenant has no readable KV record. */
53
+ export async function readStoredAddOns(kv: KVNamespace, tenantId: string): Promise<AddOnsState | null> {
54
+ const record = parseStored(await getStoredTenantRecord(kv, tenantId));
55
+ return record && record !== "unreadable" ? readAddOns(record) : null;
56
+ }
57
+
58
+ export type SetAddOnResult =
59
+ | { ok: true; state: AddOnState }
60
+ | { ok: false; status: 400; reason: "unknown_addon" | "unknown_mode" }
61
+ | { ok: false; status: 404; reason: "no_tenant_record" }
62
+ | { ok: false; status: 409; reason: "tenant_record_unreadable" }
63
+ | { ok: false; status: 503; reason: "write_not_confirmed" };
64
+
65
+ export async function setAddOn(
66
+ kv: KVNamespace,
67
+ tenantId: string,
68
+ id: unknown,
69
+ mode: unknown,
70
+ actor: string,
71
+ now: Date,
72
+ ): Promise<SetAddOnResult> {
73
+ if (!isAddOnId(id)) return { ok: false, status: 400, reason: "unknown_addon" };
74
+ if (!isAddOnMode(mode)) return { ok: false, status: 400, reason: "unknown_mode" };
75
+
76
+ const record = parseStored(await getStoredTenantRecord(kv, tenantId));
77
+ if (record === null) return { ok: false, status: 404, reason: "no_tenant_record" };
78
+ if (record === "unreadable") return { ok: false, status: 409, reason: "tenant_record_unreadable" };
79
+
80
+ const addOns = isRecord(record.addOns) ? record.addOns : {};
81
+ const entry: AddOnState = { mode, updatedAt: now.toISOString(), updatedBy: actor };
82
+ await putStoredTenantRecord(kv, tenantId, { ...record, addOns: { ...addOns, [id]: entry } });
83
+
84
+ const confirmed = parseStored(await getStoredTenantRecord(kv, tenantId));
85
+ if (!confirmed || confirmed === "unreadable") return { ok: false, status: 503, reason: "write_not_confirmed" };
86
+ const state = readAddOns(confirmed)[id];
87
+ if (state.mode !== mode) return { ok: false, status: 503, reason: "write_not_confirmed" };
88
+ return { ok: true, state };
89
+ }
@@ -7,7 +7,6 @@
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 { sha256Hex } from "@tot/public-runtime";
11
10
  import { PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION, sealedMediaTypeAllowed } from "@tot/public-runtime/hosted-image-media-types";
12
11
  import {
13
12
  durableAssetsStatusKey,
@@ -21,6 +20,7 @@ import {
21
20
  import { GC_LEASE_KEY, awaitGcQuiescence } from "@tot/public-runtime/retention-holds";
22
21
  import { authorizeAssetReference, type AssetRegistryStore, type AssetWrite } from "@tot/public-runtime/asset-registry";
23
22
  import { imageLimitsFor, type ImageTier } from "@tot/public-runtime/image-limits";
23
+ import { incrementalSha256 } from "@tot/public-runtime/incremental-sha256";
24
24
 
25
25
  const MAX_MANIFEST = 1024 * 1024;
26
26
  const POOL_ENTRY_RE = /^([0-9a-f]{64})(\.[A-Za-z0-9]+)$/;
@@ -30,13 +30,17 @@ export interface R2ObjectLike {
30
30
  size: number;
31
31
  body?: ReadableStream<Uint8Array> | null;
32
32
  arrayBuffer?(): Promise<ArrayBuffer>;
33
+ /** The sha256 R2 verified when the object was put with one (`put(..., { sha256 })`). */
34
+ checksums?: { sha256?: ArrayBuffer };
33
35
  }
34
36
 
35
37
  export interface R2BucketLike {
36
38
  get(key: string): Promise<R2ObjectLike | null>;
37
- put?(key: string, body: Uint8Array, options: {
39
+ put?(key: string, body: Uint8Array | ReadableStream<Uint8Array>, options: {
38
40
  httpMetadata: { contentType: string; cacheControl: string };
39
41
  onlyIf?: Headers;
42
+ /** R2 hashes the body as it arrives and refuses the put, writing nothing, when it differs. */
43
+ sha256?: string;
40
44
  }): Promise<unknown>;
41
45
  }
42
46
 
@@ -90,27 +94,136 @@ export async function readBounded(bucket: R2BucketLike, key: string, limit: numb
90
94
  return bytes;
91
95
  }
92
96
 
97
+
98
+ function toHex(buffer: ArrayBuffer): string {
99
+ return [...new Uint8Array(buffer)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
100
+ }
101
+
102
+
103
+ /**
104
+ * Size and sha256 of an object of at most `limit` bytes, never holding it whole: R2's own verified
105
+ * checksum when the object was put with one, else its body hashed as it streams. Null when absent,
106
+ * larger than `limit`, or its body is not the size R2 reports.
107
+ */
108
+ export async function digestBounded(bucket: Pick<R2BucketLike, "get">, key: string, limit: number): Promise<{ size: number; sha256: string } | null> {
109
+ const object = await bucket.get(key);
110
+ if (!object) return null;
111
+ if (!Number.isSafeInteger(object.size) || object.size < 0 || object.size > limit) {
112
+ await object.body?.cancel().catch(() => {});
113
+ return null;
114
+ }
115
+ const checksum = object.checksums?.sha256;
116
+ if (checksum && checksum.byteLength === 32) {
117
+ await object.body?.cancel().catch(() => {});
118
+ return { size: object.size, sha256: toHex(checksum) };
119
+ }
120
+ if (!object.body) {
121
+ // Only an in-memory test double lacks a body; R2 always streams one.
122
+ if (!object.arrayBuffer) return null;
123
+ const whole = new Uint8Array(await object.arrayBuffer());
124
+ if (whole.byteLength !== object.size) return null;
125
+ const hash = incrementalSha256();
126
+ await hash.update(whole);
127
+ return { size: object.size, sha256: await hash.hex() };
128
+ }
129
+ const hash = incrementalSha256();
130
+ const reader = object.body.getReader();
131
+ let size = 0;
132
+ try {
133
+ for (;;) {
134
+ const { done, value } = await reader.read();
135
+ if (done) break;
136
+ size += value.byteLength;
137
+ if (size > object.size || size > limit) {
138
+ await reader.cancel().catch(() => {});
139
+ return null;
140
+ }
141
+ await hash.update(value);
142
+ }
143
+ } finally {
144
+ reader.releaseLock();
145
+ }
146
+ return size === object.size ? { size, sha256: await hash.hex() } : null;
147
+ }
148
+
149
+ /**
150
+ * `body` passed through unchanged, erroring the stream on an overrun or a short end: a copy can never
151
+ * be stored at a size other than the one verified. workerd's `FixedLengthStream` then gives R2 the
152
+ * known length a streamed put requires.
153
+ */
154
+ function exactLength(body: ReadableStream<Uint8Array>, length: number): ReadableStream<Uint8Array> {
155
+ let seen = 0;
156
+ const counted = body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
157
+ transform(chunk, controller) {
158
+ seen += chunk.byteLength;
159
+ if (seen > length) throw new R2StagingError("source-overrun");
160
+ controller.enqueue(chunk);
161
+ },
162
+ flush() {
163
+ if (seen !== length) throw new R2StagingError("source-short");
164
+ },
165
+ }));
166
+ const Fixed = (globalThis as { FixedLengthStream?: new (length: number) => TransformStream<Uint8Array, Uint8Array> }).FixedLengthStream;
167
+ return typeof Fixed === "function" ? counted.pipeThrough(new Fixed(length)) : counted;
168
+ }
169
+
170
+ /** A verified object to copy as a stream: its size, and a fresh read of its body. */
171
+ export interface StreamSource {
172
+ size: number;
173
+ open(): Promise<ReadableStream<Uint8Array>>;
174
+ }
175
+
176
+ /**
177
+ * The object at `key` as a {@link StreamSource}, once its size and sha256 (hashed as a stream) are
178
+ * `address` within `limit`; null otherwise. Nothing is held: the copy re-reads the body.
179
+ */
180
+ export async function verifiedObjectSource(bucket: Pick<R2BucketLike, "get">, key: string, address: string, limit: number): Promise<StreamSource | null> {
181
+ const digest = await digestBounded(bucket, key, limit);
182
+ if (!digest || digest.size < 1 || digest.sha256 !== address) return null;
183
+ return {
184
+ size: digest.size,
185
+ async open() {
186
+ const object = await bucket.get(key);
187
+ if (!object || object.size !== digest.size) {
188
+ await object?.body?.cancel().catch(() => {});
189
+ throw new R2StagingError("original-missing");
190
+ }
191
+ if (object.body) return object.body;
192
+ // Only an in-memory test double lacks a body; R2 always streams one.
193
+ if (!object.arrayBuffer) throw new R2StagingError("original-missing");
194
+ const whole = new Uint8Array(await object.arrayBuffer());
195
+ return new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(whole); controller.close(); } });
196
+ },
197
+ };
198
+ }
199
+
93
200
  /** Wait while a bundle-GC sweep holds its lease in `bucket`. */
94
201
  export function awaitR2Quiescence(bucket: R2BucketLike, waiter: QuiescenceWaiter = awaitGcQuiescence): Promise<{ waited: boolean }> {
95
202
  return waiter({ readLease: () => readBounded(bucket, GC_LEASE_KEY, 64 * 1024) });
96
203
  }
97
204
 
98
- /** Present with exactly `bytes`, writing it create-only when absent; refuses a mismatched object. */
99
- export async function ensurePoolObject(bucket: R2BucketLike, key: string, bytes: Uint8Array, mediaType: string): Promise<void> {
205
+ /**
206
+ * 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.
209
+ */
210
+ export async function ensurePoolObject(bucket: R2BucketLike, key: string, source: Uint8Array | StreamSource, mediaType: string): Promise<void> {
100
211
  const address = POOL_ENTRY_RE.exec(key.replace(/^assets\//, ""))?.[1];
101
212
  if (!address) throw new R2StagingError("pool-key");
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);
104
- if (existing && await sha256Hex(existing) !== address) throw new R2StagingError("store-mismatch");
213
+ const size = source instanceof Uint8Array ? source.byteLength : source.size;
214
+ // Bounded by the size being staged: an object of any other size cannot hold this address.
215
+ const existing = await digestBounded(bucket, key, size);
216
+ if (existing && (existing.sha256 !== address || existing.size !== size)) throw new R2StagingError("store-mismatch");
105
217
  if (!existing) {
106
218
  if (!bucket.put) throw new R2StagingError("store-unwritable");
107
- await bucket.put(key, bytes, {
219
+ await bucket.put(key, source instanceof Uint8Array ? source : exactLength(await source.open(), size), {
108
220
  httpMetadata: { contentType: mediaType, cacheControl: IMMUTABLE },
109
221
  onlyIf: new Headers({ "If-None-Match": "*" }),
222
+ sha256: address,
110
223
  });
111
224
  }
112
- const readback = await readBounded(bucket, key, bytes.byteLength);
113
- if (!readback || readback.byteLength !== bytes.byteLength || await sha256Hex(readback) !== address) {
225
+ const readback = await digestBounded(bucket, key, size);
226
+ if (!readback || readback.size !== size || readback.sha256 !== address) {
114
227
  throw new R2StagingError("store-mismatch");
115
228
  }
116
229
  }
@@ -132,13 +245,14 @@ function mediaTypeOf(entry: string): string {
132
245
  return mediaType;
133
246
  }
134
247
 
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> {
248
+ /** The tenant's captured `<sha256><ext>`, verified as a stream within its tier; never held whole. */
249
+ async function capturedSource(captureBucket: R2BucketLike, tenant: string, entry: string, imageTier: ImageTier | undefined): Promise<StreamSource> {
137
250
  const address = POOL_ENTRY_RE.exec(entry)?.[1];
138
251
  if (!address) throw new R2StagingError("pool-key");
139
- const bytes = await readBounded(captureBucket, `original-images/${tenant}/objects/${address}`, imageLimitsFor(imageTier).maxEncodedBytes);
140
- if (!bytes || bytes.byteLength < 1 || await sha256Hex(bytes) !== address) throw new R2StagingError("original-missing");
141
- return bytes;
252
+ const source = await verifiedObjectSource(captureBucket, `original-images/${tenant}/objects/${address}`, address,
253
+ imageLimitsFor(imageTier).maxEncodedBytes);
254
+ if (!source) throw new R2StagingError("original-missing");
255
+ return source;
142
256
  }
143
257
 
144
258
  /**
@@ -151,7 +265,8 @@ async function capturedBytes(captureBucket: R2BucketLike, tenant: string, entry:
151
265
  * tenant's own capture store (which registers it); otherwise nothing is staged and the go-live
152
266
  * refuses `asset-unauthorized`.
153
267
  *
154
- * Originals are read one at a time, within the tenant's image tier, and never held across entries.
268
+ * Originals are hashed and copied as streams, one at a time, within the tenant's image tier: none is
269
+ * ever held whole.
155
270
  */
156
271
  export async function stageCapturedOriginalsR2(input: {
157
272
  tenant: string;
@@ -167,11 +282,11 @@ export async function stageCapturedOriginalsR2(input: {
167
282
  const unauthorized: string[] = [];
168
283
  for (const entry of input.entries) {
169
284
  if (authorizeAssetReference([registry], input.tenant, entry).ok) continue;
170
- const own = await capturedBytes(input.captureBucket, input.tenant, entry, input.imageTier).catch((error: unknown) => {
285
+ const own = await capturedSource(input.captureBucket, input.tenant, entry, input.imageTier).catch((error: unknown) => {
171
286
  if (error instanceof R2StagingError) return null;
172
287
  throw error;
173
288
  });
174
- if (own) owned.set(entry, own.byteLength);
289
+ if (own) owned.set(entry, own.size);
175
290
  else unauthorized.push(entry);
176
291
  }
177
292
  if (input.registry && owned.size > 0) {
@@ -184,10 +299,10 @@ export async function stageCapturedOriginalsR2(input: {
184
299
  let staged = 0;
185
300
  for (const entry of input.entries) {
186
301
  const key = `assets/${entry}`;
187
- const present = await readBounded(input.publicBucket, key, imageLimitsFor(input.imageTier).maxEncodedBytes);
302
+ const present = await digestBounded(input.publicBucket, key, imageLimitsFor(input.imageTier).maxEncodedBytes);
188
303
  const address = POOL_ENTRY_RE.exec(entry)?.[1];
189
- if (present && address && await sha256Hex(present) === address) continue;
190
- await ensurePoolObject(input.publicBucket, key, await capturedBytes(input.captureBucket, input.tenant, entry, input.imageTier), mediaTypeOf(entry));
304
+ if (present && address && present.sha256 === address) continue;
305
+ await ensurePoolObject(input.publicBucket, key, await capturedSource(input.captureBucket, input.tenant, entry, input.imageTier), mediaTypeOf(entry));
191
306
  staged++;
192
307
  }
193
308
  return staged;
@@ -281,7 +396,7 @@ export async function designateDurableR2(input: {
281
396
  for (const item of prepared) {
282
397
  await createHold(input.publicBucket, item.holdKey, item.holdBody);
283
398
  await ensurePoolObject(input.publicBucket, `assets/${item.entry}`,
284
- await capturedBytes(input.captureBucket, input.tenant, item.entry, input.imageTier), item.mediaType);
399
+ await capturedSource(input.captureBucket, input.tenant, item.entry, input.imageTier), item.mediaType);
285
400
  }
286
401
  await recordDurableStatusKv(input.kv, input.tenant, { revision: input.revision, at: now().toISOString(), declared,
287
402
  designated: prepared.map((item) => ({ entry: item.entry, name: item.image.name, source: item.image.source, verifiedStores: ["r2"] })) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.13.0",
3
+ "version": "4.14.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
+ "./incremental-sha256": "./src/incremental-sha256.ts",
27
28
  "./image-limits": "./src/image-limits.mjs",
28
29
  "./image-release-gate": "./src/image-release-gate.ts",
29
30
  "./pool-asset-headers": "./src/pool-asset-headers.ts",
@@ -0,0 +1,40 @@
1
+ /**
2
+ * An incremental sha256 that holds one chunk at a time: workerd's `crypto.DigestStream`, else (Node,
3
+ * where the tests run) `node:crypto` through `process.getBuiltinModule`, never a static import, so
4
+ * this module stays safe to bundle into the Worker.
5
+ */
6
+ type DigestStreamClass = new (algorithm: string) => WritableStream<Uint8Array> & { digest: Promise<ArrayBuffer> };
7
+ type NodeHash = { update(chunk: Uint8Array): unknown; digest(encoding: "hex"): string };
8
+
9
+ export type IncrementalSha256 = { update(chunk: Uint8Array): Promise<void>; hex(): Promise<string> };
10
+
11
+ function toHex(buffer: ArrayBuffer): string {
12
+ return [...new Uint8Array(buffer)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
13
+ }
14
+
15
+ export function incrementalSha256(): IncrementalSha256 {
16
+ const Digest = (globalThis.crypto as unknown as { DigestStream?: DigestStreamClass } | undefined)?.DigestStream;
17
+ if (typeof Digest === "function") {
18
+ const stream = new Digest("SHA-256");
19
+ const writer = stream.getWriter();
20
+ return {
21
+ update: (chunk) => writer.write(chunk),
22
+ async hex() {
23
+ await writer.close();
24
+ return toHex(await stream.digest);
25
+ },
26
+ };
27
+ }
28
+ const nodeCrypto = (globalThis as { process?: { getBuiltinModule?(id: string): unknown } }).process
29
+ ?.getBuiltinModule?.("node:crypto") as { createHash(algorithm: string): NodeHash } | undefined;
30
+ if (!nodeCrypto) throw new Error("digest-unavailable");
31
+ const hash = nodeCrypto.createHash("sha256");
32
+ return {
33
+ async update(chunk) {
34
+ hash.update(chunk);
35
+ },
36
+ async hex() {
37
+ return hash.digest("hex");
38
+ },
39
+ };
40
+ }