@hostwebhook/template-engine 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -55,6 +55,41 @@ interface CodeResult {
55
55
  */
56
56
  declare function renderTemplate(template: string, ctx: TemplateContext, opts?: TemplateOptions): TemplateResult | CodeResult;
57
57
 
58
+ /**
59
+ * Resolve a single input value — literal string or {{expression}}.
60
+ *
61
+ * If the value is wrapped in `{{...}}`, it's evaluated as a template expression
62
+ * (supports paths, pipes, $() cross-node references, JS when allowJs).
63
+ * Otherwise, the value is returned as-is (literal).
64
+ *
65
+ * Preserves types: `{{payload.count}}` returns the original number, not a string.
66
+ *
67
+ * @example
68
+ * resolveInput("active", payload) // → "active"
69
+ * resolveInput("{{payload.status}}", payload) // → "delivered"
70
+ * resolveInput("{{payload.amount}}", payload) // → 1500 (number)
71
+ * resolveInput("{{payload.date | formatDate}}", payload) // → "2024-06-15"
72
+ * resolveInput('{{$("Filter").count}}', payload, { allowJs: true, workspacePayloads }) // → 42
73
+ * resolveInput(undefined, payload) // → undefined
74
+ */
75
+ declare function resolveInput(value: string | undefined | null, payload: unknown, opts?: Pick<TemplateOptions, 'allowJs' | 'workspacePayloads'>): unknown;
76
+ /**
77
+ * Normalize a field path — strips `{{`, `}}`, and `payload.` prefix.
78
+ *
79
+ * Accepts any of these formats and returns the plain dot-notation path:
80
+ * - `"{{payload.customer.name}}"` → `"customer.name"`
81
+ * - `"payload.customer.name"` → `"customer.name"`
82
+ * - `"customer.name"` → `"customer.name"`
83
+ * - `"{{customer.name}}"` → `"customer.name"`
84
+ */
85
+ declare function resolveFieldPath(field: string): string;
86
+
87
+ /**
88
+ * Validate code/expression against blocked patterns.
89
+ * Throws if any dangerous pattern is detected.
90
+ */
91
+ declare function validateCode(code: string): void;
92
+
58
93
  /**
59
94
  * Resolve a dot-notation path against a TemplateContext.
60
95
  * Supports bracket notation for array indexing: `payload.items[0].name`
@@ -87,4 +122,4 @@ declare function applyPipe(pipe: string, val: unknown, arg?: string, ctx?: Templ
87
122
  */
88
123
  declare function evaluate(expr: string, ctx: TemplateContext, opts?: TemplateOptions): string;
89
124
 
90
- export { type CodeResult, type TemplateContext, type TemplateMode, type TemplateOptions, type TemplateResult, applyPipe, evaluate, renderTemplate, resolvePath, tryNullCoalesce, tryTernary, valueToString };
125
+ export { type CodeResult, type TemplateContext, type TemplateMode, type TemplateOptions, type TemplateResult, applyPipe, evaluate, renderTemplate, resolveFieldPath, resolveInput, resolvePath, tryNullCoalesce, tryTernary, validateCode, valueToString };
package/dist/index.d.ts CHANGED
@@ -55,6 +55,41 @@ interface CodeResult {
55
55
  */
56
56
  declare function renderTemplate(template: string, ctx: TemplateContext, opts?: TemplateOptions): TemplateResult | CodeResult;
57
57
 
58
+ /**
59
+ * Resolve a single input value — literal string or {{expression}}.
60
+ *
61
+ * If the value is wrapped in `{{...}}`, it's evaluated as a template expression
62
+ * (supports paths, pipes, $() cross-node references, JS when allowJs).
63
+ * Otherwise, the value is returned as-is (literal).
64
+ *
65
+ * Preserves types: `{{payload.count}}` returns the original number, not a string.
66
+ *
67
+ * @example
68
+ * resolveInput("active", payload) // → "active"
69
+ * resolveInput("{{payload.status}}", payload) // → "delivered"
70
+ * resolveInput("{{payload.amount}}", payload) // → 1500 (number)
71
+ * resolveInput("{{payload.date | formatDate}}", payload) // → "2024-06-15"
72
+ * resolveInput('{{$("Filter").count}}', payload, { allowJs: true, workspacePayloads }) // → 42
73
+ * resolveInput(undefined, payload) // → undefined
74
+ */
75
+ declare function resolveInput(value: string | undefined | null, payload: unknown, opts?: Pick<TemplateOptions, 'allowJs' | 'workspacePayloads'>): unknown;
76
+ /**
77
+ * Normalize a field path — strips `{{`, `}}`, and `payload.` prefix.
78
+ *
79
+ * Accepts any of these formats and returns the plain dot-notation path:
80
+ * - `"{{payload.customer.name}}"` → `"customer.name"`
81
+ * - `"payload.customer.name"` → `"customer.name"`
82
+ * - `"customer.name"` → `"customer.name"`
83
+ * - `"{{customer.name}}"` → `"customer.name"`
84
+ */
85
+ declare function resolveFieldPath(field: string): string;
86
+
87
+ /**
88
+ * Validate code/expression against blocked patterns.
89
+ * Throws if any dangerous pattern is detected.
90
+ */
91
+ declare function validateCode(code: string): void;
92
+
58
93
  /**
59
94
  * Resolve a dot-notation path against a TemplateContext.
60
95
  * Supports bracket notation for array indexing: `payload.items[0].name`
@@ -87,4 +122,4 @@ declare function applyPipe(pipe: string, val: unknown, arg?: string, ctx?: Templ
87
122
  */
88
123
  declare function evaluate(expr: string, ctx: TemplateContext, opts?: TemplateOptions): string;
89
124
 
90
- export { type CodeResult, type TemplateContext, type TemplateMode, type TemplateOptions, type TemplateResult, applyPipe, evaluate, renderTemplate, resolvePath, tryNullCoalesce, tryTernary, valueToString };
125
+ export { type CodeResult, type TemplateContext, type TemplateMode, type TemplateOptions, type TemplateResult, applyPipe, evaluate, renderTemplate, resolveFieldPath, resolveInput, resolvePath, tryNullCoalesce, tryTernary, validateCode, valueToString };
package/dist/index.js CHANGED
@@ -33,9 +33,12 @@ __export(index_exports, {
33
33
  applyPipe: () => applyPipe,
34
34
  evaluate: () => evaluate,
35
35
  renderTemplate: () => renderTemplate,
36
+ resolveFieldPath: () => resolveFieldPath,
37
+ resolveInput: () => resolveInput,
36
38
  resolvePath: () => resolvePath,
37
39
  tryNullCoalesce: () => tryNullCoalesce,
38
40
  tryTernary: () => tryTernary,
41
+ validateCode: () => validateCode,
39
42
  valueToString: () => valueToString
40
43
  });
41
44
  module.exports = __toCommonJS(index_exports);
@@ -88,6 +91,72 @@ function valueToString(val) {
88
91
 
89
92
  // src/sandbox.ts
90
93
  var vm = __toESM(require("vm"));
94
+ var BLOCKED_PATTERNS = [
95
+ /\bconstructor\b/,
96
+ // this.constructor.constructor('return process')()
97
+ /\b__proto__\b/,
98
+ // prototype chain traversal
99
+ /\bprototype\b/,
100
+ // prototype manipulation
101
+ /\bprocess\b/,
102
+ // Node.js process object
103
+ /\brequire\s*\(/,
104
+ // CommonJS require
105
+ /\bimport\s*\(/,
106
+ // Dynamic import
107
+ /\bglobal\b/,
108
+ // global object
109
+ /\bglobalThis\b/,
110
+ // globalThis reference
111
+ /\beval\s*\(/,
112
+ // eval()
113
+ /\bFunction\s*\(/,
114
+ // new Function()
115
+ /\bProxy\s*\(/,
116
+ // Proxy constructor
117
+ /\bReflect\b/,
118
+ // Reflect API
119
+ /\bBuffer\b/,
120
+ // Node.js Buffer
121
+ /\bSharedArrayBuffer\b/,
122
+ // SharedArrayBuffer
123
+ /\bAtomics\b/,
124
+ // Atomics API
125
+ /\bWebAssembly\b/,
126
+ // WebAssembly
127
+ /\b__defineGetter__\b/,
128
+ // Legacy property definition
129
+ /\b__defineSetter__\b/,
130
+ // Legacy property definition
131
+ /\b__lookupGetter__\b/,
132
+ // Legacy property lookup
133
+ /\b__lookupSetter__\b/
134
+ // Legacy property lookup
135
+ ];
136
+ function validateCode(code) {
137
+ for (const pattern of BLOCKED_PATTERNS) {
138
+ if (pattern.test(code)) {
139
+ throw new Error(`Blocked: unsafe pattern "${pattern.source}" detected`);
140
+ }
141
+ }
142
+ }
143
+ function deepFreeze(obj, seen = /* @__PURE__ */ new WeakSet()) {
144
+ if (obj === null || obj === void 0) return;
145
+ if (typeof obj !== "object" && typeof obj !== "function") return;
146
+ if (seen.has(obj)) return;
147
+ seen.add(obj);
148
+ try {
149
+ Object.freeze(obj);
150
+ for (const key of Object.getOwnPropertyNames(obj)) {
151
+ try {
152
+ const val = obj[key];
153
+ deepFreeze(val, seen);
154
+ } catch {
155
+ }
156
+ }
157
+ } catch {
158
+ }
159
+ }
91
160
  function buildNodeLookup(workspacePayloads) {
92
161
  return (nodeName) => {
93
162
  if (!workspacePayloads) throw new Error("$() requires workspace context");
@@ -118,16 +187,23 @@ function buildSandbox(ctx, opts, logs) {
118
187
  }
119
188
  return String(v);
120
189
  };
190
+ let safePayload;
191
+ try {
192
+ safePayload = structuredClone(ctx.payload);
193
+ } catch {
194
+ safePayload = JSON.parse(JSON.stringify(ctx.payload ?? null));
195
+ }
121
196
  const sandbox = {
122
- payload: ctx.payload,
197
+ payload: safePayload,
123
198
  $: buildNodeLookup(opts.workspacePayloads),
124
- headers: ctx.headers,
125
- meta: ctx.meta,
126
- JSON,
199
+ headers: { ...ctx.headers ?? {} },
200
+ meta: { ...ctx.meta ?? {} },
201
+ // Safe builtins only — no process, require, import, eval, Function, Proxy, Reflect
202
+ JSON: { parse: JSON.parse, stringify: JSON.stringify },
127
203
  Math,
128
204
  Date,
129
- Array,
130
- Object,
205
+ Array: { isArray: Array.isArray, from: Array.from, of: Array.of },
206
+ Object: { keys: Object.keys, values: Object.values, entries: Object.entries, assign: Object.assign, freeze: Object.freeze },
131
207
  String,
132
208
  Number,
133
209
  Boolean,
@@ -139,7 +215,8 @@ function buildSandbox(ctx, opts, logs) {
139
215
  encodeURIComponent,
140
216
  decodeURIComponent,
141
217
  Map,
142
- Set
218
+ Set,
219
+ undefined: void 0
143
220
  };
144
221
  if (opts.mode === "code" && logs) {
145
222
  sandbox.console = {
@@ -148,9 +225,21 @@ function buildSandbox(ctx, opts, logs) {
148
225
  error: (...args) => logs.push(`[error] ${args.map(stringify).join(" ")}`)
149
226
  };
150
227
  }
228
+ for (const key of Object.keys(sandbox)) {
229
+ const val = sandbox[key];
230
+ if (typeof val === "object" && val !== null && key !== "payload") {
231
+ deepFreeze(val);
232
+ }
233
+ }
151
234
  return vm.createContext(sandbox);
152
235
  }
153
236
  function evalJsExpression(expr, ctx, opts) {
237
+ try {
238
+ validateCode(expr);
239
+ } catch (err) {
240
+ console.error(`[template-engine] ${err instanceof Error ? err.message : err}: "${expr.slice(0, 80)}"`);
241
+ return void 0;
242
+ }
154
243
  const context = buildSandbox(ctx, { ...opts, mode: "text" });
155
244
  try {
156
245
  const script = new vm.Script(`(${expr})`, { filename: "template-expr.js" });
@@ -444,6 +533,12 @@ var vm2 = __toESM(require("vm"));
444
533
  function executeCodeTemplate(code, ctx, opts) {
445
534
  const logs = [];
446
535
  const timeout = opts.timeout ?? 5e3;
536
+ try {
537
+ validateCode(code);
538
+ } catch (err) {
539
+ const message = err instanceof Error ? err.message : String(err);
540
+ return { output: null, statusCode: 500, logs, error: message };
541
+ }
447
542
  let sandboxPayload = ctx.payload;
448
543
  if (opts.batchMode) {
449
544
  const meta = ctx.payload?._meta;
@@ -533,13 +628,36 @@ function renderTemplate(template, ctx, opts) {
533
628
  return resolveTextTemplate(template, ctx, opts);
534
629
  }
535
630
  }
631
+
632
+ // src/resolve-input.ts
633
+ function resolveInput(value, payload, opts) {
634
+ if (value == null || value === "") return value;
635
+ const str = String(value);
636
+ const match = str.match(/^\{\{([\s\S]+)\}\}$/);
637
+ if (!match) return value;
638
+ const expr = match[1].trim();
639
+ const ctx = { payload, headers: {}, meta: {} };
640
+ const resolved = resolvePath(expr, ctx);
641
+ if (resolved !== void 0) return resolved;
642
+ const evalOpts = opts ? { allowJs: opts.allowJs, workspacePayloads: opts.workspacePayloads } : void 0;
643
+ const result = evaluate(expr, ctx, evalOpts);
644
+ if (result !== "") return result;
645
+ return value;
646
+ }
647
+ function resolveFieldPath(field) {
648
+ if (!field) return "";
649
+ return field.replace(/^\{\{\s*/, "").replace(/\s*\}\}$/, "").replace(/^payload\./, "").trim();
650
+ }
536
651
  // Annotate the CommonJS export names for ESM import in node:
537
652
  0 && (module.exports = {
538
653
  applyPipe,
539
654
  evaluate,
540
655
  renderTemplate,
656
+ resolveFieldPath,
657
+ resolveInput,
541
658
  resolvePath,
542
659
  tryNullCoalesce,
543
660
  tryTernary,
661
+ validateCode,
544
662
  valueToString
545
663
  });
package/dist/index.mjs CHANGED
@@ -46,6 +46,72 @@ function valueToString(val) {
46
46
 
47
47
  // src/sandbox.ts
48
48
  import * as vm from "vm";
49
+ var BLOCKED_PATTERNS = [
50
+ /\bconstructor\b/,
51
+ // this.constructor.constructor('return process')()
52
+ /\b__proto__\b/,
53
+ // prototype chain traversal
54
+ /\bprototype\b/,
55
+ // prototype manipulation
56
+ /\bprocess\b/,
57
+ // Node.js process object
58
+ /\brequire\s*\(/,
59
+ // CommonJS require
60
+ /\bimport\s*\(/,
61
+ // Dynamic import
62
+ /\bglobal\b/,
63
+ // global object
64
+ /\bglobalThis\b/,
65
+ // globalThis reference
66
+ /\beval\s*\(/,
67
+ // eval()
68
+ /\bFunction\s*\(/,
69
+ // new Function()
70
+ /\bProxy\s*\(/,
71
+ // Proxy constructor
72
+ /\bReflect\b/,
73
+ // Reflect API
74
+ /\bBuffer\b/,
75
+ // Node.js Buffer
76
+ /\bSharedArrayBuffer\b/,
77
+ // SharedArrayBuffer
78
+ /\bAtomics\b/,
79
+ // Atomics API
80
+ /\bWebAssembly\b/,
81
+ // WebAssembly
82
+ /\b__defineGetter__\b/,
83
+ // Legacy property definition
84
+ /\b__defineSetter__\b/,
85
+ // Legacy property definition
86
+ /\b__lookupGetter__\b/,
87
+ // Legacy property lookup
88
+ /\b__lookupSetter__\b/
89
+ // Legacy property lookup
90
+ ];
91
+ function validateCode(code) {
92
+ for (const pattern of BLOCKED_PATTERNS) {
93
+ if (pattern.test(code)) {
94
+ throw new Error(`Blocked: unsafe pattern "${pattern.source}" detected`);
95
+ }
96
+ }
97
+ }
98
+ function deepFreeze(obj, seen = /* @__PURE__ */ new WeakSet()) {
99
+ if (obj === null || obj === void 0) return;
100
+ if (typeof obj !== "object" && typeof obj !== "function") return;
101
+ if (seen.has(obj)) return;
102
+ seen.add(obj);
103
+ try {
104
+ Object.freeze(obj);
105
+ for (const key of Object.getOwnPropertyNames(obj)) {
106
+ try {
107
+ const val = obj[key];
108
+ deepFreeze(val, seen);
109
+ } catch {
110
+ }
111
+ }
112
+ } catch {
113
+ }
114
+ }
49
115
  function buildNodeLookup(workspacePayloads) {
50
116
  return (nodeName) => {
51
117
  if (!workspacePayloads) throw new Error("$() requires workspace context");
@@ -76,16 +142,23 @@ function buildSandbox(ctx, opts, logs) {
76
142
  }
77
143
  return String(v);
78
144
  };
145
+ let safePayload;
146
+ try {
147
+ safePayload = structuredClone(ctx.payload);
148
+ } catch {
149
+ safePayload = JSON.parse(JSON.stringify(ctx.payload ?? null));
150
+ }
79
151
  const sandbox = {
80
- payload: ctx.payload,
152
+ payload: safePayload,
81
153
  $: buildNodeLookup(opts.workspacePayloads),
82
- headers: ctx.headers,
83
- meta: ctx.meta,
84
- JSON,
154
+ headers: { ...ctx.headers ?? {} },
155
+ meta: { ...ctx.meta ?? {} },
156
+ // Safe builtins only — no process, require, import, eval, Function, Proxy, Reflect
157
+ JSON: { parse: JSON.parse, stringify: JSON.stringify },
85
158
  Math,
86
159
  Date,
87
- Array,
88
- Object,
160
+ Array: { isArray: Array.isArray, from: Array.from, of: Array.of },
161
+ Object: { keys: Object.keys, values: Object.values, entries: Object.entries, assign: Object.assign, freeze: Object.freeze },
89
162
  String,
90
163
  Number,
91
164
  Boolean,
@@ -97,7 +170,8 @@ function buildSandbox(ctx, opts, logs) {
97
170
  encodeURIComponent,
98
171
  decodeURIComponent,
99
172
  Map,
100
- Set
173
+ Set,
174
+ undefined: void 0
101
175
  };
102
176
  if (opts.mode === "code" && logs) {
103
177
  sandbox.console = {
@@ -106,9 +180,21 @@ function buildSandbox(ctx, opts, logs) {
106
180
  error: (...args) => logs.push(`[error] ${args.map(stringify).join(" ")}`)
107
181
  };
108
182
  }
183
+ for (const key of Object.keys(sandbox)) {
184
+ const val = sandbox[key];
185
+ if (typeof val === "object" && val !== null && key !== "payload") {
186
+ deepFreeze(val);
187
+ }
188
+ }
109
189
  return vm.createContext(sandbox);
110
190
  }
111
191
  function evalJsExpression(expr, ctx, opts) {
192
+ try {
193
+ validateCode(expr);
194
+ } catch (err) {
195
+ console.error(`[template-engine] ${err instanceof Error ? err.message : err}: "${expr.slice(0, 80)}"`);
196
+ return void 0;
197
+ }
112
198
  const context = buildSandbox(ctx, { ...opts, mode: "text" });
113
199
  try {
114
200
  const script = new vm.Script(`(${expr})`, { filename: "template-expr.js" });
@@ -402,6 +488,12 @@ import * as vm2 from "vm";
402
488
  function executeCodeTemplate(code, ctx, opts) {
403
489
  const logs = [];
404
490
  const timeout = opts.timeout ?? 5e3;
491
+ try {
492
+ validateCode(code);
493
+ } catch (err) {
494
+ const message = err instanceof Error ? err.message : String(err);
495
+ return { output: null, statusCode: 500, logs, error: message };
496
+ }
405
497
  let sandboxPayload = ctx.payload;
406
498
  if (opts.batchMode) {
407
499
  const meta = ctx.payload?._meta;
@@ -491,12 +583,35 @@ function renderTemplate(template, ctx, opts) {
491
583
  return resolveTextTemplate(template, ctx, opts);
492
584
  }
493
585
  }
586
+
587
+ // src/resolve-input.ts
588
+ function resolveInput(value, payload, opts) {
589
+ if (value == null || value === "") return value;
590
+ const str = String(value);
591
+ const match = str.match(/^\{\{([\s\S]+)\}\}$/);
592
+ if (!match) return value;
593
+ const expr = match[1].trim();
594
+ const ctx = { payload, headers: {}, meta: {} };
595
+ const resolved = resolvePath(expr, ctx);
596
+ if (resolved !== void 0) return resolved;
597
+ const evalOpts = opts ? { allowJs: opts.allowJs, workspacePayloads: opts.workspacePayloads } : void 0;
598
+ const result = evaluate(expr, ctx, evalOpts);
599
+ if (result !== "") return result;
600
+ return value;
601
+ }
602
+ function resolveFieldPath(field) {
603
+ if (!field) return "";
604
+ return field.replace(/^\{\{\s*/, "").replace(/\s*\}\}$/, "").replace(/^payload\./, "").trim();
605
+ }
494
606
  export {
495
607
  applyPipe,
496
608
  evaluate,
497
609
  renderTemplate,
610
+ resolveFieldPath,
611
+ resolveInput,
498
612
  resolvePath,
499
613
  tryNullCoalesce,
500
614
  tryTernary,
615
+ validateCode,
501
616
  valueToString
502
617
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostwebhook/template-engine",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Template interpolation and sandboxed JS execution engine for HostWebhook",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",