@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.
- package/apps/storefront/src/config/kvTenantResolver.ts +15 -5
- package/apps/storefront/src/lib/assets/durableR2.ts +137 -22
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -0
- package/packages/public-runtime/src/incremental-sha256.ts +40 -0
- package/packages/public-runtime/src/tenant-record-merge.d.mts +9 -0
- package/packages/public-runtime/src/tenant-record-merge.mjs +68 -0
|
@@ -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:
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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,
|
|
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
|
|
113
|
-
if (!readback || readback.
|
|
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
|
|
136
|
-
async function
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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 &&
|
|
190
|
-
await ensurePoolObject(input.publicBucket, key, await
|
|
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
|
|
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.
|
|
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
|
+
}
|