@tokenoftrust/storefront-runner 5.0.1 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/src/lib/assets/addons/catalog.ts +17 -16
- package/apps/storefront/src/lib/assets/addons/storeEntitlements.ts +257 -0
- package/apps/storefront/src/lib/assets/captured-original-serve.ts +9 -13
- package/apps/storefront/src/lib/assets/durableR2.ts +15 -20
- package/apps/storefront/src/lib/assets/durableR2Bindings.ts +1 -4
- package/apps/storefront/src/middleware/staticBundle.ts +0 -3
- package/package.json +1 -1
- package/packages/public-runtime/src/image-limits.d.mts +1 -2
- package/packages/public-runtime/src/image-limits.mjs +20 -20
- package/packages/public-runtime/src/tenant-record-merge.mjs +5 -5
- package/packages/public-runtime/src/tenant.ts +0 -9
- package/apps/storefront/src/lib/assets/addons/addonsClient.ts +0 -59
- package/apps/storefront/src/lib/assets/addons/tenantAddOns.ts +0 -89
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The add-ons
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* High Fidelity / Large Images is read back by `resolveImageTier`, which treats every value but
|
|
7
|
-
* `"enabled"` as the standard tier.
|
|
2
|
+
* The add-ons `/admin/addons` describes. A store's state for each is read from tot20 core
|
|
3
|
+
* (`getStoreEntitlements`); it is installed and changed in Token of Trust HQ, never here. High
|
|
4
|
+
* Fidelity / Large Images is read back by `resolveImageTier`, which grants the large tier only for
|
|
5
|
+
* an entitlement tot20 reports enabled.
|
|
8
6
|
*/
|
|
9
7
|
import { IMAGE_TIERS, LARGE_IMAGES_ADDON_NAME } from "@tot/public-runtime/image-limits";
|
|
10
8
|
|
|
11
|
-
export const ADDON_MODES = ["enabled", "disabled"] as const;
|
|
12
|
-
export type AddOnMode = (typeof ADDON_MODES)[number];
|
|
13
|
-
|
|
14
9
|
export const ADDON_IDS = ["largeImages"] as const;
|
|
15
10
|
export type AddOnId = (typeof ADDON_IDS)[number];
|
|
16
11
|
|
|
@@ -18,7 +13,6 @@ export interface AddOnDefinition {
|
|
|
18
13
|
id: AddOnId;
|
|
19
14
|
name: string;
|
|
20
15
|
description: string;
|
|
21
|
-
modes: readonly AddOnMode[];
|
|
22
16
|
}
|
|
23
17
|
|
|
24
18
|
const MiB = 1024 * 1024;
|
|
@@ -34,14 +28,21 @@ export const ADDONS: readonly AddOnDefinition[] = Object.freeze([
|
|
|
34
28
|
description:
|
|
35
29
|
`Raises this store's image limit from the standard ${limits(IMAGE_TIERS.standard)} ` +
|
|
36
30
|
`to ${limits(IMAGE_TIERS.large)}. Takes effect on the next capture or upload.`,
|
|
37
|
-
modes: ADDON_MODES,
|
|
38
31
|
},
|
|
39
32
|
]);
|
|
40
33
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
34
|
+
/** What `/admin/addons` shows for one add-on. `unavailable` means tot20 could not be read. */
|
|
35
|
+
export type AddOnDisplayState = "installed" | "not-installed" | "unavailable";
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
/** Each catalog add-on's display state from a tot20 read; an unavailable read is never installed. */
|
|
38
|
+
export function addOnDisplayStates(
|
|
39
|
+
result: { status: "ok" | "not-entitled"; entitlements: Partial<Record<AddOnId, { enabled: boolean }>> } | { status: "unavailable" },
|
|
40
|
+
): Record<AddOnId, AddOnDisplayState> {
|
|
41
|
+
const states = {} as Record<AddOnId, AddOnDisplayState>;
|
|
42
|
+
for (const addOn of ADDONS) {
|
|
43
|
+
states[addOn.id] = result.status === "unavailable"
|
|
44
|
+
? "unavailable"
|
|
45
|
+
: result.entitlements[addOn.id]?.enabled === true ? "installed" : "not-installed";
|
|
46
|
+
}
|
|
47
|
+
return states;
|
|
47
48
|
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A store's add-on entitlements, read from tot20 core — the system of record for what a store has
|
|
3
|
+
* installed. The storefront never stores or writes an entitlement.
|
|
4
|
+
*
|
|
5
|
+
* GET ${TOT_CORE_URL}/api/storefront/entitlements?tenant=<appDomain>
|
|
6
|
+
*
|
|
7
|
+
* authenticated with the storefront's own operator credential (the base64 `apiKey;secretKey`
|
|
8
|
+
* license-key header), with redirects refused.
|
|
9
|
+
*
|
|
10
|
+
* The response is untrusted. Only a 200 whose body carries `code: "storefrontEntitlements:ok"` and
|
|
11
|
+
* the requested tenant (normalized as core normalizes it) is read; its add-on set is projected to
|
|
12
|
+
* exactly the fields this module knows. Everything else — no binding, a timeout, a redirect,
|
|
13
|
+
* 401/403, 5xx, a malformed body, the wrong code or tenant — is `unavailable`. `unavailable` never
|
|
14
|
+
* raises a tier and is never cached; ingest callers refuse with a retryable 503 rather than capture
|
|
15
|
+
* at standard.
|
|
16
|
+
*
|
|
17
|
+
* Only ingest (capture, upload, catalog setup) and the admin add-ons display call this. Publication,
|
|
18
|
+
* export and serve never do: they bound accepted bytes by `IMAGE_LIMITS_CEILING`.
|
|
19
|
+
*
|
|
20
|
+
* Validated results are cached per tenant for 60 s in `caches.open("store-entitlements")`.
|
|
21
|
+
*/
|
|
22
|
+
import { resolveImageTier, type ImageTier } from "@tot/public-runtime/image-limits";
|
|
23
|
+
import { readEnv } from "@/lib/publication/shared/env";
|
|
24
|
+
|
|
25
|
+
/** The add-on set as this storefront reads it. Unknown add-ons and fields are dropped. */
|
|
26
|
+
export interface StoreEntitlementSet {
|
|
27
|
+
largeImages?: { enabled: boolean; updatedAt?: string };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type EntitlementFallbackReason =
|
|
31
|
+
| "missing-binding"
|
|
32
|
+
| "missing-tenant"
|
|
33
|
+
| "timeout"
|
|
34
|
+
| "network"
|
|
35
|
+
| "redirect"
|
|
36
|
+
| "forbidden"
|
|
37
|
+
| "upstream-error"
|
|
38
|
+
| "unexpected-status"
|
|
39
|
+
| "malformed-body"
|
|
40
|
+
| "wrong-code"
|
|
41
|
+
| "tenant-mismatch";
|
|
42
|
+
|
|
43
|
+
export type StoreEntitlements =
|
|
44
|
+
| { status: "ok"; entitlements: StoreEntitlementSet }
|
|
45
|
+
| { status: "not-entitled"; entitlements: StoreEntitlementSet }
|
|
46
|
+
| { status: "unavailable"; fallbackReason: EntitlementFallbackReason };
|
|
47
|
+
|
|
48
|
+
/** The tot20 response field that carries the add-on set (tot20's wire name; the storefront stores none). */
|
|
49
|
+
export const ENTITLEMENTS_WIRE_FIELD = "addOns";
|
|
50
|
+
export const ENTITLEMENTS_OK_CODE = "storefrontEntitlements:ok";
|
|
51
|
+
export const ENTITLEMENTS_CACHE_NAME = "store-entitlements";
|
|
52
|
+
export const ENTITLEMENTS_TTL_SECONDS = 60;
|
|
53
|
+
export const ENTITLEMENTS_TIMEOUT_MS = 2_000;
|
|
54
|
+
const CACHE_ORIGIN = "https://store-entitlements.tot.internal";
|
|
55
|
+
|
|
56
|
+
interface CacheLike {
|
|
57
|
+
match(request: Request): Promise<Response | undefined>;
|
|
58
|
+
put(request: Request, response: Response): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface StoreEntitlementsDeps {
|
|
62
|
+
fetch?: (input: string, init: RequestInit) => Promise<Response>;
|
|
63
|
+
readEnv?: (key: string) => Promise<string | undefined>;
|
|
64
|
+
/** Defaults to the runtime `caches` global; absent (dev, tests) means no cache. */
|
|
65
|
+
openCache?: () => Promise<CacheLike | undefined>;
|
|
66
|
+
warn?: (message: string, payload: { tierFallbackReason: EntitlementFallbackReason; tenant: string }) => void;
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Core's tenant key: trimmed, lowercased, without a leading `www.`. */
|
|
71
|
+
export function normalizeEntitlementTenant(raw: unknown): string | undefined {
|
|
72
|
+
if (typeof raw !== "string") return undefined;
|
|
73
|
+
const value = raw.trim().toLowerCase().replace(/^www\./, "");
|
|
74
|
+
return /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/.test(value) ? value : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function defaultOpenCache(): Promise<CacheLike | undefined> {
|
|
78
|
+
const storage = (globalThis as { caches?: { open(name: string): Promise<CacheLike> } }).caches;
|
|
79
|
+
if (!storage || typeof storage.open !== "function") return undefined;
|
|
80
|
+
return storage.open(ENTITLEMENTS_CACHE_NAME);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The cache is only an optimisation: one that cannot be opened or read costs a tot20 read, never a
|
|
85
|
+
* different answer, so its failure is named and the caller reads tot20.
|
|
86
|
+
*/
|
|
87
|
+
type CacheRead = { kind: "hit"; set: StoreEntitlementSet } | { kind: "miss" } | { kind: "unreadable" };
|
|
88
|
+
|
|
89
|
+
async function openCacheOutcome(open: () => Promise<CacheLike | undefined>): Promise<CacheLike | "unreadable" | undefined> {
|
|
90
|
+
try {
|
|
91
|
+
return await open();
|
|
92
|
+
} catch {
|
|
93
|
+
return "unreadable";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cacheKey(tenant: string): Request {
|
|
98
|
+
return new Request(`${CACHE_ORIGIN}/${encodeURIComponent(tenant)}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
102
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Project the wire add-on set to the fields this module knows; `undefined` when malformed. */
|
|
106
|
+
function projectSet(raw: unknown): StoreEntitlementSet | undefined {
|
|
107
|
+
if (!isRecord(raw)) return undefined;
|
|
108
|
+
const large = raw.largeImages;
|
|
109
|
+
if (large === undefined) return {};
|
|
110
|
+
if (!isRecord(large) || typeof large.enabled !== "boolean") return undefined;
|
|
111
|
+
return {
|
|
112
|
+
largeImages: {
|
|
113
|
+
enabled: large.enabled,
|
|
114
|
+
...(typeof large.updatedAt === "string" ? { updatedAt: large.updatedAt } : {}),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function classify(entitlements: StoreEntitlementSet): StoreEntitlements {
|
|
120
|
+
return entitlements.largeImages?.enabled === true
|
|
121
|
+
? { status: "ok", entitlements }
|
|
122
|
+
: { status: "not-entitled", entitlements };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function statusReason(status: number): EntitlementFallbackReason {
|
|
126
|
+
if (status >= 300 && status < 400) return "redirect";
|
|
127
|
+
if (status === 401 || status === 403) return "forbidden";
|
|
128
|
+
if (status >= 500) return "upstream-error";
|
|
129
|
+
return "unexpected-status";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readCached(cache: CacheLike | undefined, tenant: string): Promise<CacheRead> {
|
|
133
|
+
if (!cache) return { kind: "miss" };
|
|
134
|
+
try {
|
|
135
|
+
const hit = await cache.match(cacheKey(tenant));
|
|
136
|
+
const set = hit ? projectSet(await hit.json()) : undefined;
|
|
137
|
+
return set ? { kind: "hit", set } : { kind: "miss" };
|
|
138
|
+
} catch {
|
|
139
|
+
return { kind: "unreadable" };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function writeCached(cache: CacheLike | undefined, tenant: string, set: StoreEntitlementSet): Promise<void> {
|
|
144
|
+
if (!cache) return;
|
|
145
|
+
try {
|
|
146
|
+
await cache.put(cacheKey(tenant), new Response(JSON.stringify(set), {
|
|
147
|
+
headers: { "content-type": "application/json", "cache-control": `max-age=${ENTITLEMENTS_TTL_SECONDS}` },
|
|
148
|
+
}));
|
|
149
|
+
} catch {
|
|
150
|
+
/* a cache write failure costs one extra read next time */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The entitlements tot20 records for `config.appDomain`. Never throws.
|
|
156
|
+
*/
|
|
157
|
+
export async function getStoreEntitlements(
|
|
158
|
+
config: { appDomain?: string } | null | undefined,
|
|
159
|
+
deps: StoreEntitlementsDeps = {},
|
|
160
|
+
): Promise<StoreEntitlements> {
|
|
161
|
+
const warn = deps.warn ?? ((message, payload) => console.warn(message, payload));
|
|
162
|
+
const tenant = normalizeEntitlementTenant(config?.appDomain);
|
|
163
|
+
const unavailable = (fallbackReason: EntitlementFallbackReason): StoreEntitlements => {
|
|
164
|
+
warn("store entitlements unavailable", { tierFallbackReason: fallbackReason, tenant: tenant ?? "" });
|
|
165
|
+
return { status: "unavailable", fallbackReason };
|
|
166
|
+
};
|
|
167
|
+
if (!tenant) return unavailable("missing-tenant");
|
|
168
|
+
|
|
169
|
+
const opened = await openCacheOutcome(deps.openCache ?? defaultOpenCache);
|
|
170
|
+
const cache = opened === "unreadable" ? undefined : opened;
|
|
171
|
+
const cached = await readCached(cache, tenant);
|
|
172
|
+
if (cached.kind === "hit") return classify(cached.set);
|
|
173
|
+
|
|
174
|
+
const env = deps.readEnv ?? readEnv;
|
|
175
|
+
const [baseUrl, apiKey, secretKey] = await Promise.all([
|
|
176
|
+
env("TOT_CORE_URL"), env("TOT_API_KEY"), env("TOT_SECRET_KEY"),
|
|
177
|
+
]);
|
|
178
|
+
if (!baseUrl || !apiKey || !secretKey) return unavailable("missing-binding");
|
|
179
|
+
|
|
180
|
+
const doFetch = deps.fetch ?? ((input: string, init: RequestInit) => fetch(input, init));
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? ENTITLEMENTS_TIMEOUT_MS);
|
|
183
|
+
let response: Response;
|
|
184
|
+
try {
|
|
185
|
+
response = await doFetch(
|
|
186
|
+
`${baseUrl.replace(/\/+$/, "")}/api/storefront/entitlements?tenant=${encodeURIComponent(tenant)}`,
|
|
187
|
+
{
|
|
188
|
+
method: "GET",
|
|
189
|
+
headers: { accept: "application/json", authorization: btoa(`${apiKey};${secretKey}`) },
|
|
190
|
+
redirect: "manual",
|
|
191
|
+
signal: controller.signal,
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
} catch {
|
|
195
|
+
// The error is not logged: a transport error may echo the request, credential included.
|
|
196
|
+
return unavailable(controller.signal.aborted ? "timeout" : "network");
|
|
197
|
+
} finally {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
}
|
|
200
|
+
if (response.type === "opaqueredirect") return unavailable("redirect");
|
|
201
|
+
if (response.status !== 200) return unavailable(statusReason(response.status));
|
|
202
|
+
|
|
203
|
+
let body: unknown;
|
|
204
|
+
try {
|
|
205
|
+
body = await response.json();
|
|
206
|
+
} catch {
|
|
207
|
+
return unavailable(controller.signal.aborted ? "timeout" : "malformed-body");
|
|
208
|
+
}
|
|
209
|
+
const content = isRecord(body) && isRecord(body.content) ? body.content : body;
|
|
210
|
+
if (!isRecord(content)) return unavailable("malformed-body");
|
|
211
|
+
if (content.code !== ENTITLEMENTS_OK_CODE) return unavailable("wrong-code");
|
|
212
|
+
if (normalizeEntitlementTenant(content.tenant) !== tenant) return unavailable("tenant-mismatch");
|
|
213
|
+
const set = projectSet(content[ENTITLEMENTS_WIRE_FIELD]);
|
|
214
|
+
if (!set) return unavailable("malformed-body");
|
|
215
|
+
|
|
216
|
+
await writeCached(cache, tenant, set);
|
|
217
|
+
return classify(set);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export type IngestImageTier =
|
|
221
|
+
| { ok: true; tier: ImageTier }
|
|
222
|
+
| { ok: false; fallbackReason: EntitlementFallbackReason };
|
|
223
|
+
|
|
224
|
+
/** The image tier ingest enforces, or the reason it cannot be known (refuse and retry). */
|
|
225
|
+
export async function ingestImageTier(
|
|
226
|
+
config: { appDomain?: string } | null | undefined,
|
|
227
|
+
deps?: StoreEntitlementsDeps,
|
|
228
|
+
): Promise<IngestImageTier> {
|
|
229
|
+
const result = await getStoreEntitlements(config, deps);
|
|
230
|
+
return result.status === "unavailable"
|
|
231
|
+
? { ok: false, fallbackReason: result.fallbackReason }
|
|
232
|
+
: { ok: true, tier: resolveImageTier(result.entitlements) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Seconds an ingest caller tells its client to wait before retrying an unavailable tier. */
|
|
236
|
+
export const ENTITLEMENTS_RETRY_AFTER_SECONDS = 30;
|
|
237
|
+
|
|
238
|
+
/** The body an ingest route answers with when the tier is unavailable. */
|
|
239
|
+
export function entitlementsUnavailableBody(fallbackReason: EntitlementFallbackReason) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false as const,
|
|
242
|
+
reason: "entitlements-unavailable" as const,
|
|
243
|
+
retryable: true as const,
|
|
244
|
+
fallbackReason,
|
|
245
|
+
message: "Image limits could not be confirmed with Token of Trust right now. Nothing was saved; try again shortly.",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Token of Trust HQ, where a store's add-ons are installed and changed. */
|
|
250
|
+
export async function hqAddOnsUrl(deps: Pick<StoreEntitlementsDeps, "readEnv"> = {}): Promise<string> {
|
|
251
|
+
const base = await (deps.readEnv ?? readEnv)("TOT_CORE_URL");
|
|
252
|
+
try {
|
|
253
|
+
return new URL("/hq", base || "https://app.tokenoftrust.com").href;
|
|
254
|
+
} catch {
|
|
255
|
+
return "https://app.tokenoftrust.com/hq";
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* Worker-safe serve of revision-bound captured originals.
|
|
3
3
|
*
|
|
4
4
|
* Does not import Node capture — R2 + Web Crypto only. Bytes are served only
|
|
5
|
-
* when the sealed revision manifest names the address, within the
|
|
6
|
-
* (`
|
|
5
|
+
* when the sealed revision manifest names the address, within the image ceiling
|
|
6
|
+
* (`IMAGE_LIMITS_CEILING`). The tenant's tier applied at capture; a serve never consults it, so a
|
|
7
|
+
* tier change or an entitlement outage never 404s an image already accepted.
|
|
7
8
|
*
|
|
8
9
|
* Where the runtime has `crypto.DigestStream` (workerd), the R2 body is streamed to the client
|
|
9
10
|
* through a verifier that counts and hashes it and holds back the last chunk until both match, so a
|
|
@@ -11,7 +12,7 @@
|
|
|
11
12
|
* bounded by the manifest's byte length, and verified before the first byte.
|
|
12
13
|
*/
|
|
13
14
|
import { sha256Hex } from "@tot/public-runtime";
|
|
14
|
-
import {
|
|
15
|
+
import { IMAGE_LIMITS_CEILING } from "@tot/public-runtime/image-limits";
|
|
15
16
|
import {
|
|
16
17
|
PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE, PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION, sealedMediaTypeAllowed,
|
|
17
18
|
} from "@tot/public-runtime/hosted-image-media-types";
|
|
@@ -65,9 +66,8 @@ async function readManifestEntries(
|
|
|
65
66
|
bucket: CapturedOriginalBucket,
|
|
66
67
|
tenantId: string,
|
|
67
68
|
revisionId: string,
|
|
68
|
-
imageTier: ImageTier | undefined,
|
|
69
69
|
): Promise<ManifestEntry[] | null> {
|
|
70
|
-
const maxEntryBytes =
|
|
70
|
+
const maxEntryBytes = IMAGE_LIMITS_CEILING.maxEncodedBytes;
|
|
71
71
|
if (!isSafeTenantId(tenantId) || !isSafeRevisionId(revisionId)) return null;
|
|
72
72
|
const object = await bucket.get(originalImageManifestObjectKey(tenantId, revisionId));
|
|
73
73
|
if (!object || typeof object !== "object") return null;
|
|
@@ -110,8 +110,6 @@ export async function readDurableCapturedEntry(input: {
|
|
|
110
110
|
tenantId: string;
|
|
111
111
|
provider: string | null | undefined;
|
|
112
112
|
address: string;
|
|
113
|
-
/** The tenant's image tier from its server-resolved record (`resolveImageTier`); absent is standard. */
|
|
114
|
-
imageTier?: ImageTier;
|
|
115
113
|
}): Promise<ManifestEntry | null> {
|
|
116
114
|
if (!isSafeTenantId(input.tenantId) || !ADDRESS_RE.test(input.address)) return null;
|
|
117
115
|
const required = durableRequiredStores(input.provider);
|
|
@@ -122,7 +120,7 @@ export async function readDurableCapturedEntry(input: {
|
|
|
122
120
|
if (typeof designation.entry !== "string" || !designation.entry.startsWith(`${input.address}.`) ||
|
|
123
121
|
!Array.isArray(designation.verifiedStores) || !required.every((store) => designation.verifiedStores.includes(store)) ||
|
|
124
122
|
typeof designation.designatedRevision !== "string" || !isSafeRevisionId(designation.designatedRevision)) continue;
|
|
125
|
-
const entries = await readManifestEntries(input.bucket, input.tenantId, durableCaptureRevisionId(designation.designatedRevision)
|
|
123
|
+
const entries = await readManifestEntries(input.bucket, input.tenantId, durableCaptureRevisionId(designation.designatedRevision));
|
|
126
124
|
const entry = entries?.find((entry) => entry.address === input.address &&
|
|
127
125
|
designation.entry === `${entry.address}${PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE[entry.mediaType]}`);
|
|
128
126
|
if (entry) return entry;
|
|
@@ -141,8 +139,6 @@ export async function tryServeCapturedOriginal(input: {
|
|
|
141
139
|
durable?: { kv: { get(key: string): Promise<string | null> }; provider: string | null | undefined };
|
|
142
140
|
pathname: string;
|
|
143
141
|
method?: string;
|
|
144
|
-
/** The tenant's image tier from its server-resolved record (`resolveImageTier`); absent is standard. */
|
|
145
|
-
imageTier?: ImageTier;
|
|
146
142
|
}): Promise<Response | null> {
|
|
147
143
|
const parsed = parsePinnedImagePath(input.pathname);
|
|
148
144
|
if (!parsed) return null;
|
|
@@ -151,12 +147,12 @@ export async function tryServeCapturedOriginal(input: {
|
|
|
151
147
|
}
|
|
152
148
|
|
|
153
149
|
const entries = input.revisionId
|
|
154
|
-
? await readManifestEntries(input.bucket, input.tenantId, input.revisionId
|
|
150
|
+
? await readManifestEntries(input.bucket, input.tenantId, input.revisionId).catch(() => null)
|
|
155
151
|
: null;
|
|
156
152
|
let entry = entries?.find((item) => item.address === parsed.address);
|
|
157
153
|
let durable = false;
|
|
158
154
|
if (!entry && input.revisionId) {
|
|
159
|
-
const declared = await readManifestEntries(input.bucket, input.tenantId, durableCaptureRevisionId(input.revisionId)
|
|
155
|
+
const declared = await readManifestEntries(input.bucket, input.tenantId, durableCaptureRevisionId(input.revisionId))
|
|
160
156
|
.catch(() => null);
|
|
161
157
|
entry = declared?.find((item) => item.address === parsed.address);
|
|
162
158
|
durable = !!entry;
|
|
@@ -164,7 +160,7 @@ export async function tryServeCapturedOriginal(input: {
|
|
|
164
160
|
if (!entry && input.durable) {
|
|
165
161
|
try {
|
|
166
162
|
entry = await readDurableCapturedEntry({ bucket: input.bucket, tenantId: input.tenantId,
|
|
167
|
-
address: parsed.address, ...input.durable
|
|
163
|
+
address: parsed.address, ...input.durable }) ?? undefined;
|
|
168
164
|
durable = !!entry;
|
|
169
165
|
} catch { /* Unreadable durable authority fails closed. */ }
|
|
170
166
|
}
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "@tot/public-runtime/durable-assets";
|
|
20
20
|
import { GC_LEASE_KEY, awaitGcQuiescence } from "@tot/public-runtime/retention-holds";
|
|
21
21
|
import { authorizeAssetReference, type AssetRegistryStore, type AssetWrite } from "@tot/public-runtime/asset-registry";
|
|
22
|
-
import {
|
|
22
|
+
import { IMAGE_LIMITS_CEILING } from "@tot/public-runtime/image-limits";
|
|
23
23
|
import { incrementalSha256 } from "@tot/public-runtime/incremental-sha256";
|
|
24
24
|
|
|
25
25
|
const MAX_MANIFEST = 1024 * 1024;
|
|
@@ -245,12 +245,15 @@ function mediaTypeOf(entry: string): string {
|
|
|
245
245
|
return mediaType;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
/**
|
|
249
|
-
|
|
248
|
+
/**
|
|
249
|
+
* The tenant's captured `<sha256><ext>`, verified as a stream within the image ceiling; never held whole.
|
|
250
|
+
* Capture already enforced the tenant's tier, so publication only bounds the read.
|
|
251
|
+
*/
|
|
252
|
+
async function capturedSource(captureBucket: R2BucketLike, tenant: string, entry: string): Promise<StreamSource> {
|
|
250
253
|
const address = POOL_ENTRY_RE.exec(entry)?.[1];
|
|
251
254
|
if (!address) throw new R2StagingError("pool-key");
|
|
252
255
|
const source = await verifiedObjectSource(captureBucket, `original-images/${tenant}/objects/${address}`, address,
|
|
253
|
-
|
|
256
|
+
IMAGE_LIMITS_CEILING.maxEncodedBytes);
|
|
254
257
|
if (!source) throw new R2StagingError("original-missing");
|
|
255
258
|
return source;
|
|
256
259
|
}
|
|
@@ -265,8 +268,8 @@ async function capturedSource(captureBucket: R2BucketLike, tenant: string, entry
|
|
|
265
268
|
* tenant's own capture store (which registers it); otherwise nothing is staged and the go-live
|
|
266
269
|
* refuses `asset-unauthorized`.
|
|
267
270
|
*
|
|
268
|
-
* Originals are hashed and copied as streams, one at a time, within the
|
|
269
|
-
*
|
|
271
|
+
* Originals are hashed and copied as streams, one at a time, within the image ceiling: none is ever
|
|
272
|
+
* held whole.
|
|
270
273
|
*/
|
|
271
274
|
export async function stageCapturedOriginalsR2(input: {
|
|
272
275
|
tenant: string;
|
|
@@ -274,15 +277,13 @@ export async function stageCapturedOriginalsR2(input: {
|
|
|
274
277
|
captureBucket: R2BucketLike;
|
|
275
278
|
publicBucket: R2BucketLike;
|
|
276
279
|
registry?: AssetRegistryStore;
|
|
277
|
-
/** The tenant's image tier from its server-resolved record (`resolveImageTier`); absent is standard. */
|
|
278
|
-
imageTier?: ImageTier;
|
|
279
280
|
}): Promise<number> {
|
|
280
281
|
const registry = input.registry ? await input.registry.read().catch(() => null) : null;
|
|
281
282
|
const owned = new Map<string, number>();
|
|
282
283
|
const unauthorized: string[] = [];
|
|
283
284
|
for (const entry of input.entries) {
|
|
284
285
|
if (authorizeAssetReference([registry], input.tenant, entry).ok) continue;
|
|
285
|
-
const own = await capturedSource(input.captureBucket, input.tenant, entry
|
|
286
|
+
const own = await capturedSource(input.captureBucket, input.tenant, entry).catch((error: unknown) => {
|
|
286
287
|
if (error instanceof R2StagingError) return null;
|
|
287
288
|
throw error;
|
|
288
289
|
});
|
|
@@ -299,10 +300,10 @@ export async function stageCapturedOriginalsR2(input: {
|
|
|
299
300
|
let staged = 0;
|
|
300
301
|
for (const entry of input.entries) {
|
|
301
302
|
const key = `assets/${entry}`;
|
|
302
|
-
const present = await digestBounded(input.publicBucket, key,
|
|
303
|
+
const present = await digestBounded(input.publicBucket, key, IMAGE_LIMITS_CEILING.maxEncodedBytes);
|
|
303
304
|
const address = POOL_ENTRY_RE.exec(entry)?.[1];
|
|
304
305
|
if (present && address && present.sha256 === address) continue;
|
|
305
|
-
await ensurePoolObject(input.publicBucket, key, await capturedSource(input.captureBucket, input.tenant, entry
|
|
306
|
+
await ensurePoolObject(input.publicBucket, key, await capturedSource(input.captureBucket, input.tenant, entry), mediaTypeOf(entry));
|
|
306
307
|
staged++;
|
|
307
308
|
}
|
|
308
309
|
return staged;
|
|
@@ -351,8 +352,6 @@ export async function designateDurableR2(input: {
|
|
|
351
352
|
awaitQuiescence?: QuiescenceWaiter;
|
|
352
353
|
/** The pool's asset registry: each verified designation is recorded as a transition to `durable`. */
|
|
353
354
|
registry?: AssetRegistryStore;
|
|
354
|
-
/** The tenant's image tier from its server-resolved record (`resolveImageTier`); absent is standard. */
|
|
355
|
-
imageTier?: ImageTier;
|
|
356
355
|
}): Promise<DurableDesignationResult> {
|
|
357
356
|
if (input.images.length === 0) return { ok: true, designated: [] };
|
|
358
357
|
const revision = durableCaptureRevisionId(input.revision);
|
|
@@ -396,7 +395,7 @@ export async function designateDurableR2(input: {
|
|
|
396
395
|
for (const item of prepared) {
|
|
397
396
|
await createHold(input.publicBucket, item.holdKey, item.holdBody);
|
|
398
397
|
await ensurePoolObject(input.publicBucket, `assets/${item.entry}`,
|
|
399
|
-
await capturedSource(input.captureBucket, input.tenant, item.entry
|
|
398
|
+
await capturedSource(input.captureBucket, input.tenant, item.entry), item.mediaType);
|
|
400
399
|
}
|
|
401
400
|
await recordDurableStatusKv(input.kv, input.tenant, { revision: input.revision, at: now().toISOString(), declared,
|
|
402
401
|
designated: prepared.map((item) => ({ entry: item.entry, name: item.image.name, source: item.image.source, verifiedStores: ["r2"] })) });
|
|
@@ -421,8 +420,6 @@ export function makeWorkerR2PinnedOriginalStager(deps: {
|
|
|
421
420
|
kv: StatusKvLike;
|
|
422
421
|
/** The pool's asset registry (`STATIC_BUNDLES` `registry/assets.json`). */
|
|
423
422
|
registry?: AssetRegistryStore;
|
|
424
|
-
/** The tier of the tenant this Worker resolves for `tenantId`, from its server-side record. */
|
|
425
|
-
imageTier?: (tenantId: string) => Promise<ImageTier>;
|
|
426
423
|
now?: () => Date;
|
|
427
424
|
awaitQuiescence?: QuiescenceWaiter;
|
|
428
425
|
}) {
|
|
@@ -439,17 +436,15 @@ export function makeWorkerR2PinnedOriginalStager(deps: {
|
|
|
439
436
|
if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string" || !POOL_ENTRY_RE.test(entry))) {
|
|
440
437
|
throw new R2StagingError("manifest-invalid");
|
|
441
438
|
}
|
|
442
|
-
const imageTier = deps.imageTier ? await deps.imageTier(tenantId) : undefined;
|
|
443
439
|
await awaitR2Quiescence(deps.publicBucket, deps.awaitQuiescence);
|
|
444
440
|
await stageCapturedOriginalsR2({ tenant: tenantId, entries: entries as string[],
|
|
445
|
-
captureBucket: deps.captureBucket, publicBucket: deps.publicBucket, ...(deps.registry ? { registry: deps.registry } : {})
|
|
446
|
-
...(imageTier ? { imageTier } : {}) });
|
|
441
|
+
captureBucket: deps.captureBucket, publicBucket: deps.publicBucket, ...(deps.registry ? { registry: deps.registry } : {}) });
|
|
447
442
|
const images = manifest.durableAssets?.images ?? [];
|
|
448
443
|
if (images.length > 0) {
|
|
449
444
|
if (typeof manifest.sourceSha !== "string") throw new R2StagingError("manifest-invalid");
|
|
450
445
|
const result = await designateDurableR2({ tenant: tenantId, revision: manifest.sourceSha, images,
|
|
451
446
|
captureBucket: deps.captureBucket, publicBucket: deps.publicBucket, kv: deps.kv,
|
|
452
|
-
...(deps.registry ? { registry: deps.registry } : {}),
|
|
447
|
+
...(deps.registry ? { registry: deps.registry } : {}),
|
|
453
448
|
...(deps.now ? { now: deps.now } : {}), ...(deps.awaitQuiescence ? { awaitQuiescence: deps.awaitQuiescence } : {}) });
|
|
454
449
|
if (!result.ok) throw new R2StagingError(result.reason);
|
|
455
450
|
}
|
|
@@ -7,10 +7,8 @@
|
|
|
7
7
|
import { readKv, readR2 } from "@/lib/publication/shared/env";
|
|
8
8
|
import { makeWorkerR2PinnedOriginalStager, R2StagingError, type R2BucketLike, type StatusKvLike } from "./durableR2.js";
|
|
9
9
|
import { createR2AssetRegistry, type AssetRegistryBucket } from "./assetRegistryR2.js";
|
|
10
|
-
import type { ImageTier } from "@tot/public-runtime/image-limits";
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
export async function workerR2PinnedOriginalStagerFromEnv(imageTier: (tenantId: string) => Promise<ImageTier>): Promise<
|
|
11
|
+
export async function workerR2PinnedOriginalStagerFromEnv(): Promise<
|
|
14
12
|
(args: { tenantId: string; versionId: string; treeDigest?: string | null }) => Promise<void>
|
|
15
13
|
> {
|
|
16
14
|
const [publicBucket, productImages, tenantAssets, kv] = await Promise.all([
|
|
@@ -31,6 +29,5 @@ export async function workerR2PinnedOriginalStagerFromEnv(imageTier: (tenantId:
|
|
|
31
29
|
captureBucket: captureBucket as unknown as R2BucketLike,
|
|
32
30
|
kv: kv as unknown as StatusKvLike,
|
|
33
31
|
registry: createR2AssetRegistry(publicBucket as unknown as AssetRegistryBucket),
|
|
34
|
-
imageTier,
|
|
35
32
|
});
|
|
36
33
|
}
|
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
versionIndependentAssetKey,
|
|
17
17
|
} from "@tot/public-runtime";
|
|
18
18
|
import type { StaticBundleR2BucketLike, TenantConfig } from "@tot/public-runtime";
|
|
19
|
-
import { resolveImageTier } from "@tot/public-runtime/image-limits";
|
|
20
19
|
|
|
21
20
|
export interface StaticBundleAttemptInput {
|
|
22
21
|
request: Request;
|
|
@@ -95,7 +94,6 @@ export async function tryServeStaticBundle(
|
|
|
95
94
|
revisionId: input.capturedOriginal.revisionId,
|
|
96
95
|
pathname: assetPath,
|
|
97
96
|
method,
|
|
98
|
-
imageTier: resolveImageTier(tenant),
|
|
99
97
|
...(kv ? { durable: { kv, provider: tenant.serving?.provider } } : {}),
|
|
100
98
|
});
|
|
101
99
|
if (captured) return captured;
|
|
@@ -109,7 +107,6 @@ export async function tryServeStaticBundle(
|
|
|
109
107
|
revisionId: input.capturedOriginal.revisionId,
|
|
110
108
|
pathname: assetPath,
|
|
111
109
|
method,
|
|
112
|
-
imageTier: resolveImageTier(tenant),
|
|
113
110
|
...(kv ? { durable: { kv, provider: tenant.serving?.provider } } : {}),
|
|
114
111
|
});
|
|
115
112
|
if (captured) return captured;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.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",
|
|
@@ -10,8 +10,7 @@ export declare const IMAGE_TIERS: Readonly<Record<ImageTier, ImageLimits>>;
|
|
|
10
10
|
export declare const IMAGE_LIMITS_CEILING: ImageLimits;
|
|
11
11
|
export declare const LARGE_IMAGES_ADDON_NAME: string;
|
|
12
12
|
export declare function imageLimitsFor(tier: unknown): ImageLimits;
|
|
13
|
-
export declare function resolveImageTier(
|
|
14
|
-
export declare function imageLimitsForTenant(tenant: unknown): ImageLimits;
|
|
13
|
+
export declare function resolveImageTier(entitlements: unknown): ImageTier;
|
|
15
14
|
export declare function imageLimitSummary(tier: ImageTier): string;
|
|
16
15
|
export declare function withinImagePixels(
|
|
17
16
|
width: number, height: number, frames: number, limits: Pick<ImageLimits, "maxPixels" | "maxDimension">,
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one source of storefront image size limits.
|
|
3
|
-
*
|
|
4
|
-
* no path restates a literal (`image-limits-contract.test.mjs` scans the tree for one).
|
|
2
|
+
* The one source of storefront image size limits. No path restates a literal
|
|
3
|
+
* (`image-limits-contract.test.mjs` scans the tree for one).
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* The tier ends at ingest. Capture and upload take their caps from
|
|
6
|
+
* `imageLimitsFor(resolveImageTier(entitlements))`, where `entitlements` is the store's add-on set as
|
|
7
|
+
* tot20 reports it (the storefront reads it with `getStoreEntitlements`). Every publication, export
|
|
8
|
+
* and serve path handles bytes that were already accepted, so it bounds them by
|
|
9
|
+
* `IMAGE_LIMITS_CEILING` and never consults the tier: a downgrade or an entitlement outage never
|
|
10
|
+
* invalidates accepted content.
|
|
11
|
+
*
|
|
12
|
+
* `large` is the High Fidelity / Large Images add-on: `largeImages.enabled === true` exactly. Every
|
|
13
|
+
* other value, including absent or malformed, is `standard` (fail closed).
|
|
10
14
|
*
|
|
11
15
|
* Pixels are counted as width x height x frames. No path decodes pixels inside the Worker:
|
|
12
16
|
* dimensions are read from a bounded header prefix, and display sizes are produced by the CDN.
|
|
@@ -43,19 +47,15 @@ export function imageLimitsFor(tier) {
|
|
|
43
47
|
return tier === "large" ? IMAGE_TIERS.large : IMAGE_TIERS.standard;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** @param {unknown} tenant */
|
|
57
|
-
export function imageLimitsForTenant(tenant) {
|
|
58
|
-
return imageLimitsFor(resolveImageTier(tenant));
|
|
50
|
+
/**
|
|
51
|
+
* The tier a store's add-on entitlements grant. `entitlements` is untrusted input.
|
|
52
|
+
* @param {unknown} entitlements
|
|
53
|
+
*/
|
|
54
|
+
export function resolveImageTier(entitlements) {
|
|
55
|
+
if (!entitlements || typeof entitlements !== "object" || Array.isArray(entitlements)) return "standard";
|
|
56
|
+
const largeImages = /** @type {Record<string, unknown>} */ (entitlements).largeImages;
|
|
57
|
+
if (!largeImages || typeof largeImages !== "object" || Array.isArray(largeImages)) return "standard";
|
|
58
|
+
return /** @type {Record<string, unknown>} */ (largeImages).enabled === true ? "large" : "standard";
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const mb = (bytes) => `${Math.round((bytes / MiB) * 10) / 10} MB`;
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Merge a provisioned tenant config into the `tenant:<id>` KV record already stored.
|
|
3
3
|
*
|
|
4
4
|
* Provisioners carry only the fields they own. The stored record also holds fields written by
|
|
5
|
-
* other paths (`storefront.mode
|
|
6
|
-
*
|
|
5
|
+
* other paths (`storefront.mode`), so a provisioner must read the stored record, merge, and write
|
|
6
|
+
* the result; it never replaces the record wholesale.
|
|
7
7
|
*
|
|
8
8
|
* Contract:
|
|
9
9
|
* - the provisioned config wins for every key it carries;
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
* - a stored value that is not a JSON object (unparseable, array, scalar, empty string) throws,
|
|
15
15
|
* naming the key, and the caller writes nothing.
|
|
16
16
|
*
|
|
17
|
-
* KV has no transaction. A provisioner that reads, merges and writes while
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* KV has no transaction. A provisioner that reads, merges and writes while another path writes the
|
|
18
|
+
* same record can still lose one of the two writes; the merge narrows that window to one
|
|
19
|
+
* read-to-write interval and cannot close it.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
@@ -739,15 +739,6 @@ export interface TenantConfig {
|
|
|
739
739
|
*/
|
|
740
740
|
mode?: StorefrontMode;
|
|
741
741
|
};
|
|
742
|
-
/**
|
|
743
|
-
* Per-store add-ons. `largeImages` is the High Fidelity / Large Images add-on: `mode ===
|
|
744
|
-
* "enabled"` raises the store's image limits to the large tier (`image-limits.mjs`
|
|
745
|
-
* `resolveImageTier`); any other value, or absent, is the standard tier. `updatedBy` is an
|
|
746
|
-
* opaque actor id, never an email or other personal detail.
|
|
747
|
-
*/
|
|
748
|
-
addOns?: {
|
|
749
|
-
largeImages?: { mode: "enabled" | "disabled"; updatedAt?: string; updatedBy?: string };
|
|
750
|
-
};
|
|
751
742
|
/**
|
|
752
743
|
* Internal test/e2e tenant — never a real client. A tenant carrying this
|
|
753
744
|
* marker (or an `.e2e.test` appDomain) is excluded from real/prospect-facing
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
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
|
-
}
|