Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions scripts/generate-invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const FIXTURE_INVOKE_TS = `\
import { createInvokeFn } from "@decocms/start/sdk/createInvoke";
import { getOrCreateCart, simulateCart } from "./actions/checkout";
import { createSession } from "./actions/session";
import { searchDocuments, createDocument } from "./actions/masterData";
import type { OrderForm } from "./types";

export const invoke = {
Expand All @@ -49,6 +50,16 @@ export const invoke = {
createSession: createInvokeFn(
(data: Record<string, any>) => createSession({ data }),
),

// Generic MasterData CRUD — admin-credentialed, caller-parameterized.
// These MUST be denied by default (never emitted) so no
// POST /_serverFn/<hash> route is minted for them.
searchDocuments: createInvokeFn(
(data: { entity: string; filter: string }) => searchDocuments(data),
),
createDocument: createInvokeFn(
(data: { entity: string; data: Record<string, any> }) => createDocument(data),
),
},
},
} as const;
Expand All @@ -65,6 +76,10 @@ const FIXTURE_ACTIONS_SESSION_TS = `\
export interface CreateSessionProps { data: Record<string, any>; }
export async function createSession(_props: CreateSessionProps): Promise<any> { return null; }
`;
const FIXTURE_ACTIONS_MASTERDATA_TS = `\
export async function searchDocuments(_data: any): Promise<any> { return null; }
export async function createDocument(_data: any): Promise<any> { return null; }
`;
const FIXTURE_TYPES_TS = `export type OrderForm = unknown;\n`;

describe("generate-invoke.ts — output shape", () => {
Expand Down Expand Up @@ -94,6 +109,10 @@ describe("generate-invoke.ts — output shape", () => {
path.join(appsDir, "vtex", "actions", "session.ts"),
FIXTURE_ACTIONS_SESSION_TS,
);
fs.writeFileSync(
path.join(appsDir, "vtex", "actions", "masterData.ts"),
FIXTURE_ACTIONS_MASTERDATA_TS,
);
fs.writeFileSync(path.join(appsDir, "vtex", "types.ts"), FIXTURE_TYPES_TS);
outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");

Expand Down Expand Up @@ -172,6 +191,25 @@ describe("generate-invoke.ts — output shape", () => {
expect(orderedCalls.length).toBe(3);
});

it("denies generic MasterData CRUD by default (no createServerFn emitted)", () => {
// The exposure hole: searchDocuments/createDocument run with admin
// credentials and a caller-controlled entity + _where filter. They must
// never be emitted as top-level consts, so TanStack's compiler never
// mints a POST /_serverFn/<hash> route for them.
expect(generatedOutput).not.toContain("$searchDocuments");
expect(generatedOutput).not.toContain("$createDocument");
expect(generatedOutput).not.toMatch(/\bsearchDocuments\(data\)/);
expect(generatedOutput).not.toMatch(/\bcreateDocument\(data\)/);
// And they must be absent from the exported actions map.
expect(generatedOutput).not.toMatch(/^\s*searchDocuments:/m);
expect(generatedOutput).not.toMatch(/^\s*createDocument:/m);

// Safe actions are unaffected — only the denylisted ones are dropped.
expect(generatedOutput).toContain("$getOrCreateCart");
expect(generatedOutput).toContain("$simulateCart");
expect(generatedOutput).toContain("$createSession");
});

it("preserves adapting wrappers verbatim (does not collapse to actionFn(data))", () => {
// Regression for the createSession-shape wrapper: the generator
// previously hard-coded `${importedFn}(data)` in every handler,
Expand All @@ -193,3 +231,67 @@ describe("generate-invoke.ts — output shape", () => {
expect(generatedOutput).toContain("const result = await simulateCart(data);");
});
});

describe("generate-invoke.ts — --allow escape hatch", () => {
let output: string;
let status: number | null;
let appsDir: string;

beforeAll(() => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-allow-"));
appsDir = path.join(tmp, "apps");
const siteDir = path.join(tmp, "site");
fs.mkdirSync(path.join(appsDir, "vtex", "actions"), { recursive: true });
fs.mkdirSync(path.join(siteDir, "src", "server"), { recursive: true });
fs.writeFileSync(path.join(appsDir, "vtex", "invoke.ts"), FIXTURE_INVOKE_TS);
fs.writeFileSync(
path.join(appsDir, "vtex", "actions", "checkout.ts"),
FIXTURE_ACTIONS_CHECKOUT_TS,
);
fs.writeFileSync(
path.join(appsDir, "vtex", "actions", "session.ts"),
FIXTURE_ACTIONS_SESSION_TS,
);
fs.writeFileSync(
path.join(appsDir, "vtex", "actions", "masterData.ts"),
FIXTURE_ACTIONS_MASTERDATA_TS,
);
fs.writeFileSync(path.join(appsDir, "vtex", "types.ts"), FIXTURE_TYPES_TS);
const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");

const result = spawnSync(
"npx",
[
"tsx",
GENERATOR,
"--apps-dir",
appsDir,
"--out-file",
outFile,
"--allow",
"searchDocuments",
],
{ cwd: siteDir, encoding: "utf8" },
);
status = result.status;
output = fs.readFileSync(outFile, "utf8");
}, 30_000);

afterAll(() => {
try {
fs.rmSync(path.dirname(appsDir), { recursive: true, force: true });
} catch {
// ignore
}
});

it("re-allows exactly the named action, keeping others denied", () => {
expect(status).toBe(0);
// Explicitly allowed → emitted again.
expect(output).toContain("$searchDocuments");
expect(output).toContain("const result = await searchDocuments(data);");
// Still denied — --allow was surgical, not a blanket off switch.
expect(output).not.toContain("$createDocument");
expect(output).not.toMatch(/\bcreateDocument\(data\)/);
});
});
32 changes: 32 additions & 0 deletions scripts/generate-invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,35 @@
* Env / CLI:
* --out-file override output (default: src/server/invoke.gen.ts)
* --apps-dir override @decocms/apps location (default: auto-resolve from node_modules)
* --exclude comma-separated extra action names to NOT expose (beyond the
* built-in default denylist of generic MasterData CRUD actions)
* --allow comma-separated action names to RE-ALLOW despite the default
* denylist (escape hatch — prefer a purpose-built wrapper)
*/
import fs from "node:fs";
import path from "node:path";
import { Project, type PropertyAssignment, SyntaxKind } from "ts-morph";
import { type InvokePolicyOptions, isInternalAction } from "../src/admin/invokePolicy";

const args = process.argv.slice(2);
function arg(name: string, fallback: string): string {
const idx = args.indexOf(`--${name}`);
return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
}

function list(name: string): string[] {
return arg(name, "").split(",").map((s) => s.trim()).filter(Boolean);
}

// Invoke-exposure policy. Generic MasterData CRUD is denied by default;
// --exclude adds to that, --allow re-opens specific actions. Same policy the
// runtime registrar (src/sdk/setupApps.ts) applies, so an action can't be
// gated on one exposure surface but left open on the other.
const policy: InvokePolicyOptions = {
deny: list("exclude"),
allow: list("allow"),
};

const cwd = process.cwd();
const outFile = path.resolve(cwd, arg("out-file", "src/server/invoke.gen.ts"));

Expand Down Expand Up @@ -126,6 +144,7 @@ if (!invokeVar) {
}

const actions: ActionDef[] = [];
const skippedInternal: string[] = [];
const invokeInit = invokeVar.getInitializer();
if (!invokeInit) {
console.error("invoke variable has no initializer");
Expand Down Expand Up @@ -162,6 +181,14 @@ for (const prop of actionsObj.getProperties()) {
if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
const pa = prop as PropertyAssignment;
const name = pa.getName();

// Default-deny internal actions: never emit a createServerFn const for them,
// so TanStack's compiler never mints a POST /_serverFn/<hash> route.
if (isInternalAction(name, policy)) {
skippedInternal.push(name);
continue;
}

const initText = pa.getInitializer()!.getText();

// Check if it uses createInvokeFn with unwrap
Expand Down Expand Up @@ -467,3 +494,8 @@ export const invoke = {
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, out);
console.log(`Generated ${actions.length} server functions → ${path.relative(cwd, outFile)}`);
if (skippedInternal.length > 0) {
console.log(
`🔒 Not exposed (internal/denied — no _serverFn route): ${skippedInternal.join(", ")}`,
);
}
45 changes: 9 additions & 36 deletions scripts/migrate/templates/server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,13 +400,18 @@ export const vtexActions = {} as const;
import { createServerFn } from "@tanstack/react-start";
import { getOrCreateCart, addItemsToCart, updateCartItems, addCouponToCart, simulateCart, getSellersByRegion, setShippingPostalCode, updateOrderFormAttachment } from "@decocms/apps/vtex/actions/checkout";
import { createSession, editSession } from "@decocms/apps/vtex/actions/session";
import { createDocument, getDocument, patchDocument, searchDocuments, uploadAttachment } from "@decocms/apps/vtex/actions/masterData";
// Generic MasterData CRUD (createDocument / getDocument / patchDocument /
// searchDocuments / uploadAttachment) is intentionally NOT scaffolded here.
// It runs with the store's admin appKey/appToken and takes a caller-controlled
// entity + _where filter, so it is denied by default (see
// @decocms/start/admin/invokePolicy). The real generator, generate-invoke.ts,
// enforces the same denylist when it overwrites this file post-migration;
// this scaffold matches it so a failed/skipped generator run can't fail open.
import { subscribe } from "@decocms/apps/vtex/actions/newsletter";
import { notifyMe } from "@decocms/apps/vtex/actions/misc";
import type { OrderForm } from "@decocms/apps/vtex/types";
import type { SimulationItem, RegionResult } from "@decocms/apps/vtex/actions/checkout";
import type { SessionData } from "@decocms/apps/vtex/actions/session";
import type { CreateDocumentResult, UploadAttachmentOpts } from "@decocms/apps/vtex/actions/masterData";
import type { SubscribeProps } from "@decocms/apps/vtex/actions/newsletter";
import type { NotifyMeProps } from "@decocms/apps/vtex/actions/misc";

Expand Down Expand Up @@ -487,35 +492,8 @@ const $editSession = createServerFn({ method: "POST" })
return unwrapResult(result);
});

const $createDocument = createServerFn({ method: "POST" })
.inputValidator((data: { entity: string; data: Record<string, any> }) => data)
.handler(async ({ data }): Promise<any> => {
return createDocument(data);
});

const $getDocument = createServerFn({ method: "POST" })
.inputValidator((data: { entity: string; documentId: string }) => data)
.handler(async ({ data }): Promise<any> => {
return getDocument(data);
});

const $patchDocument = createServerFn({ method: "POST" })
.inputValidator((data: { entity: string; documentId: string; data: Record<string, any> }) => data)
.handler(async ({ data }): Promise<any> => {
return patchDocument(data);
});

const $searchDocuments = createServerFn({ method: "POST" })
.inputValidator((data: { entity: string; filter: string }) => data)
.handler(async ({ data }): Promise<any> => {
return searchDocuments(data);
});

const $uploadAttachment = createServerFn({ method: "POST" })
.inputValidator((data: UploadAttachmentOpts) => data)
.handler(async ({ data }): Promise<any> => {
return uploadAttachment(data);
});
// MasterData CRUD server functions intentionally omitted — see the import
// comment above. They are denied by default in @decocms/start/admin/invokePolicy.

const $subscribe = createServerFn({ method: "POST" })
.inputValidator((data: SubscribeProps) => data)
Expand All @@ -540,11 +518,6 @@ export const vtexActions = {
updateOrderFormAttachment: $updateOrderFormAttachment as unknown as (ctx: { data: { orderFormId: string; attachment: string; body: Record<string, unknown> } }) => Promise<OrderForm>,
createSession: $createSession,
editSession: $editSession as unknown as (ctx: { data: { public: Record<string, { value: string }> } }) => Promise<SessionData>,
createDocument: $createDocument as unknown as (ctx: { data: { entity: string; data: Record<string, any> } }) => Promise<CreateDocumentResult>,
getDocument: $getDocument,
patchDocument: $patchDocument as unknown as (ctx: { data: { entity: string; documentId: string; data: Record<string, any> } }) => Promise<void>,
searchDocuments: $searchDocuments,
uploadAttachment: $uploadAttachment as unknown as (ctx: { data: UploadAttachmentOpts }) => Promise<{ ok: true }>,
subscribe: $subscribe as unknown as (ctx: { data: SubscribeProps }) => Promise<void>,
notifyMe: $notifyMe as unknown as (ctx: { data: NotifyMeProps }) => Promise<void>,
} as const;
Expand Down
5 changes: 5 additions & 0 deletions src/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export {
setInvokeActions,
setInvokeLoaders,
} from "./invoke";
export {
DEFAULT_INTERNAL_ACTIONS,
type InvokePolicyOptions,
isInternalAction,
} from "./invokePolicy";
export { LIVE_CONTROLS_SCRIPT } from "./liveControls";
export { handleMeta, setMetaData } from "./meta";
export { handleRender, setPreviewWrapper, setRenderShell } from "./render";
Expand Down
57 changes: 57 additions & 0 deletions src/admin/invokePolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_INTERNAL_ACTIONS,
isInternalAction,
} from "./invokePolicy";

describe("invokePolicy — isInternalAction", () => {
it("denies the built-in generic MasterData CRUD actions by default", () => {
for (const name of DEFAULT_INTERNAL_ACTIONS) {
expect(isInternalAction(name)).toBe(true);
}
});

it("matches both key shapes the two exposure machines produce", () => {
// Machine 1 (invoke.gen.ts) keys are bare function names.
expect(isInternalAction("searchDocuments")).toBe(true);
// Machine 2 (setupApps) keys are module-path/fnName.
expect(isInternalAction("vtex/actions/masterData/searchDocuments")).toBe(true);
// .ts aliases the registrar also emits.
expect(isInternalAction("vtex/actions/masterData/searchDocuments.ts")).toBe(true);
});

it("allows ordinary actions", () => {
expect(isInternalAction("getOrCreateCart")).toBe(false);
expect(isInternalAction("vtex/actions/checkout/addItemsToCart")).toBe(false);
expect(isInternalAction("subscribe")).toBe(false);
});

it("honors a site-provided deny list (bare or full key)", () => {
expect(isInternalAction("dangerousThing", { deny: ["dangerousThing"] })).toBe(true);
expect(
isInternalAction("site/actions/dangerousThing", { deny: ["dangerousThing"] }),
).toBe(true);
expect(isInternalAction("safeThing", { deny: ["dangerousThing"] })).toBe(false);
});

it("lets an explicit allow entry override the default denylist", () => {
expect(isInternalAction("searchDocuments", { allow: ["searchDocuments"] })).toBe(false);
expect(
isInternalAction("vtex/actions/masterData/searchDocuments", {
allow: ["searchDocuments"],
}),
).toBe(false);
});

it("allow is surgical — only the named action is re-opened", () => {
const policy = { allow: ["searchDocuments"] };
expect(isInternalAction("searchDocuments", policy)).toBe(false);
expect(isInternalAction("createDocument", policy)).toBe(true);
});

it("allow wins over an explicit deny too", () => {
expect(
isInternalAction("thing", { deny: ["thing"], allow: ["thing"] }),
).toBe(false);
});
});
Loading