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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/api/workflows/[workflowId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { syncWorkflowSchedule } from "@/lib/schedule-service";
import { sanitizeDescription } from "@/lib/sanitize-description";
import { sanitizeWorkflowData } from "@/lib/workflow/editor/sanitize-nodes";
import { isReservedSlug } from "@/lib/workflow/reserved-slugs";
import { findInvalidTemplateTokens } from "@/lib/workflow/validation/template-syntax";
async function fetchWorkflowPublicTags(
workflowId: string
): Promise<Array<{ id: string; name: string; slug: string }>> {
Expand Down Expand Up @@ -306,6 +307,23 @@ export async function PATCH(
{ status: 403 }
);
}

// KEEP-468: parse every `{{...}}` token at save time so grammar typos
// (the n8n-style `{{$trigger.input.ts}}`-shaped errors that produced
// on-chain corruption during the hackathon) are rejected with line/path
// errors instead of running through the editor and failing at runtime.
const invalidTemplates = findInvalidTemplateTokens(body.nodes);
if (invalidTemplates.length > 0) {
return NextResponse.json(
{
error: "INVALID_TEMPLATE_SYNTAX",
message:
"Workflow contains template tokens that do not parse. Fix the listed references and save again.",
invalidTemplates,
},
{ status: 400 }
);
}
}

