@decocms/blocks 7.35.0 → 7.37.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.35.0",
3
+ "version": "7.37.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -28,79 +28,94 @@ beforeEach(() => {
28
28
  });
29
29
 
30
30
  describe("parseDraftPointer", () => {
31
- it("parses authority@version, lowercasing the authority", () => {
32
- expect(parseDraftPointer("ABC.Preview-Studio.decocms.com@FF00")).toEqual({
33
- host: "abc.preview-studio.decocms.com",
34
- version: "FF00",
31
+ it("parses <authority><path>@<version>, lowercasing the authority", () => {
32
+ expect(
33
+ parseDraftPointer(
34
+ "Studio.decocms.com/api/fila/decofile/vm-1/main?token=Tok.abc@8c1d44e",
35
+ ),
36
+ ).toEqual({
37
+ host: "studio.decocms.com",
38
+ path: "/api/fila/decofile/vm-1/main?token=Tok.abc",
39
+ version: "8c1d44e",
35
40
  });
36
41
  });
37
42
 
38
- it("keeps an explicit port on the authority", () => {
39
- expect(parseDraftPointer("abc.localhost:60534@v1")).toEqual({
40
- host: "abc.localhost:60534",
43
+ it("keeps an explicit port on the authority, incl. bare localhost", () => {
44
+ expect(parseDraftPointer("localhost:4000/api/o/decofile/m/b@v1")).toEqual({
45
+ host: "localhost:4000",
46
+ path: "/api/o/decofile/m/b",
41
47
  version: "v1",
42
48
  });
43
49
  });
44
50
 
45
- it("rejects more than one @", () => {
46
- // A naive split("@") accepts this and silently uses the first two
47
- // segments — the exact hole found while spiking the fetch path.
48
- expect(parseDraftPointer("a.example@b@c")).toBeNull();
51
+ it("splits on the LAST @ — an earlier @ fails validation, never half-reads", () => {
52
+ // The path charset excludes `@`, so a smuggled one lands in `path` and
53
+ // fails PATH_RE rather than shifting the version boundary.
54
+ expect(parseDraftPointer("a.example/x@y/z@v1")).toBeNull();
55
+ expect(parseDraftPointer("a@b@c")).toBeNull();
56
+ });
57
+
58
+ it("requires a rooted path — authority-only tokens are gone", () => {
59
+ // Non-backcompat: the daemon-era `<authority>@<version>` form is invalid.
60
+ expect(parseDraftPointer("abc.preview-studio.decocms.com@v1")).toBeNull();
61
+ expect(parseDraftPointer("a.example@v1")).toBeNull();
49
62
  });
50
63
 
51
- it("rejects anything that could escape the authority", () => {
52
- // No scheme, no path, no userinfo — the token carries an authority only,
53
- // so `javascript:`/`file:`/full URLs fail structurally at parse time.
54
- expect(parseDraftPointer("https://evil.example@v1")).toBeNull();
55
- expect(parseDraftPointer("evil.example/x@v1")).toBeNull();
56
- expect(parseDraftPointer("a.example:80:80@v1")).toBeNull();
57
- expect(parseDraftPointer("a.example:abc@v1")).toBeNull();
58
- expect(parseDraftPointer(".leading.dot@v1")).toBeNull();
59
- expect(parseDraftPointer("bare-label@v1")).toBeNull();
64
+ it("rejects anything that could escape the authority or path", () => {
65
+ // No scheme, no userinfo, no fragment/space in the path — a full URL
66
+ // fails structurally at parse time.
67
+ expect(parseDraftPointer("https://evil.example/x@v1")).toBeNull();
68
+ expect(parseDraftPointer("a.example:80:80/x@v1")).toBeNull();
69
+ expect(parseDraftPointer("a.example:abc/x@v1")).toBeNull();
70
+ expect(parseDraftPointer(".leading.dot/x@v1")).toBeNull();
71
+ expect(parseDraftPointer("a.example/x#frag@v1")).toBeNull();
72
+ expect(parseDraftPointer("a.example/x y@v1")).toBeNull();
60
73
  });
61
74
 
62
75
  it("validates the version charset — it becomes a cache key", () => {
63
- expect(parseDraftPointer("a.example@")).toBeNull();
64
- expect(parseDraftPointer(`a.example@${"x".repeat(65)}`)).toBeNull();
65
- expect(parseDraftPointer("a.example@v 1")).toBeNull();
76
+ expect(parseDraftPointer("a.example/x@")).toBeNull();
77
+ expect(parseDraftPointer(`a.example/x@${"x".repeat(65)}`)).toBeNull();
78
+ expect(parseDraftPointer("a.example/x@v 1")).toBeNull();
66
79
  expect(parseDraftPointer(null)).toBeNull();
67
80
  });
68
81
  });
69
82
 
70
83
  describe("previewApiOriginForHost", () => {
71
84
  it("admits authorities under the default deco domains", () => {
85
+ // Hosted Studio (the decofile API origin) via the .decocms.com suffix.
86
+ expect(previewApiOriginForHost("studio.decocms.com", {})).toBe(
87
+ "https://studio.decocms.com",
88
+ );
89
+ // Preview daemons keep working under the same suffix.
72
90
  expect(previewApiOriginForHost("abc.preview-studio.decocms.com", {})).toBe(
73
91
  "https://abc.preview-studio.decocms.com",
74
92
  );
75
- expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe(
76
- "https://abc.local.studio.decocms.com",
93
+ // Local dev: exact-host entries, http, explicit port allowed.
94
+ expect(previewApiOriginForHost("localhost:4000", {})).toBe(
95
+ "http://localhost:4000",
96
+ );
97
+ // The native app's dev origin serves TLS (locally-trusted cert): https,
98
+ // but the explicit port is still allowed.
99
+ expect(previewApiOriginForHost("local.studio.decocms.com:4420", {})).toBe(
100
+ "https://local.studio.decocms.com:4420",
77
101
  );
78
102
  expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe(
79
103
  "http://abc.localhost:60534",
80
104
  );
81
- // -stg is its own suffix, not a substring match of .preview-studio.decocms.com —
82
- // both must be listed explicitly (regressed once: staging draft pointers
83
- // silently fell back to published content with no visible error).
84
- expect(
85
- previewApiOriginForHost("abc.preview-studio-stg.decocms.com", {}),
86
- ).toBe("https://abc.preview-studio-stg.decocms.com");
87
105
  });
88
106
 
89
107
  it("rejects hosts outside the domains — the token proposes, config disposes", () => {
90
108
  expect(previewApiOriginForHost("evil.example", {})).toBeNull();
91
109
  // Dot-prefixed suffixes guarantee a label boundary: a lookalike domain
92
110
  // that merely ends with the same characters cannot pass.
93
- expect(
94
- previewApiOriginForHost("evil-preview-studio.decocms.com", {}),
95
- ).toBeNull();
96
- // The domain itself (no label in front) is not a draft host.
97
- expect(
98
- previewApiOriginForHost("preview-studio.decocms.com", {}),
99
- ).toBeNull();
111
+ expect(previewApiOriginForHost("evil-decocms.com", {})).toBeNull();
112
+ // The suffix domain itself (no label in front) is not admitted.
113
+ expect(previewApiOriginForHost("decocms.com", {})).toBeNull();
100
114
  });
101
115
 
102
- it("allows an explicit port only under localhost-ish domains", () => {
116
+ it("allows an explicit port only for local entries", () => {
103
117
  // A public-domain token must not steer the fetch at odd ports.
118
+ expect(previewApiOriginForHost("studio.decocms.com:8500", {})).toBeNull();
104
119
  expect(
105
120
  previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {}),
106
121
  ).toBeNull();
@@ -111,9 +126,7 @@ describe("previewApiOriginForHost", () => {
111
126
  expect(previewApiOriginForHost("abc.staging.example", env)).toBe(
112
127
  "https://abc.staging.example",
113
128
  );
114
- expect(
115
- previewApiOriginForHost("abc.preview-studio.decocms.com", env),
116
- ).toBeNull();
129
+ expect(previewApiOriginForHost("studio.decocms.com", env)).toBeNull();
117
130
  });
118
131
  });
119
132
 
@@ -134,11 +147,14 @@ describe("gating", () => {
134
147
  });
135
148
  });
136
149
 
150
+ // Canonical Studio decofile-API pointer prefix (authority + path, no version).
151
+ const P = "studio.decocms.com/api/fila/decofile/vm-1/main?token=tok.abc";
152
+
137
153
  describe("resolveDraftDecofile", () => {
138
- it("fetches exactly the token's validated origin", async () => {
154
+ it("fetches the token's path on its validated origin", async () => {
139
155
  const calls: string[] = [];
140
156
  const blocks = await resolveDraftDecofile({
141
- pointer: "abc.preview-studio.decocms.com@v1",
157
+ pointer: `${P}@v1`,
142
158
  env: ENV_ON,
143
159
  fetchImpl: (async (url: string) => {
144
160
  calls.push(String(url));
@@ -147,15 +163,18 @@ describe("resolveDraftDecofile", () => {
147
163
  });
148
164
 
149
165
  expect(blocks).toEqual({ "pages-home": { title: "draft" } });
166
+ // The pointer's version rides along as `v=`, making the URL fully
167
+ // content-addressed so Studio can answer with CDN-cacheable immutable
168
+ // headers when it matches the served sha.
150
169
  expect(calls).toEqual([
151
- "https://abc.preview-studio.decocms.com/_sandbox/decofile",
170
+ "https://studio.decocms.com/api/fila/decofile/vm-1/main?token=tok.abc&v=v1",
152
171
  ]);
153
172
  });
154
173
 
155
174
  it("is inert without a host allowlist — no fetch at all", async () => {
156
175
  let called = false;
157
176
  const blocks = await resolveDraftDecofile({
158
- pointer: "abc.preview-studio.decocms.com@v1",
177
+ pointer: `${P}@v1`,
159
178
  env: {},
160
179
  fetchImpl: (async () => {
161
180
  called = true;
@@ -166,18 +185,32 @@ describe("resolveDraftDecofile", () => {
166
185
  expect(called).toBe(false);
167
186
  });
168
187
 
169
- it("refuses a parseable token whose origin no domain admits — no fetch", async () => {
188
+ it("refuses a parseable token whose origin no domain admits — no fetch, no cache", async () => {
170
189
  let called = false;
171
- const blocks = await resolveDraftDecofile({
172
- pointer: "abc.evil.example@v1",
173
- env: ENV_ON,
174
- fetchImpl: (async () => {
175
- called = true;
176
- return jsonResponse({});
177
- }) as unknown as typeof fetch,
178
- });
179
- expect(blocks).toBeNull();
190
+ const fetchImpl = (async () => {
191
+ called = true;
192
+ return jsonResponse({});
193
+ }) as unknown as typeof fetch;
194
+ expect(
195
+ await resolveDraftDecofile({
196
+ pointer: "abc.evil.example/x@v1",
197
+ env: ENV_ON,
198
+ fetchImpl,
199
+ }),
200
+ ).toBeNull();
180
201
  expect(called).toBe(false);
202
+
203
+ // Origin validation runs BEFORE the cache: a version already cached from
204
+ // an allowed origin must not be served for a disallowed authority.
205
+ await resolveDraftDecofile({ pointer: `${P}@vX`, env: ENV_ON, fetchImpl });
206
+ expect(called).toBe(true);
207
+ expect(
208
+ await resolveDraftDecofile({
209
+ pointer: "abc.evil.example/x@vX",
210
+ env: ENV_ON,
211
+ fetchImpl,
212
+ }),
213
+ ).toBeNull();
181
214
  });
182
215
 
183
216
  it("caches by version — one fetch per version, not per request", async () => {
@@ -186,7 +219,6 @@ describe("resolveDraftDecofile", () => {
186
219
  fetches++;
187
220
  return jsonResponse({ n: fetches });
188
221
  }) as unknown as typeof fetch;
189
- const P = "abc.preview-studio.decocms.com";
190
222
 
191
223
  const a = await resolveDraftDecofile({
192
224
  pointer: `${P}@v1`,
@@ -211,7 +243,6 @@ describe("resolveDraftDecofile", () => {
211
243
  fetches++;
212
244
  return jsonResponse({ n: fetches });
213
245
  }) as unknown as typeof fetch;
214
- const P = "abc.preview-studio.decocms.com";
215
246
 
216
247
  for (const v of ["v1", "v2", "v3", "v4"]) {
217
248
  await resolveDraftDecofile({
@@ -228,7 +259,6 @@ describe("resolveDraftDecofile", () => {
228
259
  });
229
260
 
230
261
  it("degrades to published on unreachable / non-2xx / unparseable", async () => {
231
- const P = "abc.preview-studio.decocms.com";
232
262
  for (const fetchImpl of [
233
263
  async () => {
234
264
  throw new Error("ECONNREFUSED");
@@ -254,12 +284,13 @@ describe("resolveDraftDecofile", () => {
254
284
 
255
285
  // DEFAULT_PREVIEW_API_DOMAINS is part of the public contract — pin it.
256
286
  describe("DEFAULT_PREVIEW_API_DOMAINS", () => {
257
- it("ships the deco-operated origins, dot-prefixed", () => {
287
+ it("ships the deco-operated origins plus local-dev exact hosts", () => {
258
288
  expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([
259
- ".preview-studio.decocms.com",
260
- ".preview-studio-stg.decocms.com",
261
- ".local.studio.decocms.com",
289
+ "local.studio.decocms.com",
290
+ "localhost",
291
+ "127.0.0.1",
262
292
  ".localhost",
293
+ ".decocms.com",
263
294
  ]);
264
295
  });
265
296
  });
@@ -337,7 +368,7 @@ describe("resolveDraftForRequest", () => {
337
368
  method: "POST",
338
369
  headers: {
339
370
  "x-forwarded-host": host,
340
- cookie: "__deco_draft=abc.preview-studio.decocms.com@v1",
371
+ cookie: `__deco_draft=${encodeURIComponent(`${P}@v1`)}`,
341
372
  },
342
373
  },
343
374
  );
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Draft preview — pull-based decofile override.
2
+ * Draft preview — pull-based decofile snapshot.
3
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.
4
+ * Studio serves the draft decofile (the merged `.deco/blocks/*.json` at the
5
+ * branch head) from its decofile API
6
+ * (`GET <origin>/api/<org>/decofile/<virtualMcpId>/<branch>?token=…`); a
7
+ * production site pulls it and renders its own real pages against it. This
8
+ * replaces pushing the decofile into a POST body, which only deco's own
9
+ * runtime honours — Next.js and most frameworks render on GET only.
9
10
  *
10
11
  * This module is the framework-agnostic half: token parsing, origin
11
12
  * validation, fetching, and version caching. Binding a resolved draft to a
@@ -23,60 +24,83 @@
23
24
  */
24
25
 
25
26
  /**
26
- * A parsed `?__draft=` token: `<host[:port]>@<version>`.
27
+ * A parsed `?__draft=` token: `<host[:port]><path[?query]>@<version>`.
27
28
  *
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.
29
+ * The token carries the AUTHORITY + PATH of the draft content API, never a
30
+ * scheme — a full URL would be an SSRF vector, and the scheme is derived from
31
+ * the matched domain instead. The path is REQUIRED and typically addresses
32
+ * Studio's decofile API (`/api/<org>/decofile/<virtualMcpId>/<branch>?token=…`);
33
+ * its query carries the signed draft grant.
33
34
  */
34
35
  export interface DraftPointer {
35
- /** Content-API authority, e.g. `abc.preview-studio.decocms.com` or `abc.localhost:60534`. */
36
+ /** Content-API authority, e.g. `studio.decocms.com` or `localhost:4000`. */
36
37
  host: string;
37
- /** Opaque content version (the server's ETag). Immutable → safe cache key. */
38
+ /** Path (+ query) on that authority serving the decofile JSON. */
39
+ path: string;
40
+ /** Opaque content version (the branch head sha / server ETag). Immutable → safe cache key. */
38
41
  version: string;
39
42
  }
40
43
 
41
- /** Lowercase DNS hostname, at least two labels (a bare label can't match any domain). */
44
+ /** Lowercase DNS hostname. A single label is allowed — exact-host domain
45
+ * entries (`localhost`, `local.studio.decocms.com`) can admit it. */
42
46
  const HOST_RE =
43
- /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
47
+ /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
44
48
  const PORT_RE = /^[0-9]{1,5}$/;
45
49
  const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
50
+ /** Rooted path with an optional query; conservative charset, no `@`/`#`/space. */
51
+ const PATH_RE = /^\/[A-Za-z0-9/_.%~=&?-]*$/;
46
52
 
47
53
  /**
48
- * Parse `<host[:port]>@<version>`. Null on anything unexpected — callers fall
49
- * back to published content. Requires EXACTLY one `@`: a naive split accepts
50
- * `a@b@c` and silently uses the first two segments.
54
+ * Parse `<host[:port]><path>@<version>`. Null on anything unexpected — callers
55
+ * fall back to published content. Splits on the LAST `@` (neither the path
56
+ * charset nor a signed token may contain one, so a stray `@` fails validation
57
+ * rather than being half-read).
51
58
  */
52
59
  export function parseDraftPointer(
53
60
  raw: string | null | undefined,
54
61
  ): DraftPointer | null {
55
62
  if (!raw) return null;
56
- const parts = raw.split("@");
57
- if (parts.length !== 2) return null;
58
- const [authority, version] = parts;
59
- if (!authority || !version || !VERSION_RE.test(version)) return null;
60
-
61
- const [host, port, extra] = authority.toLowerCase().split(":");
63
+ const at = raw.lastIndexOf("@");
64
+ if (at <= 0 || at === raw.length - 1) return null;
65
+ const authorityAndPath = raw.slice(0, at);
66
+ const version = raw.slice(at + 1);
67
+ if (!VERSION_RE.test(version)) return null;
68
+
69
+ const slash = authorityAndPath.indexOf("/");
70
+ if (slash === -1) return null;
71
+ const authority = authorityAndPath.slice(0, slash).toLowerCase();
72
+ const path = authorityAndPath.slice(slash);
73
+ if (!PATH_RE.test(path)) return null;
74
+
75
+ const [host, port, extra] = authority.split(":");
62
76
  if (extra !== undefined) return null;
63
77
  if (!host || !HOST_RE.test(host)) return null;
64
78
  if (port !== undefined && !PORT_RE.test(port)) return null;
65
79
 
66
- return { host: port === undefined ? host : `${host}:${port}`, version };
80
+ return {
81
+ host: port === undefined ? host : `${host}:${port}`,
82
+ path,
83
+ version,
84
+ };
67
85
  }
68
86
 
69
87
  /**
70
88
  * Domains the draft content API may live under — deco-operated, so shipping
71
89
  * them as defaults adds no SSRF surface. `DECO_PREVIEW_API_DOMAINS` overrides
72
- * the whole list when set. Entries are dot-prefixed suffixes, which guarantees
73
- * a label boundary on match (`evil-preview-studio.decocms.com` cannot pass).
90
+ * the whole list when set.
91
+ *
92
+ * Two entry shapes: a dot-prefixed entry is a suffix match with a guaranteed
93
+ * label boundary (`evil-decocms.com` cannot pass `.decocms.com`); a bare entry
94
+ * is an exact-host match (needed for `localhost` and dev origins, which no
95
+ * suffix can admit). Order matters only for the local/port rule: the first
96
+ * matching entry decides whether a port and `http` are allowed.
74
97
  */
75
98
  export const DEFAULT_PREVIEW_API_DOMAINS = [
76
- ".preview-studio.decocms.com",
77
- ".preview-studio-stg.decocms.com",
78
- ".local.studio.decocms.com",
99
+ "local.studio.decocms.com", // native/web dev origin (http, explicit port)
100
+ "localhost",
101
+ "127.0.0.1", // loopback dev — `localhost` can resolve to a different (IPv6) server
79
102
  ".localhost",
103
+ ".decocms.com", // hosted Studio (decofile API) + preview daemons
80
104
  ];
81
105
 
82
106
  function readApiDomains(env: Record<string, string | undefined>): string[] {
@@ -103,13 +127,26 @@ export function previewApiOriginForHost(
103
127
  ): string | null {
104
128
  const [host, port] = authority.toLowerCase().split(":");
105
129
  if (!host) return null;
106
- const domain = readApiDomains(envOrProcess(env)).find(
107
- (d) => host.length > d.length && host.endsWith(d),
130
+ const domain = readApiDomains(envOrProcess(env)).find((d) =>
131
+ d.startsWith(".") ? host.length > d.length && host.endsWith(d) : host === d,
108
132
  );
109
133
  if (!domain) return null;
110
- const local = domain.includes("localhost");
134
+ // Local entries may carry an explicit port; public domains may not (a
135
+ // public-domain token must not steer the fetch at odd ports).
136
+ const local =
137
+ domain === "localhost" ||
138
+ domain === "127.0.0.1" ||
139
+ domain.endsWith(".localhost") ||
140
+ domain === "local.studio.decocms.com";
111
141
  if (port !== undefined && !local) return null;
112
- return `${local ? "http" : "https"}://${host}${port === undefined ? "" : `:${port}`}`;
142
+ // Scheme is derived, never taken from the token. Plain-loopback dev hosts
143
+ // are http; local.studio.decocms.com is the native app's TLS dev origin
144
+ // (locally-trusted cert), so it — like every public domain — is https.
145
+ const insecure =
146
+ domain === "localhost" ||
147
+ domain === "127.0.0.1" ||
148
+ domain.endsWith(".localhost");
149
+ return `${insecure ? "http" : "https"}://${host}${port === undefined ? "" : `:${port}`}`;
113
150
  }
114
151
 
115
152
  /**
@@ -217,7 +254,7 @@ export function clearDraftCache(): void {
217
254
  }
218
255
 
219
256
  export interface ResolveDraftOptions {
220
- /** Raw `<host[:port]>@<version>` token from the request. */
257
+ /** Raw `<host[:port]><path>@<version>` token from the request. */
221
258
  pointer: string | null | undefined;
222
259
  /** Defaults to `process.env`. */
223
260
  env?: Record<string, string | undefined>;
@@ -241,16 +278,28 @@ export async function resolveDraftDecofile(
241
278
  const parsed = parseDraftPointer(options.pointer);
242
279
  if (!parsed) return null;
243
280
 
244
- const cached = byVersion.get(parsed.version);
245
- if (cached) return cached;
246
-
281
+ // Origin validation BEFORE the cache: a cached version must never be served
282
+ // for a pointer whose authority the configuration would reject.
247
283
  const origin = previewApiOriginForHost(parsed.host, env);
248
284
  if (!origin) return null;
249
285
 
286
+ const cached = byVersion.get(parsed.version);
287
+ if (cached) return cached;
288
+
250
289
  const doFetch = options.fetchImpl ?? fetch;
290
+ // The pointer's version rides along as `v=`, making the fetch URL fully
291
+ // content-addressed (path + token + version). Today the server serves it
292
+ // no-store either way — token-protected drafts are deliberately NOT
293
+ // shared-cacheable (edge caches can't re-validate the grant, so revocation
294
+ // wouldn't propagate). The param still earns its place: version-tagged
295
+ // access logs, and it's the prerequisite for edge-validated caching later
296
+ // (a CDN worker checking the token per request, keyed on path+version)
297
+ // without another runtime deploy.
298
+ const url = new URL(parsed.path, origin);
299
+ url.searchParams.append("v", parsed.version);
251
300
  let res: Response;
252
301
  try {
253
- res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
302
+ res = await doFetch(url.toString(), { cache: "no-store" });
254
303
  } catch {
255
304
  return null;
256
305
  }
@@ -355,7 +404,12 @@ export async function resolveDraftForRequest(
355
404
 
356
405
  type DraftOverrideGetter = () => Record<string, unknown> | null | undefined;
357
406
 
358
- let getDraftOverride: DraftOverrideGetter = () => undefined;
407
+ // globalThis-backed like the hosts above: bundlers can duplicate this module
408
+ // across graphs (nested node_modules installs, RSC layer splits), and a getter
409
+ // registered on one instance's module variable is invisible to the copy
410
+ // `loadBlocks()` imports — the framework binding then resolves the draft while
411
+ // the render silently serves published content.
412
+ const GG = globalThis as { __decoDraftOverrideGetter?: DraftOverrideGetter };
359
413
 
360
414
  /**
361
415
  * Inject the request-scoped draft getter.
@@ -366,7 +420,7 @@ let getDraftOverride: DraftOverrideGetter = () => undefined;
366
420
  * Never called → returns undefined → `loadBlocks()` behaves exactly as before.
367
421
  */
368
422
  export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
369
- getDraftOverride = getter;
423
+ GG.__decoDraftOverrideGetter = getter;
370
424
  }
371
425
 
372
426
  /** The current request's draft blocks, if a binding registered one. */
@@ -374,5 +428,5 @@ export function getRequestDraftOverride():
374
428
  | Record<string, unknown>
375
429
  | null
376
430
  | undefined {
377
- return getDraftOverride();
431
+ return GG.__decoDraftOverrideGetter?.();
378
432
  }
package/src/cms/index.ts CHANGED
@@ -1,4 +1,7 @@
1
- export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
1
+ export type {
2
+ ApplySectionConventionsInput,
3
+ SectionMetaEntry,
4
+ } from "./applySectionConventions";
2
5
  export { applySectionConventions } from "./applySectionConventions";
3
6
  export type { BlockSnapshot, BlockSource, KVNamespace } from "./blockSource";
4
7
  export {
@@ -43,8 +46,13 @@ export {
43
46
  onChange,
44
47
  setBlocks,
45
48
  withBlocksOverride,
49
+ withDraftBlocks,
46
50
  } from "./loader";
47
- export type { OnBeforeResolveProps, SectionModule, SectionOptions } from "./registry";
51
+ export type {
52
+ OnBeforeResolveProps,
53
+ SectionModule,
54
+ SectionOptions,
55
+ } from "./registry";
48
56
  export {
49
57
  getResolvedComponent,
50
58
  getSection,
@@ -1,6 +1,13 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
2
  import { setDraftOverrideGetter } from "./draftSource";
3
- import { findPageByPath, loadBlocks, matchPath, setBlocks } from "./loader";
3
+ import {
4
+ findPageByPath,
5
+ loadBlocks,
6
+ matchPath,
7
+ setBlocks,
8
+ withBlocksOverride,
9
+ withDraftBlocks,
10
+ } from "./loader";
4
11
 
5
12
  // Mirrors the behavior of the original deco-cx/deco Fresh framework
6
13
  // (runtime/features/render.tsx), which uses native `URLPattern` directly
@@ -28,7 +35,9 @@ describe("matchPath", () => {
28
35
 
29
36
  describe("named params (:slug)", () => {
30
37
  it("captures a single param", () => {
31
- expect(matchPath("/foo/:slug", "/foo/sabonete")).toEqual({ slug: "sabonete" });
38
+ expect(matchPath("/foo/:slug", "/foo/sabonete")).toEqual({
39
+ slug: "sabonete",
40
+ });
32
41
  });
33
42
 
34
43
  it("captures a param sandwiched between literals (VTEX PDP)", () => {
@@ -75,7 +84,9 @@ describe("matchPath", () => {
75
84
  });
76
85
 
77
86
  it("matches with the optional group absent", () => {
78
- expect(matchPath("/{granado/}?*", "/perfumaria")).toEqual({ "0": "perfumaria" });
87
+ expect(matchPath("/{granado/}?*", "/perfumaria")).toEqual({
88
+ "0": "perfumaria",
89
+ });
79
90
  });
80
91
 
81
92
  it("matches root when optional prefix and splat collapse to empty", () => {
@@ -83,17 +94,26 @@ describe("matchPath", () => {
83
94
  });
84
95
 
85
96
  it("matches with an optional prefix before a literal segment", () => {
86
- expect(matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023")).toEqual({
97
+ expect(
98
+ matchPath(
99
+ "/{granado/}?campanhas/*",
100
+ "/granado/campanhas/destaques-2023",
101
+ ),
102
+ ).toEqual({
87
103
  "0": "destaques-2023",
88
104
  });
89
- expect(matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023")).toEqual({
105
+ expect(
106
+ matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023"),
107
+ ).toEqual({
90
108
  "0": "destaques-2023",
91
109
  });
92
110
  });
93
111
 
94
112
  it("matches an optional suffix group present and absent", () => {
95
113
  expect(matchPath("/black-friday{/70-off}?", "/black-friday")).toEqual({});
96
- expect(matchPath("/black-friday{/70-off}?", "/black-friday/70-off")).toEqual({});
114
+ expect(
115
+ matchPath("/black-friday{/70-off}?", "/black-friday/70-off"),
116
+ ).toEqual({});
97
117
  });
98
118
  });
99
119
 
@@ -113,7 +133,9 @@ describe("matchPath", () => {
113
133
  // biome-ignore lint/performance/noDelete: restoring exact global state
114
134
  delete g.URLPattern;
115
135
  try {
116
- expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(/URLPattern.*Node\.js >= 24/s);
136
+ expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(
137
+ /URLPattern.*Node\.js >= 24/s,
138
+ );
117
139
  } finally {
118
140
  if (saved !== undefined) g.URLPattern = saved;
119
141
  }
@@ -202,12 +224,11 @@ describe("findPageByPath specificity", () => {
202
224
 
203
225
  describe("loadBlocks draft override — key percent-encoding", () => {
204
226
  // The published decofile encodes special characters in block keys
205
- // (`pages-Home%20(principal)-1`); the Studio draft-preview sandbox emits them
206
- // raw (`pages-Home (principal)-1`). A naive key-merge ADDS the raw-keyed
207
- // draft block instead of REPLACING the encoded published one, leaving two
208
- // `pages-` blocks with path "/" — and findPageByPath returns the first
209
- // (published) one, so the draft edit silently never renders. This shipped and
210
- // made ~73% of casaevideo's pages (home + afiliados included) ignore drafts.
227
+ // (`pages-Home%20(principal)-1`); the Studio draft emits them raw
228
+ // (`pages-Home (principal)-1`). Under snapshot semantics the draft REPLACES
229
+ // the file-backed base wholesale, so an encoded/raw twin pair can never
230
+ // coexist — these regression tests (from the merge era, when ~73% of
231
+ // casaevideo's pages silently ignored drafts) now pin that property.
211
232
 
212
233
  afterEach(() => {
213
234
  setBlocks({});
@@ -281,3 +302,76 @@ describe("loadBlocks draft override — key percent-encoding", () => {
281
302
  expect(pageBlocks).toHaveLength(0);
282
303
  });
283
304
  });
305
+
306
+ describe("loadBlocks draft snapshot semantics", () => {
307
+ afterEach(() => {
308
+ setBlocks({});
309
+ setDraftOverrideGetter(() => undefined);
310
+ });
311
+
312
+ it("a block absent from the draft is deleted — the draft is the complete truth", () => {
313
+ setBlocks({
314
+ "pages-home-1": { name: "Home", path: "/", sections: [{}] },
315
+ "pages-old-2": { name: "Old", path: "/old", sections: [{}] },
316
+ });
317
+ setDraftOverrideGetter(() => ({
318
+ "pages-home-1": { name: "Home", path: "/", sections: [{}] },
319
+ }));
320
+
321
+ const keys = Object.keys(loadBlocks());
322
+ expect(keys).toEqual(["pages-home-1"]);
323
+ expect(findPageByPath("/old")).toBeNull();
324
+ });
325
+
326
+ it("synthetic CSV-redirect base blocks survive the snapshot", () => {
327
+ setBlocks({
328
+ "__csv_redirects__bulk.csv": { redirects: [{ from: "/a", to: "/b" }] },
329
+ "pages-home-1": { name: "Home", path: "/", sections: [{}] },
330
+ });
331
+ setDraftOverrideGetter(() => ({
332
+ "pages-home-1": { name: "Home", path: "/", sections: [{}] },
333
+ }));
334
+
335
+ const blocks = loadBlocks();
336
+ expect(blocks["__csv_redirects__bulk.csv"]).toEqual({
337
+ redirects: [{ from: "/a", to: "/b" }],
338
+ });
339
+ });
340
+
341
+ it("withDraftBlocks applies snapshot semantics; withBlocksOverride keeps merge", () => {
342
+ setBlocks({
343
+ "pages-home-1": { name: "Home", path: "/", sections: [{}] },
344
+ "pages-other-2": { name: "Other", path: "/other", sections: [{}] },
345
+ });
346
+ const draft = {
347
+ "pages-home-1": { name: "Home", path: "/", sections: [{}] },
348
+ };
349
+
350
+ // Snapshot: the base-only block is gone.
351
+ withDraftBlocks(draft, () => {
352
+ expect(Object.keys(loadBlocks())).toEqual(["pages-home-1"]);
353
+ });
354
+
355
+ // Merge (admin partial payloads): the base-only block survives.
356
+ withBlocksOverride(draft, () => {
357
+ expect(Object.keys(loadBlocks()).sort()).toEqual([
358
+ "pages-home-1",
359
+ "pages-other-2",
360
+ ]);
361
+ });
362
+ });
363
+
364
+ it("an explicit scope wins over the ambient draft", () => {
365
+ setBlocks({ "pages-home-1": { name: "Home", path: "/", sections: [{}] } });
366
+ setDraftOverrideGetter(() => ({
367
+ "pages-ambient-9": { name: "A", path: "/a", sections: [{}] },
368
+ }));
369
+
370
+ withDraftBlocks(
371
+ { "pages-scoped-3": { name: "S", path: "/s", sections: [{}] } },
372
+ () => {
373
+ expect(Object.keys(loadBlocks())).toEqual(["pages-scoped-3"]);
374
+ },
375
+ );
376
+ });
377
+ });
package/src/cms/loader.ts CHANGED
@@ -31,7 +31,21 @@ interface ALSLike<T> {
31
31
  // node:async_hooks with an empty shim). The namespace import avoids Rollup's
32
32
  // named-export validation, and the runtime check prevents construction errors.
33
33
  const ALS = (asyncHooks as any).AsyncLocalStorage;
34
- const blocksOverrideStorage: ALSLike<Record<string, unknown>> = ALS
34
+
35
+ /**
36
+ * A scoped blocks override, tagged with how it composes over the base:
37
+ * - `merge`: a PARTIAL decofile (admin preview payloads) — entries replace or
38
+ * delete their base twins, everything else survives (see mergeOverride).
39
+ * - `snapshot`: a COMPLETE decofile (draft preview) — it replaces the
40
+ * file-backed base entirely; only synthetic base blocks survive (see
41
+ * applyDraftSnapshot).
42
+ */
43
+ interface BlocksOverride {
44
+ mode: "merge" | "snapshot";
45
+ blocks: Record<string, unknown>;
46
+ }
47
+
48
+ const blocksOverrideStorage: ALSLike<BlocksOverride> = ALS
35
49
  ? new ALS()
36
50
  : { getStore: () => undefined, run: (_s: any, fn: any) => fn() };
37
51
 
@@ -39,7 +53,10 @@ const blocksOverrideStorage: ALSLike<Record<string, unknown>> = ALS
39
53
  // Change listeners
40
54
  // ---------------------------------------------------------------------------
41
55
 
42
- type ChangeListener = (blocks: Record<string, unknown>, revision: string) => void;
56
+ type ChangeListener = (
57
+ blocks: Record<string, unknown>,
58
+ revision: string,
59
+ ) => void;
43
60
  const changeListeners: ChangeListener[] = [];
44
61
 
45
62
  /** Register a callback invoked whenever setBlocks() changes the decofile. */
@@ -139,9 +156,40 @@ function mergeOverride(
139
156
  }
140
157
 
141
158
  /**
142
- * Load the current blocks. If running inside a `withBlocksOverride` scope
143
- * (admin preview) or a request carrying a draft-preview override, that
144
- * override is merged on top of the base blocks.
159
+ * Base blocks that are NOT file-backed: synthesized by the generator (CSV
160
+ * redirect loaders, keyed `__csv_redirects__<file>`). A draft snapshot is the
161
+ * complete truth of `.deco/blocks/` but knows nothing about these, so they
162
+ * survive the snapshot instead of vanishing from the preview.
163
+ */
164
+ const SYNTHETIC_BLOCK_KEY_PREFIX = "__csv_redirects__";
165
+
166
+ /**
167
+ * Compose a draft SNAPSHOT over the base blocks: the draft is the complete
168
+ * decofile at the branch head, so it REPLACES every file-backed base block —
169
+ * a block absent from the draft was deleted and must not render. Only
170
+ * synthetic base blocks (see SYNTHETIC_BLOCK_KEY_PREFIX) survive. `null`
171
+ * draft values are tolerated as deletions for defensiveness.
172
+ */
173
+ function applyDraftSnapshot(
174
+ base: Record<string, unknown>,
175
+ draft: Record<string, unknown>,
176
+ ): Record<string, unknown> {
177
+ const out: Record<string, unknown> = {};
178
+ for (const [key, value] of Object.entries(base)) {
179
+ if (key.startsWith(SYNTHETIC_BLOCK_KEY_PREFIX)) out[key] = value;
180
+ }
181
+ for (const [key, value] of Object.entries(draft)) {
182
+ if (value === null || value === undefined) continue;
183
+ out[key] = value;
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * Load the current blocks. If running inside a `withBlocksOverride` /
190
+ * `withDraftBlocks` scope (admin preview / draft endpoints) or a request
191
+ * carrying an ambient draft, that override composes over the base blocks
192
+ * according to its mode.
145
193
  */
146
194
  export function loadBlocks(): Record<string, unknown> {
147
195
  // Re-sync from globalThis in case setBlocks was called in another module instance
@@ -150,11 +198,17 @@ export function loadBlocks(): Record<string, unknown> {
150
198
  revision = G.__deco.revision ?? null;
151
199
  }
152
200
 
153
- // `withBlocksOverride` (an explicit admin render of a specific payload) wins
154
- // over an ambient draft: the caller named the exact blocks to render, so a
155
- // draft pointer on the same request must not silently replace them.
156
- const override = blocksOverrideStorage.getStore() ?? getRequestDraftOverride();
157
- if (override) return mergeOverride(blockData, override);
201
+ // An explicit scope (the caller named the exact blocks to render) wins over
202
+ // an ambient draft: a draft pointer on the same request must not silently
203
+ // replace it.
204
+ const scoped = blocksOverrideStorage.getStore();
205
+ if (scoped) {
206
+ return scoped.mode === "merge"
207
+ ? mergeOverride(blockData, scoped.blocks)
208
+ : applyDraftSnapshot(blockData, scoped.blocks);
209
+ }
210
+ const draft = getRequestDraftOverride();
211
+ if (draft) return applyDraftSnapshot(blockData, draft);
158
212
  return blockData;
159
213
  }
160
214
 
@@ -171,8 +225,25 @@ export function getRevision(): string | null {
171
225
  * for the duration of the render. Other concurrent requests are not
172
226
  * affected (AsyncLocalStorage is per-request scoped).
173
227
  */
174
- export function withBlocksOverride<T>(override: Record<string, unknown>, fn: () => T): T {
175
- return blocksOverrideStorage.run(override, fn);
228
+ export function withBlocksOverride<T>(
229
+ override: Record<string, unknown>,
230
+ fn: () => T,
231
+ ): T {
232
+ return blocksOverrideStorage.run({ mode: "merge", blocks: override }, fn);
233
+ }
234
+
235
+ /**
236
+ * Run a function with a COMPLETE draft decofile (snapshot semantics — see
237
+ * applyDraftSnapshot). Used by secondary endpoints (`/deco/invoke`) that
238
+ * re-resolve the page's draft from the raw request: the draft must compose
239
+ * exactly like the page render, deletions included, or a lazy section could
240
+ * render a block the page no longer has.
241
+ */
242
+ export function withDraftBlocks<T>(
243
+ draft: Record<string, unknown>,
244
+ fn: () => T,
245
+ ): T {
246
+ return blocksOverrideStorage.run({ mode: "snapshot", blocks: draft }, fn);
176
247
  }
177
248
 
178
249
  // Higher key wins. Compared lexicographically:
@@ -213,7 +284,11 @@ function pathSpecificityKey(path: string): [number, number, number] {
213
284
 
214
285
  export function getAllPages(): Array<{ key: string; page: DecoPage }> {
215
286
  const blocks = loadBlocks();
216
- const pages: Array<{ key: string; page: DecoPage; key2: [number, number, number] }> = [];
287
+ const pages: Array<{
288
+ key: string;
289
+ page: DecoPage;
290
+ key2: [number, number, number];
291
+ }> = [];
217
292
 
218
293
  for (const [key, block] of Object.entries(blocks)) {
219
294
  if (!key.startsWith("pages-")) continue;
@@ -281,7 +356,10 @@ declare const URLPattern: {
281
356
  * `URLPattern` is native in browsers, workerd, Deno, and Node >= 24 (this
282
357
  * package's `engines` floor). Node 22 and older lack it.
283
358
  */
284
- export function matchPath(pattern: string, urlPath: string): Record<string, string> | null {
359
+ export function matchPath(
360
+ pattern: string,
361
+ urlPath: string,
362
+ ): Record<string, string> | null {
285
363
  if (typeof URLPattern === "undefined") {
286
364
  throw new Error(
287
365
  "@decocms/blocks: this runtime has no URLPattern Web API, so CMS page " +