diff --git a/docs-web/architecture/node-flow-foundation.md b/docs-web/architecture/node-flow-foundation.md index b18fd8f0cd..5d5d7119bd 100644 --- a/docs-web/architecture/node-flow-foundation.md +++ b/docs-web/architecture/node-flow-foundation.md @@ -18,7 +18,7 @@ A validated custom definition becomes executable only after its immutable artifa ## Validation, policy, and secrets -Validation resolves each definition and checks configuration, port handles and schemas, policies, graph bounds, and cycles. It rejects plaintext secret-shaped fields and generated or custom source in graph JSON. The dashboard receives credential binding ids and metadata-only states; resolved values remain behind the credential broker. Credential values and secret-shaped payloads are redacted before invocation messages, attempts, diagnostics, route responses, and debugger output are persisted or rendered. +Validation resolves each definition and checks configuration, port handles and schemas, policies, graph bounds, and cycles. Migrated Graph v1 and canonical Graph v2 inputs fail closed: malformed nodes, edges, ports, credential bindings, definition references, capabilities, policies, schemas, and metadata produce deterministic field-level issues at their original paths without discarding safe siblings. Repeated validation preserves issue ordering. It rejects plaintext secret-shaped fields and generated or custom source in graph JSON. The dashboard receives credential binding ids and metadata-only states; resolved values remain behind the credential broker. Credential values and secret-shaped payloads are redacted before invocation messages, attempts, diagnostics, route responses, and debugger output are persisted or rendered. Draft review reports requested capabilities, side effects, credential status, policy findings, and a non-executing dry run. Publication requires the current `draftRevision`, a valid graph and policy review, and every required credential binding. Runs select an immutable pinned or latest-published snapshot. diff --git a/docs-web/architecture/node-flows.md b/docs-web/architecture/node-flows.md index 12573fb52e..2faeb20b43 100644 --- a/docs-web/architecture/node-flows.md +++ b/docs-web/architecture/node-flows.md @@ -21,7 +21,7 @@ Authenticated dashboard routes resolve persisted project ownership from flow, ru | `webhook_trigger` | Emits secret-authenticated webhook input. | | `output` | Selects the result. | -These are the executable definitions. Other custom palette concepts remain non-executable until a versioned handler is registered. Graph v1 migration preserves the legacy snapshot and appends deterministic v2. +These are the executable definitions. Other custom palette concepts remain non-executable until a versioned handler is registered. Graph v1 migration preserves the legacy snapshot and appends deterministic v2. Both migrated v1 and canonical v2 graphs are validated as untrusted input: malformed nested members fail closed with stable paths at their original array indices, valid siblings are retained where safe, and repeated validation returns the same ordered issues instead of throwing. Execution uses immutable publications rather than the mutable editor row. Runs select a pinned publication or the latest published version, then use durable queue claims, leases, bounded quotas, timeout/cancellation propagation, and numbered retry attempts. Expired external attempts with unknown outcomes require operator attention and are not silently replayed. See [Node Flow Durable Execution](./node-flow-durable-execution.md). diff --git a/docs-web/content/docs/architecture-node-flow-foundation.mdx b/docs-web/content/docs/architecture-node-flow-foundation.mdx index b18fd8f0cd..5d5d7119bd 100644 --- a/docs-web/content/docs/architecture-node-flow-foundation.mdx +++ b/docs-web/content/docs/architecture-node-flow-foundation.mdx @@ -18,7 +18,7 @@ A validated custom definition becomes executable only after its immutable artifa ## Validation, policy, and secrets -Validation resolves each definition and checks configuration, port handles and schemas, policies, graph bounds, and cycles. It rejects plaintext secret-shaped fields and generated or custom source in graph JSON. The dashboard receives credential binding ids and metadata-only states; resolved values remain behind the credential broker. Credential values and secret-shaped payloads are redacted before invocation messages, attempts, diagnostics, route responses, and debugger output are persisted or rendered. +Validation resolves each definition and checks configuration, port handles and schemas, policies, graph bounds, and cycles. Migrated Graph v1 and canonical Graph v2 inputs fail closed: malformed nodes, edges, ports, credential bindings, definition references, capabilities, policies, schemas, and metadata produce deterministic field-level issues at their original paths without discarding safe siblings. Repeated validation preserves issue ordering. It rejects plaintext secret-shaped fields and generated or custom source in graph JSON. The dashboard receives credential binding ids and metadata-only states; resolved values remain behind the credential broker. Credential values and secret-shaped payloads are redacted before invocation messages, attempts, diagnostics, route responses, and debugger output are persisted or rendered. Draft review reports requested capabilities, side effects, credential status, policy findings, and a non-executing dry run. Publication requires the current `draftRevision`, a valid graph and policy review, and every required credential binding. Runs select an immutable pinned or latest-published snapshot. diff --git a/docs-web/content/docs/architecture-node-flows.mdx b/docs-web/content/docs/architecture-node-flows.mdx index f846041fde..5088f8aca8 100644 --- a/docs-web/content/docs/architecture-node-flows.mdx +++ b/docs-web/content/docs/architecture-node-flows.mdx @@ -21,7 +21,7 @@ Authenticated dashboard routes resolve persisted project ownership from flow, ru | `webhook_trigger` | Emits secret-authenticated webhook input. | | `output` | Selects the result. | -These are the executable definitions. Other custom palette concepts remain non-executable until a versioned handler is registered. Graph v1 migration preserves the legacy snapshot and appends deterministic v2. +These are the executable definitions. Other custom palette concepts remain non-executable until a versioned handler is registered. Graph v1 migration preserves the legacy snapshot and appends deterministic v2. Both migrated v1 and canonical v2 graphs are validated as untrusted input: malformed nested members fail closed with stable paths at their original array indices, valid siblings are retained where safe, and repeated validation returns the same ordered issues instead of throwing. Execution uses immutable publications rather than the mutable editor row. Runs select a pinned publication or the latest published version, then use durable queue claims, leases, bounded quotas, timeout/cancellation propagation, and numbered retry attempts. Expired external attempts with unknown outcomes require operator attention and are not silently replayed. See [Node Flow Durable Execution](/docs/architecture-node-flow-durable-execution). diff --git a/docs/architecture/node-flow-foundation.md b/docs/architecture/node-flow-foundation.md index 0160fc5227..9d9d99f833 100644 --- a/docs/architecture/node-flow-foundation.md +++ b/docs/architecture/node-flow-foundation.md @@ -26,9 +26,12 @@ Run records are persisted as `NodeFlowRunRecord` and `NodeFlowNodeRunRecord`. Bo - the graph is acyclic and has a deterministic execution order - widget fields have required id, label, type, and select option metadata - widget default values are JSON-safe and match the field type +- malformed v1 and v2 node, edge, port, credential-binding, definition-reference, capability, policy, schema, and metadata members produce stable issues at their original paths Invalid graphs throw a `ValidationError` with field-level details when persistence is attempted. The validation route returns the same structured issue list without writing data. +Normalization is fail closed and entry oriented. It retains safe siblings, does not renumber issue paths after rejecting an earlier array member, and returns issues in deterministic traversal order so repeated validation is stable. + ## Runtime `src/services/node-flow-runtime-service.ts` executes the current flow version after revalidating the graph. It creates a parent `execution_invocations` row with `type = "node_flow"`, then runs nodes in the validator's topological order. diff --git a/docs/architecture/node-flows.md b/docs/architecture/node-flows.md index 0dd621f925..e43ec92522 100644 --- a/docs/architecture/node-flows.md +++ b/docs/architecture/node-flows.md @@ -35,6 +35,8 @@ A `NodeFlowGraph` contains: Validation is owned by `src/domain/node-flows/node-flow-validation.ts`. It normalizes ids, labels, positions, widget defaults, and graph shape; rejects missing node/edge arrays; rejects duplicate node ids; rejects edges that point at missing nodes; requires at least one node; and rejects cycles. Widget validation supports `text`, `textarea`, `number`, `boolean`, `select`, `json`, `secretRef`, and `keyValue` fields. +Migration and validation treat persisted Graph v1 and canonical Graph v2 as untrusted input. Malformed collection members are rejected at their original index, such as `nodes[1].ports[0]` or `edges[2]`, while structurally valid siblings remain available to the rest of normalization. Definition references, credential bindings, capabilities, policies, port and graph schemas, and JSON metadata emit deterministic field-level issues instead of throwing. Revalidating the same graph produces the same ordered issue list. + Validation requires every node's type/version reference to resolve through the registry and rejects unknown definitions. Runtime execution then dispatches according to the registered definition's executable state and execution kind; a planning concept is not runnable merely because it has a string type. The dashboard uses the same backend-owned Graph v2 record as the runtime. The selected project controls library loading; no project means no flow, credential, publication, or run requests. The versioned registry drives palette entries, typed ports, configuration widgets, credential slots, capabilities, side-effect review, and policies. Draft saves use optimistic `draftRevision` checks and surface conflicts without overwriting the newer record. @@ -119,7 +121,7 @@ Graph v2 is the single workflow model used by backend, MCP, runtime, and dashboa The executable registry contains the original deterministic/provider/HTTP nodes plus `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, and `webhook_trigger`. Unregistered custom types remain non-executable. -Backend Graph v1 migration retains the exact prior version and appends deterministic v2. Browser canvas v1 migration returns the untouched legacy snapshot separately from the normalized graph. +Backend Graph v1 migration retains the exact prior version and appends deterministic v2. Invalid legacy members are carried across the migration boundary so validation can report their original paths rather than silently dropping them. Browser canvas v1 migration returns the untouched legacy snapshot separately from the normalized graph. ## Dashboard and security prerequisites diff --git a/src/domain/node-flows/node-flow-migrators.ts b/src/domain/node-flows/node-flow-migrators.ts index 527ae746c3..009379563f 100644 --- a/src/domain/node-flows/node-flow-migrators.ts +++ b/src/domain/node-flows/node-flow-migrators.ts @@ -13,58 +13,91 @@ export interface NodeFlowMigrationResult { legacySnapshot: TLegacy | null; } -const cloneJson = (value: T): T => JSON.parse(JSON.stringify(value)) as T; +function cloneUntrusted(value: T, seen = new WeakMap()): T { + if (!value || typeof value !== "object") return value; + const cached = seen.get(value); + if (cached !== undefined) return cached as T; + if (Array.isArray(value)) { + const clone: unknown[] = []; + seen.set(value, clone); + value.forEach((entry) => clone.push(cloneUntrusted(entry, seen))); + return clone as T; + } + const clone: Record = {}; + seen.set(value, clone); + Object.entries(value).forEach(([key, entry]) => { + clone[key] = cloneUntrusted(entry, seen); + }); + return clone as T; +} export function migrateNodeFlowGraph(graph: unknown): NodeFlowMigrationResult { if (isRecord(graph) && graph.schemaVersion === NODE_FLOW_SCHEMA_VERSION) { - return { graph: cloneJson(graph as unknown as NodeFlowGraph), migrated: false, legacySnapshot: null }; + return { graph: cloneUntrusted(graph) as unknown as NodeFlowGraph, migrated: false, legacySnapshot: null }; } - const legacy = isRecord(graph) ? cloneJson(graph as unknown as NodeFlowGraph) : { nodes: [], edges: [] }; - const nodes = Array.isArray(legacy.nodes) ? legacy.nodes.map(migrateNode) : []; - const definitionByNode = new Map(nodes.map((node) => [node.id, resolveLatestNodeDefinition(node.type)])); - const edges = Array.isArray(legacy.edges) ? legacy.edges.map((edge) => ({ - ...edge, - ...(edge.fromHandle ? {} : { fromHandle: definitionByNode.get(edge.fromNodeId)?.ports.find((port) => port.direction === "output")?.id }), - ...(edge.toHandle ? {} : { toHandle: definitionByNode.get(edge.toNodeId)?.ports.find((port) => port.direction === "input")?.id }), - })) : []; + const legacy = isRecord(graph) ? cloneUntrusted(graph) : {}; + const nodes = Array.isArray(legacy.nodes) + ? legacy.nodes.map((node) => isRecord(node) ? migrateNode(node) : node) + : legacy.nodes; + const definitionByNode = new Map( + (Array.isArray(nodes) ? nodes : []) + .filter(isRecord) + .map((node) => [stringValue(node.id), resolveLatestNodeDefinition(stringValue(node.type))]), + ); + const edges = Array.isArray(legacy.edges) ? legacy.edges.map((edge) => { + if (!isRecord(edge)) return edge; + const fromNodeId = stringValue(edge.fromNodeId); + const toNodeId = stringValue(edge.toNodeId); + return { + ...edge, + ...(edge.fromHandle ? {} : { fromHandle: definitionByNode.get(fromNodeId)?.ports.find((port) => port.direction === "output")?.id }), + ...(edge.toHandle ? {} : { toHandle: definitionByNode.get(toNodeId)?.ports.find((port) => port.direction === "input")?.id }), + }; + }) : legacy.edges; return { migrated: true, - legacySnapshot: legacy, + legacySnapshot: legacy as unknown as NodeFlowGraph, graph: { schemaVersion: NODE_FLOW_SCHEMA_VERSION, nodes, edges, - ...(legacy.inputSchema ? { inputSchema: legacy.inputSchema } : {}), - ...(legacy.schemas ? { schemas: legacy.schemas } : {}), - ...(legacy.metadata ? { metadata: legacy.metadata } : {}), - ...(legacy.publication ? { publication: legacy.publication } : {}), - }, + ...(legacy.inputSchema !== undefined ? { inputSchema: legacy.inputSchema } : {}), + ...(legacy.schemas !== undefined ? { schemas: legacy.schemas } : {}), + ...(legacy.metadata !== undefined ? { metadata: legacy.metadata } : {}), + ...(legacy.publication !== undefined ? { publication: legacy.publication } : {}), + } as unknown as NodeFlowGraph, }; } -function migrateNode(node: NodeFlowNode): NodeFlowNode { - const definition = node.definition - ? resolveLatestNodeDefinition(node.definition.type) - : resolveLatestNodeDefinition(node.type); +function migrateNode(node: Record): NodeFlowNode { + const definition = isRecord(node.definition) + ? resolveLatestNodeDefinition(stringValue(node.definition.type)) + : resolveLatestNodeDefinition(stringValue(node.type)); return { - ...node, - definition: node.definition ?? { type: node.type, version: definition?.version ?? 1 }, - ports: node.ports ?? definition?.ports.map((port) => cloneJson(port)) ?? [], - credentialBindings: node.credentialBindings ?? [], - policy: node.policy ?? (definition ? cloneJson(definition.defaultPolicy) : {}), - capabilities: node.capabilities ?? [...(definition?.capabilities ?? [])], - sideEffect: node.sideEffect ?? definition?.sideEffect ?? "none", - disabled: node.disabled ?? false, - }; + ...node as unknown as NodeFlowNode, + definition: node.definition !== undefined + ? node.definition as NodeFlowNode["definition"] + : { type: stringValue(node.type), version: definition?.version ?? 1 }, + ports: node.ports !== undefined + ? node.ports as NodeFlowNode["ports"] + : definition?.ports.map((port) => cloneUntrusted(port)) ?? [], + credentialBindings: node.credentialBindings !== undefined ? node.credentialBindings : [], + policy: node.policy !== undefined + ? node.policy as NodeFlowNode["policy"] + : definition ? cloneUntrusted(definition.defaultPolicy) : {}, + capabilities: node.capabilities !== undefined ? node.capabilities : [...(definition?.capabilities ?? [])], + sideEffect: node.sideEffect !== undefined ? node.sideEffect : definition?.sideEffect ?? "none", + disabled: node.disabled !== undefined ? node.disabled : false, + } as NodeFlowNode; } export function migrateNodeCanvasGraphV1(graph: unknown): NodeFlowMigrationResult { if (isRecord(graph) && graph.schemaVersion === NODE_FLOW_SCHEMA_VERSION && Array.isArray(graph.nodes) && graph.nodes.every(isCanonicalNode)) { return migrateNodeFlowGraph(graph); } - const legacySnapshot = cloneJson(graph); + const legacySnapshot = cloneUntrusted(graph); const legacy = isRecord(graph) ? graph : {}; const nodes = Array.isArray(legacy.nodes) ? legacy.nodes.filter(isRecord).map((node): NodeFlowNode => ({ id: stringValue(node.id), @@ -87,7 +120,7 @@ export function migrateNodeCanvasGraphV1(graph: unknown): NodeFlowMigrationResul return { migrated: true, legacySnapshot, - graph: { schemaVersion: NODE_FLOW_SCHEMA_VERSION, nodes, edges, metadata: { canvasSelection: jsonValue(legacy.selection) } }, + graph: { schemaVersion: NODE_FLOW_SCHEMA_VERSION, nodes, edges, metadata: { canvasSelection: untrustedJsonValue(legacy.selection) } }, }; } @@ -98,11 +131,11 @@ function readPorts(value: unknown, direction: "input" | "output") { } function canvasNodeData(node: Record): NodeFlowJsonObject { - const config = Array.isArray(node.config) ? Object.fromEntries(node.config.filter(isRecord).map((entry) => [stringValue(entry.id), jsonValue(entry.value)]).filter(([id]) => id)) : {}; - return { config, canvasMetadata: jsonValue(node.metadata) }; + const config = Array.isArray(node.config) ? Object.fromEntries(node.config.filter(isRecord).map((entry) => [stringValue(entry.id), untrustedJsonValue(entry.value)]).filter(([id]) => id)) : {}; + return { config, canvasMetadata: untrustedJsonValue(node.metadata) }; } -const jsonValue = (value: unknown): NodeFlowJsonValue => JSON.parse(JSON.stringify(value ?? null)) as NodeFlowJsonValue; +const untrustedJsonValue = (value: unknown): NodeFlowJsonValue => cloneUntrusted(value ?? null) as NodeFlowJsonValue; const endpointValue = (value: unknown, key: string): string => isRecord(value) ? stringValue(value[key]) : ""; const stringValue = (value: unknown): string => typeof value === "string" ? value : ""; const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === "object" && !Array.isArray(value); diff --git a/src/domain/node-flows/node-flow-validation.ts b/src/domain/node-flows/node-flow-validation.ts index ad98c35254..a7407b3a2f 100644 --- a/src/domain/node-flows/node-flow-validation.ts +++ b/src/domain/node-flows/node-flow-validation.ts @@ -5,6 +5,10 @@ import type { NodeFlowJsonObject, NodeFlowJsonValue, NodeFlowNode, + NodeFlowPort, + NodeFlowExecutionPolicy, + NodeFlowDefinitionReference, + NodeFlowSchemas, NodeFlowValidationIssue, NodeFlowValidationResponse, NodeFlowValueSchema, @@ -30,6 +34,9 @@ const WIDGET_FIELD_TYPES = new Set([ "secretRef", "keyValue", ]); +const VALUE_SCHEMA_TYPES = new Set([ + "any", "object", "array", "string", "number", "boolean", "null", +]); export class NodeFlowValidationError extends ValidationError { readonly details: NodeFlowValidationIssue[]; @@ -96,6 +103,135 @@ function isPlainJsonObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeJsonValue( + value: unknown, + path: string, + issues: NodeFlowValidationIssue[], + ancestors = new Set(), +): NodeFlowJsonValue | undefined { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (!value || typeof value !== "object") { + issues.push(issue(path, "invalid_json_value", "Value must be JSON-serializable and finite.")); + return undefined; + } + if (ancestors.has(value)) { + issues.push(issue(path, "invalid_json_value", "Value must not contain circular references.")); + return undefined; + } + ancestors.add(value); + if (Array.isArray(value)) { + const normalized = value.map((entry, index) => normalizeJsonValue(entry, `${path}[${index}]`, issues, ancestors) ?? null); + ancestors.delete(value); + return normalized; + } + const normalized: NodeFlowJsonObject = {}; + Object.entries(value).forEach(([key, entry]) => { + const normalizedEntry = normalizeJsonValue(entry, `${path}.${key}`, issues, ancestors); + if (normalizedEntry !== undefined) normalized[key] = normalizedEntry; + }); + ancestors.delete(value); + return normalized; +} + +function normalizeJsonObject( + value: unknown, + path: string, + code: string, + message: string, + issues: NodeFlowValidationIssue[], +): NodeFlowJsonObject | undefined { + if (!isRecord(value)) { + issues.push(issue(path, code, message)); + return undefined; + } + return normalizeJsonValue(value, path, issues) as NodeFlowJsonObject; +} + +function normalizeValueSchema( + value: unknown, + path: string, + issues: NodeFlowValidationIssue[], + ancestors = new Set(), +): NodeFlowValueSchema | null { + if (!isRecord(value)) { + issues.push(issue(path, "invalid_value_schema", "Value schema must be an object.")); + return null; + } + if (ancestors.has(value)) { + issues.push(issue(path, "invalid_value_schema", "Value schema must not contain circular references.")); + return null; + } + ancestors.add(value); + if (!VALUE_SCHEMA_TYPES.has(value.type as NodeFlowValueSchema["type"])) { + issues.push(issue(`${path}.type`, "invalid_schema_type", "Value schema type is invalid.")); + ancestors.delete(value); + return null; + } + const schema: NodeFlowValueSchema = { type: value.type as NodeFlowValueSchema["type"] }; + if (value.description !== undefined) { + if (typeof value.description === "string") schema.description = value.description.trim(); + else issues.push(issue(`${path}.description`, "invalid_schema_description", "Value schema description must be a string.")); + } + if (value.required !== undefined) { + if (!Array.isArray(value.required)) { + issues.push(issue(`${path}.required`, "invalid_schema_required", "Value schema required must be an array.")); + } else { + schema.required = value.required.flatMap((entry, index) => { + const required = trimmedString(entry); + if (!required) { + issues.push(issue(`${path}.required[${index}]`, "invalid_schema_required", "Required property name must be a non-empty string.")); + return []; + } + return [required]; + }); + } + } + if (value.properties !== undefined) { + if (!isRecord(value.properties)) { + issues.push(issue(`${path}.properties`, "invalid_schema_properties", "Value schema properties must be an object.")); + } else { + const properties: Record = {}; + Object.entries(value.properties).forEach(([key, property]) => { + const normalized = normalizeValueSchema(property, `${path}.properties.${key}`, issues, ancestors); + if (normalized) properties[key] = normalized; + }); + schema.properties = properties; + } + } + if (value.items !== undefined) { + const items = normalizeValueSchema(value.items, `${path}.items`, issues, ancestors); + if (items) schema.items = items; + } + ancestors.delete(value); + return schema; +} + +function normalizeGraphSchemas( + value: unknown, + issues: NodeFlowValidationIssue[], +): NodeFlowSchemas | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) { + issues.push(issue("schemas", "invalid_schemas", "Graph schemas must be an object.")); + return undefined; + } + const schemas: NodeFlowSchemas = {}; + if (value.input !== undefined) { + const input = normalizeValueSchema(value.input, "schemas.input", issues); + if (input) schemas.input = input; + } + if (value.output !== undefined) { + const output = normalizeValueSchema(value.output, "schemas.output", issues); + if (output) schemas.output = output; + } + return schemas; +} + function validateDefaultValue( field: NodeWidgetField, fieldPath: string, @@ -259,41 +395,78 @@ function containsForbiddenGraphValue(value: NodeFlowJsonValue): boolean { } function validatePolicy( - policy: NodeFlowNode["policy"], + policy: unknown, path: string, issues: NodeFlowValidationIssue[], -): void { - if (!policy) return; +): NodeFlowExecutionPolicy | undefined { + if (policy === undefined) return undefined; + if (!isRecord(policy)) { + issues.push(issue(path, "invalid_policy", "Node policy must be an object.")); + return undefined; + } + const normalized: NodeFlowExecutionPolicy = {}; const retry = policy.retry; - if (retry && (!Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1 || retry.maxAttempts > 10)) { + if (retry !== undefined && !isRecord(retry)) { + issues.push(issue(`${path}.retry`, "invalid_retry_policy", "Retry policy must be an object.")); + } + if (isRecord(retry) && (!Number.isInteger(retry.maxAttempts) || (retry.maxAttempts as number) < 1 || (retry.maxAttempts as number) > 10)) { issues.push(issue(`${path}.retry.maxAttempts`, "invalid_retry_policy", "Retry maxAttempts must be an integer from 1 to 10.")); } - if (retry && (!Number.isFinite(retry.backoffMs) || retry.backoffMs < 0 || retry.backoffMs > 300_000)) { + if (isRecord(retry) && (!Number.isFinite(retry.backoffMs) || (retry.backoffMs as number) < 0 || (retry.backoffMs as number) > 300_000)) { issues.push(issue(`${path}.retry.backoffMs`, "invalid_retry_policy", "Retry backoffMs must be between 0 and 300000.")); } - if (retry?.maxBackoffMs !== undefined && (!Number.isFinite(retry.maxBackoffMs) || retry.maxBackoffMs < retry.backoffMs)) { + if (isRecord(retry) && retry.maxBackoffMs !== undefined && (!Number.isFinite(retry.maxBackoffMs) || (retry.maxBackoffMs as number) < (retry.backoffMs as number))) { issues.push(issue(`${path}.retry.maxBackoffMs`, "invalid_retry_policy", "Retry maxBackoffMs must be at least backoffMs.")); } - if (policy.timeout && (!Number.isInteger(policy.timeout.timeoutMs) || policy.timeout.timeoutMs < 1 || policy.timeout.timeoutMs > 300_000)) { + if (isRecord(retry)) { + normalized.retry = { + maxAttempts: Number.isInteger(retry.maxAttempts) ? retry.maxAttempts as number : 1, + backoffMs: typeof retry.backoffMs === "number" && Number.isFinite(retry.backoffMs) ? retry.backoffMs : 0, + ...(typeof retry.maxBackoffMs === "number" && Number.isFinite(retry.maxBackoffMs) ? { maxBackoffMs: retry.maxBackoffMs } : {}), + }; + } + if (policy.timeout !== undefined && !isRecord(policy.timeout)) { + issues.push(issue(`${path}.timeout`, "invalid_timeout_policy", "Timeout policy must be an object.")); + } + if (isRecord(policy.timeout) && (!Number.isInteger(policy.timeout.timeoutMs) || (policy.timeout.timeoutMs as number) < 1 || (policy.timeout.timeoutMs as number) > 300_000)) { issues.push(issue(`${path}.timeout.timeoutMs`, "invalid_timeout_policy", "Timeout must be an integer from 1 to 300000 milliseconds.")); } + if (isRecord(policy.timeout)) { + normalized.timeout = { timeoutMs: Number.isInteger(policy.timeout.timeoutMs) ? policy.timeout.timeoutMs as number : 1 }; + } + return normalized; } function validateCredentialBindings( - node: NodeFlowNode, + value: unknown, nodePath: string, allowedSlots: string[], issues: NodeFlowValidationIssue[], -): void { +): NodeFlowNode["credentialBindings"] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + issues.push(issue(`${nodePath}.credentialBindings`, "invalid_credential_bindings", "Credential bindings must be an array.")); + return []; + } const slots = new Set(); - (node.credentialBindings ?? []).forEach((binding, index) => { + const normalized: NonNullable = []; + value.forEach((binding, index) => { const path = `${nodePath}.credentialBindings[${index}]`; - if (!trimmedString(binding.slot)) issues.push(issue(`${path}.slot`, "required", "Credential slot is required.")); - if (!trimmedString(binding.credentialId)) issues.push(issue(`${path}.credentialId`, "required", "Credential binding must reference a credential id.")); - if (slots.has(binding.slot)) issues.push(issue(`${path}.slot`, "duplicate_credential_slot", `Duplicate credential slot: ${binding.slot}`)); - if (!allowedSlots.includes(binding.slot)) issues.push(issue(`${path}.slot`, "unknown_credential_slot", `Definition does not declare credential slot: ${binding.slot}`)); - slots.add(binding.slot); + if (!isRecord(binding)) { + issues.push(issue(path, "invalid_credential_binding", "Credential binding must be an object.")); + return; + } + const slot = trimmedString(binding.slot); + const credentialId = trimmedString(binding.credentialId); + if (!slot) issues.push(issue(`${path}.slot`, "required", "Credential slot is required.")); + if (!credentialId) issues.push(issue(`${path}.credentialId`, "required", "Credential binding must reference a credential id.")); + if (!slot || !credentialId) return; + if (slots.has(slot)) issues.push(issue(`${path}.slot`, "duplicate_credential_slot", `Duplicate credential slot: ${slot}`)); + if (!allowedSlots.includes(slot)) issues.push(issue(`${path}.slot`, "unknown_credential_slot", `Definition does not declare credential slot: ${slot}`)); + slots.add(slot); + normalized.push({ slot, credentialId }); }); + return normalized; } function validateConfiguration( @@ -324,9 +497,93 @@ function matchesValueSchema(value: NodeFlowJsonValue, type: NodeFlowValueSchema[ return typeof value === type; } -function normalizeNode(rawNode: NodeFlowNode, index: number, issues: NodeFlowValidationIssue[]): NodeFlowNode | null { +function normalizeDefinitionReference( + value: unknown, + fallbackType: string, + path: string, + issues: NodeFlowValidationIssue[], +): { reference: NodeFlowDefinitionReference; structurallyValid: boolean } { + if (value === undefined) return { reference: { type: fallbackType, version: 1 }, structurallyValid: true }; + if (!isRecord(value)) { + issues.push(issue(path, "invalid_definition_reference", "Node definition reference must be an object.")); + return { reference: { type: fallbackType, version: 1 }, structurallyValid: false }; + } + const type = trimmedString(value.type); + const version = value.version; + if (!type) issues.push(issue(`${path}.type`, "required", "Node definition type is required.")); + if (!Number.isInteger(version) || (version as number) < 1) { + issues.push(issue(`${path}.version`, "invalid_definition_version", "Node definition version must be a positive integer.")); + } + return { + reference: { type: type ?? fallbackType, version: Number.isInteger(version) && (version as number) > 0 ? version as number : 1 }, + structurallyValid: Boolean(type) && Number.isInteger(version) && (version as number) > 0, + }; +} + +function normalizePorts( + value: unknown, + fallback: readonly NodeFlowPort[], + nodePath: string, + issues: NodeFlowValidationIssue[], +): NodeFlowPort[] { + if (value === undefined) return fallback.map((port) => ({ ...port })); + if (!Array.isArray(value)) { + issues.push(issue(`${nodePath}.ports`, "invalid_ports", "Node ports must be an array.")); + return []; + } + const portIds = new Set(); + const normalized: NodeFlowPort[] = []; + value.forEach((port, portIndex) => { + const portPath = `${nodePath}.ports[${portIndex}]`; + if (!isRecord(port)) { + issues.push(issue(portPath, "invalid_port", "Port must be an object.")); + return; + } + const id = trimmedString(port.id); + if (!id) issues.push(issue(`${portPath}.id`, "required", "Port id is required.")); + if (id && portIds.has(id)) issues.push(issue(`${portPath}.id`, "duplicate_port_id", `Duplicate port id: ${id}`)); + if (id) portIds.add(id); + const direction = port.direction === "input" || port.direction === "output" ? port.direction : null; + if (!direction) issues.push(issue(`${portPath}.direction`, "invalid_port_direction", "Port direction must be input or output.")); + const schema = normalizeValueSchema(port.schema, `${portPath}.schema`, issues); + if (port.required !== undefined && typeof port.required !== "boolean") { + issues.push(issue(`${portPath}.required`, "invalid_port_required", "Port required must be a boolean.")); + } + const cardinality = port.cardinality === undefined || port.cardinality === "one" || port.cardinality === "many" + ? port.cardinality + : null; + if (cardinality === null) issues.push(issue(`${portPath}.cardinality`, "invalid_port_cardinality", "Port cardinality must be one or many.")); + if (!id || !direction || !schema) return; + normalized.push({ + id, + direction, + schema, + ...(typeof port.required === "boolean" ? { required: port.required } : {}), + ...(cardinality ? { cardinality } : {}), + }); + }); + return normalized; +} + +function normalizeCapabilities(value: unknown, path: string, issues: NodeFlowValidationIssue[]): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) { + issues.push(issue(path, "invalid_capabilities", "Node capabilities must be an array.")); + return []; + } + return value.flatMap((entry, index) => { + const capability = trimmedString(entry); + if (!capability) { + issues.push(issue(`${path}[${index}]`, "invalid_capability", "Capability must be a non-empty string.")); + return []; + } + return [capability]; + }); +} + +function normalizeNode(rawNode: unknown, index: number, issues: NodeFlowValidationIssue[]): NodeFlowNode | null { const nodePath = `nodes[${index}]`; - if (!rawNode || typeof rawNode !== "object" || Array.isArray(rawNode)) { + if (!isRecord(rawNode)) { issues.push(issue(nodePath, "invalid_node", "Node must be an object.")); return null; } @@ -346,8 +603,8 @@ function normalizeNode(rawNode: NodeFlowNode, index: number, issues: NodeFlowVal return null; } - const widgetSchema = normalizeWidgetSchema(rawNode.widgetSchema, `${nodePath}.widgetSchema`, issues); - const position = rawNode.position + const widgetSchema = normalizeWidgetSchema(rawNode.widgetSchema as NodeWidgetSchema | undefined, `${nodePath}.widgetSchema`, issues); + const position = isRecord(rawNode.position) && typeof rawNode.position.x === "number" && Number.isFinite(rawNode.position.x) && typeof rawNode.position.y === "number" @@ -357,61 +614,64 @@ function normalizeNode(rawNode: NodeFlowNode, index: number, issues: NodeFlowVal if (rawNode.position !== undefined && !position) { issues.push(issue(`${nodePath}.position`, "invalid_position", `Node ${id} position must include finite x and y numbers.`)); } - if (rawNode.data !== undefined && !isPlainJsonObject(rawNode.data)) { - issues.push(issue(`${nodePath}.data`, "invalid_data", `Node ${id} data must be a JSON object.`)); - } + const data = rawNode.data === undefined + ? undefined + : normalizeJsonObject(rawNode.data, `${nodePath}.data`, "invalid_data", `Node ${id} data must be a JSON object.`, issues); - const definitionRef = rawNode.definition ?? { type, version: 1 }; - const definition = resolveNodeDefinition(definitionRef.type, definitionRef.version); - if (definitionRef.type !== type) { + const { reference: definitionRef, structurallyValid: validDefinitionRef } = normalizeDefinitionReference( + rawNode.definition, + type, + `${nodePath}.definition`, + issues, + ); + const definition = validDefinitionRef ? resolveNodeDefinition(definitionRef.type, definitionRef.version) : undefined; + if (validDefinitionRef && definitionRef.type !== type) { issues.push(issue(`${nodePath}.definition.type`, "definition_type_mismatch", "Node type must match its definition reference.")); } - if (!definition) { + if (validDefinitionRef && !definition) { issues.push(issue(`${nodePath}.definition`, "unknown_node_definition", `Unknown node definition: ${definitionRef.type}@${definitionRef.version}`)); } if (definition && rawNode.sideEffect !== undefined && rawNode.sideEffect !== definition.sideEffect) { issues.push(issue(`${nodePath}.sideEffect`, "definition_metadata_mismatch", "Node side effect must match its definition.")); } - if (definition && rawNode.capabilities !== undefined && [...rawNode.capabilities].sort().join("\0") !== [...definition.capabilities].sort().join("\0")) { + const capabilities = normalizeCapabilities(rawNode.capabilities, `${nodePath}.capabilities`, issues); + if (definition && capabilities !== undefined && [...capabilities].sort().join("\0") !== [...definition.capabilities].sort().join("\0")) { issues.push(issue(`${nodePath}.capabilities`, "definition_metadata_mismatch", "Node capabilities must match its definition.")); } - const ports = rawNode.ports ?? definition?.ports ?? []; - const portIds = new Set(); - ports.forEach((port, portIndex) => { - const portPath = `${nodePath}.ports[${portIndex}]`; - if (!trimmedString(port.id)) issues.push(issue(`${portPath}.id`, "required", "Port id is required.")); - if (portIds.has(port.id)) issues.push(issue(`${portPath}.id`, "duplicate_port_id", `Duplicate port id: ${port.id}`)); - portIds.add(port.id); - if (port.direction !== "input" && port.direction !== "output") issues.push(issue(`${portPath}.direction`, "invalid_port_direction", "Port direction must be input or output.")); - }); - validatePolicy(rawNode.policy, `${nodePath}.policy`, issues); - validateCredentialBindings(rawNode, nodePath, definition?.credentials.map((credential) => credential.slot) ?? [], issues); - if (rawNode.data && containsForbiddenGraphValue(rawNode.data)) { + const ports = normalizePorts(rawNode.ports, definition?.ports ?? [], nodePath, issues); + const policy = validatePolicy(rawNode.policy, `${nodePath}.policy`, issues); + const credentialBindings = validateCredentialBindings( + rawNode.credentialBindings, + nodePath, + definition?.credentials.map((credential) => credential.slot) ?? [], + issues, + ); + if (data && containsForbiddenGraphValue(data)) { issues.push(issue(`${nodePath}.data`, "unsafe_graph_data", "Graph data cannot contain raw secrets or custom source code.")); } - if (definition) validateConfiguration(isPlainJsonObject(rawNode.data) ? rawNode.data : {}, definition.configurationSchema, nodePath, issues); + if (definition) validateConfiguration(data ?? {}, definition.configurationSchema, nodePath, issues); return { id, type, title: title ?? id, - ...(rawNode.description !== undefined ? { description: String(rawNode.description).trim() } : {}), + ...(typeof rawNode.description === "string" ? { description: rawNode.description.trim() } : {}), ...(widgetSchema ? { widgetSchema } : {}), ...(position ? { position } : {}), - ...(rawNode.data !== undefined && isPlainJsonObject(rawNode.data) ? { data: rawNode.data } : {}), + ...(data ? { data } : {}), definition: definitionRef, ports, - credentialBindings: rawNode.credentialBindings ?? [], - policy: rawNode.policy ?? definition?.defaultPolicy ?? {}, - capabilities: definition?.capabilities ?? rawNode.capabilities ?? [], - sideEffect: definition?.sideEffect ?? rawNode.sideEffect ?? "none", - disabled: rawNode.disabled ?? false, + credentialBindings, + policy: policy ?? definition?.defaultPolicy ?? {}, + capabilities: definition?.capabilities ?? capabilities ?? [], + sideEffect: definition?.sideEffect ?? (rawNode.sideEffect as NodeFlowNode["sideEffect"] ?? "none"), + disabled: typeof rawNode.disabled === "boolean" ? rawNode.disabled : false, }; } -function normalizeEdge(rawEdge: NodeFlowEdge, index: number, issues: NodeFlowValidationIssue[]): NodeFlowEdge | null { +function normalizeEdge(rawEdge: unknown, index: number, issues: NodeFlowValidationIssue[]): NodeFlowEdge | null { const edgePath = `edges[${index}]`; - if (!rawEdge || typeof rawEdge !== "object" || Array.isArray(rawEdge)) { + if (!isRecord(rawEdge)) { issues.push(issue(edgePath, "invalid_edge", "Edge must be an object.")); return null; } @@ -500,9 +760,11 @@ export function validateNodeFlowGraph(graph: unknown): NodeFlowValidationRespons const nodes = Array.isArray(rawGraph.nodes) ? rawGraph.nodes.map((node, index) => normalizeNode(node, index, issues)).filter((node): node is NodeFlowNode => Boolean(node)) : []; - const edges = Array.isArray(rawGraph.edges) - ? rawGraph.edges.map((edge, index) => normalizeEdge(edge, index, issues)).filter((edge): edge is NodeFlowEdge => Boolean(edge)) + const normalizedEdges = Array.isArray(rawGraph.edges) + ? rawGraph.edges.map((edge, index) => ({ edge: normalizeEdge(edge, index, issues), sourceIndex: index })) + .filter((entry): entry is { edge: NodeFlowEdge; sourceIndex: number } => Boolean(entry.edge)) : []; + const edges = normalizedEdges.map((entry) => entry.edge); if (nodes.length === 0) { issues.push(issue("nodes", "required", "Node flow graph requires at least one node.")); @@ -516,28 +778,29 @@ export function validateNodeFlowGraph(graph: unknown): NodeFlowValidationRespons nodeIds.add(node.id); } - edges.forEach((edge, index) => { + normalizedEdges.forEach(({ edge, sourceIndex }) => { if (!nodeIds.has(edge.fromNodeId)) { - issues.push(issue(`edges[${index}].fromNodeId`, "invalid_edge_endpoint", `Edge source node does not exist: ${edge.fromNodeId}`)); + issues.push(issue(`edges[${sourceIndex}].fromNodeId`, "invalid_edge_endpoint", `Edge source node does not exist: ${edge.fromNodeId}`)); } if (!nodeIds.has(edge.toNodeId)) { - issues.push(issue(`edges[${index}].toNodeId`, "invalid_edge_endpoint", `Edge target node does not exist: ${edge.toNodeId}`)); + issues.push(issue(`edges[${sourceIndex}].toNodeId`, "invalid_edge_endpoint", `Edge target node does not exist: ${edge.toNodeId}`)); } const source = nodes.find((node) => node.id === edge.fromNodeId); const target = nodes.find((node) => node.id === edge.toNodeId); if (source && edge.fromHandle && !source.ports?.some((port) => port.id === edge.fromHandle && port.direction === "output")) { - issues.push(issue(`edges[${index}].fromHandle`, "invalid_source_port", `Source port does not exist or is not an output: ${edge.fromHandle}`)); + issues.push(issue(`edges[${sourceIndex}].fromHandle`, "invalid_source_port", `Source port does not exist or is not an output: ${edge.fromHandle}`)); } if (target && edge.toHandle && !target.ports?.some((port) => port.id === edge.toHandle && port.direction === "input")) { - issues.push(issue(`edges[${index}].toHandle`, "invalid_target_port", `Target port does not exist or is not an input: ${edge.toHandle}`)); + issues.push(issue(`edges[${sourceIndex}].toHandle`, "invalid_target_port", `Target port does not exist or is not an input: ${edge.toHandle}`)); } }); const inputSchema = normalizeWidgetSchema(rawGraph.inputSchema, "inputSchema", issues); - if (rawGraph.metadata !== undefined && !isPlainJsonObject(rawGraph.metadata)) { - issues.push(issue("metadata", "invalid_metadata", "Node flow graph metadata must be a JSON object.")); - } - if (rawGraph.metadata && containsForbiddenGraphValue(rawGraph.metadata)) { + const schemas = normalizeGraphSchemas(rawGraph.schemas, issues); + const metadata = rawGraph.metadata === undefined + ? undefined + : normalizeJsonObject(rawGraph.metadata, "metadata", "invalid_metadata", "Node flow graph metadata must be a JSON object.", issues); + if (metadata && containsForbiddenGraphValue(metadata)) { issues.push(issue("metadata", "unsafe_graph_metadata", "Graph metadata cannot contain raw secrets or custom source code.")); } validatePublication(rawGraph.publication, issues); @@ -547,8 +810,8 @@ export function validateNodeFlowGraph(graph: unknown): NodeFlowValidationRespons nodes, edges, ...(inputSchema ? { inputSchema } : {}), - ...(rawGraph.schemas ? { schemas: rawGraph.schemas } : {}), - ...(rawGraph.metadata !== undefined && isPlainJsonObject(rawGraph.metadata) ? { metadata: rawGraph.metadata } : {}), + ...(schemas ? { schemas } : {}), + ...(metadata ? { metadata } : {}), ...(rawGraph.publication ? { publication: rawGraph.publication } : {}), }; const executionOrder = computeExecutionOrder(nodes, edges, issues); @@ -561,16 +824,21 @@ export function validateNodeFlowGraph(graph: unknown): NodeFlowValidationRespons } function validatePublication( - publication: NodeFlowGraph["publication"], + publication: unknown, issues: NodeFlowValidationIssue[], ): void { - if (!publication) return; + if (publication === undefined) return; + if (!isRecord(publication)) { + issues.push(issue("publication", "invalid_publication", "Publication metadata must be an object.")); + return; + } if (!trimmedString(publication.publicationId)) issues.push(issue("publication.publicationId", "required", "Publication id is required.")); if (!trimmedString(publication.publishedBy)) issues.push(issue("publication.publishedBy", "required", "Publication author is required.")); - if (!trimmedString(publication.publishedAt) || !Number.isFinite(Date.parse(publication.publishedAt))) { + const publishedAt = trimmedString(publication.publishedAt); + if (!publishedAt || !Number.isFinite(Date.parse(publishedAt))) { issues.push(issue("publication.publishedAt", "invalid_publication", "Publication timestamp must be ISO-compatible.")); } - if (!Number.isInteger(publication.sourceVersion) || publication.sourceVersion < 1) { + if (!Number.isInteger(publication.sourceVersion) || (publication.sourceVersion as number) < 1) { issues.push(issue("publication.sourceVersion", "invalid_publication", "Publication sourceVersion must be a positive integer.")); } } diff --git a/tests/backend/domain/node-flows/node-flow-migrators.test.ts b/tests/backend/domain/node-flows/node-flow-migrators.test.ts index 5ee0b0727a..e8aaef1fe8 100644 --- a/tests/backend/domain/node-flows/node-flow-migrators.test.ts +++ b/tests/backend/domain/node-flows/node-flow-migrators.test.ts @@ -28,4 +28,31 @@ describe("node flow migrators", () => { expect(result.graph.metadata).toEqual({ canvasSelection: legacy.selection }); expect(JSON.stringify(result.graph)).not.toContain("legacySnapshot"); }); + + it("preserves malformed v1 entries for field-level validation without throwing", () => { + const legacy = { + nodes: [null, { id: "start", type: "input", title: "Start", ports: [null], credentialBindings: [null] }], + edges: [null], + }; + + const result = migrateNodeFlowGraph(legacy); + + expect(result.graph.nodes).toHaveLength(2); + expect(result.graph.nodes[0]).toBeNull(); + expect(result.graph.nodes[1]?.ports?.[0]).toBeNull(); + expect(result.graph.nodes[1]?.credentialBindings?.[0]).toBeNull(); + expect(result.graph.edges[0]).toBeNull(); + }); + + it("clones malformed canonical graphs deterministically without JSON serialization", () => { + const metadata: Record = { valid: true, invalid: undefined }; + metadata.circular = metadata; + const graph = { schemaVersion: 2, nodes: [], edges: [], metadata }; + + const result = migrateNodeFlowGraph(graph); + + expect(result.migrated).toBe(false); + expect(result.graph.metadata).not.toBe(metadata); + expect((result.graph.metadata as Record).circular).toBe(result.graph.metadata); + }); }); diff --git a/tests/backend/domain/node-flows/node-flow-validation.test.ts b/tests/backend/domain/node-flows/node-flow-validation.test.ts index 2e56cc6bbb..0c4d681008 100644 --- a/tests/backend/domain/node-flows/node-flow-validation.test.ts +++ b/tests/backend/domain/node-flows/node-flow-validation.test.ts @@ -112,4 +112,102 @@ describe("node flow validation", () => { expect(validateNodeFlowGraph(graph).executionOrder).toEqual(["a", "z"]); }); + + it("fails closed on malformed canonical nested values with stable field paths", () => { + const graph = { + schemaVersion: 2, + nodes: [ + null, + { + id: "start", + type: "input", + title: "Start", + definition: { type: "input", version: 1 }, + ports: [null, { id: "out", direction: "output", schema: { type: "any" } }], + credentialBindings: [null], + capabilities: [null], + policy: { retry: null }, + }, + ], + edges: [null, { fromNodeId: "start", toNodeId: "missing", fromHandle: "out" }], + schemas: { + input: { type: "object", properties: { valid: { type: "string" }, invalid: null } }, + output: null, + }, + metadata: { valid: true, nested: { invalid: undefined } }, + }; + + const first = validateNodeFlowGraph(graph); + const second = validateNodeFlowGraph(graph); + + expect(first).toEqual(second); + expect(first.valid).toBe(false); + expect(first.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ field: "nodes[0]", code: "invalid_node" }), + expect.objectContaining({ field: "nodes[1].ports[0]", code: "invalid_port" }), + expect.objectContaining({ field: "nodes[1].credentialBindings[0]", code: "invalid_credential_binding" }), + expect.objectContaining({ field: "nodes[1].capabilities[0]", code: "invalid_capability" }), + expect.objectContaining({ field: "nodes[1].policy.retry", code: "invalid_retry_policy" }), + expect.objectContaining({ field: "edges[0]", code: "invalid_edge" }), + expect.objectContaining({ field: "edges[1].toNodeId", code: "invalid_edge_endpoint" }), + expect.objectContaining({ field: "schemas.input.properties.invalid", code: "invalid_value_schema" }), + expect.objectContaining({ field: "schemas.output", code: "invalid_value_schema" }), + expect.objectContaining({ field: "metadata.nested.invalid", code: "invalid_json_value" }), + ])); + }); + + it("reports malformed Graph v1 references and collections without throwing", () => { + const graph = { + nodes: [ + null, + { + id: "start", + type: "input", + title: "Start", + definition: { type: "input", version: "invalid" }, + ports: null, + credentialBindings: null, + capabilities: null, + policy: null, + }, + ], + edges: [null], + }; + + const result = validateNodeFlowGraph(graph); + + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ field: "nodes[0]", code: "invalid_node" }), + expect.objectContaining({ field: "nodes[1].definition.version", code: "invalid_definition_version" }), + expect.objectContaining({ field: "nodes[1].ports", code: "invalid_ports" }), + expect.objectContaining({ field: "nodes[1].credentialBindings", code: "invalid_credential_bindings" }), + expect.objectContaining({ field: "nodes[1].capabilities", code: "invalid_capabilities" }), + expect.objectContaining({ field: "nodes[1].policy", code: "invalid_policy" }), + expect.objectContaining({ field: "edges[0]", code: "invalid_edge" }), + ])); + }); + + it.each([ + [{ schemaVersion: 2, nodes: null, edges: [] }, "nodes"], + [{ nodes: [], edges: "invalid" }, "edges"], + ])("rejects malformed graph arrays at their root path", (graph, field) => { + expect(validateNodeFlowGraph(graph).errors).toContainEqual(expect.objectContaining({ field, code: "required" })); + }); + + it("rejects circular metadata at the repeated field path without throwing", () => { + const graph = validGraph() as unknown as Record; + const metadata: Record = { safe: true }; + metadata.circular = metadata; + graph.schemaVersion = 2; + graph.metadata = metadata; + + const first = validateNodeFlowGraph(graph); + const second = validateNodeFlowGraph(graph); + + expect(first).toEqual(second); + expect(first.errors).toContainEqual(expect.objectContaining({ + field: "metadata.circular", + code: "invalid_json_value", + })); + }); });