diff --git a/Controllers/NodesController.cs b/Controllers/NodesController.cs
new file mode 100644
index 0000000..2686f72
--- /dev/null
+++ b/Controllers/NodesController.cs
@@ -0,0 +1,102 @@
+using Microsoft.AspNetCore.Mvc;
+using w4c_workflows.Filters;
+using w4c_workflows.Models.Nodes;
+using w4c_workflows.Services.Nodes;
+
+namespace w4c_workflows.Controllers;
+
+///
+/// Node catalog surface. The authoring UI uses this to render its palette and
+/// its generic parameter forms; it authenticates with the tenant operator key
+/// like the rest of the control plane.
+///
+[ApiController]
+[Route("api/nodes")]
+public class NodesController : ControllerBase
+{
+ private readonly NodeBlueprintCatalog _catalog;
+ private readonly NodeExecutorRegistry _executors;
+
+ public NodesController(NodeBlueprintCatalog catalog, NodeExecutorRegistry executors)
+ {
+ _catalog = catalog;
+ _executors = executors;
+ }
+
+ /// Lists node types for the palette, with optional search/filter.
+ [HttpGet]
+ [RequireScope("read")]
+ public IActionResult List(
+ [FromQuery] string? search,
+ [FromQuery] string? kind,
+ [FromQuery] string? category,
+ [FromQuery] bool includeHidden = false)
+ {
+ var results = _catalog.Search(search, kind, category);
+ if (!includeHidden)
+ results = results.Where(b => !b.Hidden).ToList();
+
+ return Ok(results.Select(Summarize));
+ }
+
+ /// All category labels, for the palette's category rail.
+ [HttpGet("categories")]
+ [RequireScope("read")]
+ public IActionResult Categories() => Ok(_catalog.Categories);
+
+ /// Full blueprint for one node type (optionally a pinned version).
+ [HttpGet("{type}")]
+ [RequireScope("read")]
+ public IActionResult Get(string type, [FromQuery] double? version)
+ {
+ var blueprint = _catalog.Resolve(type, version);
+ if (blueprint is null)
+ return NotFound(new { error = $"unknown node type '{type}'" });
+
+ return Ok(new NodeDetail(blueprint, _executors.CanRun(blueprint.Type)));
+ }
+
+ private NodeSummary Summarize(NodeBlueprint blueprint) => new(
+ blueprint.Type,
+ blueprint.Version,
+ blueprint.DisplayName,
+ blueprint.Description,
+ blueprint.Kind,
+ blueprint.Categories,
+ blueprint.Icon,
+ blueprint.IconColor,
+ blueprint.RunMode,
+ blueprint.Hidden,
+ blueprint.Origin,
+ blueprint.Inputs.Count,
+ blueprint.Outputs.Count,
+ blueprint.Inputs,
+ blueprint.Outputs,
+ blueprint.ProducesErrorBranch,
+ blueprint.Credentials.Any(c => c.Required),
+ _executors.CanRun(blueprint.Type));
+}
+
+/// Palette entry: blueprint identity plus port shape, without the full form schema.
+public sealed record NodeSummary(
+ string Type,
+ double Version,
+ string DisplayName,
+ string? Description,
+ string Kind,
+ IReadOnlyList Categories,
+ string? Icon,
+ string? IconColor,
+ string RunMode,
+ bool Hidden,
+ string Origin,
+ int Inputs,
+ int Outputs,
+ IReadOnlyList InputPorts,
+ IReadOnlyList OutputPorts,
+ bool HasErrorBranch,
+ bool RequiresCredentials,
+ bool Runnable);
+
+/// Full blueprint detail plus whether an executor is installed for it.
+public sealed record NodeDetail(NodeBlueprint Blueprint, bool Runnable);
diff --git a/Models/Nodes/FlowItem.cs b/Models/Nodes/FlowItem.cs
new file mode 100644
index 0000000..fa2f3cc
--- /dev/null
+++ b/Models/Nodes/FlowItem.cs
@@ -0,0 +1,190 @@
+using System.Text.Json.Nodes;
+
+namespace w4c_workflows.Models.Nodes;
+
+// ---------------------------------------------------------------------------
+// The unit of data that flows between nodes. A node receives one or more
+// FlowItems and produces zero or more per output port. Items carry their own
+// provenance so expressions like $("Prepare") and the UI's "trace this item"
+// can resolve which upstream item produced a given downstream item.
+// ---------------------------------------------------------------------------
+
+/// A binary payload stored out-of-band; items only hold a reference.
+public sealed record BinaryAttachment(
+ string AssetId,
+ string? FileName = null,
+ string? MimeType = null,
+ long? SizeBytes = null);
+
+/// A pointer back to an item emitted by another node run.
+public sealed record ItemOrigin(
+ string NodeId,
+ int OutputIndex = 0,
+ int RunIndex = 0,
+ int ItemIndex = 0);
+
+/// Where a came from (for tracing and expressions).
+public sealed record FlowItemOrigin(
+ string NodeId,
+ int OutputIndex = 0,
+ int RunIndex = 0,
+ int ItemIndex = 0,
+ List? PairedItems = null);
+
+///
+/// One data item. is the payload; holds
+/// named binary references; links it to its source.
+///
+public sealed record FlowItem
+{
+ public required JsonObject Json { get; init; }
+
+ public Dictionary? Binary { get; init; }
+
+ public FlowItemOrigin? Origin { get; init; }
+
+ /// Convenience factory for a plain JSON item.
+ public static FlowItem FromJson(JsonObject json) => new() { Json = json };
+
+ /// Wraps a bare JSON value in a single "value" field.
+ public static FlowItem Scalar(JsonNode? value) => new()
+ {
+ Json = new JsonObject { ["value"] = value?.DeepClone() },
+ };
+}
+
+/// Helpers for turning sets of items into and out of JSON.
+public static class FlowItemJson
+{
+ /// Serializes a batch of items into a JSON array (or null when empty).
+ public static string? Serialize(IReadOnlyList items)
+ {
+ if (items.Count == 0)
+ return null;
+
+ var array = new JsonArray();
+ foreach (var item in items)
+ {
+ var entry = new JsonObject { ["json"] = item.Json.DeepClone() };
+ if (item.Binary is { Count: > 0 })
+ {
+ var binary = new JsonObject();
+ foreach (var (name, attachment) in item.Binary)
+ {
+ binary[name] = new JsonObject
+ {
+ ["assetId"] = attachment.AssetId,
+ ["fileName"] = attachment.FileName,
+ ["mimeType"] = attachment.MimeType,
+ ["sizeBytes"] = attachment.SizeBytes,
+ };
+ }
+ entry["binary"] = binary;
+ }
+ if (item.Origin != null)
+ {
+ entry["origin"] = new JsonObject
+ {
+ ["nodeId"] = item.Origin.NodeId,
+ ["outputIndex"] = item.Origin.OutputIndex,
+ ["runIndex"] = item.Origin.RunIndex,
+ ["itemIndex"] = item.Origin.ItemIndex,
+ };
+ }
+ array.Add(entry);
+ }
+
+ return array.ToJsonString();
+ }
+
+ ///
+ /// Parses a JSON payload into items. Accepts a JSON array of
+ /// { "json": … } envelopes, a JSON array of bare objects, or a single
+ /// bare object (which becomes a one-item batch).
+ ///
+ public static List Parse(string? payload)
+ {
+ var items = new List();
+ if (string.IsNullOrWhiteSpace(payload))
+ return items;
+
+ JsonNode? node;
+ try
+ {
+ node = JsonNode.Parse(payload);
+ }
+ catch
+ {
+ return items;
+ }
+
+ switch (node)
+ {
+ case JsonArray array:
+ foreach (var element in array)
+ items.Add(FromElement(element));
+ break;
+ case JsonObject obj:
+ items.Add(FromObject(obj));
+ break;
+ }
+
+ return items;
+ }
+
+ private static FlowItem FromElement(JsonNode? element) => element switch
+ {
+ JsonObject obj => FromObject(obj),
+ null => new FlowItem { Json = new JsonObject() },
+ _ => FlowItem.Scalar(element),
+ };
+
+ private static FlowItem FromObject(JsonObject obj)
+ {
+ // Envelope form { json, binary, origin }: unwrap; otherwise treat the
+ // whole object as the payload.
+ if (obj.ContainsKey("json") && obj["json"] is JsonObject payload)
+ {
+ return new FlowItem
+ {
+ Json = (JsonObject)payload.DeepClone(),
+ Origin = ParseOrigin(obj["origin"] as JsonObject),
+ Binary = ParseBinary(obj["binary"] as JsonObject),
+ };
+ }
+
+ return new FlowItem { Json = (JsonObject)obj.DeepClone() };
+ }
+
+ private static FlowItemOrigin? ParseOrigin(JsonObject? origin)
+ {
+ if (origin == null)
+ return null;
+
+ return new FlowItemOrigin(
+ origin["nodeId"]?.GetValue() ?? string.Empty,
+ origin["outputIndex"]?.GetValue() ?? 0,
+ origin["runIndex"]?.GetValue() ?? 0,
+ origin["itemIndex"]?.GetValue() ?? 0);
+ }
+
+ private static Dictionary? ParseBinary(JsonObject? binary)
+ {
+ if (binary == null || binary.Count == 0)
+ return null;
+
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var (name, value) in binary)
+ {
+ if (value is not JsonObject entry)
+ continue;
+ result[name] = new BinaryAttachment(
+ entry["assetId"]?.GetValue() ?? string.Empty,
+ entry["fileName"]?.GetValue(),
+ entry["mimeType"]?.GetValue(),
+ entry["sizeBytes"]?.GetValue());
+ }
+
+ return result;
+ }
+}
diff --git a/Models/Nodes/NodeBlueprint.cs b/Models/Nodes/NodeBlueprint.cs
new file mode 100644
index 0000000..444ccac
--- /dev/null
+++ b/Models/Nodes/NodeBlueprint.cs
@@ -0,0 +1,245 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace w4c_workflows.Models.Nodes;
+
+// ---------------------------------------------------------------------------
+// The declarative definition of a node TYPE ("blueprint"). This is the catalog
+// entry that both the compiler/validator and the authoring UI consume: it says
+// what a node is called, which ports it has, which parameters it exposes and
+// which credentials it needs. Runtime behaviour lives behind INodeExecutor.
+//
+// Field names are camelCase on the wire (System.Text.Json Web defaults) so the
+// catalog JSON files in Services/Nodes/Catalog read naturally.
+// ---------------------------------------------------------------------------
+
+/// Broad role of a blueprint. Stored as text so new roles need no migration.
+public static class NodeKind
+{
+ public const string Action = "action";
+ public const string Trigger = "trigger";
+ public const string Transform = "transform";
+ public const string Control = "control";
+ public const string Ai = "ai";
+}
+
+/// Connection channel a port belongs to. "main" carries flow items.
+public static class PortKind
+{
+ public const string Main = "main";
+ public const string Error = "error";
+ public const string Ai = "ai";
+}
+
+/// How the runtime hands items to a node.
+public static class NodeRunMode
+{
+ /// Invoke once per input item (the default).
+ public const string EachItem = "eachItem";
+ /// Invoke once with the whole input array (aggregates, code nodes).
+ public const string AllItems = "allItems";
+}
+
+/// Parameter field kinds the authoring UI knows how to render.
+public static class NodeParameterType
+{
+ public const string String = "string";
+ public const string Number = "number";
+ public const string Boolean = "boolean";
+ public const string Options = "options";
+ public const string MultiOptions = "multiOptions";
+ public const string Collection = "collection";
+ public const string FixedCollection = "fixedCollection";
+ public const string ResourceLocator = "resourceLocator";
+ public const string Json = "json";
+ public const string Notice = "notice";
+ public const string DateTime = "dateTime";
+}
+
+/// Where a blueprint came from — drives provenance badges and filtering.
+public static class NodeOrigin
+{
+ public const string BuiltIn = "builtIn";
+ public const string Connector = "connector";
+ public const string OpenApi = "openApi";
+ public const string Custom = "custom";
+}
+
+///
+/// A node type definition. Version is a decimal so it can track the same
+/// minor-version history the reference integrations use (e.g. 2.7).
+///
+public sealed record NodeBlueprint
+{
+ /// Stable machine id, e.g. "httpRequest" or "core.set".
+ public required string Type { get; init; }
+
+ public double Version { get; init; } = 1;
+
+ public required string DisplayName { get; init; }
+
+ public string? Description { get; init; }
+
+ /// Optional expression string for the step-card subtitle.
+ public string? Subtitle { get; init; }
+
+ /// One of .
+ public string Kind { get; init; } = NodeKind.Action;
+
+ public List Categories { get; init; } = new();
+
+ public string? Icon { get; init; }
+
+ public string? IconColor { get; init; }
+
+ public List Inputs { get; init; } = new() { NodePort.Main };
+
+ public List Outputs { get; init; } = new() { NodePort.Main };
+
+ /// One of .
+ public string RunMode { get; init; } = NodeRunMode.EachItem;
+
+ public List Parameters { get; init; } = new();
+
+ public List Credentials { get; init; } = new();
+
+ /// Names of dynamic methods this node exposes (loadOptions/listSearch/schema).
+ public List Methods { get; init; } = new();
+
+ /// One of .
+ public string Origin { get; init; } = NodeOrigin.BuiltIn;
+
+ public string? DocumentationUrl { get; init; }
+
+ /// True when the node is hidden from the default palette (internal/test).
+ public bool Hidden { get; init; }
+
+ [JsonIgnore]
+ public bool ProducesErrorBranch =>
+ Outputs.Any(p => string.Equals(p.Kind, PortKind.Error, StringComparison.Ordinal));
+
+ /// True when the blueprint exposes a parameter with the given name at any depth.
+ public bool HasParameter(string name)
+ {
+ foreach (var parameter in NodeParameterDescender.DescendAll(Parameters))
+ {
+ if (string.Equals(parameter.Name, name, StringComparison.Ordinal))
+ return true;
+ }
+ return false;
+ }
+}
+
+/// A named, typed connection point on a node.
+public sealed record NodePort
+{
+ /// One of .
+ public string Kind { get; init; } = PortKind.Main;
+
+ /// Optional display label ("true", "false", "done", "loop").
+ public string? Label { get; init; }
+
+ public static readonly NodePort Main = new() { Kind = PortKind.Main };
+ public static readonly NodePort Error = new() { Kind = PortKind.Error, Label = "error" };
+}
+
+/// A credential a node may need, referenced by alias in task parameters.
+public sealed record NodeCredentialLink
+{
+ public required string Alias { get; init; }
+ public string? DisplayName { get; init; }
+ public bool Required { get; init; }
+}
+
+///
+/// One form field in a node's parameter schema. Intentionally close to the
+/// reference corpora's property model so migrated integrations map cleanly, but
+/// with our own (clearer) names.
+///
+public sealed record NodeParameter
+{
+ /// Machine name used in task parameters and expressions.
+ public required string Name { get; init; }
+
+ public required string DisplayName { get; init; }
+
+ /// One of .
+ public required string Type { get; init; }
+
+ /// Default value. means "no default".
+ public JsonElement Default { get; init; }
+
+ public string? Description { get; init; }
+
+ public string? Placeholder { get; init; }
+
+ public bool Required { get; init; }
+
+ /// When true the field is a configuration value and never interpolated.
+ public bool NoDataExpression { get; init; }
+
+ /// Value list for options/multiOptions.
+ public List? Options { get; init; }
+
+ /// Nested fields for collection/fixedCollection.
+ public List? Fields { get; init; }
+
+ /// Conditional visibility relative to sibling parameter values.
+ public NodeVisibility? VisibleWhen { get; init; }
+
+ /// Method on the blueprint used to fetch options dynamically.
+ public string? LoadOptionsMethod { get; init; }
+
+ /// Parameter names that must change before options are re-fetched.
+ public List? DependsOn { get; init; }
+
+ /// JSON Schema-ish extras (min/max, precision, multi-value, …).
+ [JsonPropertyName("typeOptions")]
+ public JsonElement TypeOptions { get; init; }
+}
+
+/// A selectable value for an options/multiOptions parameter.
+public sealed record NodeParameterOption
+{
+ public required string Name { get; init; }
+ public required JsonElement Value { get; init; }
+ public string? Description { get; init; }
+}
+
+///
+/// Conditional visibility for a parameter. A field is shown when every entry in
+/// matches and no entry in matches; a
+/// missing parameter is treated as not matching.
+///
+public sealed record NodeVisibility
+{
+ public Dictionary>? Show { get; init; }
+ public Dictionary>? Hide { get; init; }
+}
+
+/// Walks a parameter tree (nested collection fields) in depth-first order.
+internal static class NodeParameterDescender
+{
+ internal static IEnumerable DescendAll(IEnumerable parameters)
+ {
+ foreach (var parameter in parameters)
+ {
+ yield return parameter;
+ if (parameter.Fields is { Count: > 0 })
+ {
+ foreach (var nested in DescendAll(parameter.Fields))
+ yield return nested;
+ }
+ }
+ }
+}
+
+/// Serialization options shared by catalog loading and API responses.
+public static class NodeJson
+{
+ public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false,
+ };
+}
diff --git a/Program.cs b/Program.cs
index a1bfbe5..b4ca858 100644
--- a/Program.cs
+++ b/Program.cs
@@ -10,6 +10,7 @@ using w4c_workflows.Middleware;
using w4c_workflows.Services;
using w4c_workflows.Services.Execution;
using w4c_workflows.Services.Messaging;
+using w4c_workflows.Services.Nodes;
using w4c_workflows.Services.Runs;
using w4c_workflows.Services.Triggers;
@@ -120,6 +121,12 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+// Node catalog: the declarative blueprint registry backing GET /api/nodes and
+// the authoring UI. Core blueprints ship embedded; connector packs load from a
+// directory. Executors plug in as INodeExecutor implementations.
+builder.Services.AddSingleton(_ => new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded()));
+builder.Services.AddSingleton();
+
// Realtime SSE hub — notifies subscribed clients when a workflow file changes server-side.
builder.Services.AddSingleton();
diff --git a/Services/Nodes/Catalog/core-http-request.json b/Services/Nodes/Catalog/core-http-request.json
new file mode 100644
index 0000000..04ec877
--- /dev/null
+++ b/Services/Nodes/Catalog/core-http-request.json
@@ -0,0 +1,142 @@
+{
+ "type": "core.httpRequest",
+ "version": 1,
+ "displayName": "HTTP Request",
+ "description": "Calls an HTTP endpoint and returns the response as items",
+ "kind": "action",
+ "categories": ["Core", "Development"],
+ "icon": "node:http-request",
+ "inputs": [{ "kind": "main" }],
+ "outputs": [
+ { "kind": "main" },
+ { "kind": "error", "label": "error" }
+ ],
+ "runMode": "eachItem",
+ "parameters": [
+ {
+ "name": "method",
+ "displayName": "Method",
+ "type": "options",
+ "default": "GET",
+ "noDataExpression": true,
+ "options": [
+ { "name": "GET", "value": "GET" },
+ { "name": "POST", "value": "POST" },
+ { "name": "PUT", "value": "PUT" },
+ { "name": "PATCH", "value": "PATCH" },
+ { "name": "DELETE", "value": "DELETE" },
+ { "name": "HEAD", "value": "HEAD" },
+ { "name": "OPTIONS", "value": "OPTIONS" }
+ ]
+ },
+ {
+ "name": "url",
+ "displayName": "URL",
+ "type": "string",
+ "required": true,
+ "default": "",
+ "placeholder": "https://api.example.com/items"
+ },
+ {
+ "name": "authentication",
+ "displayName": "Authentication",
+ "type": "options",
+ "default": "none",
+ "noDataExpression": true,
+ "options": [
+ { "name": "None", "value": "none" },
+ { "name": "Predefined credential", "value": "credential" },
+ { "name": "Generic header/basic", "value": "generic" }
+ ]
+ },
+ {
+ "name": "credential",
+ "displayName": "Credential",
+ "type": "resourceLocator",
+ "visibleWhen": { "show": { "authentication": ["credential", "generic"] } },
+ "loadOptionsMethod": "listCredentials",
+ "default": ""
+ },
+ {
+ "name": "sendHeaders",
+ "displayName": "Send headers",
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "name": "headers",
+ "displayName": "Headers",
+ "type": "fixedCollection",
+ "visibleWhen": { "show": { "sendHeaders": [true] } },
+ "fields": [
+ { "name": "name", "displayName": "Name", "type": "string", "default": "" },
+ { "name": "value", "displayName": "Value", "type": "string", "default": "" }
+ ]
+ },
+ {
+ "name": "sendBody",
+ "displayName": "Send body",
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "name": "bodyContentType",
+ "displayName": "Body content type",
+ "type": "options",
+ "default": "json",
+ "visibleWhen": { "show": { "sendBody": [true] } },
+ "noDataExpression": true,
+ "options": [
+ { "name": "JSON", "value": "json" },
+ { "name": "Form urlencoded", "value": "form" },
+ { "name": "Multipart form data", "value": "multipart" },
+ { "name": "Raw text", "value": "raw" },
+ { "name": "Binary file", "value": "binary" }
+ ]
+ },
+ {
+ "name": "body",
+ "displayName": "Body",
+ "type": "json",
+ "visibleWhen": { "show": { "sendBody": [true], "bodyContentType": ["json", "form", "raw"] } },
+ "default": {}
+ },
+ {
+ "name": "binaryField",
+ "displayName": "Input binary field",
+ "type": "string",
+ "default": "data",
+ "visibleWhen": { "show": { "bodyContentType": ["binary", "multipart"] } }
+ },
+ {
+ "name": "options",
+ "displayName": "Options",
+ "type": "collection",
+ "fields": [
+ { "name": "timeoutMs", "displayName": "Timeout (ms)", "type": "number", "default": 30000 },
+ { "name": "followRedirects", "displayName": "Follow redirects", "type": "boolean", "default": true },
+ { "name": "maxRedirects", "displayName": "Max redirects", "type": "number", "default": 5, "visibleWhen": { "show": { "followRedirects": [true] } } },
+ { "name": "ignoreSslIssues", "displayName": "Ignore SSL issues", "type": "boolean", "default": false },
+ { "name": "neverError", "displayName": "Never error on non-2xx", "type": "boolean", "default": false },
+ {
+ "name": "responseFormat",
+ "displayName": "Response format",
+ "type": "options",
+ "default": "autodetect",
+ "noDataExpression": true,
+ "options": [
+ { "name": "Autodetect", "value": "autodetect" },
+ { "name": "JSON", "value": "json" },
+ { "name": "Text", "value": "text" },
+ { "name": "File", "value": "file" }
+ ]
+ },
+ { "name": "putOutputInField", "displayName": "Put output in field", "type": "string", "default": "" }
+ ]
+ }
+ ],
+ "credentials": [
+ { "alias": "httpAuth", "displayName": "HTTP credential", "required": false }
+ ],
+ "origin": "builtIn"
+}
diff --git a/Services/Nodes/Catalog/core-if.json b/Services/Nodes/Catalog/core-if.json
new file mode 100644
index 0000000..6fb5b83
--- /dev/null
+++ b/Services/Nodes/Catalog/core-if.json
@@ -0,0 +1,60 @@
+{
+ "type": "core.if",
+ "version": 1,
+ "displayName": "If",
+ "description": "Routes each item to the true or false output branch",
+ "kind": "control",
+ "categories": ["Core", "Flow"],
+ "icon": "node:if",
+ "inputs": [{ "kind": "main" }],
+ "outputs": [
+ { "kind": "main", "label": "true" },
+ { "kind": "main", "label": "false" }
+ ],
+ "runMode": "eachItem",
+ "parameters": [
+ {
+ "name": "left",
+ "displayName": "Left value",
+ "type": "string",
+ "default": "",
+ "description": "Value to test; usually an expression such as ={{ $json.status }}"
+ },
+ {
+ "name": "operator",
+ "displayName": "Operator",
+ "type": "options",
+ "default": "equals",
+ "noDataExpression": true,
+ "options": [
+ { "name": "Equals", "value": "equals" },
+ { "name": "Not equals", "value": "notEquals" },
+ { "name": "Contains", "value": "contains" },
+ { "name": "Starts with", "value": "startsWith" },
+ { "name": "Ends with", "value": "endsWith" },
+ { "name": "Is greater", "value": "greater" },
+ { "name": "Is greater or equal", "value": "greaterOrEqual" },
+ { "name": "Is less", "value": "less" },
+ { "name": "Is less or equal", "value": "lessOrEqual" },
+ { "name": "Is empty", "value": "isEmpty" },
+ { "name": "Is not empty", "value": "isNotEmpty" },
+ { "name": "Matches regex", "value": "regex" }
+ ]
+ },
+ {
+ "name": "right",
+ "displayName": "Right value",
+ "type": "string",
+ "default": "",
+ "visibleWhen": { "hide": { "operator": ["isEmpty", "isNotEmpty"] } }
+ },
+ {
+ "name": "caseSensitive",
+ "displayName": "Case sensitive",
+ "type": "boolean",
+ "default": false,
+ "visibleWhen": { "show": { "operator": ["equals", "notEquals", "contains", "startsWith", "endsWith"] } }
+ }
+ ],
+ "origin": "builtIn"
+}
diff --git a/Services/Nodes/Catalog/core-noop.json b/Services/Nodes/Catalog/core-noop.json
new file mode 100644
index 0000000..37b08b5
--- /dev/null
+++ b/Services/Nodes/Catalog/core-noop.json
@@ -0,0 +1,14 @@
+{
+ "type": "core.noop",
+ "version": 1,
+ "displayName": "No-op",
+ "description": "Passes input items through unchanged; useful as a join or anchor point",
+ "kind": "control",
+ "categories": ["Core", "Flow"],
+ "icon": "node:noop",
+ "inputs": [{ "kind": "main" }],
+ "outputs": [{ "kind": "main" }],
+ "runMode": "eachItem",
+ "parameters": [],
+ "origin": "builtIn"
+}
diff --git a/Services/Nodes/Catalog/core-set.json b/Services/Nodes/Catalog/core-set.json
new file mode 100644
index 0000000..930651d
--- /dev/null
+++ b/Services/Nodes/Catalog/core-set.json
@@ -0,0 +1,57 @@
+{
+ "type": "core.set",
+ "version": 1,
+ "displayName": "Edit fields",
+ "description": "Add, change or remove fields on each item",
+ "kind": "transform",
+ "categories": ["Core", "Data"],
+ "icon": "node:edit-fields",
+ "inputs": [{ "kind": "main" }],
+ "outputs": [{ "kind": "main" }],
+ "runMode": "eachItem",
+ "parameters": [
+ {
+ "name": "mode",
+ "displayName": "Mode",
+ "type": "options",
+ "default": "manual",
+ "noDataExpression": true,
+ "options": [
+ { "name": "Manual mapping", "value": "manual", "description": "Set fields one by one" },
+ { "name": "JSON", "value": "json", "description": "Build the item from a JSON value" }
+ ]
+ },
+ {
+ "name": "fields",
+ "displayName": "Fields",
+ "type": "fixedCollection",
+ "visibleWhen": { "show": { "mode": ["manual"] } },
+ "fields": [
+ { "name": "name", "displayName": "Name", "type": "string", "default": "" },
+ { "name": "value", "displayName": "Value", "type": "string", "default": "" }
+ ]
+ },
+ {
+ "name": "jsonOutput",
+ "displayName": "JSON",
+ "type": "json",
+ "default": {},
+ "visibleWhen": { "show": { "mode": ["json"] } }
+ },
+ {
+ "name": "keepOnlySet",
+ "displayName": "Keep only set fields",
+ "type": "boolean",
+ "default": false,
+ "description": "Discard input fields that are not listed above"
+ },
+ {
+ "name": "includeBinary",
+ "displayName": "Include binary",
+ "type": "boolean",
+ "default": true,
+ "description": "Pass binary attachments through to the output item"
+ }
+ ],
+ "origin": "builtIn"
+}
diff --git a/Services/Nodes/INodeExecutor.cs b/Services/Nodes/INodeExecutor.cs
new file mode 100644
index 0000000..a5160fa
--- /dev/null
+++ b/Services/Nodes/INodeExecutor.cs
@@ -0,0 +1,96 @@
+using System.Text.Json.Nodes;
+using w4c_workflows.Models.Nodes;
+
+namespace w4c_workflows.Services.Nodes;
+
+///
+/// Runs one node type. Implementations are registered in the node catalog and
+/// resolved by the executor registry; the run engine feeds them resolved
+/// parameters and input items and routes the returned outputs by port index.
+///
+public interface INodeExecutor
+{
+ /// Blueprint type this executor implements, e.g. "core.set".
+ string Type { get; }
+
+ Task RunAsync(NodeExecutionContext context, CancellationToken ct);
+}
+
+///
+/// Everything a node needs for one invocation. Parameters are already resolved
+/// (interpolation applied) for the target item; the raw scope stays available so
+/// a node can re-evaluate a parameter per item if it needs to.
+///
+public sealed record NodeExecutionContext
+{
+ public required NodeBlueprint Blueprint { get; init; }
+
+ /// Resolved parameters keyed by parameter name.
+ public required JsonObject Parameters { get; init; }
+
+ /// Input items per input port index.
+ public required IReadOnlyList> Inputs { get; init; }
+
+ /// Decrypted credential data keyed by credential alias.
+ public IReadOnlyDictionary Credentials { get; init; } =
+ new Dictionary();
+
+ /// Workflow-level environment variables.
+ public JsonObject Environment { get; init; } = new();
+
+ public string TenantId { get; init; } = string.Empty;
+ public string RunId { get; init; } = string.Empty;
+ public string TaskId { get; init; } = string.Empty;
+ public string? NodeName { get; init; }
+
+ /// Index of the item this invocation targets (0 for all-items mode).
+ public int ItemIndex { get; init; }
+
+ /// Run iteration index for loops (0 on the first pass).
+ public int RunIndex { get; init; }
+
+ public IServiceProvider? Services { get; init; }
+
+ /// Items from the given input port (empty when the port is absent).
+ public IReadOnlyList Input(int portIndex = 0)
+ => portIndex >= 0 && portIndex < Inputs.Count ? Inputs[portIndex] : Array.Empty();
+}
+
+///
+/// Result of one node invocation. is indexed by output
+/// port; a missing port means "no items on that port".
+///
+public sealed record NodeExecutionOutcome
+{
+ public required IReadOnlyList> Outputs { get; init; }
+
+ /// Set when the node failed but the engine may route to the error port.
+ public NodeFailure? Failure { get; init; }
+
+ public bool Succeeded => Failure == null;
+
+ /// Single-output convenience wrapper.
+ public static NodeExecutionOutcome Single(IReadOnlyList items)
+ => new() { Outputs = new[] { items } };
+
+ /// No items on any port (used by control nodes that consume input).
+ public static readonly NodeExecutionOutcome Empty = new()
+ {
+ Outputs = Array.Empty>(),
+ };
+
+ /// Failure on the error port.
+ public static NodeExecutionOutcome Failed(string message, string? code = null, string? description = null)
+ => new()
+ {
+ Outputs = Array.Empty>(),
+ Failure = new NodeFailure(message, code, description),
+ };
+}
+
+/// A node-level failure, optionally mapped to a user-facing message.
+public sealed record NodeFailure(
+ string Message,
+ string? Code = null,
+ string? Description = null,
+ int? HttpStatus = null);
diff --git a/Services/Nodes/Interpolation/NodeParameterInterpolator.cs b/Services/Nodes/Interpolation/NodeParameterInterpolator.cs
new file mode 100644
index 0000000..52a0a98
--- /dev/null
+++ b/Services/Nodes/Interpolation/NodeParameterInterpolator.cs
@@ -0,0 +1,339 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json.Nodes;
+using w4c_workflows.Models.Nodes;
+
+namespace w4c_workflows.Services.Nodes.Interpolation;
+
+/// Raised when a parameter expression cannot be resolved.
+public sealed class NodeInterpolationException : Exception
+{
+ public NodeInterpolationException(string expression, string reason)
+ : base(reason)
+ {
+ Expression = expression;
+ }
+
+ public string Expression { get; }
+}
+
+///
+/// Data a parameter expression can read from. Kept small and explicit so the
+/// resolver stays sandbox-safe (no reflection, no I/O, no arbitrary calls).
+///
+public sealed record InterpolationScope
+{
+ /// The item being mapped; exposed as $json / $item.
+ public JsonObject? Item { get; init; }
+
+ /// The node's own parameters; exposed as $parameter.
+ public JsonObject? Parameters { get; init; }
+
+ /// Workflow environment; exposed as $env.
+ public JsonObject? Environment { get; init; }
+
+ public int ItemIndex { get; init; }
+
+ public int RunIndex { get; init; }
+
+ /// Resolves items emitted by another node ($("Name") / $items("Name")).
+ public Func>? NodeItems { get; init; }
+}
+
+///
+/// Our parameter interpolation engine.
+///
+/// Rules:
+/// - A string is in expression mode when it starts with '=' or contains a
+/// {{ … }} block; otherwise it is a literal.
+/// - In expression mode, {{ … }} blocks are evaluated and any text
+/// around them stays literal.
+/// - When the whole value is a single block, the resolved value keeps its
+/// type (number, boolean, object, array).
+/// - When a block is embedded in surrounding text, the result is a string and
+/// non-string values are rendered (objects/arrays as JSON).
+/// - Objects and arrays are resolved recursively, so expressions nested in a
+/// collection field are applied per leaf.
+///
+/// Grammar supported in increment 1: literals (string/number/bool/null) and
+/// $root accessor paths with .name, ["name"] and [index].
+///
+public sealed class NodeParameterInterpolator
+{
+ private const char ExpressionPrefix = '=';
+
+ public JsonObject ResolveObject(JsonObject parameters, InterpolationScope scope)
+ {
+ var result = new JsonObject();
+ foreach (var (name, value) in parameters)
+ result[name] = ResolveNode(value, scope);
+ return result;
+ }
+
+ public JsonNode? ResolveNode(JsonNode? value, InterpolationScope scope) => value switch
+ {
+ null => null,
+ JsonObject obj => ResolveObject(obj, scope),
+ JsonArray array => ResolveArray(array, scope),
+ JsonValue jsonValue when jsonValue.TryGetValue(out var text) => ResolveString(text, scope),
+ JsonValue jsonValue => jsonValue.DeepClone(),
+ _ => value.DeepClone(),
+ };
+
+ private JsonArray ResolveArray(JsonArray array, InterpolationScope scope)
+ {
+ var result = new JsonArray();
+ foreach (var element in array)
+ result.Add(ResolveNode(element, scope));
+ return result;
+ }
+
+ /// Resolves a single string value according to the rules above.
+ public JsonNode? ResolveString(string text, InterpolationScope scope)
+ {
+ if (text.Length == 0)
+ return JsonValue.Create(text);
+
+ string body;
+ if (text[0] == ExpressionPrefix)
+ {
+ body = text[1..];
+ }
+ else
+ {
+ if (!text.Contains("{{", StringComparison.Ordinal))
+ return JsonValue.Create(text); // no delimiters → literal
+ body = text;
+ }
+
+ var blocks = ExtractBlocks(body);
+
+ if (blocks.Count == 0)
+ return JsonValue.Create(body); // '=' with no delimiters → literal text
+
+ if (blocks.Count == 1 && blocks[0].Start == 0 && blocks[0].End == body.Length)
+ {
+ // Whole-value expression: preserve the resolved type.
+ var resolved = Evaluate(blocks[0].Expression, scope);
+ return resolved?.DeepClone();
+ }
+
+ var builder = new StringBuilder();
+ var cursor = 0;
+ foreach (var block in blocks)
+ {
+ if (block.Start > cursor)
+ builder.Append(body, cursor, block.Start - cursor);
+ builder.Append(Render(Evaluate(block.Expression, scope)));
+ cursor = block.End;
+ }
+
+ if (cursor < body.Length)
+ builder.Append(body, cursor, body.Length - cursor);
+
+ return JsonValue.Create(builder.ToString());
+ }
+
+ // ---------------------------------------------------------------- expressions
+
+ private JsonNode? Evaluate(string expression, InterpolationScope scope)
+ {
+ var expr = expression.Trim();
+ if (expr.Length == 0)
+ throw new NodeInterpolationException(expression, "empty expression");
+
+ if (expr[0] == '$')
+ return EvaluateAccessor(expr, scope);
+
+ return ParseLiteral(expr, expression);
+ }
+
+ private JsonNode? EvaluateAccessor(string expr, InterpolationScope scope)
+ {
+ // Node references: $("Name"), $node("Name"), $items("Name").
+ if (TryReadNodeReference(expr, out var nodeName, out var wantsArray, out var tail))
+ {
+ var items = scope.NodeItems?.Invoke(nodeName) ?? Array.Empty();
+ JsonNode? node = wantsArray
+ ? new JsonArray(items.Select(i => (JsonNode?)i.Json.DeepClone()).ToArray())
+ : items.Count > 0
+ ? items[0].Json.DeepClone()
+ : null;
+ return tail.Length == 0 ? node : Navigate(node, tail, expr);
+ }
+
+ // Root token: $ + identifier.
+ var end = 1;
+ while (end < expr.Length && (char.IsLetterOrDigit(expr[end]) || expr[end] == '_'))
+ end++;
+
+ var root = expr[..end];
+ var path = expr[end..];
+
+ JsonNode? start = root switch
+ {
+ "$json" or "$item" => scope.Item?.DeepClone(),
+ "$parameter" => scope.Parameters?.DeepClone(),
+ "$env" => scope.Environment?.DeepClone(),
+ "$itemIndex" => JsonValue.Create(scope.ItemIndex),
+ "$runIndex" => JsonValue.Create(scope.RunIndex),
+ "$now" => JsonValue.Create(DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)),
+ "$today" => JsonValue.Create(DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)),
+ _ => throw new NodeInterpolationException(expr, $"unknown accessor '{root}'"),
+ };
+
+ return path.Length == 0 ? start : Navigate(start, path, expr);
+ }
+
+ private static bool TryReadNodeReference(string expr, out string nodeName, out bool wantsArray, out string tail)
+ {
+ nodeName = string.Empty;
+ wantsArray = false;
+ tail = string.Empty;
+
+ string? prefix = null;
+ if (expr.StartsWith("$items(", StringComparison.Ordinal))
+ {
+ prefix = "$items(";
+ wantsArray = true;
+ }
+ else if (expr.StartsWith("$node(", StringComparison.Ordinal))
+ {
+ prefix = "$node(";
+ }
+ else if (expr.StartsWith("$(", StringComparison.Ordinal))
+ {
+ prefix = "$(";
+ }
+
+ if (prefix == null)
+ return false;
+
+ var close = expr.IndexOf(')', prefix.Length);
+ if (close < 0)
+ throw new NodeInterpolationException(expr, "unterminated node reference");
+
+ var inner = expr[prefix.Length..close].Trim();
+ if (inner.Length >= 2 &&
+ ((inner[0] == '"' && inner[^1] == '"') || (inner[0] == '\'' && inner[^1] == '\'')))
+ {
+ inner = inner[1..^1];
+ }
+
+ nodeName = inner;
+ tail = expr[(close + 1)..];
+ return true;
+ }
+
+ private static JsonNode? Navigate(JsonNode? start, string path, string expression)
+ {
+ var cursor = 0;
+ var current = start;
+
+ while (cursor < path.Length)
+ {
+ var ch = path[cursor];
+
+ if (ch == '.')
+ {
+ cursor++;
+ var name = ReadIdentifier(path, ref cursor);
+ if (name.Length == 0)
+ throw new NodeInterpolationException(expression, $"expected a field name after '.' at {cursor}");
+ current = current is JsonObject obj ? obj[name] : null;
+ continue;
+ }
+
+ if (ch == '[')
+ {
+ cursor++;
+ var close = path.IndexOf(']', cursor);
+ if (close < 0)
+ throw new NodeInterpolationException(expression, "unterminated '[' in path");
+ var token = path[cursor..close].Trim();
+ cursor = close + 1;
+
+ if (token.Length >= 2 && (token[0] == '"' || token[0] == '\'') && token[^1] == token[0])
+ {
+ var key = token[1..^1];
+ current = current is JsonObject obj ? obj[key] : null;
+ }
+ else if (int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index))
+ {
+ current = current is JsonArray array && index >= 0 && index < array.Count ? array[index] : null;
+ }
+ else
+ {
+ throw new NodeInterpolationException(expression, $"invalid array index '{token}'");
+ }
+
+ continue;
+ }
+
+ throw new NodeInterpolationException(expression, $"unexpected '{ch}' in path");
+ }
+
+ return current;
+ }
+
+ private static string ReadIdentifier(string path, ref int cursor)
+ {
+ var start = cursor;
+ while (cursor < path.Length && (char.IsLetterOrDigit(path[cursor]) || path[cursor] == '_' || path[cursor] == '-'))
+ cursor++;
+ return path[start..cursor];
+ }
+
+ private static JsonNode? ParseLiteral(string literal, string expression)
+ {
+ if (literal is "true" or "false")
+ return JsonValue.Create(literal == "true");
+ if (literal is "null")
+ return null;
+
+ if (literal.Length >= 2 &&
+ ((literal[0] == '"' && literal[^1] == '"') || (literal[0] == '\'' && literal[^1] == '\'')))
+ {
+ return JsonValue.Create(literal[1..^1]);
+ }
+
+ if (decimal.TryParse(literal, NumberStyles.Number, CultureInfo.InvariantCulture, out var number))
+ return JsonValue.Create(number);
+
+ throw new NodeInterpolationException(expression, $"unsupported expression '{literal}'");
+ }
+
+ private static string Render(JsonNode? node) => node switch
+ {
+ null => string.Empty,
+ JsonValue value when value.TryGetValue(out var text) => text,
+ JsonValue value => value.ToJsonString(),
+ _ => node.ToJsonString(),
+ };
+
+ // ------------------------------------------------------------------- scanning
+
+ private readonly record struct Block(int Start, int End, string Expression);
+
+ private static List ExtractBlocks(string body)
+ {
+ var blocks = new List();
+ var cursor = 0;
+
+ while (cursor < body.Length)
+ {
+ var open = body.IndexOf("{{", cursor, StringComparison.Ordinal);
+ if (open < 0)
+ break;
+
+ var close = body.IndexOf("}}", open + 2, StringComparison.Ordinal);
+ if (close < 0)
+ break; // unterminated block: treat the remainder as literal
+
+ blocks.Add(new Block(open, close + 2, body[(open + 2)..close]));
+ cursor = close + 2;
+ }
+
+ return blocks;
+ }
+}
diff --git a/Services/Nodes/NodeBlueprintCatalog.cs b/Services/Nodes/NodeBlueprintCatalog.cs
new file mode 100644
index 0000000..9559df5
--- /dev/null
+++ b/Services/Nodes/NodeBlueprintCatalog.cs
@@ -0,0 +1,178 @@
+using System.Reflection;
+using System.Text.Json;
+using w4c_workflows.Models.Nodes;
+
+namespace w4c_workflows.Services.Nodes;
+
+///
+/// The catalog of known node types. It is the single source of truth for the
+/// compiler/validator and the authoring UI (palette + generic parameter forms).
+///
+/// Blueprints come from two places:
+/// - built-in core nodes, shipped as embedded JSON under Catalog/;
+/// - additional packs (connectors, OpenAPI imports) loaded from files.
+///
+public class NodeBlueprintCatalog
+{
+ private const string EmbeddedRoot = "w4c_workflows.Services.Nodes.Catalog.";
+
+ private readonly IReadOnlyDictionary> _byType;
+
+ public NodeBlueprintCatalog(IEnumerable blueprints)
+ {
+ var byType = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var blueprint in blueprints)
+ {
+ if (string.IsNullOrWhiteSpace(blueprint.Type))
+ throw new InvalidOperationException("a node blueprint is missing its type id");
+
+ if (!byType.TryGetValue(blueprint.Type, out var versions))
+ byType[blueprint.Type] = versions = new List();
+
+ if (versions.Any(existing => SameVersion(existing.Version, blueprint.Version)))
+ throw new InvalidOperationException(
+ $"duplicate node blueprint '{blueprint.Type}' version {blueprint.Version}");
+
+ versions.Add(blueprint);
+ }
+
+ foreach (var versions in byType.Values)
+ versions.Sort((a, b) => a.Version.CompareTo(b.Version));
+
+ _byType = byType;
+ }
+
+ /// All registered blueprints, newest version first within each type.
+ public IReadOnlyList All =>
+ _byType.Values.SelectMany(v => v).ToList();
+
+ /// Distinct category labels, sorted, for the palette's category rail.
+ public IReadOnlyList Categories =>
+ _byType.Values.SelectMany(v => v)
+ .SelectMany(b => b.Categories)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(c => c, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ public bool Contains(string type) => _byType.ContainsKey(type);
+
+ /// The default (highest) version of a type, or null when unknown.
+ public NodeBlueprint? Get(string type)
+ => _byType.TryGetValue(type, out var versions) ? versions[^1] : null;
+
+ ///
+ /// Resolves a specific version. A requested version of 0/null means "default"
+ /// (highest). Otherwise the closest version not newer than requested is used,
+ /// so a task pinned to 2.5 keeps working after 2.6 ships.
+ ///
+ public NodeBlueprint? Resolve(string type, double? version = null)
+ {
+ if (!_byType.TryGetValue(type, out var versions))
+ return null;
+
+ if (version is null or 0)
+ return versions[^1];
+
+ NodeBlueprint? match = null;
+ foreach (var candidate in versions)
+ {
+ if (candidate.Version <= version.Value + 0.0001)
+ match = candidate;
+ else
+ break;
+ }
+
+ return match;
+ }
+
+ /// All versions of a type, newest first.
+ public IReadOnlyList Versions(string type)
+ => _byType.TryGetValue(type, out var versions)
+ ? versions.OrderByDescending(v => v.Version).ToList()
+ : Array.Empty();
+
+ ///
+ /// Filters the palette. matches type, display name,
+ /// description or category; matches ;
+ /// matches a category label.
+ ///
+ public IReadOnlyList Search(string? query = null, string? kind = null, string? category = null)
+ {
+ IEnumerable results = _byType.Values.Select(v => v[^1]);
+
+ if (!string.IsNullOrWhiteSpace(kind))
+ results = results.Where(b => string.Equals(b.Kind, kind, StringComparison.OrdinalIgnoreCase));
+
+ if (!string.IsNullOrWhiteSpace(category))
+ results = results.Where(b => b.Categories.Any(c => string.Equals(c, category, StringComparison.OrdinalIgnoreCase)));
+
+ if (!string.IsNullOrWhiteSpace(query))
+ {
+ var needle = query.Trim();
+ results = results.Where(b =>
+ Contains(b.Type, needle) ||
+ Contains(b.DisplayName, needle) ||
+ Contains(b.Description, needle) ||
+ b.Categories.Any(c => Contains(c, needle)));
+ }
+
+ return results
+ .OrderBy(b => b.DisplayName, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ /// Loads core blueprints embedded in the running assembly.
+ public static IReadOnlyList LoadEmbedded()
+ => LoadEmbedded(typeof(NodeBlueprintCatalog).Assembly);
+
+ /// Loads every Catalog/*.json embedded in .
+ public static IReadOnlyList LoadEmbedded(Assembly assembly)
+ {
+ var blueprints = new List();
+
+ foreach (var resource in assembly.GetManifestResourceNames())
+ {
+ if (!resource.StartsWith(EmbeddedRoot, StringComparison.Ordinal) ||
+ !resource.EndsWith(".json", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ using var stream = assembly.GetManifestResourceStream(resource);
+ if (stream == null)
+ continue;
+
+ using var reader = new StreamReader(stream);
+ var json = reader.ReadToEnd();
+ var blueprint = JsonSerializer.Deserialize(json, NodeJson.Options)
+ ?? throw new InvalidOperationException($"node blueprint '{resource}' is empty");
+ blueprints.Add(blueprint);
+ }
+
+ return blueprints;
+ }
+
+ /// Loads all *.json blueprints from a directory (connector packs).
+ public static IReadOnlyList LoadDirectory(string directory)
+ {
+ var blueprints = new List();
+ if (!Directory.Exists(directory))
+ return blueprints;
+
+ foreach (var file in Directory.EnumerateFiles(directory, "*.json", SearchOption.AllDirectories))
+ {
+ var json = File.ReadAllText(file);
+ var blueprint = JsonSerializer.Deserialize(json, NodeJson.Options)
+ ?? throw new InvalidOperationException($"node blueprint '{file}' is empty");
+ blueprints.Add(blueprint);
+ }
+
+ return blueprints;
+ }
+
+ private static bool SameVersion(double a, double b) => Math.Abs(a - b) < 0.0001;
+
+ private static bool Contains(string? haystack, string needle)
+ => haystack?.Contains(needle, StringComparison.OrdinalIgnoreCase) == true;
+}
diff --git a/Services/Nodes/NodeExecutorRegistry.cs b/Services/Nodes/NodeExecutorRegistry.cs
new file mode 100644
index 0000000..1c68cde
--- /dev/null
+++ b/Services/Nodes/NodeExecutorRegistry.cs
@@ -0,0 +1,37 @@
+using w4c_workflows.Models.Nodes;
+
+namespace w4c_workflows.Services.Nodes;
+
+///
+/// Maps a blueprint type id to the that can run it.
+/// Blueprints may exist without an executor (catalogue-only, e.g. a connector
+/// that is listed but not installed); the compiler and API decide how to surface
+/// that, the registry only answers whether a runner exists.
+///
+public class NodeExecutorRegistry
+{
+ private readonly IReadOnlyDictionary _executors;
+
+ public NodeExecutorRegistry(IEnumerable executors)
+ {
+ var map = new Dictionary(StringComparer.Ordinal);
+ foreach (var executor in executors)
+ {
+ if (string.IsNullOrWhiteSpace(executor.Type))
+ throw new InvalidOperationException("a node executor is missing its type id");
+ if (!map.TryAdd(executor.Type, executor))
+ throw new InvalidOperationException($"duplicate node executor for type '{executor.Type}'");
+ }
+
+ _executors = map;
+ }
+
+ public IReadOnlyCollection Types => (IReadOnlyCollection)_executors.Keys;
+
+ public INodeExecutor? Resolve(string type)
+ => _executors.TryGetValue(type, out var executor) ? executor : null;
+
+ public bool CanRun(string type) => _executors.ContainsKey(type);
+
+ public bool CanRun(NodeBlueprint blueprint) => CanRun(blueprint.Type);
+}
diff --git a/w4c-workflows-api.Tests/NodeBlueprintCatalogTests.cs b/w4c-workflows-api.Tests/NodeBlueprintCatalogTests.cs
new file mode 100644
index 0000000..385efa0
--- /dev/null
+++ b/w4c-workflows-api.Tests/NodeBlueprintCatalogTests.cs
@@ -0,0 +1,121 @@
+using w4c_workflows.Models.Nodes;
+using w4c_workflows.Services.Nodes;
+using Xunit;
+
+namespace w4c_workflows.Tests;
+
+public class NodeBlueprintCatalogTests
+{
+ private static NodeBlueprint Blueprint(string type, double version, string display, string kind = NodeKind.Action, params string[] categories) =>
+ new()
+ {
+ Type = type,
+ Version = version,
+ DisplayName = display,
+ Kind = kind,
+ Categories = categories.ToList(),
+ };
+
+ [Fact]
+ public void LoadEmbedded_finds_the_core_blueprints()
+ {
+ var catalog = new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded());
+
+ Assert.True(catalog.Contains("core.noop"));
+ Assert.True(catalog.Contains("core.set"));
+ Assert.True(catalog.Contains("core.if"));
+ Assert.True(catalog.Contains("core.httpRequest"));
+ }
+
+ [Fact]
+ public void Core_blueprints_expose_expected_ports()
+ {
+ var catalog = new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded());
+
+ var branch = catalog.Get("core.if");
+ Assert.NotNull(branch);
+ Assert.Equal(2, branch!.Outputs.Count);
+ Assert.Equal("true", branch.Outputs[0].Label);
+ Assert.Equal("false", branch.Outputs[1].Label);
+
+ var request = catalog.Get("core.httpRequest");
+ Assert.NotNull(request);
+ Assert.True(request!.ProducesErrorBranch);
+ }
+
+ [Fact]
+ public void Resolve_without_version_returns_the_highest()
+ {
+ var catalog = new NodeBlueprintCatalog(new[]
+ {
+ Blueprint("demo", 1, "Demo v1"),
+ Blueprint("demo", 2, "Demo v2"),
+ Blueprint("demo", 2.5, "Demo v2.5"),
+ });
+
+ Assert.Equal(2.5, catalog.Resolve("demo")!.Version);
+ }
+
+ [Fact]
+ public void Resolve_picks_the_closest_version_not_newer_than_requested()
+ {
+ var catalog = new NodeBlueprintCatalog(new[]
+ {
+ Blueprint("demo", 1, "Demo v1"),
+ Blueprint("demo", 2, "Demo v2"),
+ Blueprint("demo", 2.5, "Demo v2.5"),
+ });
+
+ Assert.Equal(2, catalog.Resolve("demo", 2.2)!.Version);
+ Assert.Equal(1, catalog.Resolve("demo", 1.9)!.Version);
+ Assert.Equal(2, catalog.Resolve("demo", 2)!.Version);
+ Assert.Null(catalog.Resolve("demo", 0.5));
+ }
+
+ [Fact]
+ public void Duplicate_type_and_version_is_rejected()
+ {
+ var catalog = new NodeBlueprintCatalog(new[] { Blueprint("demo", 1, "A") });
+
+ Assert.Throws(() =>
+ new NodeBlueprintCatalog(new[] { Blueprint("demo", 1, "A"), Blueprint("demo", 1, "B") }));
+ }
+
+ [Fact]
+ public void Search_filters_by_query_kind_and_category()
+ {
+ var catalog = new NodeBlueprintCatalog(new[]
+ {
+ Blueprint("slack.send", 1, "Send message", NodeKind.Action, "Communication"),
+ Blueprint("if.branch", 1, "If", NodeKind.Control, "Flow"),
+ });
+
+ Assert.Single(catalog.Search(query: "slack"));
+ Assert.Single(catalog.Search(kind: NodeKind.Control));
+ Assert.Single(catalog.Search(category: "Communication"));
+ Assert.Empty(catalog.Search(query: "does-not-exist"));
+ }
+
+ [Fact]
+ public void Categories_are_distinct_and_sorted()
+ {
+ var catalog = new NodeBlueprintCatalog(new[]
+ {
+ Blueprint("a", 1, "A", NodeKind.Action, "Zeta"),
+ Blueprint("b", 1, "B", NodeKind.Action, "Alpha", "Zeta"),
+ });
+
+ Assert.Equal(new[] { "Alpha", "Zeta" }, catalog.Categories);
+ }
+
+ [Fact]
+ public void HasParameter_searches_nested_collection_fields()
+ {
+ var catalog = new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded());
+ var set = catalog.Get("core.set")!;
+
+ Assert.True(set.HasParameter("mode"));
+ Assert.True(set.HasParameter("name")); // nested inside the fixedCollection
+ Assert.False(set.HasParameter("nope"));
+ }
+}
diff --git a/w4c-workflows-api.Tests/NodeParameterInterpolatorTests.cs b/w4c-workflows-api.Tests/NodeParameterInterpolatorTests.cs
new file mode 100644
index 0000000..d36ed2d
--- /dev/null
+++ b/w4c-workflows-api.Tests/NodeParameterInterpolatorTests.cs
@@ -0,0 +1,165 @@
+using System.Text.Json.Nodes;
+using w4c_workflows.Models.Nodes;
+using w4c_workflows.Services.Nodes.Interpolation;
+using Xunit;
+
+namespace w4c_workflows.Tests;
+
+public class NodeParameterInterpolatorTests
+{
+ private readonly NodeParameterInterpolator _interpolator = new();
+
+ private static InterpolationScope Scope(
+ string itemJson = "{}",
+ string parametersJson = "{}",
+ string environmentJson = "{}",
+ Func>? nodeItems = null) =>
+ new()
+ {
+ Item = JsonNode.Parse(itemJson)!.AsObject(),
+ Parameters = JsonNode.Parse(parametersJson)!.AsObject(),
+ Environment = JsonNode.Parse(environmentJson)!.AsObject(),
+ ItemIndex = 2,
+ RunIndex = 1,
+ NodeItems = nodeItems,
+ };
+
+ [Fact]
+ public void Plain_string_is_a_literal()
+ {
+ var result = _interpolator.ResolveString("hello", Scope());
+ Assert.Equal("hello", result!.GetValue());
+ }
+
+ [Fact]
+ public void Equals_without_braces_is_still_literal_text()
+ {
+ var result = _interpolator.ResolveString("=plain text", Scope());
+ Assert.Equal("plain text", result!.GetValue());
+ }
+
+ [Fact]
+ public void Whole_value_expression_preserves_type_string()
+ {
+ var result = _interpolator.ResolveString("={{ $json.name }}", Scope("""{"name":"Alice"}"""));
+ Assert.Equal("Alice", result!.GetValue());
+ }
+
+ [Fact]
+ public void Whole_value_expression_preserves_type_number()
+ {
+ var result = _interpolator.ResolveString("={{ $json.count }}", Scope("""{"count":3}"""));
+ Assert.Equal("3", result!.ToJsonString());
+ }
+
+ [Fact]
+ public void Whole_value_expression_preserves_type_boolean()
+ {
+ var result = _interpolator.ResolveString("={{ $json.ok }}", Scope("""{"ok":true}"""));
+ Assert.Equal("true", result!.ToJsonString());
+ }
+
+ [Fact]
+ public void Embedded_expression_produces_an_interpolated_string()
+ {
+ var result = _interpolator.ResolveString("=Hi {{ $json.name }}, #{{ $json.count }}", Scope("""{"name":"Alice","count":3}"""));
+ Assert.Equal("Hi Alice, #3", result!.GetValue());
+ }
+
+ [Fact]
+ public void Nested_paths_and_array_indexes_resolve()
+ {
+ var result = _interpolator.ResolveString(
+ "={{ $json.orders[0].id }}",
+ Scope("""{"orders":[{"id":42}]}"""));
+ Assert.Equal("42", result!.ToJsonString());
+ }
+
+ [Fact]
+ public void Bracket_notation_resolves()
+ {
+ var result = _interpolator.ResolveString(
+ """={{ $json["odd name"] }}""",
+ Scope("""{"odd name":"value"}"""));
+ Assert.Equal("value", result!.GetValue());
+ }
+
+ [Fact]
+ public void Parameter_and_environment_roots_resolve()
+ {
+ var scope = Scope(parametersJson: """{"mode":"manual"}""", environmentJson: """{"REGION":"eu"}""");
+
+ Assert.Equal("manual", _interpolator.ResolveString("={{ $parameter.mode }}", scope)!.GetValue());
+ Assert.Equal("eu", _interpolator.ResolveString("={{ $env.REGION }}", scope)!.GetValue());
+ }
+
+ [Fact]
+ public void Index_roots_resolve()
+ {
+ var scope = Scope();
+ Assert.Equal("2", _interpolator.ResolveString("={{ $itemIndex }}", scope)!.ToJsonString());
+ Assert.Equal("1", _interpolator.ResolveString("={{ $runIndex }}", scope)!.ToJsonString());
+ }
+
+ [Fact]
+ public void Node_reference_returns_the_first_item_json()
+ {
+ var scope = Scope(nodeItems: _ => new List
+ {
+ FlowItem.FromJson(new JsonObject { ["id"] = 7 }),
+ });
+
+ Assert.Equal("7", _interpolator.ResolveString("""={{ $("Prep").id }}""", scope)!.ToJsonString());
+ }
+
+ [Fact]
+ public void Items_reference_returns_an_array()
+ {
+ var scope = Scope(nodeItems: _ => new List
+ {
+ FlowItem.FromJson(new JsonObject { ["id"] = 7 }),
+ FlowItem.FromJson(new JsonObject { ["id"] = 8 }),
+ });
+
+ Assert.Equal("""[{"id":7},{"id":8}]""", _interpolator.ResolveString("""={{ $items("Prep") }}""", scope)!.ToJsonString());
+ }
+
+ [Fact]
+ public void Object_parameters_are_resolved_recursively()
+ {
+ var parameters = JsonNode.Parse("""
+ {
+ "url": "https://x/{{ $json.id }}",
+ "options": { "flag": "={{ $json.ok }}" },
+ "list": ["={{ $json.id }}"]
+ }
+ """)!.AsObject();
+
+ var resolved = _interpolator.ResolveObject(parameters, Scope("""{"id":"abc","ok":true}"""));
+
+ Assert.Equal("https://x/abc", resolved["url"]!.GetValue());
+ Assert.Equal("true", resolved["options"]!["flag"]!.ToJsonString());
+ Assert.Equal("abc", resolved["list"]![0]!.GetValue());
+ }
+
+ [Fact]
+ public void Unknown_accessor_throws()
+ {
+ Assert.Throws(() =>
+ _interpolator.ResolveString("={{ $nope.x }}", Scope()));
+ }
+
+ [Fact]
+ public void Missing_path_resolves_to_null_without_throwing()
+ {
+ var result = _interpolator.ResolveString("={{ $json.missing.deep }}", Scope("""{"other":1}"""));
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Case_sensitive_literal_stays_lowercase()
+ {
+ var result = _interpolator.ResolveString("={{ $json.name }}", Scope("""{"name":"aB"}"""));
+ Assert.Equal("aB", result!.GetValue());
+ }
+}
diff --git a/w4c-workflows-api.Tests/NodesControllerTests.cs b/w4c-workflows-api.Tests/NodesControllerTests.cs
new file mode 100644
index 0000000..59188b4
--- /dev/null
+++ b/w4c-workflows-api.Tests/NodesControllerTests.cs
@@ -0,0 +1,65 @@
+using Microsoft.AspNetCore.Mvc;
+using w4c_workflows.Controllers;
+using w4c_workflows.Models.Nodes;
+using w4c_workflows.Services.Nodes;
+using Xunit;
+
+namespace w4c_workflows.Tests;
+
+public class NodesControllerTests
+{
+ private static NodesController Create() => new(
+ new NodeBlueprintCatalog(NodeBlueprintCatalog.LoadEmbedded()),
+ new NodeExecutorRegistry(Array.Empty()));
+
+ [Fact]
+ public void List_returns_core_nodes_with_port_shape()
+ {
+ var result = Create().List(search: null, kind: null, category: null);
+
+ var ok = Assert.IsType(result);
+ var items = Assert.IsAssignableFrom>(ok.Value).ToList();
+
+ var branch = items.Single(n => n.Type == "core.if");
+ Assert.Equal(2, branch.Outputs);
+ Assert.False(branch.HasErrorBranch);
+
+ var request = items.Single(n => n.Type == "core.httpRequest");
+ Assert.True(request.HasErrorBranch);
+ }
+
+ [Fact]
+ public void List_filters_by_kind()
+ {
+ var ok = (OkObjectResult)Create().List(search: null, kind: NodeKind.Control, category: null);
+ var items = ((IEnumerable)ok.Value!).ToList();
+
+ Assert.Contains(items, n => n.Type == "core.if");
+ Assert.DoesNotContain(items, n => n.Type == "core.httpRequest");
+ }
+
+ [Fact]
+ public void Get_returns_blueprint_detail()
+ {
+ var ok = Assert.IsType(Create().Get("core.set", null));
+ var detail = Assert.IsType(ok.Value);
+
+ Assert.Equal("core.set", detail.Blueprint.Type);
+ Assert.False(detail.Runnable); // no executors installed yet
+ }
+
+ [Fact]
+ public void Get_unknown_type_returns_404()
+ {
+ Assert.IsType(Create().Get("does.not.exist", null));
+ }
+
+ [Fact]
+ public void Categories_are_exposed()
+ {
+ var ok = (OkObjectResult)Create().Categories();
+ var categories = Assert.IsAssignableFrom>(ok.Value);
+
+ Assert.Contains("Core", categories);
+ }
+}
diff --git a/w4c-workflows-api.csproj b/w4c-workflows-api.csproj
index addfd2e..97ca45d 100644
--- a/w4c-workflows-api.csproj
+++ b/w4c-workflows-api.csproj
@@ -15,6 +15,13 @@
+
+
+
+
+