@decocms/blocks-cli 7.20.1 → 7.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.20.1",
3
+ "version": "7.20.2",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "lint:unused": "knip"
31
31
  },
32
32
  "dependencies": {
33
- "@decocms/blocks": "7.20.1",
33
+ "@decocms/blocks": "7.20.2",
34
34
  "ts-morph": "^27.0.0",
35
35
  "tsx": "^4.22.5"
36
36
  },
@@ -268,3 +268,111 @@ describe("generate-invoke.ts — default --apps-dir resolution", () => {
268
268
  }
269
269
  }, 30_000);
270
270
  });
271
+
272
+ describe("generate-invoke.ts — privileged MasterData actions are never exposed", () => {
273
+ // Security regression guard: generic admin-credentialed MasterData CRUD
274
+ // (searchDocuments / createDocument / patchDocument / getDocument /
275
+ // uploadAttachment) must NOT be emitted as createServerFn endpoints, even if
276
+ // they appear in invoke.vtex.actions. Each emitted action is a public,
277
+ // unauthenticated /_serverFn/<hash>; a generic (entity, filter) proxy over the
278
+ // app's appKey/appToken is broken access control (e.g. entity: "CL" dumps
279
+ // customer PII). The generator's PRIVILEGED_ACTIONS guard must skip them.
280
+ const FIXTURE_WITH_PRIVILEGED = `\
281
+ import { createInvokeFn } from "@decocms/tanstack/sdk/createInvoke";
282
+ import { getOrCreateCart } from "./actions/checkout";
283
+ import { createDocument, searchDocuments, patchDocument } from "./actions/masterData";
284
+ import type { OrderForm } from "./types";
285
+
286
+ export const invoke = {
287
+ vtex: {
288
+ actions: {
289
+ getOrCreateCart: createInvokeFn(
290
+ (data: { orderFormId?: string }) => getOrCreateCart(data),
291
+ ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
292
+
293
+ createDocument: createInvokeFn(
294
+ (data: { entity: string; data: Record<string, any> }) => createDocument(data),
295
+ ),
296
+ searchDocuments: createInvokeFn(
297
+ (data: { entity: string; filter: string }) => searchDocuments(data),
298
+ ),
299
+ patchDocument: createInvokeFn(
300
+ (data: { entity: string; documentId: string; data: Record<string, any> }) =>
301
+ patchDocument(data),
302
+ ),
303
+ },
304
+ },
305
+ } as const;
306
+ `;
307
+ const FIXTURE_ACTIONS_MASTERDATA_TS = `\
308
+ export async function createDocument(_p: any): Promise<any> { return null; }
309
+ export async function searchDocuments(_p: any): Promise<any> { return null; }
310
+ export async function patchDocument(_p: any): Promise<any> { return null; }
311
+ `;
312
+
313
+ let generatedOutput: string;
314
+ let stderr: string;
315
+ let status: number | null;
316
+ let cleanup: () => void;
317
+
318
+ beforeAll(() => {
319
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-priv-"));
320
+ const appsDir = path.join(tmp, "apps-vtex");
321
+ const siteDir = path.join(tmp, "site");
322
+ fs.mkdirSync(path.join(appsDir, "actions"), { recursive: true });
323
+ fs.mkdirSync(path.join(siteDir, "src", "server"), { recursive: true });
324
+ fs.writeFileSync(path.join(appsDir, "invoke.ts"), FIXTURE_WITH_PRIVILEGED);
325
+ fs.writeFileSync(path.join(appsDir, "actions", "checkout.ts"), FIXTURE_ACTIONS_CHECKOUT_TS);
326
+ fs.writeFileSync(path.join(appsDir, "actions", "masterData.ts"), FIXTURE_ACTIONS_MASTERDATA_TS);
327
+ fs.writeFileSync(path.join(appsDir, "types.ts"), FIXTURE_TYPES_TS);
328
+ const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
329
+ const result = spawnSync(
330
+ "npx",
331
+ ["tsx", GENERATOR, "--apps-dir", appsDir, "--out-file", outFile],
332
+ { cwd: siteDir, encoding: "utf8" },
333
+ );
334
+ status = result.status;
335
+ stderr = result.stderr;
336
+ generatedOutput = fs.readFileSync(outFile, "utf8");
337
+ cleanup = () => fs.rmSync(tmp, { recursive: true, force: true });
338
+ }, 30_000);
339
+
340
+ afterAll(() => {
341
+ try {
342
+ cleanup();
343
+ } catch {
344
+ // ignore
345
+ }
346
+ });
347
+
348
+ it("runs to completion", () => {
349
+ expect(status, stderr).toBe(0);
350
+ });
351
+
352
+ it("does NOT emit createServerFn consts for privileged MasterData actions", () => {
353
+ expect(generatedOutput).not.toContain("$createDocument");
354
+ expect(generatedOutput).not.toContain("$searchDocuments");
355
+ expect(generatedOutput).not.toContain("$patchDocument");
356
+ });
357
+
358
+ it("does NOT list privileged actions in the exported vtexActions map", () => {
359
+ expect(generatedOutput).not.toMatch(/^\s*createDocument:/m);
360
+ expect(generatedOutput).not.toMatch(/^\s*searchDocuments:/m);
361
+ expect(generatedOutput).not.toMatch(/^\s*patchDocument:/m);
362
+ });
363
+
364
+ it("does NOT import the privileged action functions", () => {
365
+ expect(generatedOutput).not.toContain("actions/masterData");
366
+ });
367
+
368
+ it("still emits the safe action alongside the skipped ones", () => {
369
+ expect(generatedOutput).toContain("const $getOrCreateCart = createServerFn");
370
+ expect(generatedOutput).toContain("const result = await getOrCreateCart(data);");
371
+ });
372
+
373
+ it("warns on stderr for each skipped privileged action", () => {
374
+ expect(stderr).toContain('Skipping privileged action "createDocument"');
375
+ expect(stderr).toContain('Skipping privileged action "searchDocuments"');
376
+ expect(stderr).toContain('Skipping privileged action "patchDocument"');
377
+ });
378
+ });
@@ -56,6 +56,25 @@ function arg(name: string, fallback: string): string {
56
56
  const cwd = process.cwd();
57
57
  const outFile = path.resolve(cwd, arg("out-file", "src/server/invoke.gen.ts"));
58
58
 
59
+ // Security guard: generic, admin-credentialed VTEX MasterData CRUD must never
60
+ // be emitted as a client-callable /_serverFn endpoint. Each emitted action
61
+ // becomes a public, unauthenticated createServerFn; a generic action that takes
62
+ // a client-supplied `entity` and hits MasterData with the app's appKey/appToken
63
+ // is broken access control (e.g. searchDocuments({ entity: "CL" }) dumps
64
+ // customer PII). These names are refused even if present in invoke.vtex.actions,
65
+ // so the exclusion can't silently regress via an edit to the source contract.
66
+ // Sites needing MasterData define a narrow, entity-pinned, authenticated action
67
+ // in their own src/server/invoke.ts instead (the masterData.ts functions stay
68
+ // exported for that server-side use).
69
+ const PRIVILEGED_ACTIONS = new Set([
70
+ "createDocument",
71
+ "getDocument",
72
+ "patchDocument",
73
+ "searchDocuments",
74
+ "searchDocumentsFull",
75
+ "uploadAttachment",
76
+ ]);
77
+
59
78
  function resolveAppsDir(): string {
60
79
  const explicit = arg("apps-dir", "");
61
80
  if (explicit) return path.resolve(cwd, explicit);
@@ -200,6 +219,17 @@ for (const prop of actionsObj.getProperties()) {
200
219
  if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
201
220
  const pa = prop as PropertyAssignment;
202
221
  const name = pa.getName();
222
+
223
+ // Never emit generic admin-credentialed MasterData CRUD as a serverFn — see
224
+ // PRIVILEGED_ACTIONS above. Skipping here means no top-level createServerFn
225
+ // const and no vtexActions entry is generated for it.
226
+ if (PRIVILEGED_ACTIONS.has(name)) {
227
+ console.warn(
228
+ `⚠ Skipping privileged action "${name}" — generic MasterData CRUD is not exposed as a serverFn (security).`,
229
+ );
230
+ continue;
231
+ }
232
+
203
233
  const initText = pa.getInitializer()!.getText();
204
234
 
205
235
  // Check if it uses createInvokeFn with unwrap