@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 +1 -1
- package/src/cms/draftSource.test.ts +86 -0
- package/src/cms/draftSource.ts +52 -8
- package/src/cms/index.ts +1 -0
package/package.json
CHANGED
|
@@ -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(
|
package/src/cms/draftSource.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
195
|
-
*
|
|
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.
|
|
214
|
-
*
|
|
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 (
|
|
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 (
|
|
436
|
+
if (!isDraftPreviewEnabled(env)) return null;
|
|
393
437
|
const pointer = draftPointerFromRequest(request);
|
|
394
438
|
if (!pointer) return null;
|
|
395
439
|
const host =
|