@decocms/blocks-admin 7.77.5 → 7.77.7
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 +2 -2
- package/src/admin/invoke.test.ts +56 -38
- package/src/admin/invoke.ts +29 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-admin",
|
|
3
|
-
"version": "7.77.
|
|
3
|
+
"version": "7.77.7",
|
|
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.
|
|
28
|
+
"@decocms/blocks": "7.77.7"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"react": "^19.0.0",
|
package/src/admin/invoke.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"
|
|
182
|
-
{
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
});
|
package/src/admin/invoke.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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>);
|