diff --git a/package-lock.json b/package-lock.json index 0742559..a0a2aed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@clawnify/clawflow", - "version": "0.9.2", + "version": "0.9.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clawnify/clawflow", - "version": "0.9.2", + "version": "0.9.4", "license": "MIT", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index 6576411..aa034b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clawnify/clawflow", - "version": "0.9.4", + "version": "0.9.5", "description": "The n8n for agents. A declarative, AI-native workflow format that agents can read, write, and run.", "type": "module", "main": "./dist/index.js", diff --git a/src/core/validate.ts b/src/core/validate.ts index 4e09133..b2dd1db 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -153,6 +153,25 @@ function validateNodes( } } +// ---- Allowed keys per node type (BaseNode keys are always allowed) ---------------- + +const BASE_KEYS = new Set(["name", "do", "output", "retry", "timeout"]); + +const ALLOWED_KEYS: Record> = { + ai: new Set([...BASE_KEYS, "prompt", "input", "schema", "model", "temperature", "maxTokens", "attachments"]), + agent: new Set([...BASE_KEYS, "task", "input", "tools", "agentId"]), + branch: new Set([...BASE_KEYS, "on", "paths", "default"]), + condition: new Set([...BASE_KEYS, "if", "then", "else"]), + loop: new Set([...BASE_KEYS, "over", "as", "nodes"]), + parallel: new Set([...BASE_KEYS, "nodes", "mode"]), + http: new Set([...BASE_KEYS, "url", "method", "body", "headers"]), + memory: new Set([...BASE_KEYS, "action", "key", "value"]), + wait: new Set([...BASE_KEYS, "for", "prompt", "preview", "event"]), + sleep: new Set([...BASE_KEYS, "duration"]), + code: new Set([...BASE_KEYS, "run", "input"]), + exec: new Set([...BASE_KEYS, "command", "cwd"]), +}; + /** Validate required fields per node type */ function validateNodeFields(node: FlowNode, errors: ValidationError[]): void { const e = (field: string, msg: string) => @@ -164,6 +183,16 @@ function validateNodeFields(node: FlowNode, errors: ValidationError[]): void { return; } + // Check for unknown keys + const allowed = ALLOWED_KEYS[nodeType]; + if (allowed) { + for (const key of Object.keys(node)) { + if (!allowed.has(key)) { + e(key, `Unknown field "${key}" on ${nodeType} node "${node.name}"`); + } + } + } + switch (nodeType) { case "ai": { const n = node as AiNode; diff --git a/tests/core.test.ts b/tests/core.test.ts index 7345588..614cedc 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1166,6 +1166,38 @@ describe("validateFlow", () => { assert.equal(result.ok, true); }); + it("catches branch path placed as sibling of paths", () => { + const flow: FlowDefinition = { + flow: "misplaced-branch-path", + nodes: [ + { name: "classify", do: "code" as const, run: "'densita'", output: "order_type" }, + { + name: "route", do: "branch" as const, on: "order_type", + paths: { + densita: [{ name: "d1", do: "code" as const, run: "'ok'", output: "x" }], + }, + // This is the bug: diametri is a sibling of paths, not inside it + diametri: [{ name: "d2", do: "code" as const, run: "'ok'", output: "y" }], + } as any, + ], + }; + const result = validateFlow(flow); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => e.message.includes("Unknown field \"diametri\""))); + }); + + it("catches unknown fields on any node type", () => { + const flow: FlowDefinition = { + flow: "unknown-field", + nodes: [ + { name: "bad", do: "ai" as const, prompt: "hello", bogus: true } as any, + ], + }; + const result = validateFlow(flow); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => e.message.includes("Unknown field \"bogus\""))); + }); + it("catches unknown node type", () => { const flow: FlowDefinition = { flow: "unknown-type",