@tokenoftrust/storefront-runner 4.13.1 → 4.15.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,7 +17,8 @@
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
+ import { TENANT_RECORD_PREFIX, getStoredTenantRecord } from "@/config/storedTenantRecord";
21
+ import { mergeStoredTenantRecord } from "@tot/public-runtime/tenant-record-merge";
21
22
 
22
23
  const DOMAIN_PREFIX = "domain:";
23
24
  const TENANT_PREFIX = TENANT_RECORD_PREFIX;
@@ -76,16 +77,25 @@ export class KvTenantResolver implements TenantResolver {
76
77
  }
77
78
 
78
79
  /**
79
- * Register (or update) a tenant in KV. Idempotent: writes the config blob and a
80
- * domain->id index entry for each claimed hostname. Pass `makeDefault` to point
81
- * the unknown-host fallback at this tenant. Call this from onboarding/migration.
80
+ * Register (or update) a tenant in KV. Idempotent: merges the config into the stored
81
+ * `tenant:<id>` record (provisioned keys win, stored keys the config does not carry survive;
82
+ * see `@tot/public-runtime/tenant-record-merge`) and writes a domain->id index entry for each
83
+ * claimed hostname. The stored record is read uncached. A stored record that is not a JSON
84
+ * object throws before any write. Pass `makeDefault` to point the unknown-host fallback at
85
+ * this tenant. Call this from onboarding/migration.
82
86
  */
83
87
  export async function putTenant(
84
88
  kv: KVNamespace,
85
89
  config: TenantConfig,
86
90
  opts: { makeDefault?: boolean } = {},
87
91
  ): Promise<void> {
88
- await kv.put(TENANT_PREFIX + config.tenant_id, JSON.stringify(config));
92
+ const key = TENANT_PREFIX + config.tenant_id;
93
+ const record = mergeStoredTenantRecord(
94
+ key,
95
+ await getStoredTenantRecord(kv, config.tenant_id),
96
+ config as unknown as Record<string, unknown>,
97
+ );
98
+ await kv.put(key, record);
89
99
  for (const domain of config.domains) {
90
100
  const host = cleanHost(domain);
91
101
  if (host) await kv.put(DOMAIN_PREFIX + host, config.tenant_id);
@@ -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.1",
3
+ "version": "4.15.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,7 +24,9 @@
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",
29
+ "./tenant-record-merge": "./src/tenant-record-merge.mjs",
28
30
  "./image-release-gate": "./src/image-release-gate.ts",
29
31
  "./pool-asset-headers": "./src/pool-asset-headers.ts",
30
32
  "./catalog-files": "./src/catalog-files.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
+ }
@@ -0,0 +1,9 @@
1
+ export declare function mergeTenantRecord(
2
+ stored: Record<string, unknown>,
3
+ provisioned: Record<string, unknown>,
4
+ ): Record<string, unknown>;
5
+ export declare function mergeStoredTenantRecord(
6
+ key: string,
7
+ storedRaw: string | null | undefined,
8
+ provisioned: Record<string, unknown>,
9
+ ): string;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Merge a provisioned tenant config into the `tenant:<id>` KV record already stored.
3
+ *
4
+ * Provisioners carry only the fields they own. The stored record also holds fields written by
5
+ * other paths (`storefront.mode`, `addOns.largeImages`), so a provisioner must read the stored
6
+ * record, merge, and write the result; it never replaces the record wholesale.
7
+ *
8
+ * Contract:
9
+ * - the provisioned config wins for every key it carries;
10
+ * - plain objects merge recursively; arrays and scalars from the provisioned config replace;
11
+ * - keys the provisioned config does not carry are preserved;
12
+ * - `__proto__`, `constructor` and `prototype` keys are dropped at every level;
13
+ * - no stored record: the provisioned config is written as given;
14
+ * - a stored value that is not a JSON object (unparseable, array, scalar, empty string) throws,
15
+ * naming the key, and the caller writes nothing.
16
+ *
17
+ * KV has no transaction. A provisioner that reads, merges and writes while the admin add-ons
18
+ * switch writes the same record can still lose one of the two writes; the merge narrows that
19
+ * window to one read-to-write interval and cannot close it.
20
+ */
21
+
22
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
23
+
24
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
25
+ function isPlainObject(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ /**
30
+ * @param {Record<string, unknown>} stored
31
+ * @param {Record<string, unknown>} provisioned
32
+ * @returns {Record<string, unknown>}
33
+ */
34
+ export function mergeTenantRecord(stored, provisioned) {
35
+ /** @type {Record<string, unknown>} */
36
+ const out = {};
37
+ for (const [key, value] of Object.entries(stored)) {
38
+ if (UNSAFE_KEYS.has(key)) continue;
39
+ out[key] = value;
40
+ }
41
+ for (const [key, value] of Object.entries(provisioned)) {
42
+ if (UNSAFE_KEYS.has(key)) continue;
43
+ const existing = out[key];
44
+ out[key] = isPlainObject(value) && isPlainObject(existing) ? mergeTenantRecord(existing, value) : value;
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * The JSON to write at `key` for `provisioned`, given the raw stored value (`null` when absent).
51
+ * @param {string} key
52
+ * @param {string | null | undefined} storedRaw
53
+ * @param {Record<string, unknown>} provisioned
54
+ * @returns {string}
55
+ */
56
+ export function mergeStoredTenantRecord(key, storedRaw, provisioned) {
57
+ if (storedRaw === null || storedRaw === undefined) return JSON.stringify(provisioned);
58
+ let stored;
59
+ try {
60
+ stored = JSON.parse(storedRaw);
61
+ } catch {
62
+ throw new Error(`stored KV record ${key} is not valid JSON; refusing to overwrite it`);
63
+ }
64
+ if (!isPlainObject(stored)) {
65
+ throw new Error(`stored KV record ${key} is not a JSON object; refusing to overwrite it`);
66
+ }
67
+ return JSON.stringify(mergeTenantRecord(stored, provisioned));
68
+ }