@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.
- package/apps/storefront/src/config/kvTenantResolver.ts +2 -1
- package/apps/storefront/src/config/storedTenantRecord.ts +27 -0
- package/apps/storefront/src/lib/assets/addons/addonsClient.ts +59 -0
- package/apps/storefront/src/lib/assets/addons/catalog.ts +47 -0
- package/apps/storefront/src/lib/assets/addons/tenantAddOns.ts +89 -0
- package/apps/storefront/src/lib/assets/durableR2.ts +137 -22
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/incremental-sha256.ts +40 -0
|
@@ -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 =
|
|
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
|
|
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.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
|
+
}
|