@decocms/blocks-admin 7.77.6 → 7.77.8

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-admin",
3
- "version": "7.77.6",
3
+ "version": "7.77.8",
4
4
  "type": "module",
5
5
  "description": "Deco admin protocol: /live/_meta, /.decofile, /deco/invoke, /deco/render, plus admin-facing site setup and commerce-app auto-configuration",
6
6
  "repository": {
@@ -25,7 +25,7 @@
25
25
  "lint:unused": "knip"
26
26
  },
27
27
  "dependencies": {
28
- "@decocms/blocks": "7.77.6"
28
+ "@decocms/blocks": "7.77.8"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "react": "^19.0.0",
@@ -14,14 +14,10 @@
14
14
  * Set-Cookie headers (readable via `response.headers.getSetCookie()`).
15
15
  */
16
16
 
17
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
17
  import { clearDraftCache, loadBlocks, setBlocks } from "@decocms/blocks/cms";
19
18
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
20
- import {
21
- clearInvokeHandlers,
22
- handleInvoke,
23
- registerInvokeHandlers,
24
- } from "./invoke";
19
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
20
+ import { clearInvokeHandlers, handleInvoke, registerInvokeHandlers } from "./invoke";
25
21
 
26
22
  const COOKIE_A = "checkout.vtex.com__orderFormId=of-123; Path=/; HttpOnly";
27
23
  const COOKIE_B = "segment=eyJjYW1wYWlnbnMiOiJ4In0=; Path=/; HttpOnly";
@@ -58,9 +54,7 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
58
54
  });
59
55
 
60
56
  const request = makeInvokeRequest("vtex/actions/addItemsToCart");
61
- const response = await RequestContext.run(request, () =>
62
- handleInvoke(request),
63
- );
57
+ const response = await RequestContext.run(request, () => handleInvoke(request));
64
58
 
65
59
  const cookies = response.headers.getSetCookie();
66
60
  expect(cookies).toHaveLength(3);
@@ -79,9 +73,7 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
79
73
  });
80
74
 
81
75
  const request = makeInvokeRequest("vtex/actions/foo");
82
- const response = await RequestContext.run(request, () =>
83
- handleInvoke(request),
84
- );
76
+ const response = await RequestContext.run(request, () => handleInvoke(request));
85
77
 
86
78
  // The regressed bug appended a single comma-joined string, so
87
79
  // `getSetCookie()` returned a 1-element array. The fix appends each
@@ -98,9 +90,7 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
98
90
  });
99
91
 
100
92
  const request = makeInvokeRequest("vtex/actions/withHeader");
101
- const response = await RequestContext.run(request, () =>
102
- handleInvoke(request),
103
- );
93
+ const response = await RequestContext.run(request, () => handleInvoke(request));
104
94
  expect(response.headers.get("x-vtex-trace-id")).toBe("abc-123");
105
95
  });
106
96
 
@@ -110,9 +100,7 @@ describe("handleInvoke — Set-Cookie propagation (single)", () => {
110
100
  });
111
101
 
112
102
  const request = makeInvokeRequest("vtex/loaders/productList");
113
- const response = await RequestContext.run(request, () =>
114
- handleInvoke(request),
115
- );
103
+ const response = await RequestContext.run(request, () => handleInvoke(request));
116
104
  expect(response.headers.getSetCookie()).toEqual([]);
117
105
  });
118
106
  });
@@ -139,9 +127,7 @@ describe("handleInvoke — Set-Cookie propagation (batch)", () => {
139
127
  "vtex/actions/addItemsToCart": { orderFormId: "x" },
140
128
  "vtex/loaders/productList": {},
141
129
  });
142
- const response = await RequestContext.run(request, () =>
143
- handleInvoke(request),
144
- );
130
+ const response = await RequestContext.run(request, () => handleInvoke(request));
145
131
 
146
132
  const cookies = response.headers.getSetCookie();
147
133
  expect(cookies).toHaveLength(3);
@@ -165,8 +151,7 @@ describe("handleInvoke — draft binding", () => {
165
151
  clearDraftCache();
166
152
  setBlocks({ "site/x": { value: "published" } });
167
153
  registerInvokeHandlers({
168
- "site/loaders/x.ts": async () =>
169
- (loadBlocks() as Record<string, any>)["site/x"],
154
+ "site/loaders/x.ts": async () => (loadBlocks() as Record<string, any>)["site/x"],
170
155
  });
171
156
  });
