@decocms/blocks 7.50.1 → 7.52.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.50.1",
3
+ "version": "7.52.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -10,6 +10,7 @@ import {
10
10
  previewApiOriginForHost,
11
11
  resolveDraftDecofile,
12
12
  resolveDraftForRequest,
13
+ setDecoSiteHost,
13
14
  setDraftPreviewHosts,
14
15
  } from "./draftSource";
15
16
 
@@ -323,6 +324,91 @@ describe("site-block preview hosts", () => {
323
324
  });
324
325
  });
325
326
 
327
+ describe("deco-hosted preview domains (setDecoSiteHost)", () => {
328
+ it("infers <site>.deco.site and enables the feature", () => {
329
+ setDecoSiteHost("als-storefront");
330
+ try {
331
+ expect(isDraftPreviewEnabled({})).toBe(true);
332
+ expect(isDraftHostAllowed("als-storefront.deco.site", {})).toBe(true);
333
+ expect(isDraftHostAllowed("other.deco.site", {})).toBe(false);
334
+ // A custom production domain is never inferred.
335
+ expect(isDraftHostAllowed("www.als-storefront.com", {})).toBe(false);
336
+ } finally {
337
+ setDecoSiteHost(null);
338
+ }
339
+ });
340
+
341
+ it("infers the <site>.deco-cx.workers.dev deploy host", () => {
342
+ setDecoSiteHost("casaevideo-tanstack");
343
+ try {
344
+ expect(isDraftHostAllowed("casaevideo-tanstack.deco-cx.workers.dev", {})).toBe(true);
345
+ // Exact match only — another worker on the same account is not admitted.
346
+ expect(isDraftHostAllowed("other-site.deco-cx.workers.dev", {})).toBe(false);
347
+ // No nested subdomain widens the match.
348
+ expect(
349
+ isDraftHostAllowed("casaevideo-tanstack.evil.deco-cx.workers.dev", {}),
350
+ ).toBe(false);
351
+ // Wrong apex.
352
+ expect(isDraftHostAllowed("casaevideo-tanstack.deco-cx.workers.example", {})).toBe(false);
353
+ } finally {
354
+ setDecoSiteHost(null);
355
+ }
356
+ });
357
+
358
+ it("is merged ON TOP of the site block, not replacing it", () => {
359
+ setDraftPreviewHosts(["fila.vtex.app"]);
360
+ setDecoSiteHost("als-storefront");
361
+ try {
362
+ expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(true);
363
+ expect(isDraftHostAllowed("als-storefront.deco.site", {})).toBe(true);
364
+ } finally {
365
+ setDraftPreviewHosts([]);
366
+ setDecoSiteHost(null);
367
+ }
368
+ });
369
+
370
+ it("is merged ON TOP of the env escape hatch too", () => {
371
+ setDecoSiteHost("als-storefront");
372
+ try {
373
+ const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" };
374
+ expect(isDraftHostAllowed("other.example", env)).toBe(true);
375
+ expect(isDraftHostAllowed("als-storefront.deco.site", env)).toBe(true);
376
+ } finally {
377
+ setDecoSiteHost(null);
378
+ }
379
+ });
380
+
381
+ it("a blank/null/undefined site name registers no host", () => {
382
+ // The binding passes DECO_SITE_NAME straight through — an unnamed site
383
+ // (unset binding, blank value) is not armed.
384
+ for (const site of [" ", null, undefined]) {
385
+ setDecoSiteHost(site);
386
+ try {
387
+ expect(isDraftPreviewEnabled({})).toBe(false);
388
+ } finally {
389
+ setDecoSiteHost(null);
390
+ }
391
+ }
392
+ });
393
+
394
+ it("DECO_ALLOWED_PREVIEW_HOSTS=none kills the inferred hosts too", () => {
395
+ setDraftPreviewHosts(["fila.vtex.app"]);
396
+ setDecoSiteHost("als-storefront");
397
+ try {
398
+ // The kill switch wins over the inferred hosts AND the site block, so a
399
+ // bad rollout can be stopped without a deploy.
400
+ const env = { DECO_ALLOWED_PREVIEW_HOSTS: "none" };
401
+ expect(isDraftPreviewEnabled(env)).toBe(false);
402
+ expect(isDraftHostAllowed("als-storefront.deco.site", env)).toBe(false);
403
+ expect(isDraftHostAllowed("als-storefront.deco-cx.workers.dev", env)).toBe(false);
404
+ expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(false);
405
+ } finally {
406
+ setDraftPreviewHosts([]);
407
+ setDecoSiteHost(null);
408
+ }
409
+ });
410
+ });
411
+
326
412
  describe("draftPointerFromRequest", () => {
327
413
  it("reads the pointer from the __deco_draft cookie (in-preview navigation)", () => {
328
414
  const req = new Request(
@@ -162,7 +162,7 @@ export function previewApiOriginForHost(
162
162
  // is invisible to the others. (The MIDDLEWARE runtime is a separate world
163
163
  // even so — which is why the page-side gate is the authoritative one and the
164
164
  // middleware only hard-gates when the env override is present.)
165
- const G = globalThis as { __decoDraftHosts?: string[] };
165
+ const G = globalThis as { __decoDraftHosts?: string[]; __decoSite?: string };
166
166
 
167
167
  /** Install the site-declared preview hosts. Called by the framework binding at setup. */
168
168
  export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
@@ -172,6 +172,32 @@ export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
172
172
  .filter(Boolean);
173
173
  }
174
174
 
175
+ /**
176
+ * Register the resolved site name (`DECO_SITE_NAME` / an explicit setup
177
+ * option, resolved by the framework binding at setup). deco-operated preview
178
+ * hosts are derived from it, both exact:
179
+ *
180
+ * - `<site>.deco.site` — the stable deco-hosted domain.
181
+ * - `<site>.deco-cx.workers.dev` — the workers.dev deploy URL
182
+ * (e.g. `casaevideo-tanstack.deco-cx.workers.dev`).
183
+ *
184
+ * Both are MERGED with the site-block/env list rather than replacing it: they
185
+ * are deco-operated infra, so a signed draft grant can preview there out of the
186
+ * box, while a custom production domain — never inferred here — stays inert.
187
+ * Fed from the trusted setup-time site name, never from the request or a draft.
188
+ *
189
+ * Threat model, now that a named site is no longer inert by default: the
190
+ * request host is spoofable on a direct-to-origin request (the edge is trusted
191
+ * to set `x-forwarded-host`), so for a named site the SIGNED `?__draft=` grant
192
+ * is the sole remaining gate — host-scoping only bounds blast radius. Set
193
+ * `DECO_ALLOWED_PREVIEW_HOSTS=none` to kill preview entirely, including these
194
+ * inferred hosts, without a deploy.
195
+ */
196
+ export function setDecoSiteHost(site: string | null | undefined): void {
197
+ const s = (site ?? "").trim().toLowerCase();
198
+ G.__decoSite = s || undefined;
199
+ }
200
+
175
201
  /**
176
202
  * Hosts allowed to render drafts.
177
203
  *
@@ -179,20 +205,35 @@ export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
179
205
  * reviewed in a PR, versioned with branches. `DECO_ALLOWED_PREVIEW_HOSTS`
180
206
  * REPLACES it when set: an operational escape hatch (kill a bad value without
181
207
  * a deploy, add a machine-specific port) — not the primary configuration.
208
+ *
209
+ * The deco-hosted domains inferred from the site name (via `setDecoSiteHost`)
210
+ * are always ADDED on top, so a signed draft grant can preview on
211
+ * deco-operated infra without any per-site config.
212
+ *
213
+ * The sentinel `DECO_ALLOWED_PREVIEW_HOSTS=none` is a KILL SWITCH: it disables
214
+ * preview entirely — the inferred hosts and the site block included — so a bad
215
+ * rollout can be stopped without a deploy. It must win over every other source.
182
216
  */
183
217
  function readAllowedHosts(env: Record<string, string | undefined>): string[] {
184
218
  const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "")
185
219
  .split(",")
186
220
  .map((s) => s.trim().toLowerCase())
187
221
  .filter(Boolean);
188
- return fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []);
222
+ if (fromEnv.includes("none")) return [];
223
+ const configured = fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []);
224
+ const site = G.__decoSite;
225
+ if (!site) return configured;
226
+ const inferred = [`${site}.deco.site`, `${site}.deco-cx.workers.dev`].filter(
227
+ (h) => !configured.includes(h),
228
+ );
229
+ return inferred.length > 0 ? [...configured, ...inferred] : configured;
189
230
  }
190
231
 
191
232
  /**
192
233
  * Whether `host` (as seen on the request) may render drafts.
193
234
  *
194
- * Compared against `DECO_ALLOWED_PREVIEW_HOSTS` verbatim, port included —
195
- * local dev is `localhost:3100`, not `localhost`. The header is spoofable by a
235
+ * Compared against the allowlist verbatim, port included — local dev is
236
+ * `localhost:3100`, not `localhost`. The header is spoofable by a
196
237
  * direct-to-origin request, but the draft id is the actual capability;
197
238
  * host-scoping bounds blast radius (production domains stay inert), it is not
198
239
  * a secret.
@@ -210,8 +251,11 @@ export function isDraftHostAllowed(
210
251
  /**
211
252
  * True when any host is allowed to preview. A plain env read — callers use it
212
253
  * to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an
213
- * unconfigured site never loses static/ISR rendering. The per-request host
214
- * match happens later, in `isDraftHostAllowed`.
254
+ * unconfigured site never loses static/ISR rendering. A site with no config
255
+ * but a resolved name is now enabled here (its inferred `<site>.deco.site` /
256
+ * `<site>.deco-cx.workers.dev` hosts); `DECO_ALLOWED_PREVIEW_HOSTS=none`
257
+ * forces it back to fully inert. The per-request host match happens later, in
258
+ * `isDraftHostAllowed`.
215
259
  */
216
260
  export function isDraftPreviewEnabled(
217
261
  env?: Record<string, string | undefined>,
@@ -273,7 +317,7 @@ export async function resolveDraftDecofile(
273
317
  options: ResolveDraftOptions,
274
318
  ): Promise<Record<string, unknown> | null> {
275
319
  const env = envOrProcess(options.env);
276
- if (readAllowedHosts(env).length === 0) return null;
320
+ if (!isDraftPreviewEnabled(env)) return null;
277
321
 
278
322
  const parsed = parseDraftPointer(options.pointer);
279
323
  if (!parsed) return null;
@@ -389,7 +433,7 @@ export async function resolveDraftForRequest(
389
433
  options: ResolveDraftForRequestOptions = {},
390
434
  ): Promise<Record<string, unknown> | null> {
391
435
  const env = envOrProcess(options.env);
392
- if (readAllowedHosts(env).length === 0) return null;
436
+ if (!isDraftPreviewEnabled(env)) return null;
393
437
  const pointer = draftPointerFromRequest(request);
394
438
  if (!pointer) return null;
395
439
  const host =
package/src/cms/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  previewApiOriginForHost,
34
34
  resolveDraftDecofile,
35
35
  resolveDraftForRequest,
36
+ setDecoSiteHost,
36
37
  setDraftOverrideGetter,
37
38
  setDraftPreviewHosts,
38
39
  } from "./draftSource";
@@ -1,5 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { getOptimizedMediaUrl, getSrcSet } from "./Image";
2
+ import {
3
+ getImageQuality,
4
+ getOptimizedMediaUrl,
5
+ getSrcSet,
6
+ type ImageQuality,
7
+ registerImageQuality,
8
+ } from "./Image";
3
9
 
4
10
  describe("getOptimizedMediaUrl", () => {
5
11
  let warnSpy: ReturnType<typeof vi.spyOn>;
@@ -98,3 +104,82 @@ describe("getSrcSet", () => {
98
104
  expect(result).toContain("foo.jpg");
99
105
  });
100
106
  });
107
+
108
+ describe("registerImageQuality", () => {
109
+ // Module-level setter, so every test has to put it back or it leaks into
110
+ // the rest of the file.
111
+ afterEach(() => {
112
+ registerImageQuality(undefined);
113
+ });
114
+
115
+ it("emits no quality param by default", () => {
116
+ // The guarantee that makes this safe to land: every site that does not
117
+ // opt in keeps byte-identical URLs, so no CDN cache is invalidated.
118
+ expect(getImageQuality()).toBeUndefined();
119
+ const result = getOptimizedMediaUrl({
120
+ originalSrc: "https://cdn.example.com/foo.jpg",
121
+ width: 200,
122
+ fit: "cover",
123
+ });
124
+ expect(result).not.toContain("quality");
125
+ });
126
+
127
+ it("emits the registered quality for CDN-routed images", () => {
128
+ registerImageQuality("high");
129
+ const result = getOptimizedMediaUrl({
130
+ originalSrc: "https://cdn.example.com/foo.jpg",
131
+ width: 200,
132
+ fit: "cover",
133
+ });
134
+ expect(result).toContain("quality=high");
135
+ });
136
+ it("pins the exact URL, param order included", () => {
137
+ // Param ORDER is part of the CDN cache key, and `toContain` cannot see
138
+ // it: reordering the params would keep every other assertion here green
139
+ // while cold-caching every image on every site. It is also the property
140
+ // that lets a site swap its node_modules patch for this setter without a
141
+ // cache flush, so it needs a real equality check.
142
+ registerImageQuality("high");
143
+ expect(
144
+ getOptimizedMediaUrl({
145
+ originalSrc: "https://cdn.example.com/foo.jpg",
146
+ width: 200,
147
+ height: 300,
148
+ fit: "cover",
149
+ }),
150
+ ).toBe(
151
+ "https://decoims.com/image?fit=cover&width=200&height=300&quality=high&src=https://cdn.example.com/foo.jpg",
152
+ );
153
+ });
154
+
155
+ it("carries the quality into every srcset entry", () => {
156
+ registerImageQuality("high");
157
+ const result = getSrcSet("https://cdn.example.com/foo.jpg", 100);
158
+ const entries = result?.split(", ") ?? [];
159
+ expect(entries.length).toBeGreaterThan(1);
160
+ for (const entry of entries) {
161
+ expect(entry).toContain("quality=high");
162
+ }
163
+ });
164
+
165
+ it("leaves VTEX sources alone — they resize via their own path syntax", () => {
166
+ registerImageQuality("high");
167
+ const result = getOptimizedMediaUrl({
168
+ originalSrc:
169
+ "https://acme.vtexassets.com/arquivos/ids/123456/product.jpg?v=1",
170
+ width: 200,
171
+ height: 300,
172
+ fit: "cover",
173
+ });
174
+ expect(result).toContain("/arquivos/ids/123456-200-300/");
175
+ expect(result).not.toContain("quality");
176
+ });
177
+
178
+ it("treats an empty string as unset, for untyped callers", () => {
179
+ // Unreachable from TypeScript now that the parameter is a union, but a
180
+ // value read from env or CMS config arrives as a plain string, so the
181
+ // runtime guard still earns its keep.
182
+ registerImageQuality("" as unknown as ImageQuality);
183
+ expect(getImageQuality()).toBeUndefined();
184
+ });
185
+ });
@@ -31,6 +31,61 @@ export function getImageCdnDomain(): string {
31
31
  return imageCdnDomain;
32
32
  }
33
33
 
34
+ // -------------------------------------------------------------------------
35
+ // Configurable image quality
36
+ // -------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Quality levels the Deco image CDN accepts as a site-wide default.
40
+ *
41
+ * The CDN maps these to 60% / 70% / 80%. It also accepts `original` (100%),
42
+ * deliberately excluded here: a global 100% default hurts performance on
43
+ * every page, which is the same call `DefaultQualityOptions` makes in the
44
+ * Fresh implementation this file was ported from (deco-cx/apps,
45
+ * website/components/Image.tsx).
46
+ *
47
+ * A union rather than `string` because the CDN **fails silently** on
48
+ * anything else: it answers 200 and serves its 60% default. So the obvious
49
+ * `registerImageQuality("80")` — quality means 1-100 in Cloudflare Images,
50
+ * next/image and imgix — would quietly serve the LOWEST quality while
51
+ * changing the cache key on every image. Measured against production
52
+ * decoims.com on one asset: no param 2911 B, `low` 2911, `medium` 3235,
53
+ * `high` 5189, and `"80"` / `"HIGH"` / garbage all 2911.
54
+ */
55
+ export type ImageQuality = "low" | "medium" | "high";
56
+
57
+ let imageQuality: ImageQuality | undefined;
58
+
59
+ /**
60
+ * Register the quality level `getOptimizedMediaUrl` asks the image CDN for.
61
+ *
62
+ * Call once at module scope in your site's setup, NOT from a loader, action or
63
+ * anything else on the request path. This is module-level state shared by every
64
+ * request in a Worker isolate: setting it per-request would leak across
65
+ * concurrent requests, and setting it on only one of the SSR/hydration paths
66
+ * would produce mismatched `src`/`srcSet` and re-download every image.
67
+ *
68
+ * Unset by default, which emits no `quality` param and leaves the CDN on its
69
+ * 60% default — so existing sites are byte-for-byte unaffected and no CDN
70
+ * cache is invalidated. Set it when a site's art direction needs fidelity over
71
+ * bytes: on fashion/editorial catalogues the default compression visibly
72
+ * softens fabric texture and print detail.
73
+ *
74
+ * Applies to every URL built by `getOptimizedMediaUrl` — `Image`, `getSrcSet`
75
+ * and also `Video` when it is given `forceOptimizedSrc`. It does NOT apply to
76
+ * VTEX- or Shopify-hosted sources: those are resized through their own native
77
+ * URL syntax (`optimizeVTEX` / `optimizeShopify`), which returns before the
78
+ * query-param block. On a VTEX storefront that means product imagery is
79
+ * untouched and only CMS/banner assets are affected.
80
+ */
81
+ export function registerImageQuality(quality: ImageQuality | undefined) {
82
+ imageQuality = quality || undefined;
83
+ }
84
+
85
+ export function getImageQuality(): ImageQuality | undefined {
86
+ return imageQuality;
87
+ }
88
+
34
89
  // -------------------------------------------------------------------------
35
90
  // Fit options & optimization types
36
91
  // -------------------------------------------------------------------------
@@ -126,6 +181,7 @@ export function getOptimizedMediaUrl(opts: OptimizationOptions): string {
126
181
  params.set("fit", fit);
127
182
  params.set("width", `${width}`);
128
183
  if (height) params.set("height", `${height}`);
184
+ if (imageQuality) params.set("quality", imageQuality);
129
185
 
130
186
  return `https://${imageCdnDomain}/image?${params}&src=${imageSource}`;
131
187
  }
@@ -8,10 +8,13 @@ export {
8
8
  default as Image,
9
9
  registerImageCdnDomain,
10
10
  getImageCdnDomain,
11
+ registerImageQuality,
12
+ getImageQuality,
11
13
  getOptimizedMediaUrl,
12
14
  getSrcSet,
13
15
  FACTORS,
14
16
  type ImageProps,
17
+ type ImageQuality,
15
18
  type FitOptions,
16
19
  } from "./Image";
17
20
  export { Picture, Source, type PictureProps, type SourceProps } from "./Picture";