@unoverse-platform/studio 0.1.90 → 0.1.91
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/local/plugin.ts +20 -12
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +140 -0
- package/vendor/base/src/agent-mcp/lean.ts +111 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +130 -0
- package/vendor/base/src/auth/auth.ts +278 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +128 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +643 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +199 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +198 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.mjs +64 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +107 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +150 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +187 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +154 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafeExpression — a restricted evaluator for `return …` template config.
|
|
3
|
+
*
|
|
4
|
+
* WHY (SECURITY.md §"Untrusted template expressions"): template expressions ride inside
|
|
5
|
+
* workflow definitions, which travel by paste / marketplace component / shared export —
|
|
6
|
+
* an untrusted surface. The old resolver ran them via `new Function`, which is NOT a
|
|
7
|
+
* sandbox: the body reaches every Node global (`process.env` → secret theft, `fetch` →
|
|
8
|
+
* exfiltration, prototype-chain → RCE). A trojaned component could hide code in a prop.
|
|
9
|
+
*
|
|
10
|
+
* This evaluator parses the expression once (cached, like the old memoize) and walks an
|
|
11
|
+
* ALLOWLIST of AST node types, evaluating in-process against the data context only. Security
|
|
12
|
+
* is by ABSENCE: there is no `process`, `require`, `constructor` for an expression to reach —
|
|
13
|
+
* the interpreter simply never implements them, so there is nothing to escape to. No V8
|
|
14
|
+
* isolate, no marshalling → negligible overhead vs a native function call, and pure-JS (no
|
|
15
|
+
* native module). It supports the real use — data-shaping: member access, indexing, object /
|
|
16
|
+
* array literals, operators, ternaries, template strings, and safe array/string/JSON/Math
|
|
17
|
+
* methods (incl. `.map(x => …)` arrow callbacks). Anything else throws (and is logged).
|
|
18
|
+
*/
|
|
19
|
+
import { parseExpressionAt, type Node as AcornNode } from "acorn";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import memoizeImport from "fast-memoize";
|
|
22
|
+
/**
|
|
23
|
+
* fast-memoize ships CommonJS (`module.exports = memoize`) with ESM-shaped typings
|
|
24
|
+
* (`export default`). Under NodeNext those disagree: the runtime value IS the function,
|
|
25
|
+
* but TypeScript types the default import as the module namespace and calls it uncallable.
|
|
26
|
+
* Verified at runtime before casting, rather than assuming.
|
|
27
|
+
*/
|
|
28
|
+
type Memoizer = <F extends (...args: any[]) => any>(fn: F) => F;
|
|
29
|
+
const memoize = memoizeImport as unknown as Memoizer;
|
|
30
|
+
|
|
31
|
+
/** Global namespaces an expression may reference — pure, no I/O, no ambient authority. */
|
|
32
|
+
const SAFE_GLOBALS: Record<string, unknown> = {
|
|
33
|
+
JSON,
|
|
34
|
+
Math,
|
|
35
|
+
Number,
|
|
36
|
+
String,
|
|
37
|
+
Boolean,
|
|
38
|
+
parseInt,
|
|
39
|
+
parseFloat,
|
|
40
|
+
isNaN,
|
|
41
|
+
isFinite,
|
|
42
|
+
// URL encoding. Pure string transforms with no I/O and nothing to reach, so they meet the
|
|
43
|
+
// same bar as parseInt above. Needed because a manifest builds request URLs from data: a
|
|
44
|
+
// table NAME or a record id goes into a path segment, and without these an id containing a
|
|
45
|
+
// space or a slash silently produces a malformed URL rather than an encoded one.
|
|
46
|
+
encodeURIComponent,
|
|
47
|
+
encodeURI,
|
|
48
|
+
// Content addressing. A pure function of its argument with no I/O and nothing to reach,
|
|
49
|
+
// so it meets the same bar as parseInt above: hashing a string cannot observe or change
|
|
50
|
+
// anything outside the expression.
|
|
51
|
+
//
|
|
52
|
+
// Needed because STABLE IDS ARE DATA, not decoration. A node that pulls a page derives a
|
|
53
|
+
// universal id from the url and a content id from the text, and downstream dedup and ref
|
|
54
|
+
// hydration join on exactly those. Without this the ids have to be minted in TypeScript,
|
|
55
|
+
// which means the node cannot be a manifest at all, for the sake of one pure function.
|
|
56
|
+
sha256: (value: unknown) => createHash("sha256").update(String(value ?? "")).digest("hex"),
|
|
57
|
+
// Text to bytes. A pure function of its argument with no I/O and nothing to reach, the
|
|
58
|
+
// same bar as sha256 above.
|
|
59
|
+
//
|
|
60
|
+
// Needed because BYTES TRAVEL AS BASE64 between calls (`encoding: binary` decodes them at
|
|
61
|
+
// send time), and the sandbox can express no encoder of its own: `charCodeAt` is not a
|
|
62
|
+
// safe method and there is no Buffer. So a node holding plain TEXT it wants to store —
|
|
63
|
+
// parsed markdown written back to S3 is the case that forced this — could not reach any
|
|
64
|
+
// binary body path at all, for want of one pure transform. UTF-8, because the text is
|
|
65
|
+
// real prose, not ASCII.
|
|
66
|
+
toBase64: (value: unknown) => Buffer.from(String(value ?? ""), "utf8").toString("base64"),
|
|
67
|
+
Object: { keys: Object.keys, values: Object.values, entries: Object.entries, fromEntries: Object.fromEntries, assign: (...a: object[]) => Object.assign({}, ...a) },
|
|
68
|
+
Array: { isArray: Array.isArray, from: Array.from, of: Array.of },
|
|
69
|
+
/**
|
|
70
|
+
* TIME, readable and formattable, but never constructible.
|
|
71
|
+
*
|
|
72
|
+
* `now` alone was not enough to be useful. Half the APIs a node talks to take a date RANGE —
|
|
73
|
+
* "transactions since", "runs between" — and every one of them wants `YYYY-MM-DD` or a full ISO
|
|
74
|
+
* string. An expression could do the arithmetic (`Date.now() - 30 * 86400000`) and then had no way
|
|
75
|
+
* to render the answer: `new Date(...)` is a NewExpression and refused, and `toISOString` is not a
|
|
76
|
+
* safe method, so the number stayed a number. A node needing a date range therefore could not be a
|
|
77
|
+
* manifest at all, for want of one pure formatter.
|
|
78
|
+
*
|
|
79
|
+
* ONE function, not a family. `iso` returns the full ISO string and `.split("T")[0]` — already
|
|
80
|
+
* allowed — gives the date part, so "YYYY-MM-DD" needs no second primitive. Fewer things to learn
|
|
81
|
+
* and fewer to keep consistent.
|
|
82
|
+
*
|
|
83
|
+
* It meets the same bar as `sha256` above: a pure function of its argument, no I/O, nothing
|
|
84
|
+
* reachable through it. `Date.now` was already exposed, so nothing new is observable — this only
|
|
85
|
+
* formats what an expression could already read.
|
|
86
|
+
*/
|
|
87
|
+
Date: {
|
|
88
|
+
now: Date.now,
|
|
89
|
+
iso: (ms?: unknown) => {
|
|
90
|
+
const t = ms === undefined || ms === null ? Date.now() : Number(ms);
|
|
91
|
+
// A clear message beats `new Date(NaN)`'s RangeError, which names neither the value nor the
|
|
92
|
+
// field it came from. Getting a string where a timestamp was meant is the likely mistake.
|
|
93
|
+
if (!Number.isFinite(t))
|
|
94
|
+
throw new UnsafeExpressionError(`Date.iso needs a timestamp in milliseconds, got ${JSON.stringify(ms)}`);
|
|
95
|
+
return new Date(t).toISOString();
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Method names callable on user data (array/string/JSON/Math) — all side-effect-free reads
|
|
101
|
+
* or pure transforms. NOT included: anything that could reach a constructor or mutate host. */
|
|
102
|
+
const SAFE_METHODS = new Set([
|
|
103
|
+
// array (non-mutating)
|
|
104
|
+
"map", "filter", "slice", "concat", "join", "includes", "indexOf", "lastIndexOf",
|
|
105
|
+
"find", "findIndex", "some", "every", "reduce", "flat", "flatMap", "at", "keys", "values", "entries",
|
|
106
|
+
// `toSorted`, and deliberately NOT `sort`. Both order an array; only one of them is a pure
|
|
107
|
+
// transform. `sort` reorders its receiver IN PLACE, and the receiver here is live run data — an
|
|
108
|
+
// expression doing `signal.items.sort()` would permanently reorder the upstream node's own output
|
|
109
|
+
// for every other reader of it, which is a side effect this evaluator is supposed to make
|
|
110
|
+
// impossible. `toSorted` returns a new array and leaves the original alone.
|
|
111
|
+
//
|
|
112
|
+
// Needed because CANONICAL ORDER IS PART OF A CONTENT HASH. A content id is
|
|
113
|
+
// `sha256(JSON.stringify(value, Object.keys(value).toSorted()))`, and without the sort the same
|
|
114
|
+
// object hashes differently depending on the order its keys happened to be built in — so dedup
|
|
115
|
+
// and ref hydration, which join on exactly those ids, would miss.
|
|
116
|
+
"toSorted",
|
|
117
|
+
// string
|
|
118
|
+
"split", "toUpperCase", "toLowerCase", "trim", "trimStart", "trimEnd", "replace", "replaceAll",
|
|
119
|
+
"substring", "substr", "startsWith", "endsWith", "padStart", "padEnd", "repeat", "charAt", "match",
|
|
120
|
+
// number
|
|
121
|
+
"toFixed", "toPrecision",
|
|
122
|
+
// shared
|
|
123
|
+
"toString",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
/** Property names that are the classic prototype-chain escape — never readable. */
|
|
127
|
+
const BLOCKED_PROPS = new Set(["constructor", "__proto__", "prototype", "__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__"]);
|
|
128
|
+
|
|
129
|
+
class UnsafeExpressionError extends Error {}
|
|
130
|
+
|
|
131
|
+
const parseCached = memoize((expr: string): AcornNode => {
|
|
132
|
+
// Strip a leading `return` and trailing `;` — the field's convention is a return-expression.
|
|
133
|
+
const cleaned = expr.trim().replace(/^return\b/, "").replace(/;\s*$/, "").trim();
|
|
134
|
+
if (!cleaned) throw new UnsafeExpressionError("empty expression");
|
|
135
|
+
return parseExpressionAt(cleaned, 0, { ecmaVersion: 2022 }) as AcornNode;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
type Scope = Record<string, unknown>;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* STATEMENTS, inside a block-body arrow. The Code node's contract is "any JS return statement", and
|
|
142
|
+
* for two days it could not honour it.
|
|
143
|
+
*
|
|
144
|
+
* Replacing `new Function` with this interpreter (2026-07-25) closed a real hole, but it also
|
|
145
|
+
* rejected block-body arrows on the reasoning that "data-shaping never needs them". The Code node
|
|
146
|
+
* disproves that: real extraction code declares locals, matches, branches, and returns — the exact
|
|
147
|
+
* shape of every `return (() => { ... })()` anyone had written. Worse, the rejection was INVISIBLE.
|
|
148
|
+
* `TemplateResolver` catches a throw and never reassigns, so the raw SOURCE TEXT stayed as the
|
|
149
|
+
* field's value and the node emitted its own code as data.
|
|
150
|
+
*
|
|
151
|
+
* WHAT THIS DOES NOT CHANGE, which is the whole point: no new globals, no `new`, no `constructor`,
|
|
152
|
+
* no `require`, no `process`, no property outside BLOCKED_PROPS. Security here is by ABSENCE, and
|
|
153
|
+
* absence is untouched — statements are evaluated by this interpreter, not handed to the host.
|
|
154
|
+
*
|
|
155
|
+
* NO LOOPS, deliberately. `while`/`for` would let a pasted expression hang the engine, and iteration
|
|
156
|
+
* is already covered by `.map`/`.filter`/`.reduce`, which are bounded by their input. A denial of
|
|
157
|
+
* service is a smaller hole than RCE but it is still a hole, and nothing needs it.
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
/** Names DECLARED in a given scope, so an assignment can find the right one to write. */
|
|
161
|
+
const DECLARED = Symbol("declared");
|
|
162
|
+
|
|
163
|
+
/** A fresh block scope. Declarations land here; assignment walks up to where the name was declared. */
|
|
164
|
+
function childScope(parent: Scope): Scope {
|
|
165
|
+
const s: Scope = Object.create(parent);
|
|
166
|
+
Object.defineProperty(s, DECLARED, { value: new Set<string>(), enumerable: false });
|
|
167
|
+
return s;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function declare(scope: Scope, name: string, value: unknown): void {
|
|
171
|
+
const set = (scope as any)[DECLARED] as Set<string> | undefined;
|
|
172
|
+
set?.add(name);
|
|
173
|
+
scope[name] = value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Assign to the nearest scope that DECLARED the name.
|
|
178
|
+
*
|
|
179
|
+
* Undeclared assignment throws rather than creating a binding. That is what keeps the injected data
|
|
180
|
+
* context read-only: without it, `signal = whatever` would either mutate the caller's object or
|
|
181
|
+
* silently shadow it, and a template must never be able to rewrite the run's own data.
|
|
182
|
+
*/
|
|
183
|
+
function assign(scope: Scope, name: string, value: unknown): void {
|
|
184
|
+
for (let s: any = scope; s; s = Object.getPrototypeOf(s)) {
|
|
185
|
+
const own = Object.getOwnPropertyDescriptor(s, DECLARED)?.value as Set<string> | undefined;
|
|
186
|
+
if (own?.has(name)) {
|
|
187
|
+
s[name] = value;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw new UnsafeExpressionError(`assignment to undeclared '${name}'`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Functions THIS interpreter created, i.e. arrows written in the expression itself.
|
|
196
|
+
*
|
|
197
|
+
* Calling one has to be allowed for an IIFE — `(() => { ... })()` is the only way an expression
|
|
198
|
+
* language gets a statement body — but the allowlist for a DIRECT call was "is it a safe global?",
|
|
199
|
+
* so the interpreter refused to call its own arrow with "call of non-allowlisted function".
|
|
200
|
+
*
|
|
201
|
+
* A WeakSet and not a flag on the function: nothing a template can reach may add to it, because the
|
|
202
|
+
* only place that registers is the arrow case below. A host function that arrived through the data
|
|
203
|
+
* context is still not callable, which is the property that matters.
|
|
204
|
+
*/
|
|
205
|
+
const OURS = new WeakSet<Function>();
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* NAMED HELPER BAGS — objects whose members may be CALLED as methods.
|
|
209
|
+
*
|
|
210
|
+
* The member-call rule is "the receiver is a safe-global namespace, or the method name is a
|
|
211
|
+
* known-safe builtin", which is what keeps `whatever.call(...)` and `x.constructor(...)` out.
|
|
212
|
+
* A helper bag is neither, so without this a manifest could pass `helpers.row` to `.map()`
|
|
213
|
+
* and yet not call `helpers.row(r)` directly — the same function, callable one way and not
|
|
214
|
+
* the other, which is the kind of rule nobody can hold in their head.
|
|
215
|
+
*
|
|
216
|
+
* A WeakSet and not a marker property, for the same reason as OURS: only `makeHelpers` below
|
|
217
|
+
* registers, and nothing an expression can reach may add to it. An object that arrived on the
|
|
218
|
+
* data context is still not a namespace, however it is shaped.
|
|
219
|
+
*/
|
|
220
|
+
const NAMESPACES = new WeakSet<object>();
|
|
221
|
+
|
|
222
|
+
/** One named helper: a sandboxed function a manifest can call by name. */
|
|
223
|
+
export interface HelperDef {
|
|
224
|
+
args?: string[];
|
|
225
|
+
body: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Build a callable bag from `helpers:` declarations in a package's shared/ folder.
|
|
230
|
+
*
|
|
231
|
+
* WHY THIS EXISTS: an expression is one string, so a projection of any size had to be written
|
|
232
|
+
* as one string — a 45-line JavaScript blob inside a YAML scalar, unreadable and undiffable,
|
|
233
|
+
* with no way to give the parts names. Splitting it needed a way for one expression to call
|
|
234
|
+
* another, and there wasn't one.
|
|
235
|
+
*
|
|
236
|
+
* The bodies are ORDINARY expressions with no extra authority: same sandbox, same allowlist,
|
|
237
|
+
* same absence of process/require/fetch. A helper sees ONLY its declared arguments and its
|
|
238
|
+
* sibling helpers — not `config`, not `credentials`, not the caller's scope. That is
|
|
239
|
+
* deliberate: a named function whose result depends on ambient state it never named is the
|
|
240
|
+
* thing this format is trying to get away from.
|
|
241
|
+
*
|
|
242
|
+
* Parsed EAGERLY, so a typo in a helper fails when the package loads, naming the helper,
|
|
243
|
+
* rather than on the first request that happens to reach it.
|
|
244
|
+
*/
|
|
245
|
+
export function makeHelpers(defs: Record<string, HelperDef>): Record<string, (...a: unknown[]) => unknown> {
|
|
246
|
+
const bag: Record<string, (...a: unknown[]) => unknown> = {};
|
|
247
|
+
for (const [name, def] of Object.entries(defs ?? {})) {
|
|
248
|
+
const params = Array.isArray(def?.args) ? def.args.map(String) : [];
|
|
249
|
+
const code = String(def?.body ?? "").replace(/^return\s+/, "");
|
|
250
|
+
if (!code.trim()) throw new UnsafeExpressionError(`helper '${name}' has an empty body`);
|
|
251
|
+
// Both checks up front: parsable, and made only of forms the interpreter implements.
|
|
252
|
+
// The second is what catches an illegal construct on a branch no test happens to enter.
|
|
253
|
+
parseCached(code);
|
|
254
|
+
assertSupportedExpression(code);
|
|
255
|
+
const fn = (...callArgs: unknown[]) => {
|
|
256
|
+
// Rooted on the bag alone: siblings are reachable, the caller's scope is not.
|
|
257
|
+
const scope = childScope({ helpers: bag } as Scope);
|
|
258
|
+
params.forEach((p, i) => declare(scope, p, callArgs[i]));
|
|
259
|
+
return evalNode(parseCached(code), scope);
|
|
260
|
+
};
|
|
261
|
+
OURS.add(fn);
|
|
262
|
+
bag[name] = fn;
|
|
263
|
+
}
|
|
264
|
+
NAMESPACES.add(bag);
|
|
265
|
+
return bag;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Marks a `return`, so it unwinds through nested blocks without using a host exception. */
|
|
269
|
+
const RETURNED = Symbol("returned");
|
|
270
|
+
type Completion = { [RETURNED]: true; value: unknown } | undefined;
|
|
271
|
+
|
|
272
|
+
function execStatement(node: any, scope: Scope): Completion {
|
|
273
|
+
switch (node.type) {
|
|
274
|
+
case "VariableDeclaration":
|
|
275
|
+
for (const d of node.declarations) {
|
|
276
|
+
if (d.id.type !== "Identifier") throw new UnsafeExpressionError("destructuring declaration");
|
|
277
|
+
declare(scope, d.id.name, d.init ? evalNode(d.init, scope) : undefined);
|
|
278
|
+
}
|
|
279
|
+
return undefined;
|
|
280
|
+
|
|
281
|
+
case "ExpressionStatement":
|
|
282
|
+
evalNode(node.expression, scope);
|
|
283
|
+
return undefined;
|
|
284
|
+
|
|
285
|
+
case "ReturnStatement":
|
|
286
|
+
return { [RETURNED]: true, value: node.argument ? evalNode(node.argument, scope) : undefined };
|
|
287
|
+
|
|
288
|
+
case "IfStatement":
|
|
289
|
+
if (evalNode(node.test, scope)) return execStatement(node.consequent, scope);
|
|
290
|
+
return node.alternate ? execStatement(node.alternate, scope) : undefined;
|
|
291
|
+
|
|
292
|
+
case "BlockStatement": {
|
|
293
|
+
// Its own scope, so a `const` inside an if-block cannot leak out — and an assignment to an
|
|
294
|
+
// OUTER `let` still reaches it, via `assign` walking the chain. Getting that backwards would
|
|
295
|
+
// silently drop every `if (m) title = ...`, which is the commonest shape there is.
|
|
296
|
+
const inner = childScope(scope);
|
|
297
|
+
for (const s of node.body) {
|
|
298
|
+
const done = execStatement(s, inner);
|
|
299
|
+
if (done) return done;
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
case "EmptyStatement":
|
|
305
|
+
return undefined;
|
|
306
|
+
|
|
307
|
+
default:
|
|
308
|
+
throw new UnsafeExpressionError(`disallowed statement: ${node.type}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function prop(obj: unknown, key: PropertyKey): unknown {
|
|
313
|
+
if (BLOCKED_PROPS.has(String(key))) throw new UnsafeExpressionError(`blocked property '${String(key)}'`);
|
|
314
|
+
if (obj == null) return undefined;
|
|
315
|
+
return (obj as Record<PropertyKey, unknown>)[key];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
319
|
+
function evalNode(node: any, scope: Scope): unknown {
|
|
320
|
+
switch (node.type) {
|
|
321
|
+
case "Literal":
|
|
322
|
+
return node.value;
|
|
323
|
+
case "Identifier":
|
|
324
|
+
if (node.name in scope) return scope[node.name];
|
|
325
|
+
if (Object.prototype.hasOwnProperty.call(SAFE_GLOBALS, node.name)) return SAFE_GLOBALS[node.name];
|
|
326
|
+
if (node.name === "undefined") return undefined;
|
|
327
|
+
throw new UnsafeExpressionError(`unknown identifier '${node.name}'`);
|
|
328
|
+
case "TemplateLiteral": {
|
|
329
|
+
let out = "";
|
|
330
|
+
node.quasis.forEach((q: any, i: number) => {
|
|
331
|
+
out += q.value.cooked;
|
|
332
|
+
if (i < node.expressions.length) out += String(evalNode(node.expressions[i], scope));
|
|
333
|
+
});
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
case "MemberExpression": {
|
|
337
|
+
const obj = evalNode(node.object, scope);
|
|
338
|
+
const key = node.computed ? (evalNode(node.property, scope) as PropertyKey) : node.property.name;
|
|
339
|
+
return prop(obj, key);
|
|
340
|
+
}
|
|
341
|
+
case "ObjectExpression": {
|
|
342
|
+
const o: Record<string, unknown> = {};
|
|
343
|
+
for (const p of node.properties) {
|
|
344
|
+
if (p.type === "SpreadElement") {
|
|
345
|
+
Object.assign(o, evalNode(p.argument, scope) as object);
|
|
346
|
+
} else {
|
|
347
|
+
const k = p.computed ? String(evalNode(p.key, scope)) : (p.key.name ?? p.key.value);
|
|
348
|
+
if (BLOCKED_PROPS.has(String(k))) throw new UnsafeExpressionError(`blocked key '${k}'`);
|
|
349
|
+
o[k] = evalNode(p.value, scope);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return o;
|
|
353
|
+
}
|
|
354
|
+
case "ArrayExpression": {
|
|
355
|
+
const arr: unknown[] = [];
|
|
356
|
+
for (const el of node.elements) {
|
|
357
|
+
if (el == null) arr.push(undefined);
|
|
358
|
+
else if (el.type === "SpreadElement") arr.push(...(evalNode(el.argument, scope) as unknown[]));
|
|
359
|
+
else arr.push(evalNode(el, scope));
|
|
360
|
+
}
|
|
361
|
+
return arr;
|
|
362
|
+
}
|
|
363
|
+
case "BinaryExpression": {
|
|
364
|
+
const l = evalNode(node.left, scope) as any;
|
|
365
|
+
const r = evalNode(node.right, scope) as any;
|
|
366
|
+
switch (node.operator) {
|
|
367
|
+
case "+": return l + r;
|
|
368
|
+
case "-": return l - r;
|
|
369
|
+
case "*": return l * r;
|
|
370
|
+
case "/": return l / r;
|
|
371
|
+
case "%": return l % r;
|
|
372
|
+
case "**": return l ** r;
|
|
373
|
+
case "==": return l == r;
|
|
374
|
+
case "!=": return l != r;
|
|
375
|
+
case "===": return l === r;
|
|
376
|
+
case "!==": return l !== r;
|
|
377
|
+
case "<": return l < r;
|
|
378
|
+
case ">": return l > r;
|
|
379
|
+
case "<=": return l <= r;
|
|
380
|
+
case ">=": return l >= r;
|
|
381
|
+
default: throw new UnsafeExpressionError(`operator '${node.operator}'`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
case "LogicalExpression": {
|
|
385
|
+
const l = evalNode(node.left, scope) as any;
|
|
386
|
+
if (node.operator === "&&") return l && evalNode(node.right, scope);
|
|
387
|
+
if (node.operator === "||") return l || evalNode(node.right, scope);
|
|
388
|
+
if (node.operator === "??") return l ?? evalNode(node.right, scope);
|
|
389
|
+
throw new UnsafeExpressionError(`logical '${node.operator}'`);
|
|
390
|
+
}
|
|
391
|
+
case "UnaryExpression": {
|
|
392
|
+
const v = evalNode(node.argument, scope) as any;
|
|
393
|
+
switch (node.operator) {
|
|
394
|
+
case "!": return !v;
|
|
395
|
+
case "-": return -v;
|
|
396
|
+
case "+": return +v;
|
|
397
|
+
case "typeof": return typeof v;
|
|
398
|
+
default: throw new UnsafeExpressionError(`unary '${node.operator}'`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
case "ConditionalExpression":
|
|
402
|
+
return evalNode(node.test, scope) ? evalNode(node.consequent, scope) : evalNode(node.alternate, scope);
|
|
403
|
+
case "CallExpression": {
|
|
404
|
+
const args = node.arguments.map((a: any) => evalNode(a, scope));
|
|
405
|
+
if (node.callee.type === "MemberExpression") {
|
|
406
|
+
const obj = evalNode(node.callee.object, scope);
|
|
407
|
+
const method = node.callee.computed ? String(evalNode(node.callee.property, scope)) : node.callee.property.name;
|
|
408
|
+
// Allow a method call if the receiver is a safe-global namespace (JSON/Math/Object/…),
|
|
409
|
+
// OR the method is a known-safe array/string builtin. Never a user-supplied name that
|
|
410
|
+
// could be `constructor`, `call`, `apply`, `bind`, etc.
|
|
411
|
+
const isSafeGlobalNs =
|
|
412
|
+
Object.values(SAFE_GLOBALS).includes(obj as never) || (obj !== null && typeof obj === "object" && NAMESPACES.has(obj));
|
|
413
|
+
if (!isSafeGlobalNs && !SAFE_METHODS.has(method)) throw new UnsafeExpressionError(`method '${method}'`);
|
|
414
|
+
if (BLOCKED_PROPS.has(method)) throw new UnsafeExpressionError(`method '${method}'`);
|
|
415
|
+
const fn = prop(obj, method);
|
|
416
|
+
if (typeof fn !== "function") {
|
|
417
|
+
// A HELPER BAG IS A CLOSED NAMESPACE. `helpers.nopeReport(...)` is a misspelling, and
|
|
418
|
+
// the general member-call rule below — undefined, quietly — turned it into a node that
|
|
419
|
+
// emits undefined on every connector with nothing anywhere reporting why. Throwing
|
|
420
|
+
// UnsafeExpressionError (not TypeError) is what lets the author-time expression scan
|
|
421
|
+
// report it: the scan runs every expression against a dummy scope and deliberately
|
|
422
|
+
// ignores every error EXCEPT the sandbox's own verdict, because missing DATA is
|
|
423
|
+
// expected there and a missing NAME never is.
|
|
424
|
+
if (obj !== null && typeof obj === "object" && NAMESPACES.has(obj))
|
|
425
|
+
throw new UnsafeExpressionError(`unknown helper '${method}'`);
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
return (fn as (...a: unknown[]) => unknown).apply(obj, args);
|
|
429
|
+
}
|
|
430
|
+
// Direct call: only a safe-global function (parseInt, String, …).
|
|
431
|
+
const fn = evalNode(node.callee, scope);
|
|
432
|
+
if (typeof fn !== "function") throw new UnsafeExpressionError("call of non-function");
|
|
433
|
+
// A safe global (parseInt, String, …) OR an arrow this expression itself defined — the latter
|
|
434
|
+
// is what makes `(() => { ... })()` work. Anything else, including a function that arrived on
|
|
435
|
+
// the data context, is still refused.
|
|
436
|
+
if (!Object.values(SAFE_GLOBALS).includes(fn as never) && !OURS.has(fn as Function))
|
|
437
|
+
throw new UnsafeExpressionError("call of non-allowlisted function");
|
|
438
|
+
return (fn as (...a: unknown[]) => unknown)(...args);
|
|
439
|
+
}
|
|
440
|
+
case "ArrowFunctionExpression": {
|
|
441
|
+
// BOTH BODY FORMS. `x => x.name` is the common .map/.filter callback; the block form is what
|
|
442
|
+
// every `return (() => { ... })()` in a Code node is made of, and rejecting it made that node
|
|
443
|
+
// emit its own source text instead of running (see the statement section above).
|
|
444
|
+
const params: string[] = node.params.map((p: any) => {
|
|
445
|
+
if (p.type !== "Identifier") throw new UnsafeExpressionError("non-identifier arrow param");
|
|
446
|
+
return p.name;
|
|
447
|
+
});
|
|
448
|
+
const isBlock = node.body.type === "BlockStatement";
|
|
449
|
+
const fn = (...callArgs: unknown[]) => {
|
|
450
|
+
// A DECLARING scope, not a bare Object.create: parameters and any `const` inside the body
|
|
451
|
+
// must be assignable, and `assign` only writes where a name was declared.
|
|
452
|
+
const local = childScope(scope);
|
|
453
|
+
params.forEach((name, i) => declare(local, name, callArgs[i]));
|
|
454
|
+
if (!isBlock) return evalNode(node.body, local);
|
|
455
|
+
// Falling off the end with no `return` is undefined, exactly as in JS.
|
|
456
|
+
const done = execStatement(node.body, local);
|
|
457
|
+
return done ? done.value : undefined;
|
|
458
|
+
};
|
|
459
|
+
OURS.add(fn);
|
|
460
|
+
return fn;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Assignment, and ONLY to a name this expression declared.
|
|
465
|
+
*
|
|
466
|
+
* `let title = ''; if (m) title = m[1]` is the shape of nearly every extraction, so without this
|
|
467
|
+
* the block form would parse and then quietly do nothing. `assign` throws for an undeclared
|
|
468
|
+
* name, which is what keeps the run's own data (`signal`, `config`, `calls`) read-only: a
|
|
469
|
+
* template may compute from them and may never rewrite them.
|
|
470
|
+
*
|
|
471
|
+
* Member assignment (`obj.x = 1`) is NOT allowed. It is the one form that could reach through to
|
|
472
|
+
* a caller's object, and building a new object literal expresses the same thing without it.
|
|
473
|
+
*/
|
|
474
|
+
case "AssignmentExpression": {
|
|
475
|
+
if (node.left.type !== "Identifier")
|
|
476
|
+
throw new UnsafeExpressionError(`assignment to ${node.left.type === "MemberExpression" ? "a property" : node.left.type}`);
|
|
477
|
+
const name = node.left.name;
|
|
478
|
+
const right = evalNode(node.right, scope) as any;
|
|
479
|
+
if (node.operator === "=") {
|
|
480
|
+
assign(scope, name, right);
|
|
481
|
+
return right;
|
|
482
|
+
}
|
|
483
|
+
const current = evalNode(node.left, scope) as any;
|
|
484
|
+
const next =
|
|
485
|
+
node.operator === "+=" ? current + right
|
|
486
|
+
: node.operator === "-=" ? current - right
|
|
487
|
+
: node.operator === "*=" ? current * right
|
|
488
|
+
: node.operator === "/=" ? current / right
|
|
489
|
+
: node.operator === "%=" ? current % right
|
|
490
|
+
: node.operator === "??=" ? (current ?? right)
|
|
491
|
+
: node.operator === "||=" ? (current || right)
|
|
492
|
+
: node.operator === "&&=" ? (current && right)
|
|
493
|
+
: (() => { throw new UnsafeExpressionError(`assignment operator '${node.operator}'`); })();
|
|
494
|
+
assign(scope, name, next);
|
|
495
|
+
return next;
|
|
496
|
+
}
|
|
497
|
+
default:
|
|
498
|
+
throw new UnsafeExpressionError(`disallowed expression: ${node.type}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Evaluate a `return …` template expression against a data context, safely.
|
|
505
|
+
* Throws UnsafeExpressionError for anything outside the allowlist (logged by the caller).
|
|
506
|
+
*/
|
|
507
|
+
export function evaluateSafeExpression(code: string, context: Scope): unknown {
|
|
508
|
+
const ast = parseCached(code);
|
|
509
|
+
return evalNode(ast, context);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Syntax-check a `return …` expression without evaluating it (throws on a parse error).
|
|
513
|
+
* Used by config validation — replaces a bare `new Function(code)` compile check. */
|
|
514
|
+
export function assertParsableExpression(code: string): void {
|
|
515
|
+
parseCached(code);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Every AST node type `evalNode` implements. Anything else is refused at run time. */
|
|
519
|
+
const SUPPORTED = new Set([
|
|
520
|
+
"ArrayExpression", "ArrowFunctionExpression", "AssignmentExpression", "BinaryExpression",
|
|
521
|
+
"BlockStatement", "CallExpression", "ConditionalExpression", "EmptyStatement",
|
|
522
|
+
"ExpressionStatement", "Identifier", "IfStatement", "Literal", "LogicalExpression",
|
|
523
|
+
"MemberExpression", "ObjectExpression", "Program", "Property", "ReturnStatement",
|
|
524
|
+
"TemplateLiteral", "TemplateElement", "UnaryExpression", "VariableDeclaration",
|
|
525
|
+
"VariableDeclarator",
|
|
526
|
+
]);
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* STATICALLY reject anything the interpreter cannot run, on EVERY branch.
|
|
530
|
+
*
|
|
531
|
+
* `assertParsableExpression` above only parses, and the sandbox check that complements it
|
|
532
|
+
* only sees the path a dummy scope happens to reach. Both missed the same real bug: a
|
|
533
|
+
* `...spread` inside a `.map()` callback that a dummy scope never entered, because
|
|
534
|
+
* `response.results` was `{}` and the expression died on "map is not a function" long before
|
|
535
|
+
* it got there. That error is ignored on purpose, so the spread shipped, reached the vendor,
|
|
536
|
+
* embedded the query, and failed on the way back with `disallowed expression: SpreadElement`.
|
|
537
|
+
*
|
|
538
|
+
* Walking the tree needs no scope and no execution, so a dead branch is checked exactly like
|
|
539
|
+
* a live one. That is the whole point: an expression is legal or it is not, and which
|
|
540
|
+
* arguments it happens to receive has nothing to do with it.
|
|
541
|
+
*/
|
|
542
|
+
export function assertSupportedExpression(code: string): void {
|
|
543
|
+
const walk = (node: any): void => {
|
|
544
|
+
if (!node || typeof node.type !== "string") return;
|
|
545
|
+
if (!SUPPORTED.has(node.type)) throw new UnsafeExpressionError(`disallowed expression: ${node.type}`);
|
|
546
|
+
for (const key of Object.keys(node)) {
|
|
547
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc") continue;
|
|
548
|
+
const child = (node as any)[key];
|
|
549
|
+
if (Array.isArray(child)) child.forEach(walk);
|
|
550
|
+
else if (child && typeof child === "object") walk(child);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
walk(parseCached(code));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export { UnsafeExpressionError };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String Template Resolver using Handlebars
|
|
3
|
+
* For handling template string mode with ${variable} syntax
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import Handlebars from "handlebars";
|
|
7
|
+
import memoizeImport from "fast-memoize";
|
|
8
|
+
/**
|
|
9
|
+
* fast-memoize ships CommonJS (`module.exports = memoize`) with ESM-shaped typings
|
|
10
|
+
* (`export default`). Under NodeNext those disagree: the runtime value IS the function,
|
|
11
|
+
* but TypeScript types the default import as the module namespace and calls it uncallable.
|
|
12
|
+
* Verified at runtime before casting, rather than assuming.
|
|
13
|
+
*/
|
|
14
|
+
type Memoizer = <F extends (...args: any[]) => any>(fn: F) => F;
|
|
15
|
+
const memoize = memoizeImport as unknown as Memoizer;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The data a template renders against. DECLARED HERE rather than imported from the engine's
|
|
19
|
+
* TemplateResolver, which is where it used to live: this module is the lower layer of the
|
|
20
|
+
* two, and pointing it back up at its own consumer is what tied the whole template folder
|
|
21
|
+
* to the engine. Structurally identical, so nothing that already passes a context changes.
|
|
22
|
+
*/
|
|
23
|
+
export interface TemplateContext {
|
|
24
|
+
input?: any;
|
|
25
|
+
workflow?: any;
|
|
26
|
+
[key: string]: any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Register custom helpers
|
|
30
|
+
Handlebars.registerHelper("toJSON", function (context) {
|
|
31
|
+
return JSON.stringify(context, null, 2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Register a "filter" helper
|
|
35
|
+
Handlebars.registerHelper("filter", function (array, key, value) {
|
|
36
|
+
if (!Array.isArray(array)) return [];
|
|
37
|
+
return array.filter((item) => item && item[key] === value);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Register equality helper for conditionals
|
|
41
|
+
Handlebars.registerHelper("eq", function (a, b) {
|
|
42
|
+
return a === b;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Register contains helper for string matching
|
|
46
|
+
Handlebars.registerHelper("contains", function (str, substring) {
|
|
47
|
+
if (typeof str !== "string" || typeof substring !== "string") return false;
|
|
48
|
+
return str.toLowerCase().includes(substring.toLowerCase());
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Create a memoized template compiler for performance
|
|
52
|
+
const compileTemplate = memoize((template: string) => {
|
|
53
|
+
// Disable HTML escaping to prevent issues like "I'll" becoming "I'll"
|
|
54
|
+
return Handlebars.compile(template, { noEscape: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export function resolveStringTemplate(template: string, context: TemplateContext, logger: any): any {
|
|
58
|
+
try {
|
|
59
|
+
// Template processing
|
|
60
|
+
|
|
61
|
+
// Compile and execute the template
|
|
62
|
+
const compiledTemplate = compileTemplate(template);
|
|
63
|
+
const result = compiledTemplate(context);
|
|
64
|
+
|
|
65
|
+
// Template processed
|
|
66
|
+
return result;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
logger.debug("Failed to resolve string template", {
|
|
69
|
+
error: error instanceof Error ? error.message : String(error),
|
|
70
|
+
template: template.substring(0, 200) + (template.length > 200 ? "..." : ""),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expressions and templates — the two ways a manifest or a workflow turns data into a value.
|
|
3
|
+
*
|
|
4
|
+
* LIVES HERE, not in the engine, because neither file knows anything about workflows,
|
|
5
|
+
* XState or the database: one is a sandboxed expression evaluator, the other a Handlebars
|
|
6
|
+
* wrapper. They sat under `engine/` only because that is where they were first needed,
|
|
7
|
+
* before manifests existed, and the manifest runtime then had to reach five directories
|
|
8
|
+
* upward by SOURCE URL to get at them. That path resolved inside this monorepo and nowhere
|
|
9
|
+
* else, which was the single thing keeping this package unpublishable.
|
|
10
|
+
*
|
|
11
|
+
* Three consumers now, and all three import it the same way: the manifest runtime, the
|
|
12
|
+
* engine's own template resolution, and node config validation.
|
|
13
|
+
*/
|
|
14
|
+
export {
|
|
15
|
+
evaluateSafeExpression,
|
|
16
|
+
assertParsableExpression,
|
|
17
|
+
assertSupportedExpression,
|
|
18
|
+
makeHelpers,
|
|
19
|
+
UnsafeExpressionError,
|
|
20
|
+
type HelperDef,
|
|
21
|
+
} from "./SafeExpression.js";
|
|
22
|
+
export { resolveStringTemplate, type TemplateContext } from "./StringTemplateResolver.js";
|