PSA/shared/workflow/runtime/expressionEngine.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

143 lines
4.7 KiB
TypeScript

import jsonata from 'jsonata';
import type { Expr } from './types';
import { WORKFLOW_RUNTIME_ALLOWED_FUNCTIONS } from './expressionFunctions';
const MAX_OUTPUT_BYTES = 256 * 1024;
const DEFAULT_TIMEOUT_MS = 25;
const allowedFunctions = new Set<string>(WORKFLOW_RUNTIME_ALLOWED_FUNCTIONS);
export type ExpressionContext = {
payload?: unknown;
vars?: Record<string, unknown>;
meta?: Record<string, unknown>;
error?: unknown;
event?: unknown;
[key: string]: unknown;
};
export type CompiledExpression = {
evaluate: (ctx: ExpressionContext, timeoutMs?: number) => Promise<unknown>;
source: string;
};
export async function evaluateExpressionSource(
source: string,
ctx: ExpressionContext,
timeoutMs?: number
): Promise<unknown> {
const compiled = compileExpression({ $expr: source });
return compiled.evaluate(ctx, timeoutMs);
}
export function validateExpressionSource(source: string): void {
const normalizedSource = normalizeExpressionSource(source);
const functionCalls = extractFunctionCalls(normalizedSource);
for (const fn of functionCalls) {
const normalizedFn = fn.startsWith('$') ? fn.slice(1) : fn;
if (!allowedFunctions.has(normalizedFn)) {
throw new Error(`Expression uses disallowed function: ${normalizedFn}`);
}
}
const expr = jsonata(normalizedSource);
// Access AST to ensure parse happens now; jsonata throws on invalid syntax
expr.ast();
}
export function compileExpression(expr: Expr): CompiledExpression {
const normalizedSource = normalizeExpressionSource(expr.$expr);
validateExpressionSource(normalizedSource);
const compiled = jsonata(normalizedSource);
compiled.registerFunction('nowIso', () => new Date().toISOString());
compiled.registerFunction('coalesce', (...args: unknown[]) => {
for (const arg of args) {
if (arg !== null && arg !== undefined) return arg;
}
return null;
});
compiled.registerFunction('len', (value: unknown) => {
if (typeof value === 'string' || Array.isArray(value)) {
return value.length;
}
return 0;
});
compiled.registerFunction('toString', (value: unknown) => {
if (value === null || value === undefined) return '';
return String(value);
});
compiled.registerFunction('append', (list: unknown, value: unknown) => {
const base = Array.isArray(list) ? list : list === null || list === undefined ? [] : [list];
const toAdd = Array.isArray(value) ? value : [value];
return base.concat(toAdd);
});
return {
source: expr.$expr,
evaluate: async (ctx: ExpressionContext, timeoutMs?: number) => {
const start = Date.now();
const result = await Promise.resolve(compiled.evaluate(ctx));
const duration = Date.now() - start;
if (duration > (timeoutMs ?? DEFAULT_TIMEOUT_MS)) {
throw new Error(`Expression evaluation exceeded ${timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`);
}
if (!isJsonSerializable(result)) {
throw new Error('Expression result is not JSON-serializable');
}
let serialized: string;
try {
serialized = JSON.stringify(result) ?? '';
} catch (error) {
throw new Error('Expression result is not JSON-serializable');
}
if (!serialized && serialized !== '') {
throw new Error('Expression result is not JSON-serializable');
}
if (serialized && serialized.length > MAX_OUTPUT_BYTES) {
throw new Error('Expression result exceeded max output size');
}
return result;
}
};
}
function normalizeExpressionSource(source: string): string {
// JSONata uses `=` / `!=` for equality checks; many authors intuitively write `==`.
// Normalize `==` to `=` for compatibility with workflow fixtures and designer output.
const normalized = source.replace(/==/g, '=');
return normalized.replace(
/(^|[^.$A-Za-z0-9_])([A-Za-z_][A-Za-z0-9_]*)(?=\s*\()/g,
(match, prefix, name: string) => {
if (!allowedFunctions.has(name)) {
return match;
}
return `${prefix}$${name}`;
}
);
}
function extractFunctionCalls(source: string): string[] {
const calls: string[] = [];
const regex = /([A-Za-z_$][A-Za-z0-9_]*)\s*\(/g;
let match;
while ((match = regex.exec(source)) !== null) {
calls.push(match[1]);
}
return calls;
}
function isJsonSerializable(value: unknown): boolean {
if (value === undefined) return false;
const valueType = typeof value;
if (valueType === 'function' || valueType === 'symbol' || valueType === 'bigint') return false;
if (value === null) return true;
if (Array.isArray(value)) {
return value.every((item) => isJsonSerializable(item));
}
if (valueType === 'object') {
if (value instanceof Date) return true;
return Object.values(value as Record<string, unknown>).every((item) => isJsonSerializable(item));
}
return true;
}