@decocms/blocks 7.28.0-beta.0 → 7.28.0-beta.2

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.28.0-beta.0",
3
+ "version": "7.28.0-beta.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -3,13 +3,15 @@ import { beforeEach, describe, expect, it } from "vitest";
3
3
  import {
4
4
  buildDraftOrigin,
5
5
  clearDraftCache,
6
+ DEFAULT_SANDBOX_ORIGIN_SUFFIXES,
7
+ isDraftHostAllowed,
6
8
  isDraftPreviewEnabled,
7
9
  parseDraftPointer,
8
10
  resolveDraftDecofile,
9
11
  } from "./draftSource";
10
12
 
11
13
  const ENV_ON = {
12
- DECO_DRAFT_PREVIEW: "1",
14
+ DECO_DRAFT_PREVIEW_HOST: "preview.example",
13
15
  DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com",
14
16
  };
15
17
 
@@ -57,24 +59,27 @@ describe("parseDraftPointer", () => {
57
59
 
58
60
  describe("buildDraftOrigin", () => {
59
61
  it("builds https from the configured suffix", () => {
60
- expect(buildDraftOrigin("abc", [".preview-studio.decocms.com"])).toBe(
62
+ expect(buildDraftOrigin("abc", ".preview-studio.decocms.com")).toBe(
61
63
  "https://abc.preview-studio.decocms.com",
62
64
  );
63
65
  });
64
66
 
65
67
  it("uses http for a localhost suffix (local e2e)", () => {
66
- expect(buildDraftOrigin("abc", [".localhost:3200"])).toBe("http://abc.localhost:3200");
68
+ expect(buildDraftOrigin("abc", ".localhost:3200")).toBe("http://abc.localhost:3200");
67
69
  });
68
70
 
69
71
  it("returns null with no configured suffix — never guesses an origin", () => {
70
- expect(buildDraftOrigin("abc", [])).toBeNull();
72
+ expect(buildDraftOrigin("abc", "")).toBeNull();
71
73
  });
72
74
  });
73
75
 
74
76
  describe("isDraftPreviewEnabled", () => {
75
- it("needs both the flag and a suffix", () => {
77
+ it("is on iff an allowed host is configured — suffixes have defaults", () => {
78
+ // Inverted from the old two-key gate: suffixes now default to the
79
+ // deco-operated origins, so the single opt-in is the host allowlist.
80
+ // Upgrading the package with no host configured stays fully inert.
76
81
  expect(isDraftPreviewEnabled(ENV_ON)).toBe(true);
77
- expect(isDraftPreviewEnabled({ DECO_DRAFT_PREVIEW: "1" })).toBe(false);
82
+ expect(isDraftPreviewEnabled({ DECO_DRAFT_PREVIEW_HOST: "a.example" })).toBe(true);
78
83
  expect(
79
84
  isDraftPreviewEnabled({
80
85
  DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com",
@@ -84,7 +89,41 @@ describe("isDraftPreviewEnabled", () => {
84
89
  });
85
90
  });
86
91
 
92
+ describe("isDraftHostAllowed", () => {
93
+ const env = { DECO_DRAFT_PREVIEW_HOST: "fila.vtex.app, localhost:3100" };
94
+
95
+ it("matches listed hosts verbatim, port included, case-insensitively", () => {
96
+ expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(true);
97
+ expect(isDraftHostAllowed("FILA.VTEX.APP", env)).toBe(true);
98
+ expect(isDraftHostAllowed("localhost:3100", env)).toBe(true);
99
+ });
100
+
101
+ it("rejects everything else — the production domain on the same build", () => {
102
+ expect(isDraftHostAllowed("fila.com.br", env)).toBe(false);
103
+ expect(isDraftHostAllowed("localhost", env)).toBe(false); // port matters
104
+ expect(isDraftHostAllowed(null, env)).toBe(false);
105
+ expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(false);
106
+ });
107
+ });
108
+
87
109
  describe("resolveDraftDecofile", () => {
110
+ it("uses the default deco suffixes when none are configured", async () => {
111
+ const calls: string[] = [];
112
+ await resolveDraftDecofile({
113
+ pointer: "abc@v1",
114
+ env: { DECO_DRAFT_PREVIEW_HOST: "preview.example" },
115
+ fetchImpl: (async (url: string) => {
116
+ calls.push(String(url));
117
+ throw new Error("unreachable");
118
+ }) as unknown as typeof fetch,
119
+ });
120
+ expect(calls).toEqual(
121
+ DEFAULT_SANDBOX_ORIGIN_SUFFIXES.map(
122
+ (sfx) => `${sfx.includes("localhost") ? "http" : "https"}://abc${sfx}/_sandbox/decofile`,
123
+ ),
124
+ );
125
+ });
126
+
88
127
  it("fetches the sandbox decofile and returns it", async () => {
89
128
  const calls: string[] = [];
90
129
  const blocks = await resolveDraftDecofile({
@@ -100,7 +139,7 @@ describe("resolveDraftDecofile", () => {
100
139
  expect(calls).toEqual(["https://abc.preview-studio.decocms.com/_sandbox/decofile"]);
101
140
  });
102
141
 
103
- it("is inert unless explicitly enabled — no fetch at all", async () => {
142
+ it("is inert without a host allowlist — no fetch at all", async () => {
104
143
  let called = false;
105
144
  const blocks = await resolveDraftDecofile({
106
145
  pointer: "abc@v1",
@@ -152,6 +191,30 @@ describe("resolveDraftDecofile", () => {
152
191
  expect(fetches).toBe(5);
153
192
  });
154
193
 
194
+ it("falls through the suffix list until one origin answers", async () => {
195
+ // A deployment can serve sandboxes from more than one origin (cluster and
196
+ // desktop-link); the handle doesn't say which. The first suffix here is
197
+ // unreachable — the resolver must try the next, not give up.
198
+ const calls: string[] = [];
199
+ const blocks = await resolveDraftDecofile({
200
+ pointer: "abc@v1",
201
+ env: {
202
+ DECO_DRAFT_PREVIEW_HOST: "preview.example",
203
+ DECO_SANDBOX_ORIGIN_SUFFIXES: ".dead.example, .alive.example",
204
+ },
205
+ fetchImpl: (async (url: string) => {
206
+ calls.push(String(url));
207
+ if (String(url).includes("dead")) throw new Error("ECONNREFUSED");
208
+ return jsonResponse({ ok: true });
209
+ }) as unknown as typeof fetch,
210
+ });
211
+ expect(blocks).toEqual({ ok: true });
212
+ expect(calls).toEqual([
213
+ "https://abc.dead.example/_sandbox/decofile",
214
+ "https://abc.alive.example/_sandbox/decofile",
215
+ ]);
216
+ });
217
+
155
218
  it("degrades to published on a malformed pointer, without fetching", async () => {
156
219
  let called = false;
157
220
  const blocks = await resolveDraftDecofile({
@@ -14,9 +14,12 @@
14
14
  * dependency-injection shape as `setFastDeployKVGetter`, so `blocks` keeps its
15
15
  * zero-dependency direction.
16
16
  *
17
- * Inert unless BOTH `DECO_DRAFT_PREVIEW=1` and `DECO_SANDBOX_ORIGIN_SUFFIXES`
18
- * are set, mirroring Fast Deploy's opt-in: upgrading the package must never be
19
- * enough to start fetching from the network and rendering unpublished content.
17
+ * Inert unless `DECO_DRAFT_PREVIEW_HOST` names the request's host: upgrading
18
+ * the package must never be enough to start fetching from the network and
19
+ * rendering unpublished content. Host-scoping (rather than a boolean) exists
20
+ * because one deployment commonly serves several domains — the preview domain
21
+ * may render drafts while the production domain, on the same build, must
22
+ * ignore a `?__draft=` entirely.
20
23
  */
21
24
 
22
25
  /** A parsed `<handle>@<version>` draft pointer. */
@@ -54,23 +57,75 @@ export function parseDraftPointer(raw: string | null | undefined): DraftPointer
54
57
  return { handle, version };
55
58
  }
56
59
 
57
- /** Configured suffixes, e.g. `.preview-studio.decocms.com,.localhost:3200`. */
60
+ /**
61
+ * Default sandbox origin suffixes — deco-operated domains, so shipping them as
62
+ * defaults adds no SSRF surface: the origin is still configuration, never
63
+ * caller input. `DECO_SANDBOX_ORIGIN_SUFFIXES` overrides (e.g. to pin a local
64
+ * link port: `.localhost:60534`).
65
+ */
66
+ export const DEFAULT_SANDBOX_ORIGIN_SUFFIXES = [
67
+ ".preview-studio.decocms.com",
68
+ ".local.studio.decocms.com",
69
+ ".localhost",
70
+ ];
71
+
72
+ /**
73
+ * Suffixes are tried in order until one answers — a deployment can serve
74
+ * sandboxes from more than one origin (cluster and desktop-link), and the
75
+ * handle alone does not say which one it lives under.
76
+ */
58
77
  function readSuffixes(env: Record<string, string | undefined>): string[] {
59
- return (env.DECO_SANDBOX_ORIGIN_SUFFIXES ?? "")
78
+ const configured = (env.DECO_SANDBOX_ORIGIN_SUFFIXES ?? "")
60
79
  .split(",")
61
80
  .map((s) => s.trim())
62
81
  .filter(Boolean);
82
+ return configured.length > 0 ? configured : DEFAULT_SANDBOX_ORIGIN_SUFFIXES;
83
+ }
84
+
85
+ /** Hosts allowed to render drafts (`DECO_DRAFT_PREVIEW_HOST`, comma list). */
86
+ function readAllowedHosts(env: Record<string, string | undefined>): string[] {
87
+ return (env.DECO_DRAFT_PREVIEW_HOST ?? "")
88
+ .split(",")
89
+ .map((s) => s.trim().toLowerCase())
90
+ .filter(Boolean);
91
+ }
92
+
93
+ /**
94
+ * Whether `host` (as seen on the request) may render drafts.
95
+ *
96
+ * Compared against `DECO_DRAFT_PREVIEW_HOST` verbatim, port included — local
97
+ * dev is `localhost:3100`, not `localhost`. The header is spoofable by a
98
+ * direct-to-origin request, but the sandbox handle is the actual capability;
99
+ * host-scoping bounds blast radius (production domains stay inert), it is not
100
+ * a secret.
101
+ */
102
+ export function isDraftHostAllowed(
103
+ host: string | null | undefined,
104
+ env?: Record<string, string | undefined>,
105
+ ): boolean {
106
+ if (!host) return false;
107
+ const e = envOrProcess(env);
108
+ return readAllowedHosts(e).includes(host.trim().toLowerCase());
109
+ }
110
+
111
+ function envOrProcess(
112
+ env?: Record<string, string | undefined>,
113
+ ): Record<string, string | undefined> {
114
+ return (
115
+ env ??
116
+ (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
117
+ {}
118
+ );
63
119
  }
64
120
 
65
121
  /**
66
- * Build the sandbox origin for a handle.
122
+ * Build the sandbox origin for a handle under ONE configured suffix.
67
123
  *
68
- * The origin comes from CONFIGURED suffixes, never from caller input, so there
69
- * is no SSRF surface to defend and no allowlist to keep correct. A `localhost`
124
+ * The origin comes from configuration, never from caller input, so there is no
125
+ * SSRF surface to defend and no allowlist to keep correct. A `localhost`
70
126
  * suffix (local e2e) speaks http; everything else is https.
71
127
  */
72
- export function buildDraftOrigin(handle: string, suffixes: string[]): string | null {
73
- const suffix = suffixes[0];
128
+ export function buildDraftOrigin(handle: string, suffix: string): string | null {
74
129
  if (!suffix) return null;
75
130
  if (!HANDLE_RE.test(handle)) return null;
76
131
  const scheme = suffix.includes("localhost") ? "http" : "https";
@@ -125,11 +180,8 @@ export interface ResolveDraftOptions {
125
180
  export async function resolveDraftDecofile(
126
181
  options: ResolveDraftOptions,
127
182
  ): Promise<Record<string, unknown> | null> {
128
- const env =
129
- options.env ??
130
- (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
131
- {};
132
- if (env.DECO_DRAFT_PREVIEW !== "1") return null;
183
+ const env = envOrProcess(options.env);
184
+ if (readAllowedHosts(env).length === 0) return null;
133
185
 
134
186
  const parsed = parseDraftPointer(options.pointer);
135
187
  if (!parsed) return null;
@@ -137,36 +189,44 @@ export async function resolveDraftDecofile(
137
189
  const cached = byVersion.get(parsed.version);
138
190
  if (cached) return cached;
139
191
 
140
- const origin = buildDraftOrigin(parsed.handle, readSuffixes(env));
141
- if (!origin) return null;
142
-
143
192
  const doFetch = options.fetchImpl ?? fetch;
144
- let res: Response;
145
- try {
146
- res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
147
- } catch {
148
- return null;
149
- }
150
- if (!res.ok) return null;
193
+ // Suffixes are tried in order; the first that answers with parseable JSON
194
+ // wins. A miss on one origin (unreachable, non-2xx, garbage) is expected —
195
+ // the handle only exists under one of them — so every failure falls through
196
+ // to the next rather than aborting the resolve.
197
+ for (const suffix of readSuffixes(env)) {
198
+ const origin = buildDraftOrigin(parsed.handle, suffix);
199
+ if (!origin) continue;
151
200
 
152
- let blocks: Record<string, unknown>;
153
- try {
154
- blocks = (await res.json()) as Record<string, unknown>;
155
- } catch {
156
- return null;
157
- }
201
+ let res: Response;
202
+ try {
203
+ res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
204
+ } catch {
205
+ continue;
206
+ }
207
+ if (!res.ok) continue;
208
+
209
+ let blocks: Record<string, unknown>;
210
+ try {
211
+ blocks = (await res.json()) as Record<string, unknown>;
212
+ } catch {
213
+ continue;
214
+ }
158
215
 
159
- cacheDraft(parsed.version, blocks);
160
- return blocks;
216
+ cacheDraft(parsed.version, blocks);
217
+ return blocks;
218
+ }
219
+ return null;
161
220
  }
162
221
 
163
- /** True when the feature is switched on and configured. Inert otherwise. */
222
+ /**
223
+ * True when any host is allowed to preview. A plain env read — callers use it
224
+ * to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an
225
+ * unconfigured site never loses static/ISR rendering. The per-request host
226
+ * match happens later, in `isDraftHostAllowed`.
227
+ */
164
228
  export function isDraftPreviewEnabled(env?: Record<string, string | undefined>): boolean {
165
- const e =
166
- env ??
167
- (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
168
- {};
169
- return e.DECO_DRAFT_PREVIEW === "1" && readSuffixes(e).length > 0;
229
+ return readAllowedHosts(envOrProcess(env)).length > 0;
170
230
  }
171
231
 
172
232
  // ---------------------------------------------------------------------------
package/src/cms/index.ts CHANGED
@@ -16,7 +16,9 @@ export type { DraftPointer, ResolveDraftOptions } from "./draftSource";
16
16
  export {
17
17
  buildDraftOrigin,
18
18
  clearDraftCache,
19
+ DEFAULT_SANDBOX_ORIGIN_SUFFIXES,
19
20
  getRequestDraftOverride,
21
+ isDraftHostAllowed,
20
22
  isDraftPreviewEnabled,
21
23
  parseDraftPointer,
22
24
  resolveDraftDecofile,