@decocms/blocks 7.28.0-beta.5 → 7.28.1

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.
@@ -1,258 +0,0 @@
1
- import { beforeEach, describe, expect, it } from "vitest";
2
-
3
- import {
4
- clearDraftCache,
5
- DEFAULT_PREVIEW_API_DOMAINS,
6
- isDraftHostAllowed,
7
- isDraftPreviewEnabled,
8
- parseDraftPointer,
9
- previewApiOriginForHost,
10
- resolveDraftDecofile,
11
- setDraftPreviewHosts,
12
- } from "./draftSource";
13
-
14
- const ENV_ON = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" };
15
-
16
- function jsonResponse(body: unknown, init?: ResponseInit): Response {
17
- return new Response(JSON.stringify(body), {
18
- status: 200,
19
- headers: { "content-type": "application/json" },
20
- ...init,
21
- });
22
- }
23
-
24
- beforeEach(() => {
25
- clearDraftCache();
26
- });
27
-
28
- describe("parseDraftPointer", () => {
29
- it("parses authority@version, lowercasing the authority", () => {
30
- expect(parseDraftPointer("ABC.Preview-Studio.decocms.com@FF00")).toEqual({
31
- host: "abc.preview-studio.decocms.com",
32
- version: "FF00",
33
- });
34
- });
35
-
36
- it("keeps an explicit port on the authority", () => {
37
- expect(parseDraftPointer("abc.localhost:60534@v1")).toEqual({
38
- host: "abc.localhost:60534",
39
- version: "v1",
40
- });
41
- });
42
-
43
- it("rejects more than one @", () => {
44
- // A naive split("@") accepts this and silently uses the first two
45
- // segments — the exact hole found while spiking the fetch path.
46
- expect(parseDraftPointer("a.example@b@c")).toBeNull();
47
- });
48
-
49
- it("rejects anything that could escape the authority", () => {
50
- // No scheme, no path, no userinfo — the token carries an authority only,
51
- // so `javascript:`/`file:`/full URLs fail structurally at parse time.
52
- expect(parseDraftPointer("https://evil.example@v1")).toBeNull();
53
- expect(parseDraftPointer("evil.example/x@v1")).toBeNull();
54
- expect(parseDraftPointer("a.example:80:80@v1")).toBeNull();
55
- expect(parseDraftPointer("a.example:abc@v1")).toBeNull();
56
- expect(parseDraftPointer(".leading.dot@v1")).toBeNull();
57
- expect(parseDraftPointer("bare-label@v1")).toBeNull();
58
- });
59
-
60
- it("validates the version charset — it becomes a cache key", () => {
61
- expect(parseDraftPointer("a.example@")).toBeNull();
62
- expect(parseDraftPointer(`a.example@${"x".repeat(65)}`)).toBeNull();
63
- expect(parseDraftPointer("a.example@v 1")).toBeNull();
64
- expect(parseDraftPointer(null)).toBeNull();
65
- });
66
- });
67
-
68
- describe("previewApiOriginForHost", () => {
69
- it("admits authorities under the default deco domains", () => {
70
- expect(previewApiOriginForHost("abc.preview-studio.decocms.com", {})).toBe(
71
- "https://abc.preview-studio.decocms.com",
72
- );
73
- expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe(
74
- "https://abc.local.studio.decocms.com",
75
- );
76
- expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe("http://abc.localhost:60534");
77
- });
78
-
79
- it("rejects hosts outside the domains — the token proposes, config disposes", () => {
80
- expect(previewApiOriginForHost("evil.example", {})).toBeNull();
81
- // Dot-prefixed suffixes guarantee a label boundary: a lookalike domain
82
- // that merely ends with the same characters cannot pass.
83
- expect(previewApiOriginForHost("evil-preview-studio.decocms.com", {})).toBeNull();
84
- // The domain itself (no label in front) is not a draft host.
85
- expect(previewApiOriginForHost("preview-studio.decocms.com", {})).toBeNull();
86
- });
87
-
88
- it("allows an explicit port only under localhost-ish domains", () => {
89
- // A public-domain token must not steer the fetch at odd ports.
90
- expect(previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {})).toBeNull();
91
- });
92
-
93
- it("honours a configured override instead of the defaults", () => {
94
- const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" };
95
- expect(previewApiOriginForHost("abc.staging.example", env)).toBe("https://abc.staging.example");
96
- expect(previewApiOriginForHost("abc.preview-studio.decocms.com", env)).toBeNull();
97
- });
98
- });
99
-
100
- describe("gating", () => {
101
- it("is on iff an allowed host is configured — API domains have defaults", () => {
102
- expect(isDraftPreviewEnabled(ENV_ON)).toBe(true);
103
- expect(isDraftPreviewEnabled({})).toBe(false);
104
- });
105
-
106
- it("matches request hosts verbatim, port included, case-insensitively", () => {
107
- const env = { DECO_ALLOWED_PREVIEW_HOSTS: "fila.vtex.app, localhost:3100" };
108
- expect(isDraftHostAllowed("FILA.VTEX.APP", env)).toBe(true);
109
- expect(isDraftHostAllowed("localhost:3100", env)).toBe(true);
110
- expect(isDraftHostAllowed("fila.com.br", env)).toBe(false);
111
- expect(isDraftHostAllowed("localhost", env)).toBe(false);
112
- expect(isDraftHostAllowed(null, env)).toBe(false);
113
- expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(false);
114
- });
115
- });
116
-
117
- describe("resolveDraftDecofile", () => {
118
- it("fetches exactly the token's validated origin", async () => {
119
- const calls: string[] = [];
120
- const blocks = await resolveDraftDecofile({
121
- pointer: "abc.preview-studio.decocms.com@v1",
122
- env: ENV_ON,
123
- fetchImpl: (async (url: string) => {
124
- calls.push(String(url));
125
- return jsonResponse({ "pages-home": { title: "draft" } });
126
- }) as unknown as typeof fetch,
127
- });
128
-
129
- expect(blocks).toEqual({ "pages-home": { title: "draft" } });
130
- expect(calls).toEqual(["https://abc.preview-studio.decocms.com/_sandbox/decofile"]);
131
- });
132
-
133
- it("is inert without a host allowlist — no fetch at all", async () => {
134
- let called = false;
135
- const blocks = await resolveDraftDecofile({
136
- pointer: "abc.preview-studio.decocms.com@v1",
137
- env: {},
138
- fetchImpl: (async () => {
139
- called = true;
140
- return jsonResponse({});
141
- }) as unknown as typeof fetch,
142
- });
143
- expect(blocks).toBeNull();
144
- expect(called).toBe(false);
145
- });
146
-
147
- it("refuses a parseable token whose origin no domain admits — no fetch", async () => {
148
- let called = false;
149
- const blocks = await resolveDraftDecofile({
150
- pointer: "abc.evil.example@v1",
151
- env: ENV_ON,
152
- fetchImpl: (async () => {
153
- called = true;
154
- return jsonResponse({});
155
- }) as unknown as typeof fetch,
156
- });
157
- expect(blocks).toBeNull();
158
- expect(called).toBe(false);
159
- });
160
-
161
- it("caches by version — one fetch per version, not per request", async () => {
162
- let fetches = 0;
163
- const fetchImpl = (async () => {
164
- fetches++;
165
- return jsonResponse({ n: fetches });
166
- }) as unknown as typeof fetch;
167
- const P = "abc.preview-studio.decocms.com";
168
-
169
- const a = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
170
- const b = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
171
- expect(fetches).toBe(1);
172
- expect(b).toBe(a);
173
-
174
- await resolveDraftDecofile({ pointer: `${P}@v2`, env: ENV_ON, fetchImpl });
175
- expect(fetches).toBe(2);
176
- });
177
-
178
- it("bounds the cache so multi-MB decofiles can't accumulate", async () => {
179
- let fetches = 0;
180
- const fetchImpl = (async () => {
181
- fetches++;
182
- return jsonResponse({ n: fetches });
183
- }) as unknown as typeof fetch;
184
- const P = "abc.preview-studio.decocms.com";
185
-
186
- for (const v of ["v1", "v2", "v3", "v4"]) {
187
- await resolveDraftDecofile({ pointer: `${P}@${v}`, env: ENV_ON, fetchImpl });
188
- }
189
- expect(fetches).toBe(4);
190
- await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
191
- expect(fetches).toBe(5); // v1 evicted (cap 3) — re-fetch, never stale
192
- await resolveDraftDecofile({ pointer: `${P}@v4`, env: ENV_ON, fetchImpl });
193
- expect(fetches).toBe(5); // v4 resident
194
- });
195
-
196
- it("degrades to published on unreachable / non-2xx / unparseable", async () => {
197
- const P = "abc.preview-studio.decocms.com";
198
- for (const fetchImpl of [
199
- async () => {
200
- throw new Error("ECONNREFUSED");
201
- },
202
- async () => new Response("nope", { status: 404 }),
203
- async () =>
204
- new Response("<html>not json</html>", {
205
- status: 200,
206
- headers: { "content-type": "text/html" },
207
- }),
208
- ]) {
209
- clearDraftCache();
210
- expect(
211
- await resolveDraftDecofile({
212
- pointer: `${P}@v1`,
213
- env: ENV_ON,
214
- fetchImpl: fetchImpl as unknown as typeof fetch,
215
- }),
216
- ).toBeNull();
217
- }
218
- });
219
- });
220
-
221
- // DEFAULT_PREVIEW_API_DOMAINS is part of the public contract — pin it.
222
- describe("DEFAULT_PREVIEW_API_DOMAINS", () => {
223
- it("ships the deco-operated origins, dot-prefixed", () => {
224
- expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([
225
- ".preview-studio.decocms.com",
226
- ".local.studio.decocms.com",
227
- ".localhost",
228
- ]);
229
- });
230
- });
231
-
232
- describe("site-block preview hosts", () => {
233
- it("enables the feature from the site block alone — no env needed", () => {
234
- setDraftPreviewHosts(["fila.vtex.app", "LOCALHOST:3100", 42, " "]);
235
- try {
236
- expect(isDraftPreviewEnabled({})).toBe(true);
237
- expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(true);
238
- // Sanitized: lowercased, non-strings and blanks dropped.
239
- expect(isDraftHostAllowed("localhost:3100", {})).toBe(true);
240
- expect(isDraftHostAllowed("evil.example", {})).toBe(false);
241
- } finally {
242
- setDraftPreviewHosts([]);
243
- }
244
- });
245
-
246
- it("env REPLACES the block hosts when set — the operational escape hatch", () => {
247
- setDraftPreviewHosts(["fila.vtex.app"]);
248
- try {
249
- const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" };
250
- expect(isDraftHostAllowed("other.example", env)).toBe(true);
251
- // Not merged: env is a kill switch / override, so the block value must
252
- // not survive alongside it.
253
- expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(false);
254
- } finally {
255
- setDraftPreviewHosts([]);
256
- }
257
- });
258
- });
@@ -1,284 +0,0 @@
1
- /**
2
- * Draft preview — pull-based decofile override.
3
- *
4
- * A Studio preview serves the working-tree draft at
5
- * `GET <origin>/_sandbox/decofile`; a production site pulls it and renders its
6
- * own real pages against it. This replaces pushing the decofile into a POST
7
- * body, which only deco's own runtime honours — Next.js and most frameworks
8
- * render on GET only.
9
- *
10
- * This module is the framework-agnostic half: token parsing, origin
11
- * validation, fetching, and version caching. Binding a resolved draft to a
12
- * request is framework-specific (see `@decocms/nextjs`'s draft wiring) and
13
- * reaches this module through {@link setDraftOverrideGetter} — the same
14
- * dependency-injection shape as `setFastDeployKVGetter`, so `blocks` keeps its
15
- * zero-dependency direction.
16
- *
17
- * Inert unless `DECO_ALLOWED_PREVIEW_HOSTS` 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.
23
- */
24
-
25
- /**
26
- * A parsed `?__draft=` token: `<host[:port]>@<version>`.
27
- *
28
- * The token carries the AUTHORITY of the draft content API, never a scheme or
29
- * path — a full URL would be an SSRF vector, and the scheme is derived from
30
- * the matched domain instead. Reserved evolution: a future signed token uses a
31
- * distinguishable prefix (e.g. `s1.`), so this strict two-part parse rejects
32
- * it cleanly rather than half-reading it.
33
- */
34
- export interface DraftPointer {
35
- /** Content-API authority, e.g. `abc.preview-studio.decocms.com` or `abc.localhost:60534`. */
36
- host: string;
37
- /** Opaque content version (the server's ETag). Immutable → safe cache key. */
38
- version: string;
39
- }
40
-
41
- /** Lowercase DNS hostname, at least two labels (a bare label can't match any domain). */
42
- const HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
43
- const PORT_RE = /^[0-9]{1,5}$/;
44
- const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
45
-
46
- /**
47
- * Parse `<host[:port]>@<version>`. Null on anything unexpected — callers fall
48
- * back to published content. Requires EXACTLY one `@`: a naive split accepts
49
- * `a@b@c` and silently uses the first two segments.
50
- */
51
- export function parseDraftPointer(raw: string | null | undefined): DraftPointer | null {
52
- if (!raw) return null;
53
- const parts = raw.split("@");
54
- if (parts.length !== 2) return null;
55
- const [authority, version] = parts;
56
- if (!authority || !version || !VERSION_RE.test(version)) return null;
57
-
58
- const [host, port, extra] = authority.toLowerCase().split(":");
59
- if (extra !== undefined) return null;
60
- if (!host || !HOST_RE.test(host)) return null;
61
- if (port !== undefined && !PORT_RE.test(port)) return null;
62
-
63
- return { host: port === undefined ? host : `${host}:${port}`, version };
64
- }
65
-
66
- /**
67
- * Domains the draft content API may live under — deco-operated, so shipping
68
- * them as defaults adds no SSRF surface. `DECO_PREVIEW_API_DOMAINS` overrides
69
- * the whole list when set. Entries are dot-prefixed suffixes, which guarantees
70
- * a label boundary on match (`evil-preview-studio.decocms.com` cannot pass).
71
- */
72
- export const DEFAULT_PREVIEW_API_DOMAINS = [
73
- ".preview-studio.decocms.com",
74
- ".local.studio.decocms.com",
75
- ".localhost",
76
- ];
77
-
78
- function readApiDomains(env: Record<string, string | undefined>): string[] {
79
- const configured = (env.DECO_PREVIEW_API_DOMAINS ?? "")
80
- .split(",")
81
- .map((s) => s.trim().toLowerCase())
82
- .filter(Boolean);
83
- return configured.length > 0 ? configured : DEFAULT_PREVIEW_API_DOMAINS;
84
- }
85
-
86
- /**
87
- * Validate the token's authority against the configured domains and derive the
88
- * fetch origin, or null if no domain admits it.
89
- *
90
- * The token proposes, configuration disposes: only the hostname-suffix match
91
- * decides, so a caller can steer WHICH label under your domains, never which
92
- * domains. Scheme is derived — http for localhost-ish domains, https
93
- * otherwise — and an explicit port is allowed only there, so a public-domain
94
- * token cannot aim at odd ports.
95
- */
96
- export function previewApiOriginForHost(
97
- authority: string,
98
- env?: Record<string, string | undefined>,
99
- ): string | null {
100
- const [host, port] = authority.toLowerCase().split(":");
101
- if (!host) return null;
102
- const domain = readApiDomains(envOrProcess(env)).find(
103
- (d) => host.length > d.length && host.endsWith(d),
104
- );
105
- if (!domain) return null;
106
- const local = domain.includes("localhost");
107
- if (port !== undefined && !local) return null;
108
- return `${local ? "http" : "https"}://${host}${port === undefined ? "" : `:${port}`}`;
109
- }
110
-
111
- /**
112
- * Hosts declared by the site itself (the global `site` block's `previewHosts`),
113
- * installed once at setup time by the framework binding.
114
- *
115
- * MUST be fed from the setup-time base blocks, never from `loadBlocks()` at
116
- * request time: the request path merges the draft override, and an allowlist
117
- * readable through the override could be rewritten by the very draft it gates.
118
- */
119
- // globalThis-backed, like the block loader itself: bundlers can duplicate
120
- // this module across graphs, and a plain module variable set in one instance
121
- // is invisible to the others. (The MIDDLEWARE runtime is a separate world
122
- // even so — which is why the page-side gate is the authoritative one and the
123
- // middleware only hard-gates when the env override is present.)
124
- const G = globalThis as { __decoDraftHosts?: string[] };
125
-
126
- /** Install the site-declared preview hosts. Called by the framework binding at setup. */
127
- export function setDraftPreviewHosts(hosts: readonly unknown[]): void {
128
- G.__decoDraftHosts = hosts
129
- .filter((h): h is string => typeof h === "string")
130
- .map((h) => h.trim().toLowerCase())
131
- .filter(Boolean);
132
- }
133
-
134
- /**
135
- * Hosts allowed to render drafts.
136
- *
137
- * The site block is the expected source — the opt-in lives in the repo,
138
- * reviewed in a PR, versioned with branches. `DECO_ALLOWED_PREVIEW_HOSTS`
139
- * REPLACES it when set: an operational escape hatch (kill a bad value without
140
- * a deploy, add a machine-specific port) — not the primary configuration.
141
- */
142
- function readAllowedHosts(env: Record<string, string | undefined>): string[] {
143
- const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "")
144
- .split(",")
145
- .map((s) => s.trim().toLowerCase())
146
- .filter(Boolean);
147
- return fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []);
148
- }
149
-
150
- /**
151
- * Whether `host` (as seen on the request) may render drafts.
152
- *
153
- * Compared against `DECO_ALLOWED_PREVIEW_HOSTS` verbatim, port included —
154
- * local dev is `localhost:3100`, not `localhost`. The header is spoofable by a
155
- * direct-to-origin request, but the draft id is the actual capability;
156
- * host-scoping bounds blast radius (production domains stay inert), it is not
157
- * a secret.
158
- */
159
- export function isDraftHostAllowed(
160
- host: string | null | undefined,
161
- env?: Record<string, string | undefined>,
162
- ): boolean {
163
- if (!host) return false;
164
- return readAllowedHosts(envOrProcess(env)).includes(host.trim().toLowerCase());
165
- }
166
-
167
- /**
168
- * True when any host is allowed to preview. A plain env read — callers use it
169
- * to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an
170
- * unconfigured site never loses static/ISR rendering. The per-request host
171
- * match happens later, in `isDraftHostAllowed`.
172
- */
173
- export function isDraftPreviewEnabled(env?: Record<string, string | undefined>): boolean {
174
- return readAllowedHosts(envOrProcess(env)).length > 0;
175
- }
176
-
177
- function envOrProcess(
178
- env?: Record<string, string | undefined>,
179
- ): Record<string, string | undefined> {
180
- return (
181
- env ??
182
- (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
183
- {}
184
- );
185
- }
186
-
187
- /**
188
- * Version cache. Bounded on purpose: a decofile is routinely multi-megabyte,
189
- * so an unbounded map keyed by version would grow with every save until the
190
- * process died. Content-addressed, so a hit is always correct.
191
- */
192
- const MAX_CACHED_VERSIONS = 3;
193
- const byVersion = new Map<string, Record<string, unknown>>();
194
-
195
- function cacheDraft(version: string, blocks: Record<string, unknown>): void {
196
- byVersion.delete(version);
197
- byVersion.set(version, blocks);
198
- while (byVersion.size > MAX_CACHED_VERSIONS) {
199
- const oldest = byVersion.keys().next().value;
200
- if (oldest === undefined) break;
201
- byVersion.delete(oldest);
202
- }
203
- }
204
-
205
- /** Test seam — drops every cached version. */
206
- export function clearDraftCache(): void {
207
- byVersion.clear();
208
- }
209
-
210
- export interface ResolveDraftOptions {
211
- /** Raw `<host[:port]>@<version>` token from the request. */
212
- pointer: string | null | undefined;
213
- /** Defaults to `process.env`. */
214
- env?: Record<string, string | undefined>;
215
- /** Defaults to global `fetch`. Injected in tests. */
216
- fetchImpl?: typeof fetch;
217
- }
218
-
219
- /**
220
- * Resolve a draft token to a decofile, or null to render published content.
221
- *
222
- * Null on every failure path — disabled, malformed token, disallowed origin,
223
- * unreachable, non-2xx — because a draft that cannot be resolved must degrade
224
- * to published rather than break the page.
225
- */
226
- export async function resolveDraftDecofile(
227
- options: ResolveDraftOptions,
228
- ): Promise<Record<string, unknown> | null> {
229
- const env = envOrProcess(options.env);
230
- if (readAllowedHosts(env).length === 0) return null;
231
-
232
- const parsed = parseDraftPointer(options.pointer);
233
- if (!parsed) return null;
234
-
235
- const cached = byVersion.get(parsed.version);
236
- if (cached) return cached;
237
-
238
- const origin = previewApiOriginForHost(parsed.host, env);
239
- if (!origin) return null;
240
-
241
- const doFetch = options.fetchImpl ?? fetch;
242
- let res: Response;
243
- try {
244
- res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
245
- } catch {
246
- return null;
247
- }
248
- if (!res.ok) return null;
249
-
250
- let blocks: Record<string, unknown>;
251
- try {
252
- blocks = (await res.json()) as Record<string, unknown>;
253
- } catch {
254
- return null;
255
- }
256
-
257
- cacheDraft(parsed.version, blocks);
258
- return blocks;
259
- }
260
-
261
- // ---------------------------------------------------------------------------
262
- // Request binding (dependency-injected by the framework binding)
263
- // ---------------------------------------------------------------------------
264
-
265
- type DraftOverrideGetter = () => Record<string, unknown> | null | undefined;
266
-
267
- let getDraftOverride: DraftOverrideGetter = () => undefined;
268
-
269
- /**
270
- * Inject the request-scoped draft getter.
271
- *
272
- * Binding a value to "the current request" is framework-specific and `blocks`
273
- * must not know about any framework: `@decocms/nextjs` backs this with React
274
- * `cache()` (App Router has no AsyncLocalStorage request scope of its own).
275
- * Never called → returns undefined → `loadBlocks()` behaves exactly as before.
276
- */
277
- export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
278
- getDraftOverride = getter;
279
- }
280
-
281
- /** The current request's draft blocks, if a binding registered one. */
282
- export function getRequestDraftOverride(): Record<string, unknown> | null | undefined {
283
- return getDraftOverride();
284
- }