@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.
@@ -1,16 +1,11 @@
1
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.
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
- export function isAddOnId(value: unknown): value is AddOnId {
42
- return typeof value === "string" && (ADDON_IDS as readonly string[]).includes(value);
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
- export function isAddOnMode(value: unknown): value is AddOnMode {
46
- return typeof value === "string" && (ADDON_MODES as readonly string[]).includes(value);
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 tenant's image tier
6
- * (`imageTier`, resolved by the caller from the server-side tenant record).
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 { imageLimitsFor, type ImageTier } from "@tot/public-runtime/image-limits";
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 = imageLimitsFor(imageTier).maxEncodedBytes;
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), input.imageTier);
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, input.imageTier).catch(() => null)
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), input.imageTier)
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, ...(input.imageTier ? { imageTier: input.imageTier } : {}) }) ?? undefined;
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 { imageLimitsFor, type ImageTier } from "@tot/public-runtime/image-limits";
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
- /** 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> {
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
- imageLimitsFor(imageTier).maxEncodedBytes);
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 tenant's image tier: none is
269
- * ever held whole.
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, input.imageTier).catch((error: unknown) => {
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, imageLimitsFor(input.imageTier).maxEncodedBytes);
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, input.imageTier), mediaTypeOf(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, input.imageTier), item.mediaType);
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 } : {}), ...(imageTier ? { imageTier } : {}),
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
- /** `imageTier` resolves a tenant's tier from its server-side record; the caller owns that lookup. */
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": "5.0.1",
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(tenant: unknown): ImageTier;
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. Every capture, upload, publication and serving
3
- * path takes its byte, pixel and dimension caps from `imageLimitsFor(resolveImageTier(tenant))`;
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
- * Tiers are per tenant. `large` is the High Fidelity / Large Images add-on, read only from the
7
- * server-resolved tenant record (`addOns.largeImages.mode === "enabled"`); every other value,
8
- * including absent or malformed, is `standard` (fail closed, the same contract as
9
- * `storefront.mode`).
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
- /** @param {unknown} tenant */
47
- export function resolveImageTier(tenant) {
48
- if (!tenant || typeof tenant !== "object") return "standard";
49
- const addOns = /** @type {Record<string, unknown>} */ (tenant).addOns;
50
- if (!addOns || typeof addOns !== "object") return "standard";
51
- const largeImages = /** @type {Record<string, unknown>} */ (addOns).largeImages;
52
- if (!largeImages || typeof largeImages !== "object") return "standard";
53
- return /** @type {Record<string, unknown>} */ (largeImages).mode === "enabled" ? "large" : "standard";
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`, `addOns.largeImages`), so a provisioner must read the stored
6
- * record, merge, and write the result; it never replaces the record wholesale.
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 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.
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
- }