// Validate visibility value if provided
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ services:
SQS_QUEUE_URL: http://localstack:4566/000000000000/keeperhub-workflow-queue
INTEGRATION_ENCRYPTION_KEY: ${INTEGRATION_ENCRYPTION_KEY:-0000000000000000000000000000000000000000000000000000000000000000}
HEALTH_PORT: "3080"
TURNKEY_API_PUBLIC_KEY: ${TURNKEY_API_PUBLIC_KEY:-}
TURNKEY_API_PRIVATE_KEY: ${TURNKEY_API_PRIVATE_KEY:-}
TURNKEY_ORGANIZATION_ID: ${TURNKEY_ORGANIZATION_ID:-}
depends_on:
db:
condition: service_healthy
Expand Down
1 change: 1 addition & 0 deletions docs/workflows/_meta.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export default {
creating: "Creating Workflows",
templating: "Templating Reference",
"import-export": "Import/Export",
hub: "Hub",
marketplace: "Marketplace",
Expand Down
124 changes: 124 additions & 0 deletions docs/workflows/templating.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
---
title: "Templating reference"
description: "Canonical grammar for the `{{...}}` template tokens you can use to wire upstream node outputs into a downstream node's config."
---

# Templating reference

KeeperHub workflows wire data between nodes using `{{...}}` template tokens. A template token is any string of the form `{{ ... }}` that appears inside a node's config field. When the workflow runs, the executor replaces each token with the corresponding upstream value.

This page is the canonical reference. If a token does not match one of the grammars listed below, the executor will refuse to run the action and surface an error pointing at the offending reference. If a token does match the grammar but the upstream value cannot be resolved at runtime (the node has not run yet, the field does not exist, the upstream returned `null`), the executor will likewise abort the action with a structured error.

## Supported grammars

Three forms are recognized. Use the stored format whenever possible; the editor's `@` autocomplete writes it directly. The other two exist for backwards compatibility and direct hand-authoring.

### Stored format

```
{{@nodeId:Label[.path]}}
```

- `nodeId` is the upstream node's id (the one shown in the URL when you click the node, also persisted in the workflow JSON)
- `Label` is the upstream node's display name (rendered in the editor; case-insensitive at runtime)
- `path` is an optional dot-separated field accessor

Examples:

```
{{@a_1:HTTP Request.data.user.name}}
{{@trigger_node:Trigger.timestamp}}
{{@a_2:Read Contract.result}}
{{@n3:My Step}}
```

This is the form the editor produces when you click a field from the autocomplete dropdown. Prefer it because the `nodeId` is stable across renames; if you change a node's display label, references that use the stored format keep working.

### Display format (label-only)

```
{{Label[.path]}}
```

Examples:

```
{{HTTP Request.data.items[0].name}}
{{Trigger.timestamp}}
```

Resolves by case-insensitive label match. Brittle if you rename nodes; the editor will keep showing the same label, but if two nodes share a label the resolver picks the first match. Prefer the stored format above for new authoring.

### Legacy `$` format

```
{{$nodeId[.path]}}
```

Examples:

```
{{$node_1.data.items[0].name}}
{{$trigger.timestamp}}
```

Predates the stored format. Behaves like the stored format for resolution purposes (id-based lookup), but lacks the embedded display label that makes the stored form readable in the workflow JSON.

## Path syntax

The `path` portion of any of the three forms supports dotted field access and array indexing:

```
data.user.name // nested object
data.items[0].name // first array element, then field
status // top-level field
```

Indices must be numeric. There is no slice syntax, no wildcard, no expression evaluation; what you see is what gets resolved.

## What is NOT supported

These shapes parse as invalid and the workflow will fail to save:

| Token | Why |
|-------|-----|
| `{{}}` or `{{ }}` | Empty body |
| `{{@nodeId}}` | Stored format requires a colon and a label |
| `{{@:Label}}` or `{{@id:}}` | Stored format requires both halves |
| `{{$}}` | Legacy `$` format requires a body |

The following parses but will fail at **runtime** if the reference cannot be resolved:

| Token | Why |
|-------|-----|
| `{{$trigger.input.ts}}` | n8n-style `$variable` is not recognized as a node id |
| `{{Some Label.x}}` where `Some Label` does not match any node | Display format requires a real label |
| `{{@n1:Label.does.not.exist}}` | Field path does not exist on the upstream output |

When a runtime resolution fails, the action aborts with a structured error listing every unresolved reference. Earlier behaviour silently substituted an empty string or left the literal `{{...}}` token in the rendered value, which caused real on-chain corruption when the corrupted value flowed into a write action. The strict mode is the default.

## Where templates can appear

Templates work in any string-valued config field. Common places:

- Action input fields (URLs, addresses, message bodies)
- Conditions (Condition node expressions)
- Database Query parameters (parameterized as `$1`, `$2`, ... at execution time)
- Run Code source (string-valued upstream data is JSON-stringified into the code so it remains valid JavaScript when inlined)

Templates do **not** work inside binary fields (images, file uploads) or inside the workflow's structural metadata (node ids, edge ids, position).

## Runtime resolution mode

The executor reads `KEEPERHUB_TEMPLATE_RESOLVE_MODE` once at process start:

- `strict` (default): unresolved references abort the action with a clear error
- `legacy`: unresolved references silently substitute empty string or pass the literal `{{...}}` token through, matching pre-strict behaviour. Operators get a structured warn so they can quantify exposure during a migration window.

Use `legacy` only as a temporary opt-out while migrating an older workflow library; the failure mode it preserves is what produced the original on-chain corruption incident, so the goal is to leave it disabled.

## Tips

- Click a field in the editor's autocomplete dropdown rather than typing `@` references by hand. Hand-authored references are the main source of typos this validator catches.
- If you see `INVALID_TEMPLATE_SYNTAX` on save, check the `invalidTemplates` field in the response: each entry tells you the exact token and the reason it failed to parse.
- If you see `Unresolved template reference` at runtime, the upstream node either has not run yet (check the workflow topology), produced no data (check the upstream node's run output), or you typed the field path wrong (check the autocomplete suggestions).
84 changes: 68 additions & 16 deletions lib/utils/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@
* Template processing utilities for workflow node outputs
* Supports syntax like {{nodeName.field}} or {{nodeName.nested.field}}
* New format: {{@nodeId:DisplayName.field}} for ID-based references with display names
*
* KEEP-468: resolvers accept an optional tracker so callers can detect
* unresolved references and fail closed. Tracker shape is shared with
* `lib/workflow/executor/template-resolution.ts`.
*/

import {
recordUnresolved,
type TemplateResolutionTracker,
} from "@/lib/workflow/executor/template-resolution";

// Regex constants for performance
const TEMPLATE_PATTERN = /\{\{([^}]+)\}\}/g;
const ARRAY_ACCESS_PATTERN = /^([^[]+)\[(\d+)\]$/;
Expand Down Expand Up @@ -52,7 +61,8 @@ export type NodeOutputs = {
function processNewFormatReference(
trimmed: string,
nodeOutputs: NodeOutputs,
match: string
match: string,
tracker?: TemplateResolutionTracker
): string {
const withoutAt = trimmed.substring(1);
const colonIndex = withoutAt.indexOf(":");
Expand All @@ -71,22 +81,43 @@ function processNewFormatReference(
if (nodeOutput) {
return formatValue(nodeOutput.data);
}
recordUnresolved(tracker, {
token: match,
reason: "no-node",
detail: `Node "${nodeId}" has no output yet.`,
});
return "";
}

const value = resolveFieldPath(nodeOutputs[nodeId]?.data, fieldPath);
const nodeOutput = nodeOutputs[nodeId];
if (!nodeOutput) {
recordUnresolved(tracker, {
token: match,
reason: "no-node",
detail: `Node "${nodeId}" has no output yet.`,
});
return "";
}

const value = resolveFieldPath(nodeOutput.data, fieldPath);
if (value !== undefined && value !== null) {
return formatValue(value);
}

recordUnresolved(tracker, {
token: match,
reason: "no-path",
detail: `Field "${fieldPath}" not found on node "${nodeId}".`,
});
return "";
}

// Helper function to process legacy $ references ($nodeId)
function processLegacyDollarReference(
trimmed: string,
nodeOutputs: NodeOutputs,
_match: string
match: string,
tracker?: TemplateResolutionTracker
): string {
const withoutDollar = trimmed.substring(1);

Expand All @@ -95,6 +126,11 @@ function processLegacyDollarReference(
if (nodeOutput) {
return formatValue(nodeOutput.data);
}
recordUnresolved(tracker, {
token: match,
reason: "no-node",
detail: `Node "${withoutDollar}" has no output yet.`,
});
return "";
}

Expand All @@ -103,20 +139,31 @@ function processLegacyDollarReference(
return formatValue(value);
}

recordUnresolved(tracker, {
token: match,
reason: "no-path",
detail: `Reference "${withoutDollar}" did not resolve.`,
});
return "";
}

// Helper function to process legacy label references
function processLegacyLabelReference(
trimmed: string,
nodeOutputs: NodeOutputs,
_match: string
match: string,
tracker?: TemplateResolutionTracker
): string {
if (!(trimmed.includes(".") || trimmed.includes("["))) {
const nodeOutput = findNodeOutputByLabel(trimmed, nodeOutputs);
if (nodeOutput) {
return formatValue(nodeOutput.data);
}
recordUnresolved(tracker, {
token: match,
reason: "no-node",
detail: `No node matched label "${trimmed}".`,
});
return "";
}

Expand All @@ -125,6 +172,11 @@ function processLegacyLabelReference(
return formatValue(value);
}

recordUnresolved(tracker, {
token: match,
reason: "no-path",
detail: `Reference "${trimmed}" did not resolve.`,
});
return "";
}

Expand All @@ -139,7 +191,8 @@ function processLegacyLabelReference(
*/
export function processTemplate(
template: string,
nodeOutputs: NodeOutputs
nodeOutputs: NodeOutputs,
tracker?: TemplateResolutionTracker
): string {
if (!template || typeof template !== "string") {
return template;
Expand All @@ -148,16 +201,13 @@ export function processTemplate(
return template.replace(TEMPLATE_PATTERN, (match, expression) => {
const trimmed = expression.trim();

let result: string;
if (trimmed.startsWith("@")) {
result = processNewFormatReference(trimmed, nodeOutputs, match);
} else if (trimmed.startsWith("$")) {
result = processLegacyDollarReference(trimmed, nodeOutputs, match);
} else {
result = processLegacyLabelReference(trimmed, nodeOutputs, match);
return processNewFormatReference(trimmed, nodeOutputs, match, tracker);
}

return result;
if (trimmed.startsWith("$")) {
return processLegacyDollarReference(trimmed, nodeOutputs, match, tracker);
}
return processLegacyLabelReference(trimmed, nodeOutputs, match, tracker);
});
}

Expand All @@ -166,21 +216,23 @@ export function processTemplate(
*/
export function processConfigTemplates(
config: Record<string, unknown>,
nodeOutputs: NodeOutputs
nodeOutputs: NodeOutputs,
tracker?: TemplateResolutionTracker
): Record<string, unknown> {
const processed: Record<string, unknown> = {};

for (const [key, value] of Object.entries(config)) {
if (typeof value === "string") {
processed[key] = processTemplate(value, nodeOutputs);
processed[key] = processTemplate(value, nodeOutputs, tracker);
} else if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
processed[key] = processConfigTemplates(
value as Record<string, unknown>,
nodeOutputs
nodeOutputs,
tracker
);
} else {
processed[key] = value;
Expand Down
Loading
Loading