// server/lib/template-resolver.ts — Resolves {{steps.X.output.Y}} and {{trigger.field}} templates
//
// Used by workflow engine to flow data between steps.
// Extends the fillTemplate() pattern from utils.ts with workflow-specific contexts.
export interface TemplateContext {
steps: Record<string, { output?: unknown; status?: string; duration_ms?: number }>;
trigger: Record<string, unknown>;
input?: unknown; // For for_each child steps — the current item
env?: Record<string, unknown>;
}
const TEMPLATE_REGEX = /\{\{([^}]+)\}\}/g;
/** Block prototype pollution / traversal attacks */
const BLOCKED_PROPS = new Set([
"__proto__", "constructor", "prototype",
"__defineGetter__", "__defineSetter__",
"__lookupGetter__", "__lookupSetter__",
]);
/**
* Safely walk a dot-path into an object, blocking dangerous property names.
*/
function walkPath(value: unknown, parts: string[], startIndex: number): unknown {
let current = value;
for (let i = startIndex; i < parts.length; i++) {
if (current === null || current === undefined) return undefined;
if (typeof current !== "object") return undefined;
if (BLOCKED_PROPS.has(parts[i])) return undefined; // Block prototype traversal
current = (current as Record<string, unknown>)[parts[i]];
}
return current;
}
/**
* Resolve a single template expression like "steps.check.output.count" against the context.
* Returns the resolved value, or undefined if the path doesn't exist.
*/
function resolveExpression(expr: string, ctx: TemplateContext): unknown {
const trimmed = expr.trim();
// Built-in variables
if (trimmed === "now") return new Date().toISOString();
if (trimmed === "today") return new Date().toISOString().slice(0, 10);
if (trimmed === "timestamp") return Date.now();
// Navigate the dot path
const parts = trimmed.split(".");
const root = parts[0];
let value: unknown;
if (root === "steps" && parts.length >= 2) {
const stepKey = parts[1];
const stepData = ctx.steps[stepKey];
if (!stepData) return undefined;
value = walkPath(stepData, parts, 2);
} else if (root === "trigger") {
value = walkPath(ctx.trigger, parts, 1);
} else if (root === "input") {
value = walkPath(ctx.input, parts, 1);
} else if (root === "env" && ctx.env) {
value = walkPath(ctx.env, parts, 1);
} else {
return undefined;
}
return value;
}
/**
* Resolve all {{...}} placeholders in a string.
* If the entire string is a single template AND the resolved value is not a string,
* return the raw value (preserving numbers, objects, arrays, booleans).
*/
function resolveString(template: string, ctx: TemplateContext): unknown {
// Check if the entire string is a single template expression
const singleMatch = template.match(/^\{\{([^}]+)\}\}$/);
if (singleMatch) {
const resolved = resolveExpression(singleMatch[1], ctx);
return resolved !== undefined ? resolved : template;
}
// Multiple templates or mixed text — string interpolation
return template.replace(TEMPLATE_REGEX, (match, expr) => {
const resolved = resolveExpression(expr, ctx);
if (resolved === undefined) return match;
if (typeof resolved === "object" && resolved !== null) return JSON.stringify(resolved);
return String(resolved);
});
}
/**
* Recursively resolve templates in any JSON-compatible value.
* - Strings: resolve {{...}} patterns
* - Objects: recurse into values
* - Arrays: recurse into elements
* - Primitives: return as-is
*/
export function resolveTemplate(value: unknown, ctx: TemplateContext, depth = 0): unknown {
// Guard against infinite recursion from circular or deeply nested structures
if (depth > 20) return value;
if (typeof value === "string") {
return resolveString(value, ctx);
}
if (Array.isArray(value)) {
return value.map((item) => resolveTemplate(item, ctx, depth + 1));
}
if (value !== null && typeof value === "object") {
const resolved: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
resolved[k] = resolveTemplate(v, ctx, depth + 1);
}
return resolved;
}
return value;
}
/**
* Evaluate a simple condition expression.
* Supports: ==, !=, >, <, >=, <=, contains, !contains, exists, !exists
* Templates in the expression are resolved first.
*
* Examples:
* "{{steps.check.output.count}} > 10"
* "{{steps.fetch.output.status}} == 'active'"
* "{{trigger.type}} != 'test'"
*/
export function evaluateCondition(expression: string, ctx: TemplateContext): boolean {
// Resolve all templates in the expression first
const resolved = resolveString(expression, ctx);
const expr = String(resolved);
// exists / !exists checks
if (expr.includes(" exists")) {
const parts = expr.split(/\s+exists/);
const val = parts[0].trim();
const isNegated = expression.trim().startsWith("!");
const exists = val !== "" && val !== "undefined" && val !== "null" && !val.includes("{{");
return isNegated ? !exists : exists;
}
// Binary operators
const operators = ["!==", "===", "!=", "==", ">=", "<=", ">", "<", " contains ", " !contains "];
for (const op of operators) {
const idx = expr.indexOf(op);
if (idx === -1) continue;
let left = expr.substring(0, idx).trim();
let right = expr.substring(idx + op.length).trim();
// Strip quotes from right side
if ((right.startsWith("'") && right.endsWith("'")) || (right.startsWith('"') && right.endsWith('"'))) {
right = right.slice(1, -1);
}
if ((left.startsWith("'") && left.endsWith("'")) || (left.startsWith('"') && left.endsWith('"'))) {
left = left.slice(1, -1);
}
const numLeft = Number(left);
const numRight = Number(right);
const bothNumeric = !isNaN(numLeft) && !isNaN(numRight) && left !== "" && right !== "";
switch (op.trim()) {
case "===":
case "==":
return bothNumeric ? numLeft === numRight : left === right;
case "!==":
case "!=":
return bothNumeric ? numLeft !== numRight : left !== right;
case ">":
return bothNumeric ? numLeft > numRight : left > right;
case "<":
return bothNumeric ? numLeft < numRight : left < right;
case ">=":
return bothNumeric ? numLeft >= numRight : left >= right;
case "<=":
return bothNumeric ? numLeft <= numRight : left <= right;
case "contains":
return left.includes(right);
case "!contains":
return !left.includes(right);
}
}
// Truthy/falsy check — if expression resolved to a value
if (expr === "true" || expr === "1") return true;
if (expr === "false" || expr === "0" || expr === "" || expr === "null" || expr === "undefined") return false;
// If there are still unresolved templates, condition is false
if (expr.includes("{{")) return false;
// Non-empty string is truthy
return expr.length > 0;
}