@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 +1 -1
- package/src/cms/draftSource.test.ts +70 -7
- package/src/cms/draftSource.ts +99 -39
- package/src/cms/index.ts +2 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
72
|
+
expect(buildDraftOrigin("abc", "")).toBeNull();
|
|
71
73
|
});
|
|
72
74
|
});
|
|
73
75
|
|
|
74
76
|
describe("isDraftPreviewEnabled", () => {
|
|
75
|
-
it("
|
|
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({
|
|
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
|
|
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({
|
package/src/cms/draftSource.ts
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
69
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
216
|
+
cacheDraft(parsed.version, blocks);
|
|
217
|
+
return blocks;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
161
220
|
}
|
|
162
221
|
|
|
163
|
-
/**
|
|
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
|
-
|
|
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,
|