172
157
  afterEach(() => {
@@ -177,14 +162,11 @@ describe("handleInvoke — draft binding", () => {
177
162
  });
178
163
 
179
164
  function invokeReq(headers: Record<string, string>): Request {
180
- return new Request(
181
- "https://preview.example/deco/invoke/site/loaders/x.ts",
182
- {
183
- method: "POST",
184
- headers: { "content-type": "application/json", ...headers },
185
- body: "{}",
186
- },
187
- );
165
+ return new Request("https://preview.example/deco/invoke/site/loaders/x.ts", {
166
+ method: "POST",
167
+ headers: { "content-type": "application/json", ...headers },
168
+ body: "{}",
169
+ });
188
170
  }
189
171
 
190
172
  it("resolves loaders against the DRAFT when the request carries an allowed draft cookie", async () => {
@@ -197,8 +179,7 @@ describe("handleInvoke — draft binding", () => {
197
179
  const res = await handleInvoke(
198
180
  invokeReq({
199
181
  "x-forwarded-host": "preview.example",
200
- cookie:
201
- "__deco_draft=studio.decocms.com/api/o/decofile/m/main%3Ftoken%3Dt@vX",
182
+ cookie: "__deco_draft=studio.decocms.com/api/o/decofile/m/main%3Ftoken%3Dt@vX",
202
183
  }),
203
184
  );
204
185
  expect(await res.json()).toEqual({ value: "draft" });
@@ -212,9 +193,7 @@ describe("handleInvoke — draft binding", () => {
212
193
  return new Response("{}");
213
194
  }) as unknown as typeof fetch;
214
195
 
215
- const res = await handleInvoke(
216
- invokeReq({ "x-forwarded-host": "preview.example" }),
217
- );
196
+ const res = await handleInvoke(invokeReq({ "x-forwarded-host": "preview.example" }));
218
197
  expect(await res.json()).toEqual({ value: "published" });
219
198
  expect(called).toBe(false);
220
199
  });
@@ -229,10 +208,49 @@ describe("handleInvoke — draft binding", () => {
229
208
  const res = await handleInvoke(
230
209
  invokeReq({
231
210
  "x-forwarded-host": "prod.example",
232
- cookie:
233
- "__deco_draft=studio.decocms.com/api/o/decofile/m/main%3Ftoken%3Dt@vX",
211
+ cookie: "__deco_draft=studio.decocms.com/api/o/decofile/m/main%3Ftoken%3Dt@vX",
234
212
  }),
235
213
  );
236
214
  expect(await res.json()).toEqual({ value: "published" });
237
215
  });
238
216
  });
217
+
218
+ describe("handleInvoke — malformed JSON body", () => {
219
+ const seen: unknown[] = [];
220
+ beforeEach(() => {
221
+ seen.length = 0;
222
+ clearInvokeHandlers();
223
+ registerInvokeHandlers({
224
+ "site/loaders/echo.ts": async (props: unknown) => {
225
+ seen.push(props);
226
+ return { ok: true };
227
+ },
228
+ });
229
+ });
230
+
231
+ const post = (body: string, headers: Record<string, string>) =>
232
+ new Request("http://localhost/deco/invoke/site/loaders/echo.ts", {
233
+ method: "POST",
234
+ headers,
235
+ body,
236
+ });
237
+
238
+ it("answers 400 Invalid JSON for a declared-JSON body that does not parse, without running the handler", async () => {
239
+ const res = await handleInvoke(post("{not json", { "content-type": "application/json" }));
240
+ expect(res.status).toBe(400);
241
+ expect(await res.json()).toEqual({ error: "Invalid JSON" });
242
+ expect(seen).toEqual([]);
243
+ });
244
+
245
+ it("treats an empty declared-JSON body as no props", async () => {
246
+ const res = await handleInvoke(post("", { "content-type": "application/json" }));
247
+ expect(res.status).toBe(200);
248
+ expect(seen).toEqual([{}]);
249
+ });
250
+
251
+ it("stays lenient without a JSON content-type (try-json: {} on failure)", async () => {
252
+ const res = await handleInvoke(post("{not json", {}));
253
+ expect(res.status).toBe(200);
254
+ expect(seen).toEqual([{}]);
255
+ });
256
+ });
@@ -95,13 +95,11 @@ function forwardCtxHeadersTo(response: Response): void {
95
95
  }
96
96
 
97
97
  const isDev =
98
- typeof globalThis.process !== "undefined" &&
99
- globalThis.process.env?.NODE_ENV === "development";
98
+ typeof globalThis.process !== "undefined" && globalThis.process.env?.NODE_ENV === "development";
100
99
 
101
100
  function selectFields(data: unknown, select?: string[]): unknown {
102
101
  if (!select?.length || !data || typeof data !== "object") return data;
103
- if (Array.isArray(data))
104
- return data.map((item) => selectFields(item, select));
102
+ if (Array.isArray(data)) return data.map((item) => selectFields(item, select));
105
103
  const result: Record<string, unknown> = {};
106
104
  for (const key of select) {
107
105
  if (key in (data as Record<string, unknown>)) {
@@ -133,9 +131,7 @@ async function parseBody(request: Request): Promise<any> {
133
131
  for (const [key, value] of formData.entries()) {
134
132
  if (obj[key] !== undefined) {
135
133
  // Multiple values → array
136
- const existing = Array.isArray(obj[key])
137
- ? (obj[key] as unknown[])
138
- : [obj[key]];
134
+ const existing = Array.isArray(obj[key]) ? (obj[key] as unknown[]) : [obj[key]];
139
135
  existing.push(value);
140
136
  obj[key] = existing;
141
137
  } else {
@@ -162,14 +158,30 @@ async function parseBody(request: Request): Promise<any> {
162
158
  return {};
163
159
  }
164
160
 
165
- // JSON (default for POST)
161
+ // JSON (default for POST). An empty body is `{}` (a bodiless POST is a
162
+ // legitimate "no props" call). A body that DECLARES itself JSON but does
163
+ // not parse is a client bug, not "no props": running the handler with `{}`
164
+ // would silently execute an action without its input. Mirrors
165
+ // deco-runtime's `json` parsing strategy, which throws on invalid JSON
166
+ // instead of swallowing it. Without an explicit JSON content-type the
167
+ // lenient `try-json` behavior stays (`{}` on failure).
168
+ const text = await request.text();
169
+ if (text.trim() === "") return {};
166
170
  try {
167
- return await request.json();
171
+ return JSON.parse(text);
168
172
  } catch {
173
+ if (contentType.includes("application/json")) throw new InvalidJsonBody();
169
174
  return {};
170
175
  }
171
176
  }
172
177
 
178
+ /** Thrown by `parseBody` for a declared-JSON body that does not parse → 400. */
179
+ class InvalidJsonBody extends Error {
180
+ constructor() {
181
+ super("Invalid JSON");
182
+ }
183
+ }
184
+
173
185
  function findHandler(
174
186
  key: string,
175
187
  ): { handler: InvokeLoader | InvokeAction; type: "loader" | "action" } | null {
@@ -244,7 +256,13 @@ async function dispatchInvoke(request: Request): Promise<Response> {
244
256
  const invokeKey = pathParts[1] || "";
245
257
  const select = url.searchParams.get("select")?.split(",").filter(Boolean);
246
258
 
247
- const body = await parseBody(request);
259
+ let body: any;
260
+ try {
261
+ body = await parseBody(request);
262
+ } catch (error) {
263
+ if (error instanceof InvalidJsonBody) return errorResponse(error.message, 400);
264
+ throw error;
265
+ }
248
266
 
249
267
  // Single invoke by key
250
268
  if (invokeKey) {
@@ -274,12 +292,7 @@ async function dispatchInvoke(request: Request): Promise<Response> {
274
292
  }
275
293
 
276
294
  // Batch invoke
277
- if (
278
- request.method === "POST" &&
279
- body &&
280
- typeof body === "object" &&
281
- !Array.isArray(body)
282
- ) {
295
+ if (request.method === "POST" && body && typeof body === "object" && !Array.isArray(body)) {
283
296
  const results: Record<string, unknown> = {};
284
297
 
285
298
  const entries = Object.entries(body as Record<string, unknown>);