@decocms/blocks 7.51.0 → 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.51.0",
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";